git-coco 0.48.0 → 0.49.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.esm.mjs +1031 -8
- package/dist/index.js +1029 -8
- package/package.json +2 -1
package/dist/index.esm.mjs
CHANGED
|
@@ -17,7 +17,9 @@ import ora from 'ora';
|
|
|
17
17
|
import now from 'performance-now';
|
|
18
18
|
import prettyMilliseconds from 'pretty-ms';
|
|
19
19
|
import * as fs$1 from 'node:fs';
|
|
20
|
+
import { mkdtempSync, writeFileSync, rmSync, readFileSync as readFileSync$1 } from 'node:fs';
|
|
20
21
|
import * as os$1 from 'node:os';
|
|
22
|
+
import { tmpdir as tmpdir$1 } from 'node:os';
|
|
21
23
|
import * as path$1 from 'node:path';
|
|
22
24
|
import { ChatAnthropic } from '@langchain/anthropic';
|
|
23
25
|
import { ChatOllama } from '@langchain/ollama';
|
|
@@ -53,7 +55,7 @@ import { pathToFileURL } from 'url';
|
|
|
53
55
|
/**
|
|
54
56
|
* Current build version from package.json
|
|
55
57
|
*/
|
|
56
|
-
const BUILD_VERSION = "0.
|
|
58
|
+
const BUILD_VERSION = "0.49.0";
|
|
57
59
|
|
|
58
60
|
const isInteractive = (config) => {
|
|
59
61
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -15505,6 +15507,54 @@ async function getDefaultBranch(repository, runner) {
|
|
|
15505
15507
|
return undefined;
|
|
15506
15508
|
}
|
|
15507
15509
|
}
|
|
15510
|
+
/**
|
|
15511
|
+
* Local-only fallback for the default branch — used when no GitHub
|
|
15512
|
+
* remote is configured, when `gh` isn't authenticated, or when
|
|
15513
|
+
* `gh repo view` fails (e.g. private repo we can't access, offline).
|
|
15514
|
+
*
|
|
15515
|
+
* Detection order, picking the first that resolves:
|
|
15516
|
+
* 1. `origin/HEAD` — the symbolic ref set by `git clone` pointing at
|
|
15517
|
+
* whatever the remote's default branch was at clone time. This is
|
|
15518
|
+
* the most authoritative local signal.
|
|
15519
|
+
* 2. Conventional branch names checked against local refs in order:
|
|
15520
|
+
* `main`, `master`, `develop`, `trunk`.
|
|
15521
|
+
*
|
|
15522
|
+
* Returns `undefined` when nothing matches — caller surfaces that as
|
|
15523
|
+
* "no default branch detected" without claiming any particular cause.
|
|
15524
|
+
*
|
|
15525
|
+
* Pure local-ref reads (no network) — safe to call on every overview
|
|
15526
|
+
* load regardless of provider state.
|
|
15527
|
+
*/
|
|
15528
|
+
async function detectLocalDefaultBranch(git) {
|
|
15529
|
+
// origin/HEAD — set by `git clone` to track the remote's HEAD. The
|
|
15530
|
+
// symbolic-ref output is the full ref (refs/remotes/origin/main); we
|
|
15531
|
+
// strip the prefix to get just the branch name. `--short` would do it
|
|
15532
|
+
// too but isn't supported on older git, and the prefix is fixed-length.
|
|
15533
|
+
try {
|
|
15534
|
+
const ref = (await git.raw(['symbolic-ref', 'refs/remotes/origin/HEAD'])).trim();
|
|
15535
|
+
const match = ref.match(/^refs\/remotes\/origin\/(.+)$/);
|
|
15536
|
+
if (match) {
|
|
15537
|
+
return match[1];
|
|
15538
|
+
}
|
|
15539
|
+
}
|
|
15540
|
+
catch {
|
|
15541
|
+
// symbolic-ref returns non-zero when origin/HEAD doesn't exist —
|
|
15542
|
+
// expected on fresh repos and `git init`-only working trees. Fall
|
|
15543
|
+
// through to the conventional-name check.
|
|
15544
|
+
}
|
|
15545
|
+
// Conventional names — most repos follow one of these. `rev-parse
|
|
15546
|
+
// --verify --quiet <ref>` returns 0 + hash on hit, non-zero on miss.
|
|
15547
|
+
for (const candidate of ['main', 'master', 'develop', 'trunk']) {
|
|
15548
|
+
try {
|
|
15549
|
+
await git.raw(['rev-parse', '--verify', '--quiet', `refs/heads/${candidate}`]);
|
|
15550
|
+
return candidate;
|
|
15551
|
+
}
|
|
15552
|
+
catch {
|
|
15553
|
+
// Not present — try the next one.
|
|
15554
|
+
}
|
|
15555
|
+
}
|
|
15556
|
+
return undefined;
|
|
15557
|
+
}
|
|
15508
15558
|
async function getCurrentPullRequest(runner) {
|
|
15509
15559
|
try {
|
|
15510
15560
|
return parsePullRequestJson(await runner([
|
|
@@ -15519,9 +15569,14 @@ async function getCurrentPullRequest(runner) {
|
|
|
15519
15569
|
}
|
|
15520
15570
|
}
|
|
15521
15571
|
async function getProviderOverview(git, runner = defaultGhRunner) {
|
|
15522
|
-
const [remotes, currentBranchOutput] = await Promise.all([
|
|
15572
|
+
const [remotes, currentBranchOutput, localDefaultBranch] = await Promise.all([
|
|
15523
15573
|
git.getRemotes(true),
|
|
15524
15574
|
git.raw(['branch', '--show-current']),
|
|
15575
|
+
// Read local default-branch signal up-front in parallel — used as
|
|
15576
|
+
// the fallback when gh is unavailable / unauthenticated / can't see
|
|
15577
|
+
// the repo. Coco aims to be platform-agnostic + work offline; the
|
|
15578
|
+
// GH-specific paths layer on top of this, they don't replace it.
|
|
15579
|
+
detectLocalDefaultBranch(git),
|
|
15525
15580
|
]);
|
|
15526
15581
|
const remote = remotes.find((entry) => entry.name === 'origin') || remotes[0];
|
|
15527
15582
|
const remoteUrl = remote?.refs.push || remote?.refs.fetch;
|
|
@@ -15535,7 +15590,10 @@ async function getProviderOverview(git, runner = defaultGhRunner) {
|
|
|
15535
15590
|
const currentBranch = currentBranchOutput.trim() || undefined;
|
|
15536
15591
|
if (repository.provider !== 'github') {
|
|
15537
15592
|
return {
|
|
15538
|
-
repository
|
|
15593
|
+
repository: {
|
|
15594
|
+
...repository,
|
|
15595
|
+
defaultBranch: localDefaultBranch,
|
|
15596
|
+
},
|
|
15539
15597
|
currentBranch,
|
|
15540
15598
|
authenticated: false,
|
|
15541
15599
|
message: repository.message || 'Unsupported remote provider.',
|
|
@@ -15546,20 +15604,27 @@ async function getProviderOverview(git, runner = defaultGhRunner) {
|
|
|
15546
15604
|
}
|
|
15547
15605
|
catch {
|
|
15548
15606
|
return {
|
|
15549
|
-
repository
|
|
15607
|
+
repository: {
|
|
15608
|
+
...repository,
|
|
15609
|
+
defaultBranch: localDefaultBranch,
|
|
15610
|
+
},
|
|
15550
15611
|
currentBranch,
|
|
15551
15612
|
authenticated: false,
|
|
15552
15613
|
message: 'GitHub CLI is missing or not authenticated.',
|
|
15553
15614
|
};
|
|
15554
15615
|
}
|
|
15555
|
-
const [
|
|
15616
|
+
const [providerDefaultBranch, currentPullRequest] = await Promise.all([
|
|
15556
15617
|
getDefaultBranch(repository, runner),
|
|
15557
15618
|
getCurrentPullRequest(runner),
|
|
15558
15619
|
]);
|
|
15559
15620
|
return {
|
|
15560
15621
|
repository: {
|
|
15561
15622
|
...repository,
|
|
15562
|
-
|
|
15623
|
+
// gh's answer wins when it has one — it knows the remote's
|
|
15624
|
+
// current state, including custom default-branch settings the
|
|
15625
|
+
// local refs can't reflect. Fall back to local detection when gh
|
|
15626
|
+
// returns undefined (offline, private repo, transient failure).
|
|
15627
|
+
defaultBranch: providerDefaultBranch || localDefaultBranch,
|
|
15563
15628
|
},
|
|
15564
15629
|
currentBranch,
|
|
15565
15630
|
currentPullRequest,
|
|
@@ -16525,6 +16590,149 @@ async function runCommitDraftWorkflow(input = {}) {
|
|
|
16525
16590
|
}
|
|
16526
16591
|
}
|
|
16527
16592
|
|
|
16593
|
+
function createChangelogArgv(input) {
|
|
16594
|
+
return {
|
|
16595
|
+
$0: 'coco',
|
|
16596
|
+
_: ['changelog'],
|
|
16597
|
+
interactive: false,
|
|
16598
|
+
verbose: true,
|
|
16599
|
+
version: false,
|
|
16600
|
+
help: false,
|
|
16601
|
+
mode: 'stdout',
|
|
16602
|
+
range: '',
|
|
16603
|
+
branch: '',
|
|
16604
|
+
tag: '',
|
|
16605
|
+
sinceLastTag: false,
|
|
16606
|
+
withDiff: false,
|
|
16607
|
+
onlyDiff: false,
|
|
16608
|
+
author: false,
|
|
16609
|
+
...input,
|
|
16610
|
+
};
|
|
16611
|
+
}
|
|
16612
|
+
async function captureStdout(action) {
|
|
16613
|
+
const originalWrite = process.stdout.write.bind(process.stdout);
|
|
16614
|
+
let output = '';
|
|
16615
|
+
process.stdout.write = ((chunk, ...args) => {
|
|
16616
|
+
output += typeof chunk === 'string' ? chunk : chunk.toString();
|
|
16617
|
+
const callback = args.find((arg) => typeof arg === 'function');
|
|
16618
|
+
callback?.();
|
|
16619
|
+
return true;
|
|
16620
|
+
});
|
|
16621
|
+
try {
|
|
16622
|
+
await action();
|
|
16623
|
+
return output;
|
|
16624
|
+
}
|
|
16625
|
+
finally {
|
|
16626
|
+
process.stdout.write = originalWrite;
|
|
16627
|
+
}
|
|
16628
|
+
}
|
|
16629
|
+
/**
|
|
16630
|
+
* Generate a pull-request body for the current branch by running
|
|
16631
|
+
* `coco changelog --branch <base>` and parsing the title / content
|
|
16632
|
+
* out of the captured stdout.
|
|
16633
|
+
*
|
|
16634
|
+
* The changelog handler emits `${title}\n\n${content}[\n\nPart of <ticket>]`
|
|
16635
|
+
* (see `commands/changelog/handler.ts` line 306). We split on the first
|
|
16636
|
+
* blank-line boundary so the caller gets a clean title + body pair to
|
|
16637
|
+
* pre-fill the PR creation prompt with. Ticket footer (when present)
|
|
16638
|
+
* stays in the body so the resulting PR keeps the reference.
|
|
16639
|
+
*
|
|
16640
|
+
* Captures the raw stdout (rather than going through `runChangelogAction`,
|
|
16641
|
+
* which strips blank lines via its `compactOutputLines` filter) so the
|
|
16642
|
+
* title-vs-body separator survives intact.
|
|
16643
|
+
*
|
|
16644
|
+
* Returns the standard LogAiActionResult plus extracted `title` / `body`
|
|
16645
|
+
* fields. Falls back to undefined `title` / `body` when the changelog
|
|
16646
|
+
* fails or produces no parseable output; the caller is expected to
|
|
16647
|
+
* surface that as a prompt with empty fields rather than aborting.
|
|
16648
|
+
*/
|
|
16649
|
+
async function runPullRequestBodyWorkflow(input = {}) {
|
|
16650
|
+
const baseBranch = input.baseBranch || 'main';
|
|
16651
|
+
const argv = createChangelogArgv({ branch: baseBranch });
|
|
16652
|
+
let raw = '';
|
|
16653
|
+
try {
|
|
16654
|
+
raw = await captureStdout(() => handler$7(argv, new Logger({
|
|
16655
|
+
verbose: true,
|
|
16656
|
+
silent: false,
|
|
16657
|
+
})));
|
|
16658
|
+
}
|
|
16659
|
+
catch (error) {
|
|
16660
|
+
return {
|
|
16661
|
+
ok: false,
|
|
16662
|
+
message: error.message,
|
|
16663
|
+
};
|
|
16664
|
+
}
|
|
16665
|
+
const text = raw.trim();
|
|
16666
|
+
if (!text) {
|
|
16667
|
+
return {
|
|
16668
|
+
ok: false,
|
|
16669
|
+
message: 'No changelog output produced — branch may have no commits ahead of base.',
|
|
16670
|
+
};
|
|
16671
|
+
}
|
|
16672
|
+
// First blank-line boundary separates title from body. Falls back to
|
|
16673
|
+
// "everything is the title" when no blank line is found — typical of
|
|
16674
|
+
// very small changesets where the changelog content collapsed to one
|
|
16675
|
+
// line.
|
|
16676
|
+
const blankIdx = text.indexOf('\n\n');
|
|
16677
|
+
const title = blankIdx > 0 ? text.slice(0, blankIdx).trim() : text.split('\n')[0].trim();
|
|
16678
|
+
const body = blankIdx > 0 ? text.slice(blankIdx + 2).trim() : '';
|
|
16679
|
+
// Keep the standard LogAiActionResult shape (message + telemetry
|
|
16680
|
+
// details + editable text) so palette callers get a consistent
|
|
16681
|
+
// surface. The captured telemetry lines are dropped here — the PR
|
|
16682
|
+
// body should be the actionable content, not the LLM trace.
|
|
16683
|
+
return {
|
|
16684
|
+
ok: true,
|
|
16685
|
+
message: title || 'Pull request body drafted.',
|
|
16686
|
+
details: [],
|
|
16687
|
+
editable: text,
|
|
16688
|
+
title,
|
|
16689
|
+
body,
|
|
16690
|
+
};
|
|
16691
|
+
}
|
|
16692
|
+
/**
|
|
16693
|
+
* Run `coco changelog` and return the raw captured stdout, intact —
|
|
16694
|
+
* blank lines preserved, no telemetry stripping. Use this when you
|
|
16695
|
+
* want to show or copy the changelog as the user would see it from
|
|
16696
|
+
* the CLI (the chromed-up `runChangelogAction` collapses blank lines
|
|
16697
|
+
* via `compactOutputLines` which is wrong for any UI that wants the
|
|
16698
|
+
* full prose output).
|
|
16699
|
+
*
|
|
16700
|
+
* The argv defaults match `createChangelogArgv` — pass overrides via
|
|
16701
|
+
* `input`. Common shapes:
|
|
16702
|
+
*
|
|
16703
|
+
* - { branch: 'main' } — commits on current branch vs main
|
|
16704
|
+
* - { sinceLastTag: true } — since last tag
|
|
16705
|
+
* - { tag: 'v1.0.0' } — since a specific tag
|
|
16706
|
+
* - { range: 'abc..def' } — between two refs
|
|
16707
|
+
*
|
|
16708
|
+
* Returns:
|
|
16709
|
+
* - { ok: true, message, text } on success (message = first non-blank
|
|
16710
|
+
* line, useful for status surface; text = full raw output)
|
|
16711
|
+
* - { ok: false, message } on changelog handler error or empty output
|
|
16712
|
+
*/
|
|
16713
|
+
async function runChangelogTextWorkflow(input = {}) {
|
|
16714
|
+
const argv = createChangelogArgv(input);
|
|
16715
|
+
let raw = '';
|
|
16716
|
+
try {
|
|
16717
|
+
raw = await captureStdout(() => handler$7(argv, new Logger({
|
|
16718
|
+
verbose: true,
|
|
16719
|
+
silent: false,
|
|
16720
|
+
})));
|
|
16721
|
+
}
|
|
16722
|
+
catch (error) {
|
|
16723
|
+
return { ok: false, message: error.message };
|
|
16724
|
+
}
|
|
16725
|
+
const text = raw.trim();
|
|
16726
|
+
if (!text) {
|
|
16727
|
+
return {
|
|
16728
|
+
ok: false,
|
|
16729
|
+
message: 'No changelog output produced — branch may have no commits ahead of base.',
|
|
16730
|
+
};
|
|
16731
|
+
}
|
|
16732
|
+
const firstLine = text.split('\n').find((line) => line.trim()) || 'Changelog generated.';
|
|
16733
|
+
return { ok: true, message: firstLine, text };
|
|
16734
|
+
}
|
|
16735
|
+
|
|
16528
16736
|
const LOG_INK_CONTEXT_KEYS = [
|
|
16529
16737
|
'bisect',
|
|
16530
16738
|
'branches',
|
|
@@ -17402,7 +17610,14 @@ const LOG_INK_KEY_BINDINGS = [
|
|
|
17402
17610
|
id: 'editCommit',
|
|
17403
17611
|
keys: ['e'],
|
|
17404
17612
|
label: 'edit commit',
|
|
17405
|
-
description: 'Edit the manual commit summary or body.',
|
|
17613
|
+
description: 'Edit the manual commit summary or body inline.',
|
|
17614
|
+
contexts: ['commits'],
|
|
17615
|
+
},
|
|
17616
|
+
{
|
|
17617
|
+
id: 'editCommitExternal',
|
|
17618
|
+
keys: ['E'],
|
|
17619
|
+
label: 'edit in $EDITOR',
|
|
17620
|
+
description: 'Open the current commit draft in $EDITOR (or $VISUAL) for full editing, write-back on save.',
|
|
17406
17621
|
contexts: ['commits'],
|
|
17407
17622
|
},
|
|
17408
17623
|
{
|
|
@@ -17645,7 +17860,7 @@ function getLogInkFooterHints(options) {
|
|
|
17645
17860
|
}
|
|
17646
17861
|
if (options.activeView === 'compose') {
|
|
17647
17862
|
return {
|
|
17648
|
-
contextual: ['e edit', 'c commit', 'I AI draft', 'gs hunks', 'esc back'],
|
|
17863
|
+
contextual: ['e edit', 'E $EDITOR', 'c commit', 'I AI draft', 'gs hunks', 'esc back'],
|
|
17649
17864
|
global: NORMAL_GLOBAL_HINTS,
|
|
17650
17865
|
};
|
|
17651
17866
|
}
|
|
@@ -17713,6 +17928,12 @@ function getLogInkFooterHints(options) {
|
|
|
17713
17928
|
global: NORMAL_GLOBAL_HINTS,
|
|
17714
17929
|
};
|
|
17715
17930
|
}
|
|
17931
|
+
if (options.activeView === 'changelog') {
|
|
17932
|
+
return {
|
|
17933
|
+
contextual: ['j/k scroll', 'pg up/dn', 'y yank', 'E $EDITOR', 'c PR', 'r regen', '< back'],
|
|
17934
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
17935
|
+
};
|
|
17936
|
+
}
|
|
17716
17937
|
if (options.compareBaseSet) {
|
|
17717
17938
|
// History view with a compare base set — Enter is overridden to
|
|
17718
17939
|
// open the compare diff; show the override + the bail-out key.
|
|
@@ -17945,6 +18166,10 @@ function formatSortIndicator(mode, options = {}) {
|
|
|
17945
18166
|
return `${options.ascii ? 'v' : '▼'} ${mode}`;
|
|
17946
18167
|
}
|
|
17947
18168
|
|
|
18169
|
+
const DEFAULT_CHANGELOG_VIEW_STATE = {
|
|
18170
|
+
status: 'idle',
|
|
18171
|
+
scrollOffset: 0,
|
|
18172
|
+
};
|
|
17948
18173
|
const DEFAULT_LOG_INK_STATUS_FILTER_MASK = {
|
|
17949
18174
|
staged: true,
|
|
17950
18175
|
unstaged: true,
|
|
@@ -18285,6 +18510,8 @@ function createLogInkState(rows, options = {}) {
|
|
|
18285
18510
|
inspectorTab: 'inspector',
|
|
18286
18511
|
inspectorActionIndex: 0,
|
|
18287
18512
|
bootLoading: options.bootLoading ?? false,
|
|
18513
|
+
changelogView: { ...DEFAULT_CHANGELOG_VIEW_STATE },
|
|
18514
|
+
changelogCache: {},
|
|
18288
18515
|
};
|
|
18289
18516
|
}
|
|
18290
18517
|
function getSelectedInkCommit(state) {
|
|
@@ -18908,6 +19135,105 @@ function applyLogInkAction(state, action) {
|
|
|
18908
19135
|
pendingKey: undefined,
|
|
18909
19136
|
};
|
|
18910
19137
|
}
|
|
19138
|
+
case 'setChangelogLoading':
|
|
19139
|
+
return {
|
|
19140
|
+
...state,
|
|
19141
|
+
changelogView: {
|
|
19142
|
+
status: 'loading',
|
|
19143
|
+
branch: action.branch,
|
|
19144
|
+
baseLabel: action.baseLabel,
|
|
19145
|
+
scrollOffset: 0,
|
|
19146
|
+
},
|
|
19147
|
+
pendingKey: undefined,
|
|
19148
|
+
};
|
|
19149
|
+
case 'setChangelogReady': {
|
|
19150
|
+
// Cache the result so re-entry (or `c` to PR) reuses it instead of
|
|
19151
|
+
// re-running the LLM. Keyed by branch so a checkout naturally
|
|
19152
|
+
// produces a fresh generation.
|
|
19153
|
+
const cached = {
|
|
19154
|
+
text: action.text,
|
|
19155
|
+
baseLabel: action.baseLabel,
|
|
19156
|
+
generatedAt: Date.now(),
|
|
19157
|
+
};
|
|
19158
|
+
return {
|
|
19159
|
+
...state,
|
|
19160
|
+
changelogView: {
|
|
19161
|
+
status: 'ready',
|
|
19162
|
+
text: action.text,
|
|
19163
|
+
branch: action.branch,
|
|
19164
|
+
baseLabel: action.baseLabel,
|
|
19165
|
+
scrollOffset: 0,
|
|
19166
|
+
},
|
|
19167
|
+
changelogCache: {
|
|
19168
|
+
...state.changelogCache,
|
|
19169
|
+
[action.branch]: cached,
|
|
19170
|
+
},
|
|
19171
|
+
pendingKey: undefined,
|
|
19172
|
+
};
|
|
19173
|
+
}
|
|
19174
|
+
case 'setChangelogError':
|
|
19175
|
+
return {
|
|
19176
|
+
...state,
|
|
19177
|
+
changelogView: {
|
|
19178
|
+
status: 'error',
|
|
19179
|
+
branch: action.branch,
|
|
19180
|
+
baseLabel: action.baseLabel,
|
|
19181
|
+
error: action.error,
|
|
19182
|
+
scrollOffset: 0,
|
|
19183
|
+
},
|
|
19184
|
+
pendingKey: undefined,
|
|
19185
|
+
};
|
|
19186
|
+
case 'setChangelogText': {
|
|
19187
|
+
// Used by the $EDITOR round-trip: user edits the cached text, we
|
|
19188
|
+
// update the view AND the cache entry so subsequent re-entry
|
|
19189
|
+
// reflects the edits. Branch key is taken from the current view
|
|
19190
|
+
// (which is what the user just edited against).
|
|
19191
|
+
if (state.changelogView.status !== 'ready' || !state.changelogView.branch) {
|
|
19192
|
+
return state;
|
|
19193
|
+
}
|
|
19194
|
+
const branch = state.changelogView.branch;
|
|
19195
|
+
const existing = state.changelogCache[branch];
|
|
19196
|
+
return {
|
|
19197
|
+
...state,
|
|
19198
|
+
changelogView: {
|
|
19199
|
+
...state.changelogView,
|
|
19200
|
+
text: action.text,
|
|
19201
|
+
},
|
|
19202
|
+
changelogCache: {
|
|
19203
|
+
...state.changelogCache,
|
|
19204
|
+
[branch]: {
|
|
19205
|
+
text: action.text,
|
|
19206
|
+
baseLabel: existing?.baseLabel || state.changelogView.baseLabel || '',
|
|
19207
|
+
// Updated-at timestamp reflects the edit. Not the original
|
|
19208
|
+
// generation time — `r` (regenerate) is the explicit knob
|
|
19209
|
+
// for "I want fresh LLM output, not my edits".
|
|
19210
|
+
generatedAt: Date.now(),
|
|
19211
|
+
},
|
|
19212
|
+
},
|
|
19213
|
+
pendingKey: undefined,
|
|
19214
|
+
};
|
|
19215
|
+
}
|
|
19216
|
+
case 'pageChangelog':
|
|
19217
|
+
return {
|
|
19218
|
+
...state,
|
|
19219
|
+
changelogView: {
|
|
19220
|
+
...state.changelogView,
|
|
19221
|
+
scrollOffset: clampIndex(state.changelogView.scrollOffset + action.delta, action.lineCount),
|
|
19222
|
+
},
|
|
19223
|
+
pendingKey: undefined,
|
|
19224
|
+
};
|
|
19225
|
+
case 'clearChangelogCache': {
|
|
19226
|
+
// Targeted clear for a single branch, or wholesale wipe when
|
|
19227
|
+
// `branch` is omitted. Wholesale used on session reset / config
|
|
19228
|
+
// change; targeted reserved for future "this generation looks
|
|
19229
|
+
// wrong, drop it" UX.
|
|
19230
|
+
if (!action.branch) {
|
|
19231
|
+
return { ...state, changelogCache: {}, pendingKey: undefined };
|
|
19232
|
+
}
|
|
19233
|
+
const next = { ...state.changelogCache };
|
|
19234
|
+
delete next[action.branch];
|
|
19235
|
+
return { ...state, changelogCache: next, pendingKey: undefined };
|
|
19236
|
+
}
|
|
18911
19237
|
default:
|
|
18912
19238
|
return state;
|
|
18913
19239
|
}
|
|
@@ -19449,6 +19775,17 @@ function submitInputPrompt(state) {
|
|
|
19449
19775
|
action({ type: 'closeInputPrompt' }),
|
|
19450
19776
|
];
|
|
19451
19777
|
}
|
|
19778
|
+
if (state.inputPrompt.kind === 'create-pr') {
|
|
19779
|
+
// Multi-line content: line 1 is the PR title, lines 2+ are the body
|
|
19780
|
+
// (leading blank line tolerated). The generic empty-value guard
|
|
19781
|
+
// above (line ~627) covers truly-empty submissions; the workflow
|
|
19782
|
+
// handler in app.ts has the belt-and-suspenders title check for
|
|
19783
|
+
// the "newline-then-body" edge.
|
|
19784
|
+
return [
|
|
19785
|
+
{ type: 'runWorkflowAction', id: 'create-pr', payload: value },
|
|
19786
|
+
action({ type: 'closeInputPrompt' }),
|
|
19787
|
+
];
|
|
19788
|
+
}
|
|
19452
19789
|
const id = state.inputPrompt.kind;
|
|
19453
19790
|
return [
|
|
19454
19791
|
{ type: 'runWorkflowAction', id, payload: value },
|
|
@@ -19845,6 +20182,46 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
19845
20182
|
return [action({ type: 'setPendingConfirmation', value: 'bisect-reset' })];
|
|
19846
20183
|
}
|
|
19847
20184
|
}
|
|
20185
|
+
// Changelog view local keymap. Scoped to `activeView === 'changelog'`
|
|
20186
|
+
// so the letters stay free everywhere else. Bindings:
|
|
20187
|
+
//
|
|
20188
|
+
// j / k → scroll line down / up (1 line)
|
|
20189
|
+
// pgdn / pgup → scroll page down / up (10 lines)
|
|
20190
|
+
// y → yank text to clipboard
|
|
20191
|
+
// E → open in $EDITOR (companion to compose's `E` from #913)
|
|
20192
|
+
// c → create-PR seeded with this changelog
|
|
20193
|
+
// r → regenerate (skip cache, re-run LLM)
|
|
20194
|
+
//
|
|
20195
|
+
// Back-out is `<` / Esc handled by the global pop-view path lower
|
|
20196
|
+
// down. The view only renders when `state.changelogView.status`
|
|
20197
|
+
// is 'ready' — scroll keystrokes early-return when changelogLineCount
|
|
20198
|
+
// is missing so they no-op gracefully during loading / error states.
|
|
20199
|
+
if (state.activeView === 'changelog') {
|
|
20200
|
+
if (inputValue === 'j' && context.changelogLineCount) {
|
|
20201
|
+
return [action({ type: 'pageChangelog', delta: 1, lineCount: context.changelogLineCount })];
|
|
20202
|
+
}
|
|
20203
|
+
if (inputValue === 'k' && context.changelogLineCount) {
|
|
20204
|
+
return [action({ type: 'pageChangelog', delta: -1, lineCount: context.changelogLineCount })];
|
|
20205
|
+
}
|
|
20206
|
+
if (key.pageDown && context.changelogLineCount) {
|
|
20207
|
+
return [action({ type: 'pageChangelog', delta: 10, lineCount: context.changelogLineCount })];
|
|
20208
|
+
}
|
|
20209
|
+
if (key.pageUp && context.changelogLineCount) {
|
|
20210
|
+
return [action({ type: 'pageChangelog', delta: -10, lineCount: context.changelogLineCount })];
|
|
20211
|
+
}
|
|
20212
|
+
if (inputValue === 'y') {
|
|
20213
|
+
return [{ type: 'yankChangelog' }];
|
|
20214
|
+
}
|
|
20215
|
+
if (inputValue === 'E') {
|
|
20216
|
+
return [{ type: 'openChangelogInEditor' }];
|
|
20217
|
+
}
|
|
20218
|
+
if (inputValue === 'c') {
|
|
20219
|
+
return [{ type: 'startCreatePullRequest' }];
|
|
20220
|
+
}
|
|
20221
|
+
if (inputValue === 'r') {
|
|
20222
|
+
return [{ type: 'regenerateChangelog' }];
|
|
20223
|
+
}
|
|
20224
|
+
}
|
|
19848
20225
|
if (inputValue === 'g') {
|
|
19849
20226
|
if (state.pendingKey === 'g') {
|
|
19850
20227
|
return [
|
|
@@ -20629,6 +21006,34 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
20629
21006
|
if (inputValue === 'C' && state.activeView === 'conflicts') {
|
|
20630
21007
|
return [action({ type: 'setStatus', value: 'Resolve all conflicts before continuing' })];
|
|
20631
21008
|
}
|
|
21009
|
+
// Global `C` — create a pull request from the current branch. The
|
|
21010
|
+
// runtime callback handles pre-flight (current branch resolution,
|
|
21011
|
+
// provider check) and seeds the input prompt with a changelog-derived
|
|
21012
|
+
// title + body before handing control back to the user for editing.
|
|
21013
|
+
// Conflicts view handles `C` above (continue-operation). Compose view
|
|
21014
|
+
// gets an explicit guard — claiming the keystroke with a status
|
|
21015
|
+
// message — so users mid-draft don't fat-finger out of their commit
|
|
21016
|
+
// into a PR-creation flow. Without this guard the keystroke would
|
|
21017
|
+
// fall through to the generic workflow-by-key dispatch at the end of
|
|
21018
|
+
// this function, which would fire `create-pr` to its handler.
|
|
21019
|
+
if (inputValue === 'C' && state.activeView === 'compose') {
|
|
21020
|
+
return [action({
|
|
21021
|
+
type: 'setStatus',
|
|
21022
|
+
value: 'Finish or cancel the commit draft before creating a PR.',
|
|
21023
|
+
})];
|
|
21024
|
+
}
|
|
21025
|
+
if (inputValue === 'C' && state.activeView !== 'conflicts') {
|
|
21026
|
+
return [{ type: 'startCreatePullRequest' }];
|
|
21027
|
+
}
|
|
21028
|
+
// Global `L` — generate the changelog for the current branch and
|
|
21029
|
+
// push the dedicated `changelog` view. Scoped to history and branches
|
|
21030
|
+
// — those are the natural "where am I, what landed here recently"
|
|
21031
|
+
// entry points. Avoids polluting every view's global namespace; the
|
|
21032
|
+
// changelog is reachable from anywhere via `g L` (added in keymap).
|
|
21033
|
+
if (inputValue === 'L' &&
|
|
21034
|
+
(state.activeView === 'history' || state.activeView === 'branches')) {
|
|
21035
|
+
return [{ type: 'startChangelogView' }];
|
|
21036
|
+
}
|
|
20632
21037
|
// `c` on a stash diff cherry-picks the file under the cursor —
|
|
20633
21038
|
// materializes that single path from the stash into the working tree
|
|
20634
21039
|
// (`git checkout <stashRef> -- <path>`). Routed through the y-confirm
|
|
@@ -20812,6 +21217,24 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
20812
21217
|
events.push(action({ type: 'commitCompose', action: { type: 'setEditing', value: true } }));
|
|
20813
21218
|
return events;
|
|
20814
21219
|
}
|
|
21220
|
+
// Capital `E` — open the commit draft in $EDITOR (or $VISUAL). Companion
|
|
21221
|
+
// to lowercase `e` which activates inline editing inside the panel:
|
|
21222
|
+
// `e` for quick tweaks in-place, `E` for "I want the full power of my
|
|
21223
|
+
// editor — syntax highlighting, multi-line nav, paste buffers, etc."
|
|
21224
|
+
// The runtime callback handles the temp-file write, editor session,
|
|
21225
|
+
// and read-back; the input handler emits a single event the
|
|
21226
|
+
// dispatcher routes there. As with lowercase `e`, fires from status
|
|
21227
|
+
// and diff views too (auto-pushes into compose first), since those
|
|
21228
|
+
// are the natural entry points to commit-message work.
|
|
21229
|
+
if (inputValue === 'E' &&
|
|
21230
|
+
(state.activeView === 'status' || state.activeView === 'diff' || state.activeView === 'compose')) {
|
|
21231
|
+
const events = [];
|
|
21232
|
+
if (state.activeView !== 'compose') {
|
|
21233
|
+
events.push(action({ type: 'pushView', value: 'compose' }));
|
|
21234
|
+
}
|
|
21235
|
+
events.push({ type: 'openComposeInEditor' });
|
|
21236
|
+
return events;
|
|
21237
|
+
}
|
|
20815
21238
|
if (inputValue === 'c' &&
|
|
20816
21239
|
(state.activeView === 'status' || state.activeView === 'diff' || state.activeView === 'compose')) {
|
|
20817
21240
|
const events = [];
|
|
@@ -21876,6 +22299,12 @@ function stageConflictResolved(git, path) {
|
|
|
21876
22299
|
return runAction$1(() => git.raw(['add', '--', path]), `Staged ${path} (marked resolved)`);
|
|
21877
22300
|
}
|
|
21878
22301
|
|
|
22302
|
+
function parseCreatedPullRequestUrl(output) {
|
|
22303
|
+
return output
|
|
22304
|
+
.split('\n')
|
|
22305
|
+
.map((line) => line.trim())
|
|
22306
|
+
.find((line) => line.startsWith('https://'));
|
|
22307
|
+
}
|
|
21879
22308
|
async function runGhAction(runner, args, successMessage) {
|
|
21880
22309
|
try {
|
|
21881
22310
|
return successMessage(await runner(args));
|
|
@@ -21887,6 +22316,34 @@ async function runGhAction(runner, args, successMessage) {
|
|
|
21887
22316
|
};
|
|
21888
22317
|
}
|
|
21889
22318
|
}
|
|
22319
|
+
function buildCreatePullRequestArgs(input) {
|
|
22320
|
+
const args = [
|
|
22321
|
+
'pr',
|
|
22322
|
+
'create',
|
|
22323
|
+
'--base',
|
|
22324
|
+
input.base,
|
|
22325
|
+
'--head',
|
|
22326
|
+
input.head,
|
|
22327
|
+
'--title',
|
|
22328
|
+
input.title,
|
|
22329
|
+
'--body',
|
|
22330
|
+
input.body,
|
|
22331
|
+
];
|
|
22332
|
+
if (input.draft) {
|
|
22333
|
+
args.push('--draft');
|
|
22334
|
+
}
|
|
22335
|
+
return args;
|
|
22336
|
+
}
|
|
22337
|
+
function createPullRequest(input, runner = defaultGhRunner) {
|
|
22338
|
+
return runGhAction(runner, buildCreatePullRequestArgs(input), (output) => {
|
|
22339
|
+
const url = parseCreatedPullRequestUrl(output);
|
|
22340
|
+
return {
|
|
22341
|
+
ok: true,
|
|
22342
|
+
message: url ? `Created pull request: ${url}` : 'Created pull request',
|
|
22343
|
+
url,
|
|
22344
|
+
};
|
|
22345
|
+
});
|
|
22346
|
+
}
|
|
21890
22347
|
function isPullRequestMergeStrategy(value) {
|
|
21891
22348
|
return value === 'merge' || value === 'squash' || value === 'rebase';
|
|
21892
22349
|
}
|
|
@@ -23492,6 +23949,121 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
|
|
|
23492
23949
|
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Branches', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
|
|
23493
23950
|
}
|
|
23494
23951
|
|
|
23952
|
+
/**
|
|
23953
|
+
* Changelog surface — full-screen view that renders LLM-generated
|
|
23954
|
+
* release notes for the current branch. Reached via `L` from history
|
|
23955
|
+
* or branches; rendered as a real surface (not an input prompt) so the
|
|
23956
|
+
* content gets proper scroll, editing, yank, and create-PR follow-ups.
|
|
23957
|
+
*
|
|
23958
|
+
* Replaces the input-prompt-in-sidebar implementation from #906 (PR
|
|
23959
|
+
* feedback: cramped, no useful navigation, hotkeys invisible).
|
|
23960
|
+
*
|
|
23961
|
+
* Display states:
|
|
23962
|
+
* - loading : spinner + "generating changelog vs main…"
|
|
23963
|
+
* - ready : full text with scroll, header showing branch + base +
|
|
23964
|
+
* cache age, footer hints driven by the keymap
|
|
23965
|
+
* - error : error message + "press r to retry"
|
|
23966
|
+
*
|
|
23967
|
+
* View-local bindings (also reflected in footer hints + help):
|
|
23968
|
+
* - j/k scroll line
|
|
23969
|
+
* - pgup/pgdn scroll page
|
|
23970
|
+
* - y yank to clipboard
|
|
23971
|
+
* - E open in $EDITOR (write-back updates view + cache)
|
|
23972
|
+
* - c create-PR seeded with this content
|
|
23973
|
+
* - r regenerate (force-refresh, skip cache)
|
|
23974
|
+
* - </Esc pop back to prior view
|
|
23975
|
+
*
|
|
23976
|
+
* Caching: state.changelogCache is keyed by branch name. Re-entering
|
|
23977
|
+
* the view for the same branch hits the cache (no LLM call); switching
|
|
23978
|
+
* branches naturally produces a fresh generation. `r` is the explicit
|
|
23979
|
+
* "I want fresh output right now" knob.
|
|
23980
|
+
*/
|
|
23981
|
+
/**
|
|
23982
|
+
* Pluralization-free relative-time string for cache age. Coarse on
|
|
23983
|
+
* purpose — exact seconds don't help, but "5 minutes ago" vs "2 hours
|
|
23984
|
+
* ago" tells the user whether the cached content might be stale.
|
|
23985
|
+
*/
|
|
23986
|
+
function formatCacheAge(generatedAt, now) {
|
|
23987
|
+
const diffMs = Math.max(0, now - generatedAt);
|
|
23988
|
+
const sec = Math.floor(diffMs / 1000);
|
|
23989
|
+
if (sec < 5)
|
|
23990
|
+
return 'just now';
|
|
23991
|
+
if (sec < 60)
|
|
23992
|
+
return `${sec}s ago`;
|
|
23993
|
+
const min = Math.floor(sec / 60);
|
|
23994
|
+
if (min < 60)
|
|
23995
|
+
return `${min}m ago`;
|
|
23996
|
+
const hr = Math.floor(min / 60);
|
|
23997
|
+
if (hr < 24)
|
|
23998
|
+
return `${hr}h ago`;
|
|
23999
|
+
const day = Math.floor(hr / 24);
|
|
24000
|
+
return `${day}d ago`;
|
|
24001
|
+
}
|
|
24002
|
+
function renderChangelogSurface(h, components, state, _context, _contextStatus, bodyRows, width, theme) {
|
|
24003
|
+
const { Box, Text } = components;
|
|
24004
|
+
const focused = state.focus === 'commits';
|
|
24005
|
+
const view = state.changelogView;
|
|
24006
|
+
// Reserve rows for the header (1) + cache hint line (1) + 1 for
|
|
24007
|
+
// borders. Body fills the rest. Min of 4 so even ultra-short terminals
|
|
24008
|
+
// don't collapse to negative space.
|
|
24009
|
+
const listRows = Math.max(4, bodyRows - 3);
|
|
24010
|
+
const maxLineWidth = Math.max(20, width - 4);
|
|
24011
|
+
const headerLeft = view.branch
|
|
24012
|
+
? `Changelog: ${view.branch}${view.baseLabel ? ` (${view.baseLabel})` : ''}`
|
|
24013
|
+
: 'Changelog';
|
|
24014
|
+
let headerRight = '';
|
|
24015
|
+
let lines;
|
|
24016
|
+
if (view.status === 'loading') {
|
|
24017
|
+
headerRight = 'generating…';
|
|
24018
|
+
lines = [
|
|
24019
|
+
h(Text, { key: 'changelog-loading', dimColor: true }, `Generating changelog ${view.baseLabel ? `(${view.baseLabel})` : ''}…`),
|
|
24020
|
+
h(Text, { key: 'changelog-loading-hint', dimColor: true }, ''),
|
|
24021
|
+
h(Text, { key: 'changelog-loading-hint-2', dimColor: true }, 'Esc cancels and returns to the previous view.'),
|
|
24022
|
+
];
|
|
24023
|
+
}
|
|
24024
|
+
else if (view.status === 'error') {
|
|
24025
|
+
headerRight = 'error';
|
|
24026
|
+
lines = [
|
|
24027
|
+
h(Text, { key: 'changelog-error', color: 'red' }, `Changelog generation failed.`),
|
|
24028
|
+
h(Text, { key: 'changelog-error-msg', dimColor: true }, view.error || 'No additional detail.'),
|
|
24029
|
+
h(Text, { key: 'changelog-error-hint', dimColor: true }, ''),
|
|
24030
|
+
h(Text, { key: 'changelog-error-retry', dimColor: true }, 'Press `r` to retry, `<` / Esc to go back.'),
|
|
24031
|
+
];
|
|
24032
|
+
}
|
|
24033
|
+
else if (view.status === 'ready' && view.text) {
|
|
24034
|
+
const allLines = view.text.split('\n');
|
|
24035
|
+
const totalLines = allLines.length;
|
|
24036
|
+
const scrollOffset = Math.min(view.scrollOffset, Math.max(0, totalLines - 1));
|
|
24037
|
+
const visible = allLines.slice(scrollOffset, scrollOffset + listRows);
|
|
24038
|
+
const cached = view.branch ? state.changelogCache[view.branch] : undefined;
|
|
24039
|
+
const ageHint = cached ? formatCacheAge(cached.generatedAt, Date.now()) : 'just now';
|
|
24040
|
+
headerRight = `${scrollOffset + 1}–${Math.min(totalLines, scrollOffset + listRows)} / ${totalLines} · ${ageHint}`;
|
|
24041
|
+
lines = visible.length === 0
|
|
24042
|
+
? [h(Text, { key: 'changelog-empty', dimColor: true }, '(empty changelog)')]
|
|
24043
|
+
: visible.map((line, offset) => h(Text, {
|
|
24044
|
+
key: `changelog-line-${scrollOffset + offset}`,
|
|
24045
|
+
dimColor: false,
|
|
24046
|
+
}, truncateCells(line || ' ', maxLineWidth)));
|
|
24047
|
+
}
|
|
24048
|
+
else {
|
|
24049
|
+
// 'idle' — view was pushed but loading hasn't started yet. Should
|
|
24050
|
+
// be a single-frame transient; we render the same loading copy so
|
|
24051
|
+
// there's no jarring "empty" frame.
|
|
24052
|
+
headerRight = '';
|
|
24053
|
+
lines = [
|
|
24054
|
+
h(Text, { key: 'changelog-idle', dimColor: true }, 'Preparing changelog…'),
|
|
24055
|
+
];
|
|
24056
|
+
}
|
|
24057
|
+
return h(Box, {
|
|
24058
|
+
borderColor: focusBorderColor(theme, focused),
|
|
24059
|
+
borderStyle: theme.borderStyle,
|
|
24060
|
+
flexDirection: 'column',
|
|
24061
|
+
flexShrink: 0,
|
|
24062
|
+
paddingX: 1,
|
|
24063
|
+
width,
|
|
24064
|
+
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle(headerLeft, focused)), h(Text, { dimColor: true }, headerRight)), ...lines);
|
|
24065
|
+
}
|
|
24066
|
+
|
|
23495
24067
|
/**
|
|
23496
24068
|
* Compose surface — the in-TUI commit-message composer. Combines a
|
|
23497
24069
|
* summary line, a body field, and a state-line footer; an inline
|
|
@@ -25495,6 +26067,9 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
|
|
|
25495
26067
|
if (state.activeView === 'conflicts') {
|
|
25496
26068
|
return renderConflictsSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
25497
26069
|
}
|
|
26070
|
+
if (state.activeView === 'changelog') {
|
|
26071
|
+
return renderChangelogSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
26072
|
+
}
|
|
25498
26073
|
return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits);
|
|
25499
26074
|
}
|
|
25500
26075
|
|
|
@@ -27349,6 +27924,292 @@ function LogInkApp(deps) {
|
|
|
27349
27924
|
});
|
|
27350
27925
|
dispatch({ type: 'setStatus', value: result.message });
|
|
27351
27926
|
}, [dispatch]);
|
|
27927
|
+
// `C` keystroke handler — start the create-pull-request flow. Resolves
|
|
27928
|
+
// the head + base branches from the live context, runs
|
|
27929
|
+
// `coco changelog --branch <base>` (via `runPullRequestBodyWorkflow`)
|
|
27930
|
+
// to seed a title + body, then opens a multi-line input prompt
|
|
27931
|
+
// pre-filled with that content for the user to edit before submission.
|
|
27932
|
+
//
|
|
27933
|
+
// On submit, the workflow handler `'create-pr'` parses the prompt
|
|
27934
|
+
// value (line 1 = title, lines 2+ = body) and runs
|
|
27935
|
+
// `createPullRequest({ base, head, title, body })`. If anything in the
|
|
27936
|
+
// pre-flight goes sideways (no current branch, no provider, gh CLI
|
|
27937
|
+
// missing) we surface the failure on the status line and skip the
|
|
27938
|
+
// prompt entirely — better than opening a prompt the user can't
|
|
27939
|
+
// actually submit successfully.
|
|
27940
|
+
const startCreatePullRequest = React.useCallback(async () => {
|
|
27941
|
+
const head = context.branches?.currentBranch || context.provider?.currentBranch;
|
|
27942
|
+
if (!head) {
|
|
27943
|
+
dispatch({ type: 'setStatus', value: 'No current branch to create a PR from.' });
|
|
27944
|
+
return;
|
|
27945
|
+
}
|
|
27946
|
+
const defaultBranch = context.provider?.repository.defaultBranch;
|
|
27947
|
+
if (!defaultBranch) {
|
|
27948
|
+
dispatch({
|
|
27949
|
+
type: 'setStatus',
|
|
27950
|
+
value: 'No default branch detected. Set origin/HEAD or ensure main/master exists locally.',
|
|
27951
|
+
});
|
|
27952
|
+
return;
|
|
27953
|
+
}
|
|
27954
|
+
if (head === defaultBranch) {
|
|
27955
|
+
dispatch({ type: 'setStatus', value: `Current branch is ${defaultBranch}; check out a feature branch first.` });
|
|
27956
|
+
return;
|
|
27957
|
+
}
|
|
27958
|
+
if (context.pullRequest?.currentPullRequest || context.provider?.currentPullRequest) {
|
|
27959
|
+
const existing = context.pullRequest?.currentPullRequest || context.provider?.currentPullRequest;
|
|
27960
|
+
dispatch({
|
|
27961
|
+
type: 'setStatus',
|
|
27962
|
+
value: existing
|
|
27963
|
+
? `PR #${existing.number} already open for ${head}. Use the PR view to manage it.`
|
|
27964
|
+
: `A pull request is already open for ${head}.`,
|
|
27965
|
+
});
|
|
27966
|
+
return;
|
|
27967
|
+
}
|
|
27968
|
+
dispatch({ type: 'setStatus', value: `generating PR body from changelog (vs ${defaultBranch})…` });
|
|
27969
|
+
const body = await runPullRequestBodyWorkflow({ baseBranch: defaultBranch });
|
|
27970
|
+
// Fallback shape when the changelog generation fails — open the
|
|
27971
|
+
// prompt with empty title + body rather than aborting, so the user
|
|
27972
|
+
// can still author the PR manually. The status line surfaces why
|
|
27973
|
+
// we couldn't pre-fill.
|
|
27974
|
+
const initialTitle = body.title || head.replace(/^(feat|fix|chore|docs|refactor|test)\//, '').replace(/[-_]/g, ' ');
|
|
27975
|
+
const initialBody = body.body || '';
|
|
27976
|
+
const initial = initialBody ? `${initialTitle}\n\n${initialBody}` : initialTitle;
|
|
27977
|
+
if (!body.ok) {
|
|
27978
|
+
dispatch({ type: 'setStatus', value: `PR body generation failed: ${body.message}. Edit manually.` });
|
|
27979
|
+
}
|
|
27980
|
+
else {
|
|
27981
|
+
dispatch({ type: 'setStatus', value: 'PR body drafted — review and Ctrl+D to submit.' });
|
|
27982
|
+
}
|
|
27983
|
+
dispatch({
|
|
27984
|
+
type: 'openInputPrompt',
|
|
27985
|
+
kind: 'create-pr',
|
|
27986
|
+
label: `Create PR: ${head} → ${defaultBranch} (line 1 title · rest body · Enter newline · Ctrl+D submit)`,
|
|
27987
|
+
initial,
|
|
27988
|
+
multiline: true,
|
|
27989
|
+
});
|
|
27990
|
+
}, [
|
|
27991
|
+
context.branches?.currentBranch,
|
|
27992
|
+
context.provider?.currentBranch,
|
|
27993
|
+
context.provider?.currentPullRequest,
|
|
27994
|
+
context.provider?.repository.defaultBranch,
|
|
27995
|
+
context.pullRequest?.currentPullRequest,
|
|
27996
|
+
dispatch,
|
|
27997
|
+
]);
|
|
27998
|
+
// Copy an arbitrary string to the system clipboard. Distinct from
|
|
27999
|
+
// `yankFromActiveView` which derives the value from the current view
|
|
28000
|
+
// — this one takes the value as an explicit event payload, used by
|
|
28001
|
+
// the changelog view's `y` keystroke (and a candidate for future
|
|
28002
|
+
// "copy this" surfaces). Surfaces a status confirming what landed
|
|
28003
|
+
// in clipboard.
|
|
28004
|
+
const yankText = React.useCallback(async (value, label) => {
|
|
28005
|
+
const clipboard = clipboardRunner || defaultClipboardRunner;
|
|
28006
|
+
if (!value) {
|
|
28007
|
+
dispatch({ type: 'setStatus', value: `Nothing to copy — ${label} is empty.` });
|
|
28008
|
+
return;
|
|
28009
|
+
}
|
|
28010
|
+
try {
|
|
28011
|
+
await clipboard(value);
|
|
28012
|
+
dispatch({ type: 'setStatus', value: `Copied ${label} to clipboard.` });
|
|
28013
|
+
}
|
|
28014
|
+
catch (error) {
|
|
28015
|
+
dispatch({
|
|
28016
|
+
type: 'setStatus',
|
|
28017
|
+
value: `Copy failed (${label}): ${error.message}`,
|
|
28018
|
+
});
|
|
28019
|
+
}
|
|
28020
|
+
}, [clipboardRunner, dispatch]);
|
|
28021
|
+
// `L` keystroke handler — generate (or recall from cache) a changelog
|
|
28022
|
+
// for the current branch and push the dedicated `changelog` surface
|
|
28023
|
+
// to display it. The view renders the full text in the main panel
|
|
28024
|
+
// (not cramped into an input prompt), with its own keymap for scroll,
|
|
28025
|
+
// yank, $EDITOR, create-PR, and regenerate.
|
|
28026
|
+
//
|
|
28027
|
+
// Caching: `state.changelogCache` is keyed by branch name. On `L`,
|
|
28028
|
+
// we check the cache first and reuse if hit (no LLM call); the user
|
|
28029
|
+
// presses `r` from inside the view to force a regenerate. Switching
|
|
28030
|
+
// branches naturally produces a fresh generation since the cache key
|
|
28031
|
+
// changes.
|
|
28032
|
+
//
|
|
28033
|
+
// Surface lifecycle: we push the `changelog` view BEFORE awaiting the
|
|
28034
|
+
// workflow, so the user sees a loading state instead of a blank
|
|
28035
|
+
// history view while the LLM runs. On error, we keep the view pushed
|
|
28036
|
+
// and render the error there (with `r` to retry) instead of bailing
|
|
28037
|
+
// back to history with a status-line message that may scroll past.
|
|
28038
|
+
const startChangelogView = React.useCallback(async (options = {}) => {
|
|
28039
|
+
const head = context.branches?.currentBranch || context.provider?.currentBranch;
|
|
28040
|
+
if (!head) {
|
|
28041
|
+
dispatch({ type: 'setStatus', value: 'No current branch — check out a branch first.' });
|
|
28042
|
+
return;
|
|
28043
|
+
}
|
|
28044
|
+
const defaultBranch = context.provider?.repository.defaultBranch;
|
|
28045
|
+
// The changelog command will fall back to its own defaults when no
|
|
28046
|
+
// branch arg is passed, but being explicit about the base is more
|
|
28047
|
+
// honest about what the user is seeing. With the local default-
|
|
28048
|
+
// branch fallback in providerData (#912), `defaultBranch` is
|
|
28049
|
+
// populated even for non-GitHub / offline scenarios — we only fall
|
|
28050
|
+
// through to `--since-last-tag` when truly nothing resolves.
|
|
28051
|
+
const argv = defaultBranch && head !== defaultBranch
|
|
28052
|
+
? { branch: defaultBranch }
|
|
28053
|
+
: { sinceLastTag: true };
|
|
28054
|
+
const baseLabel = defaultBranch && head !== defaultBranch
|
|
28055
|
+
? `vs ${defaultBranch}`
|
|
28056
|
+
: 'since last tag';
|
|
28057
|
+
// Cache hit — skip the LLM, push view with ready content. The
|
|
28058
|
+
// generated-at timestamp on the cache entry drives the "(cached, N
|
|
28059
|
+
// ago)" hint in the header, so the user knows whether to press `r`.
|
|
28060
|
+
const cached = !options.force ? state.changelogCache[head] : undefined;
|
|
28061
|
+
if (cached) {
|
|
28062
|
+
dispatch({ type: 'pushView', value: 'changelog' });
|
|
28063
|
+
dispatch({
|
|
28064
|
+
type: 'setChangelogReady',
|
|
28065
|
+
branch: head,
|
|
28066
|
+
baseLabel: cached.baseLabel,
|
|
28067
|
+
text: cached.text,
|
|
28068
|
+
});
|
|
28069
|
+
dispatch({
|
|
28070
|
+
type: 'setStatus',
|
|
28071
|
+
value: `Changelog loaded from cache (${cached.baseLabel}). r to regenerate.`,
|
|
28072
|
+
});
|
|
28073
|
+
return;
|
|
28074
|
+
}
|
|
28075
|
+
// No cache (or force=true via `r`) — push view with loading state,
|
|
28076
|
+
// then run the workflow.
|
|
28077
|
+
dispatch({ type: 'pushView', value: 'changelog' });
|
|
28078
|
+
dispatch({ type: 'setChangelogLoading', branch: head, baseLabel });
|
|
28079
|
+
dispatch({ type: 'setStatus', value: `generating changelog (${baseLabel})…` });
|
|
28080
|
+
const result = await runChangelogTextWorkflow(argv);
|
|
28081
|
+
if (!result.ok || !result.text) {
|
|
28082
|
+
dispatch({
|
|
28083
|
+
type: 'setChangelogError',
|
|
28084
|
+
branch: head,
|
|
28085
|
+
baseLabel,
|
|
28086
|
+
error: result.message,
|
|
28087
|
+
});
|
|
28088
|
+
dispatch({ type: 'setStatus', value: `Changelog failed: ${result.message}` });
|
|
28089
|
+
return;
|
|
28090
|
+
}
|
|
28091
|
+
dispatch({
|
|
28092
|
+
type: 'setChangelogReady',
|
|
28093
|
+
branch: head,
|
|
28094
|
+
baseLabel,
|
|
28095
|
+
text: result.text,
|
|
28096
|
+
});
|
|
28097
|
+
dispatch({
|
|
28098
|
+
type: 'setStatus',
|
|
28099
|
+
value: 'Changelog ready — y yank · E $EDITOR · c PR · r regen · < back.',
|
|
28100
|
+
});
|
|
28101
|
+
}, [
|
|
28102
|
+
context.branches?.currentBranch,
|
|
28103
|
+
context.provider?.currentBranch,
|
|
28104
|
+
context.provider?.repository.defaultBranch,
|
|
28105
|
+
dispatch,
|
|
28106
|
+
state.changelogCache,
|
|
28107
|
+
]);
|
|
28108
|
+
// `r` keystroke inside the changelog view — re-run generation
|
|
28109
|
+
// ignoring any cached result. Thin wrapper since the underlying
|
|
28110
|
+
// logic in `startChangelogView` already supports the force path.
|
|
28111
|
+
const regenerateChangelog = React.useCallback(() => {
|
|
28112
|
+
void startChangelogView({ force: true });
|
|
28113
|
+
}, [startChangelogView]);
|
|
28114
|
+
// `y` keystroke inside the changelog view — yank the current text
|
|
28115
|
+
// to the system clipboard. Pulled from view state rather than from
|
|
28116
|
+
// wherever the cursor is (no per-row selection on this surface).
|
|
28117
|
+
const yankChangelog = React.useCallback(() => {
|
|
28118
|
+
const text = state.changelogView.text;
|
|
28119
|
+
if (!text) {
|
|
28120
|
+
dispatch({ type: 'setStatus', value: 'No changelog text to copy.' });
|
|
28121
|
+
return;
|
|
28122
|
+
}
|
|
28123
|
+
void yankText(text, 'changelog');
|
|
28124
|
+
}, [dispatch, state.changelogView.text, yankText]);
|
|
28125
|
+
// `E` keystroke inside the changelog view — open the current text in
|
|
28126
|
+
// $EDITOR / $VISUAL, read it back, update view + cache. Mirrors the
|
|
28127
|
+
// compose `E` flow (#913) but on the changelog-view state slice.
|
|
28128
|
+
// After save, `setChangelogText` updates both view and cache so the
|
|
28129
|
+
// edits persist across view re-entry.
|
|
28130
|
+
const openChangelogInEditor = React.useCallback(() => {
|
|
28131
|
+
const current = state.changelogView.text;
|
|
28132
|
+
if (current === undefined) {
|
|
28133
|
+
dispatch({ type: 'setStatus', value: 'Changelog not loaded yet — wait for generation.' });
|
|
28134
|
+
return;
|
|
28135
|
+
}
|
|
28136
|
+
let dir;
|
|
28137
|
+
try {
|
|
28138
|
+
dir = mkdtempSync(path$1.join(tmpdir$1(), 'coco-changelog-'));
|
|
28139
|
+
}
|
|
28140
|
+
catch (error) {
|
|
28141
|
+
dispatch({
|
|
28142
|
+
type: 'setStatus',
|
|
28143
|
+
value: `Failed to create temp file for editor: ${error.message}`,
|
|
28144
|
+
});
|
|
28145
|
+
return;
|
|
28146
|
+
}
|
|
28147
|
+
const file = path$1.join(dir, 'CHANGELOG.md');
|
|
28148
|
+
try {
|
|
28149
|
+
writeFileSync(file, current, 'utf8');
|
|
28150
|
+
}
|
|
28151
|
+
catch (error) {
|
|
28152
|
+
dispatch({
|
|
28153
|
+
type: 'setStatus',
|
|
28154
|
+
value: `Failed to seed temp file: ${error.message}`,
|
|
28155
|
+
});
|
|
28156
|
+
try {
|
|
28157
|
+
rmSync(dir, { recursive: true, force: true });
|
|
28158
|
+
}
|
|
28159
|
+
catch { /* ignore */ }
|
|
28160
|
+
return;
|
|
28161
|
+
}
|
|
28162
|
+
const editorEnv = process.env.VISUAL || process.env.EDITOR || 'vi';
|
|
28163
|
+
const editorArgs = editorEnv.trim().split(/\s+/).filter(Boolean);
|
|
28164
|
+
const editor = editorArgs[0] || 'vi';
|
|
28165
|
+
const editorPrefixArgs = editorArgs.slice(1);
|
|
28166
|
+
const out = process.stdout;
|
|
28167
|
+
const stdin = process.stdin;
|
|
28168
|
+
const ENTER_ALT = '\x1b[?1049h';
|
|
28169
|
+
const EXIT_ALT = '\x1b[?1049l';
|
|
28170
|
+
const SHOW_CURSOR = '\x1b[?25h';
|
|
28171
|
+
const HIDE_CURSOR = '\x1b[?25l';
|
|
28172
|
+
let editorOk = false;
|
|
28173
|
+
try {
|
|
28174
|
+
stdin.setRawMode?.(false);
|
|
28175
|
+
out.write(`${SHOW_CURSOR}${EXIT_ALT}`);
|
|
28176
|
+
const result = spawnSync(editor, [...editorPrefixArgs, file], { stdio: 'inherit' });
|
|
28177
|
+
if (result.error) {
|
|
28178
|
+
dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}` });
|
|
28179
|
+
}
|
|
28180
|
+
else if (result.signal) {
|
|
28181
|
+
dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}` });
|
|
28182
|
+
}
|
|
28183
|
+
else if (typeof result.status === 'number' && result.status !== 0) {
|
|
28184
|
+
dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}` });
|
|
28185
|
+
}
|
|
28186
|
+
else {
|
|
28187
|
+
editorOk = true;
|
|
28188
|
+
}
|
|
28189
|
+
}
|
|
28190
|
+
finally {
|
|
28191
|
+
out.write(`${ENTER_ALT}${HIDE_CURSOR}`);
|
|
28192
|
+
stdin.setRawMode?.(true);
|
|
28193
|
+
resumeRef?.current?.();
|
|
28194
|
+
}
|
|
28195
|
+
if (editorOk) {
|
|
28196
|
+
try {
|
|
28197
|
+
const content = readFileSync$1(file, 'utf8');
|
|
28198
|
+
dispatch({ type: 'setChangelogText', text: content });
|
|
28199
|
+
dispatch({ type: 'setStatus', value: 'Changelog updated from editor.' });
|
|
28200
|
+
}
|
|
28201
|
+
catch (error) {
|
|
28202
|
+
dispatch({
|
|
28203
|
+
type: 'setStatus',
|
|
28204
|
+
value: `Failed to read back edited changelog: ${error.message}`,
|
|
28205
|
+
});
|
|
28206
|
+
}
|
|
28207
|
+
}
|
|
28208
|
+
try {
|
|
28209
|
+
rmSync(dir, { recursive: true, force: true });
|
|
28210
|
+
}
|
|
28211
|
+
catch { /* ignore */ }
|
|
28212
|
+
}, [dispatch, resumeRef, state.changelogView.text]);
|
|
27352
28213
|
// Open a file in $EDITOR (or $VISUAL) by suspending Ink's hold on the
|
|
27353
28214
|
// terminal, spawning the editor synchronously inheriting stdio, then
|
|
27354
28215
|
// restoring the alt screen + raw mode and forcing a re-render. The
|
|
@@ -27407,6 +28268,116 @@ function LogInkApp(deps) {
|
|
|
27407
28268
|
// refresh so the file row reflects the new staged/unstaged state.
|
|
27408
28269
|
void refreshWorktreeContext({ silent: true });
|
|
27409
28270
|
}, [dispatch, refreshWorktreeContext, resumeRef]);
|
|
28271
|
+
// `E` keystroke handler — open the current commit draft in $EDITOR
|
|
28272
|
+
// (or $VISUAL), then read the file back and update the compose state
|
|
28273
|
+
// with the saved content. Mirrors the suspend → spawn → resume
|
|
28274
|
+
// terminal dance of `openInEditor` but operates on an in-memory
|
|
28275
|
+
// draft (round-tripped through a temp file) rather than a worktree
|
|
28276
|
+
// file. Useful when the inline compose editor isn't enough — long
|
|
28277
|
+
// bodies, markdown highlighting, paste from elsewhere, etc.
|
|
28278
|
+
//
|
|
28279
|
+
// Empty drafts are still written to the temp file so the user gets
|
|
28280
|
+
// a blank canvas; the read-back uses `setDraft` which splits content
|
|
28281
|
+
// into summary + body via `splitCommitDraft`, so the new content
|
|
28282
|
+
// re-populates both fields correctly regardless of which one was
|
|
28283
|
+
// active before.
|
|
28284
|
+
const openComposeInEditor = React.useCallback(() => {
|
|
28285
|
+
// Build the current draft text the same way `createManualCommit`
|
|
28286
|
+
// would — single string, blank line between summary and body.
|
|
28287
|
+
// Round-tripping through this format keeps the parse symmetric:
|
|
28288
|
+
// the editor sees what a real commit message would look like, and
|
|
28289
|
+
// `splitCommitDraft` on the way back reverses it cleanly.
|
|
28290
|
+
const composeState = state.commitCompose;
|
|
28291
|
+
const draft = formatCommitComposeMessage(composeState.summary, composeState.body);
|
|
28292
|
+
// Temp dir + file. mkdtemp is cleaned up at the end regardless of
|
|
28293
|
+
// editor success/failure (`finally` block below). `.md` extension
|
|
28294
|
+
// helps editors pick up markdown highlighting — most commit-
|
|
28295
|
+
// message workflows treat the body as markdown-ish.
|
|
28296
|
+
let dir;
|
|
28297
|
+
try {
|
|
28298
|
+
dir = mkdtempSync(path$1.join(tmpdir$1(), 'coco-compose-'));
|
|
28299
|
+
}
|
|
28300
|
+
catch (error) {
|
|
28301
|
+
dispatch({
|
|
28302
|
+
type: 'setStatus',
|
|
28303
|
+
value: `Failed to create temp file for editor: ${error.message}`,
|
|
28304
|
+
});
|
|
28305
|
+
return;
|
|
28306
|
+
}
|
|
28307
|
+
const file = path$1.join(dir, 'COMMIT_EDITMSG.md');
|
|
28308
|
+
try {
|
|
28309
|
+
writeFileSync(file, draft, 'utf8');
|
|
28310
|
+
}
|
|
28311
|
+
catch (error) {
|
|
28312
|
+
dispatch({
|
|
28313
|
+
type: 'setStatus',
|
|
28314
|
+
value: `Failed to seed temp file: ${error.message}`,
|
|
28315
|
+
});
|
|
28316
|
+
try {
|
|
28317
|
+
rmSync(dir, { recursive: true, force: true });
|
|
28318
|
+
}
|
|
28319
|
+
catch { /* ignore */ }
|
|
28320
|
+
return;
|
|
28321
|
+
}
|
|
28322
|
+
const editorEnv = process.env.VISUAL || process.env.EDITOR || 'vi';
|
|
28323
|
+
const editorArgs = editorEnv.trim().split(/\s+/).filter(Boolean);
|
|
28324
|
+
const editor = editorArgs[0] || 'vi';
|
|
28325
|
+
const editorPrefixArgs = editorArgs.slice(1);
|
|
28326
|
+
const out = process.stdout;
|
|
28327
|
+
const stdin = process.stdin;
|
|
28328
|
+
const ENTER_ALT = '\x1b[?1049h';
|
|
28329
|
+
const EXIT_ALT = '\x1b[?1049l';
|
|
28330
|
+
const SHOW_CURSOR = '\x1b[?25h';
|
|
28331
|
+
const HIDE_CURSOR = '\x1b[?25l';
|
|
28332
|
+
let editorOk = false;
|
|
28333
|
+
try {
|
|
28334
|
+
stdin.setRawMode?.(false);
|
|
28335
|
+
out.write(`${SHOW_CURSOR}${EXIT_ALT}`);
|
|
28336
|
+
const result = spawnSync(editor, [...editorPrefixArgs, file], { stdio: 'inherit' });
|
|
28337
|
+
if (result.error) {
|
|
28338
|
+
dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}` });
|
|
28339
|
+
}
|
|
28340
|
+
else if (result.signal) {
|
|
28341
|
+
dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}` });
|
|
28342
|
+
}
|
|
28343
|
+
else if (typeof result.status === 'number' && result.status !== 0) {
|
|
28344
|
+
dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}` });
|
|
28345
|
+
}
|
|
28346
|
+
else {
|
|
28347
|
+
editorOk = true;
|
|
28348
|
+
}
|
|
28349
|
+
}
|
|
28350
|
+
finally {
|
|
28351
|
+
out.write(`${ENTER_ALT}${HIDE_CURSOR}`);
|
|
28352
|
+
stdin.setRawMode?.(true);
|
|
28353
|
+
resumeRef?.current?.();
|
|
28354
|
+
}
|
|
28355
|
+
// Read the (possibly edited) file back and update compose state.
|
|
28356
|
+
// We only do this when the editor exited cleanly — a crash / kill
|
|
28357
|
+
// shouldn't blow away the user's draft. The setDraft action
|
|
28358
|
+
// re-splits into summary + body via splitCommitDraft.
|
|
28359
|
+
if (editorOk) {
|
|
28360
|
+
try {
|
|
28361
|
+
const content = readFileSync$1(file, 'utf8');
|
|
28362
|
+
dispatch({ type: 'commitCompose', action: { type: 'setDraft', value: content } });
|
|
28363
|
+
dispatch({ type: 'setStatus', value: 'Commit draft updated from editor.' });
|
|
28364
|
+
}
|
|
28365
|
+
catch (error) {
|
|
28366
|
+
dispatch({
|
|
28367
|
+
type: 'setStatus',
|
|
28368
|
+
value: `Failed to read back edited draft: ${error.message}`,
|
|
28369
|
+
});
|
|
28370
|
+
}
|
|
28371
|
+
}
|
|
28372
|
+
// Always clean up the temp dir — even on failure paths above. We
|
|
28373
|
+
// don't want abandoned coco-compose-* directories accumulating in
|
|
28374
|
+
// /tmp across sessions. Best-effort; ignore errors (e.g. file
|
|
28375
|
+
// already removed by the user from inside their editor).
|
|
28376
|
+
try {
|
|
28377
|
+
rmSync(dir, { recursive: true, force: true });
|
|
28378
|
+
}
|
|
28379
|
+
catch { /* ignore */ }
|
|
28380
|
+
}, [dispatch, resumeRef, state.commitCompose]);
|
|
27410
28381
|
// Resolve the destructive-action target from the live filtered+sorted
|
|
27411
28382
|
// list the user is looking at, run the action against it, surface the
|
|
27412
28383
|
// result on the status line, and silently refresh so the deleted item
|
|
@@ -27834,6 +28805,32 @@ function LogInkApp(deps) {
|
|
|
27834
28805
|
// — input prompts validate before they reach here, but the
|
|
27835
28806
|
// strategy guard stays as a defensive belt-and-suspenders since
|
|
27836
28807
|
// a future palette path could call us with a raw value.
|
|
28808
|
+
'create-pr': async () => {
|
|
28809
|
+
// The input-prompt submit handler validates non-empty title
|
|
28810
|
+
// already; this is the defensive belt-and-suspenders for
|
|
28811
|
+
// future palette callers passing in a raw payload.
|
|
28812
|
+
const text = (payload || '').trim();
|
|
28813
|
+
if (!text) {
|
|
28814
|
+
return { ok: false, message: 'Pull request title is required (first line of the prompt).' };
|
|
28815
|
+
}
|
|
28816
|
+
const lines = text.split('\n');
|
|
28817
|
+
const title = lines[0].trim();
|
|
28818
|
+
if (!title) {
|
|
28819
|
+
return { ok: false, message: 'Pull request title cannot be blank.' };
|
|
28820
|
+
}
|
|
28821
|
+
// Body: lines 2+, with the leading blank line tolerated. Empty
|
|
28822
|
+
// body is allowed — GitHub renders an empty PR body fine.
|
|
28823
|
+
const body = lines.slice(1).join('\n').replace(/^\n+/, '').trimEnd();
|
|
28824
|
+
const head = context.branches?.currentBranch || context.provider?.currentBranch;
|
|
28825
|
+
const base = context.provider?.repository.defaultBranch;
|
|
28826
|
+
if (!head) {
|
|
28827
|
+
return { ok: false, message: 'No current branch detected.' };
|
|
28828
|
+
}
|
|
28829
|
+
if (!base) {
|
|
28830
|
+
return { ok: false, message: 'No default branch detected. Configure the GitHub remote.' };
|
|
28831
|
+
}
|
|
28832
|
+
return createPullRequest({ base, head, title, body });
|
|
28833
|
+
},
|
|
27837
28834
|
'merge-pr': async () => {
|
|
27838
28835
|
const strategy = (payload || 'merge').toLowerCase();
|
|
27839
28836
|
if (!isPullRequestMergeStrategy(strategy)) {
|
|
@@ -28310,6 +29307,11 @@ function LogInkApp(deps) {
|
|
|
28310
29307
|
: state.diffSource === 'commit'
|
|
28311
29308
|
? filePreview?.hunks
|
|
28312
29309
|
: undefined,
|
|
29310
|
+
// Line count of the changelog text, used by the changelog view's
|
|
29311
|
+
// j/k/PgUp/PgDn scroll bindings to clamp `pageChangelog` deltas.
|
|
29312
|
+
// Computed from view state rather than threaded through context
|
|
29313
|
+
// because the surface owns its own content — no external loader.
|
|
29314
|
+
changelogLineCount: state.changelogView.text?.split('\n').length,
|
|
28313
29315
|
}).forEach((event) => {
|
|
28314
29316
|
if (event.type === 'exit') {
|
|
28315
29317
|
exit();
|
|
@@ -28335,6 +29337,27 @@ function LogInkApp(deps) {
|
|
|
28335
29337
|
else if (event.type === 'runAiCommitDraft') {
|
|
28336
29338
|
void runAiCommitDraft();
|
|
28337
29339
|
}
|
|
29340
|
+
else if (event.type === 'startCreatePullRequest') {
|
|
29341
|
+
void startCreatePullRequest();
|
|
29342
|
+
}
|
|
29343
|
+
else if (event.type === 'startChangelogView') {
|
|
29344
|
+
void startChangelogView();
|
|
29345
|
+
}
|
|
29346
|
+
else if (event.type === 'regenerateChangelog') {
|
|
29347
|
+
regenerateChangelog();
|
|
29348
|
+
}
|
|
29349
|
+
else if (event.type === 'yankChangelog') {
|
|
29350
|
+
yankChangelog();
|
|
29351
|
+
}
|
|
29352
|
+
else if (event.type === 'openChangelogInEditor') {
|
|
29353
|
+
openChangelogInEditor();
|
|
29354
|
+
}
|
|
29355
|
+
else if (event.type === 'openComposeInEditor') {
|
|
29356
|
+
openComposeInEditor();
|
|
29357
|
+
}
|
|
29358
|
+
else if (event.type === 'yankText') {
|
|
29359
|
+
void yankText(event.value, event.label);
|
|
29360
|
+
}
|
|
28338
29361
|
else if (event.type === 'runWorkflowAction') {
|
|
28339
29362
|
void runWorkflowAction(event.id, event.payload);
|
|
28340
29363
|
}
|