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.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.48.0";
81
+ const BUILD_VERSION = "0.49.0";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -15530,6 +15530,54 @@ async function getDefaultBranch(repository, runner) {
15530
15530
  return undefined;
15531
15531
  }
15532
15532
  }
15533
+ /**
15534
+ * Local-only fallback for the default branch — used when no GitHub
15535
+ * remote is configured, when `gh` isn't authenticated, or when
15536
+ * `gh repo view` fails (e.g. private repo we can't access, offline).
15537
+ *
15538
+ * Detection order, picking the first that resolves:
15539
+ * 1. `origin/HEAD` — the symbolic ref set by `git clone` pointing at
15540
+ * whatever the remote's default branch was at clone time. This is
15541
+ * the most authoritative local signal.
15542
+ * 2. Conventional branch names checked against local refs in order:
15543
+ * `main`, `master`, `develop`, `trunk`.
15544
+ *
15545
+ * Returns `undefined` when nothing matches — caller surfaces that as
15546
+ * "no default branch detected" without claiming any particular cause.
15547
+ *
15548
+ * Pure local-ref reads (no network) — safe to call on every overview
15549
+ * load regardless of provider state.
15550
+ */
15551
+ async function detectLocalDefaultBranch(git) {
15552
+ // origin/HEAD — set by `git clone` to track the remote's HEAD. The
15553
+ // symbolic-ref output is the full ref (refs/remotes/origin/main); we
15554
+ // strip the prefix to get just the branch name. `--short` would do it
15555
+ // too but isn't supported on older git, and the prefix is fixed-length.
15556
+ try {
15557
+ const ref = (await git.raw(['symbolic-ref', 'refs/remotes/origin/HEAD'])).trim();
15558
+ const match = ref.match(/^refs\/remotes\/origin\/(.+)$/);
15559
+ if (match) {
15560
+ return match[1];
15561
+ }
15562
+ }
15563
+ catch {
15564
+ // symbolic-ref returns non-zero when origin/HEAD doesn't exist —
15565
+ // expected on fresh repos and `git init`-only working trees. Fall
15566
+ // through to the conventional-name check.
15567
+ }
15568
+ // Conventional names — most repos follow one of these. `rev-parse
15569
+ // --verify --quiet <ref>` returns 0 + hash on hit, non-zero on miss.
15570
+ for (const candidate of ['main', 'master', 'develop', 'trunk']) {
15571
+ try {
15572
+ await git.raw(['rev-parse', '--verify', '--quiet', `refs/heads/${candidate}`]);
15573
+ return candidate;
15574
+ }
15575
+ catch {
15576
+ // Not present — try the next one.
15577
+ }
15578
+ }
15579
+ return undefined;
15580
+ }
15533
15581
  async function getCurrentPullRequest(runner) {
15534
15582
  try {
15535
15583
  return parsePullRequestJson(await runner([
@@ -15544,9 +15592,14 @@ async function getCurrentPullRequest(runner) {
15544
15592
  }
15545
15593
  }
15546
15594
  async function getProviderOverview(git, runner = defaultGhRunner) {
15547
- const [remotes, currentBranchOutput] = await Promise.all([
15595
+ const [remotes, currentBranchOutput, localDefaultBranch] = await Promise.all([
15548
15596
  git.getRemotes(true),
15549
15597
  git.raw(['branch', '--show-current']),
15598
+ // Read local default-branch signal up-front in parallel — used as
15599
+ // the fallback when gh is unavailable / unauthenticated / can't see
15600
+ // the repo. Coco aims to be platform-agnostic + work offline; the
15601
+ // GH-specific paths layer on top of this, they don't replace it.
15602
+ detectLocalDefaultBranch(git),
15550
15603
  ]);
15551
15604
  const remote = remotes.find((entry) => entry.name === 'origin') || remotes[0];
15552
15605
  const remoteUrl = remote?.refs.push || remote?.refs.fetch;
@@ -15560,7 +15613,10 @@ async function getProviderOverview(git, runner = defaultGhRunner) {
15560
15613
  const currentBranch = currentBranchOutput.trim() || undefined;
15561
15614
  if (repository.provider !== 'github') {
15562
15615
  return {
15563
- repository,
15616
+ repository: {
15617
+ ...repository,
15618
+ defaultBranch: localDefaultBranch,
15619
+ },
15564
15620
  currentBranch,
15565
15621
  authenticated: false,
15566
15622
  message: repository.message || 'Unsupported remote provider.',
@@ -15571,20 +15627,27 @@ async function getProviderOverview(git, runner = defaultGhRunner) {
15571
15627
  }
15572
15628
  catch {
15573
15629
  return {
15574
- repository,
15630
+ repository: {
15631
+ ...repository,
15632
+ defaultBranch: localDefaultBranch,
15633
+ },
15575
15634
  currentBranch,
15576
15635
  authenticated: false,
15577
15636
  message: 'GitHub CLI is missing or not authenticated.',
15578
15637
  };
15579
15638
  }
15580
- const [defaultBranch, currentPullRequest] = await Promise.all([
15639
+ const [providerDefaultBranch, currentPullRequest] = await Promise.all([
15581
15640
  getDefaultBranch(repository, runner),
15582
15641
  getCurrentPullRequest(runner),
15583
15642
  ]);
15584
15643
  return {
15585
15644
  repository: {
15586
15645
  ...repository,
15587
- defaultBranch,
15646
+ // gh's answer wins when it has one — it knows the remote's
15647
+ // current state, including custom default-branch settings the
15648
+ // local refs can't reflect. Fall back to local detection when gh
15649
+ // returns undefined (offline, private repo, transient failure).
15650
+ defaultBranch: providerDefaultBranch || localDefaultBranch,
15588
15651
  },
15589
15652
  currentBranch,
15590
15653
  currentPullRequest,
@@ -16550,6 +16613,149 @@ async function runCommitDraftWorkflow(input = {}) {
16550
16613
  }
16551
16614
  }
16552
16615
 
16616
+ function createChangelogArgv(input) {
16617
+ return {
16618
+ $0: 'coco',
16619
+ _: ['changelog'],
16620
+ interactive: false,
16621
+ verbose: true,
16622
+ version: false,
16623
+ help: false,
16624
+ mode: 'stdout',
16625
+ range: '',
16626
+ branch: '',
16627
+ tag: '',
16628
+ sinceLastTag: false,
16629
+ withDiff: false,
16630
+ onlyDiff: false,
16631
+ author: false,
16632
+ ...input,
16633
+ };
16634
+ }
16635
+ async function captureStdout(action) {
16636
+ const originalWrite = process.stdout.write.bind(process.stdout);
16637
+ let output = '';
16638
+ process.stdout.write = ((chunk, ...args) => {
16639
+ output += typeof chunk === 'string' ? chunk : chunk.toString();
16640
+ const callback = args.find((arg) => typeof arg === 'function');
16641
+ callback?.();
16642
+ return true;
16643
+ });
16644
+ try {
16645
+ await action();
16646
+ return output;
16647
+ }
16648
+ finally {
16649
+ process.stdout.write = originalWrite;
16650
+ }
16651
+ }
16652
+ /**
16653
+ * Generate a pull-request body for the current branch by running
16654
+ * `coco changelog --branch <base>` and parsing the title / content
16655
+ * out of the captured stdout.
16656
+ *
16657
+ * The changelog handler emits `${title}\n\n${content}[\n\nPart of <ticket>]`
16658
+ * (see `commands/changelog/handler.ts` line 306). We split on the first
16659
+ * blank-line boundary so the caller gets a clean title + body pair to
16660
+ * pre-fill the PR creation prompt with. Ticket footer (when present)
16661
+ * stays in the body so the resulting PR keeps the reference.
16662
+ *
16663
+ * Captures the raw stdout (rather than going through `runChangelogAction`,
16664
+ * which strips blank lines via its `compactOutputLines` filter) so the
16665
+ * title-vs-body separator survives intact.
16666
+ *
16667
+ * Returns the standard LogAiActionResult plus extracted `title` / `body`
16668
+ * fields. Falls back to undefined `title` / `body` when the changelog
16669
+ * fails or produces no parseable output; the caller is expected to
16670
+ * surface that as a prompt with empty fields rather than aborting.
16671
+ */
16672
+ async function runPullRequestBodyWorkflow(input = {}) {
16673
+ const baseBranch = input.baseBranch || 'main';
16674
+ const argv = createChangelogArgv({ branch: baseBranch });
16675
+ let raw = '';
16676
+ try {
16677
+ raw = await captureStdout(() => handler$7(argv, new Logger({
16678
+ verbose: true,
16679
+ silent: false,
16680
+ })));
16681
+ }
16682
+ catch (error) {
16683
+ return {
16684
+ ok: false,
16685
+ message: error.message,
16686
+ };
16687
+ }
16688
+ const text = raw.trim();
16689
+ if (!text) {
16690
+ return {
16691
+ ok: false,
16692
+ message: 'No changelog output produced — branch may have no commits ahead of base.',
16693
+ };
16694
+ }
16695
+ // First blank-line boundary separates title from body. Falls back to
16696
+ // "everything is the title" when no blank line is found — typical of
16697
+ // very small changesets where the changelog content collapsed to one
16698
+ // line.
16699
+ const blankIdx = text.indexOf('\n\n');
16700
+ const title = blankIdx > 0 ? text.slice(0, blankIdx).trim() : text.split('\n')[0].trim();
16701
+ const body = blankIdx > 0 ? text.slice(blankIdx + 2).trim() : '';
16702
+ // Keep the standard LogAiActionResult shape (message + telemetry
16703
+ // details + editable text) so palette callers get a consistent
16704
+ // surface. The captured telemetry lines are dropped here — the PR
16705
+ // body should be the actionable content, not the LLM trace.
16706
+ return {
16707
+ ok: true,
16708
+ message: title || 'Pull request body drafted.',
16709
+ details: [],
16710
+ editable: text,
16711
+ title,
16712
+ body,
16713
+ };
16714
+ }
16715
+ /**
16716
+ * Run `coco changelog` and return the raw captured stdout, intact —
16717
+ * blank lines preserved, no telemetry stripping. Use this when you
16718
+ * want to show or copy the changelog as the user would see it from
16719
+ * the CLI (the chromed-up `runChangelogAction` collapses blank lines
16720
+ * via `compactOutputLines` which is wrong for any UI that wants the
16721
+ * full prose output).
16722
+ *
16723
+ * The argv defaults match `createChangelogArgv` — pass overrides via
16724
+ * `input`. Common shapes:
16725
+ *
16726
+ * - { branch: 'main' } — commits on current branch vs main
16727
+ * - { sinceLastTag: true } — since last tag
16728
+ * - { tag: 'v1.0.0' } — since a specific tag
16729
+ * - { range: 'abc..def' } — between two refs
16730
+ *
16731
+ * Returns:
16732
+ * - { ok: true, message, text } on success (message = first non-blank
16733
+ * line, useful for status surface; text = full raw output)
16734
+ * - { ok: false, message } on changelog handler error or empty output
16735
+ */
16736
+ async function runChangelogTextWorkflow(input = {}) {
16737
+ const argv = createChangelogArgv(input);
16738
+ let raw = '';
16739
+ try {
16740
+ raw = await captureStdout(() => handler$7(argv, new Logger({
16741
+ verbose: true,
16742
+ silent: false,
16743
+ })));
16744
+ }
16745
+ catch (error) {
16746
+ return { ok: false, message: error.message };
16747
+ }
16748
+ const text = raw.trim();
16749
+ if (!text) {
16750
+ return {
16751
+ ok: false,
16752
+ message: 'No changelog output produced — branch may have no commits ahead of base.',
16753
+ };
16754
+ }
16755
+ const firstLine = text.split('\n').find((line) => line.trim()) || 'Changelog generated.';
16756
+ return { ok: true, message: firstLine, text };
16757
+ }
16758
+
16553
16759
  const LOG_INK_CONTEXT_KEYS = [
16554
16760
  'bisect',
16555
16761
  'branches',
@@ -17427,7 +17633,14 @@ const LOG_INK_KEY_BINDINGS = [
17427
17633
  id: 'editCommit',
17428
17634
  keys: ['e'],
17429
17635
  label: 'edit commit',
17430
- description: 'Edit the manual commit summary or body.',
17636
+ description: 'Edit the manual commit summary or body inline.',
17637
+ contexts: ['commits'],
17638
+ },
17639
+ {
17640
+ id: 'editCommitExternal',
17641
+ keys: ['E'],
17642
+ label: 'edit in $EDITOR',
17643
+ description: 'Open the current commit draft in $EDITOR (or $VISUAL) for full editing, write-back on save.',
17431
17644
  contexts: ['commits'],
17432
17645
  },
17433
17646
  {
@@ -17670,7 +17883,7 @@ function getLogInkFooterHints(options) {
17670
17883
  }
17671
17884
  if (options.activeView === 'compose') {
17672
17885
  return {
17673
- contextual: ['e edit', 'c commit', 'I AI draft', 'gs hunks', 'esc back'],
17886
+ contextual: ['e edit', 'E $EDITOR', 'c commit', 'I AI draft', 'gs hunks', 'esc back'],
17674
17887
  global: NORMAL_GLOBAL_HINTS,
17675
17888
  };
17676
17889
  }
@@ -17738,6 +17951,12 @@ function getLogInkFooterHints(options) {
17738
17951
  global: NORMAL_GLOBAL_HINTS,
17739
17952
  };
17740
17953
  }
17954
+ if (options.activeView === 'changelog') {
17955
+ return {
17956
+ contextual: ['j/k scroll', 'pg up/dn', 'y yank', 'E $EDITOR', 'c PR', 'r regen', '< back'],
17957
+ global: NORMAL_GLOBAL_HINTS,
17958
+ };
17959
+ }
17741
17960
  if (options.compareBaseSet) {
17742
17961
  // History view with a compare base set — Enter is overridden to
17743
17962
  // open the compare diff; show the override + the bail-out key.
@@ -17970,6 +18189,10 @@ function formatSortIndicator(mode, options = {}) {
17970
18189
  return `${options.ascii ? 'v' : '▼'} ${mode}`;
17971
18190
  }
17972
18191
 
18192
+ const DEFAULT_CHANGELOG_VIEW_STATE = {
18193
+ status: 'idle',
18194
+ scrollOffset: 0,
18195
+ };
17973
18196
  const DEFAULT_LOG_INK_STATUS_FILTER_MASK = {
17974
18197
  staged: true,
17975
18198
  unstaged: true,
@@ -18310,6 +18533,8 @@ function createLogInkState(rows, options = {}) {
18310
18533
  inspectorTab: 'inspector',
18311
18534
  inspectorActionIndex: 0,
18312
18535
  bootLoading: options.bootLoading ?? false,
18536
+ changelogView: { ...DEFAULT_CHANGELOG_VIEW_STATE },
18537
+ changelogCache: {},
18313
18538
  };
18314
18539
  }
18315
18540
  function getSelectedInkCommit(state) {
@@ -18933,6 +19158,105 @@ function applyLogInkAction(state, action) {
18933
19158
  pendingKey: undefined,
18934
19159
  };
18935
19160
  }
19161
+ case 'setChangelogLoading':
19162
+ return {
19163
+ ...state,
19164
+ changelogView: {
19165
+ status: 'loading',
19166
+ branch: action.branch,
19167
+ baseLabel: action.baseLabel,
19168
+ scrollOffset: 0,
19169
+ },
19170
+ pendingKey: undefined,
19171
+ };
19172
+ case 'setChangelogReady': {
19173
+ // Cache the result so re-entry (or `c` to PR) reuses it instead of
19174
+ // re-running the LLM. Keyed by branch so a checkout naturally
19175
+ // produces a fresh generation.
19176
+ const cached = {
19177
+ text: action.text,
19178
+ baseLabel: action.baseLabel,
19179
+ generatedAt: Date.now(),
19180
+ };
19181
+ return {
19182
+ ...state,
19183
+ changelogView: {
19184
+ status: 'ready',
19185
+ text: action.text,
19186
+ branch: action.branch,
19187
+ baseLabel: action.baseLabel,
19188
+ scrollOffset: 0,
19189
+ },
19190
+ changelogCache: {
19191
+ ...state.changelogCache,
19192
+ [action.branch]: cached,
19193
+ },
19194
+ pendingKey: undefined,
19195
+ };
19196
+ }
19197
+ case 'setChangelogError':
19198
+ return {
19199
+ ...state,
19200
+ changelogView: {
19201
+ status: 'error',
19202
+ branch: action.branch,
19203
+ baseLabel: action.baseLabel,
19204
+ error: action.error,
19205
+ scrollOffset: 0,
19206
+ },
19207
+ pendingKey: undefined,
19208
+ };
19209
+ case 'setChangelogText': {
19210
+ // Used by the $EDITOR round-trip: user edits the cached text, we
19211
+ // update the view AND the cache entry so subsequent re-entry
19212
+ // reflects the edits. Branch key is taken from the current view
19213
+ // (which is what the user just edited against).
19214
+ if (state.changelogView.status !== 'ready' || !state.changelogView.branch) {
19215
+ return state;
19216
+ }
19217
+ const branch = state.changelogView.branch;
19218
+ const existing = state.changelogCache[branch];
19219
+ return {
19220
+ ...state,
19221
+ changelogView: {
19222
+ ...state.changelogView,
19223
+ text: action.text,
19224
+ },
19225
+ changelogCache: {
19226
+ ...state.changelogCache,
19227
+ [branch]: {
19228
+ text: action.text,
19229
+ baseLabel: existing?.baseLabel || state.changelogView.baseLabel || '',
19230
+ // Updated-at timestamp reflects the edit. Not the original
19231
+ // generation time — `r` (regenerate) is the explicit knob
19232
+ // for "I want fresh LLM output, not my edits".
19233
+ generatedAt: Date.now(),
19234
+ },
19235
+ },
19236
+ pendingKey: undefined,
19237
+ };
19238
+ }
19239
+ case 'pageChangelog':
19240
+ return {
19241
+ ...state,
19242
+ changelogView: {
19243
+ ...state.changelogView,
19244
+ scrollOffset: clampIndex(state.changelogView.scrollOffset + action.delta, action.lineCount),
19245
+ },
19246
+ pendingKey: undefined,
19247
+ };
19248
+ case 'clearChangelogCache': {
19249
+ // Targeted clear for a single branch, or wholesale wipe when
19250
+ // `branch` is omitted. Wholesale used on session reset / config
19251
+ // change; targeted reserved for future "this generation looks
19252
+ // wrong, drop it" UX.
19253
+ if (!action.branch) {
19254
+ return { ...state, changelogCache: {}, pendingKey: undefined };
19255
+ }
19256
+ const next = { ...state.changelogCache };
19257
+ delete next[action.branch];
19258
+ return { ...state, changelogCache: next, pendingKey: undefined };
19259
+ }
18936
19260
  default:
18937
19261
  return state;
18938
19262
  }
@@ -19474,6 +19798,17 @@ function submitInputPrompt(state) {
19474
19798
  action({ type: 'closeInputPrompt' }),
19475
19799
  ];
19476
19800
  }
19801
+ if (state.inputPrompt.kind === 'create-pr') {
19802
+ // Multi-line content: line 1 is the PR title, lines 2+ are the body
19803
+ // (leading blank line tolerated). The generic empty-value guard
19804
+ // above (line ~627) covers truly-empty submissions; the workflow
19805
+ // handler in app.ts has the belt-and-suspenders title check for
19806
+ // the "newline-then-body" edge.
19807
+ return [
19808
+ { type: 'runWorkflowAction', id: 'create-pr', payload: value },
19809
+ action({ type: 'closeInputPrompt' }),
19810
+ ];
19811
+ }
19477
19812
  const id = state.inputPrompt.kind;
19478
19813
  return [
19479
19814
  { type: 'runWorkflowAction', id, payload: value },
@@ -19870,6 +20205,46 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
19870
20205
  return [action({ type: 'setPendingConfirmation', value: 'bisect-reset' })];
19871
20206
  }
19872
20207
  }
20208
+ // Changelog view local keymap. Scoped to `activeView === 'changelog'`
20209
+ // so the letters stay free everywhere else. Bindings:
20210
+ //
20211
+ // j / k → scroll line down / up (1 line)
20212
+ // pgdn / pgup → scroll page down / up (10 lines)
20213
+ // y → yank text to clipboard
20214
+ // E → open in $EDITOR (companion to compose's `E` from #913)
20215
+ // c → create-PR seeded with this changelog
20216
+ // r → regenerate (skip cache, re-run LLM)
20217
+ //
20218
+ // Back-out is `<` / Esc handled by the global pop-view path lower
20219
+ // down. The view only renders when `state.changelogView.status`
20220
+ // is 'ready' — scroll keystrokes early-return when changelogLineCount
20221
+ // is missing so they no-op gracefully during loading / error states.
20222
+ if (state.activeView === 'changelog') {
20223
+ if (inputValue === 'j' && context.changelogLineCount) {
20224
+ return [action({ type: 'pageChangelog', delta: 1, lineCount: context.changelogLineCount })];
20225
+ }
20226
+ if (inputValue === 'k' && context.changelogLineCount) {
20227
+ return [action({ type: 'pageChangelog', delta: -1, lineCount: context.changelogLineCount })];
20228
+ }
20229
+ if (key.pageDown && context.changelogLineCount) {
20230
+ return [action({ type: 'pageChangelog', delta: 10, lineCount: context.changelogLineCount })];
20231
+ }
20232
+ if (key.pageUp && context.changelogLineCount) {
20233
+ return [action({ type: 'pageChangelog', delta: -10, lineCount: context.changelogLineCount })];
20234
+ }
20235
+ if (inputValue === 'y') {
20236
+ return [{ type: 'yankChangelog' }];
20237
+ }
20238
+ if (inputValue === 'E') {
20239
+ return [{ type: 'openChangelogInEditor' }];
20240
+ }
20241
+ if (inputValue === 'c') {
20242
+ return [{ type: 'startCreatePullRequest' }];
20243
+ }
20244
+ if (inputValue === 'r') {
20245
+ return [{ type: 'regenerateChangelog' }];
20246
+ }
20247
+ }
19873
20248
  if (inputValue === 'g') {
19874
20249
  if (state.pendingKey === 'g') {
19875
20250
  return [
@@ -20654,6 +21029,34 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
20654
21029
  if (inputValue === 'C' && state.activeView === 'conflicts') {
20655
21030
  return [action({ type: 'setStatus', value: 'Resolve all conflicts before continuing' })];
20656
21031
  }
21032
+ // Global `C` — create a pull request from the current branch. The
21033
+ // runtime callback handles pre-flight (current branch resolution,
21034
+ // provider check) and seeds the input prompt with a changelog-derived
21035
+ // title + body before handing control back to the user for editing.
21036
+ // Conflicts view handles `C` above (continue-operation). Compose view
21037
+ // gets an explicit guard — claiming the keystroke with a status
21038
+ // message — so users mid-draft don't fat-finger out of their commit
21039
+ // into a PR-creation flow. Without this guard the keystroke would
21040
+ // fall through to the generic workflow-by-key dispatch at the end of
21041
+ // this function, which would fire `create-pr` to its handler.
21042
+ if (inputValue === 'C' && state.activeView === 'compose') {
21043
+ return [action({
21044
+ type: 'setStatus',
21045
+ value: 'Finish or cancel the commit draft before creating a PR.',
21046
+ })];
21047
+ }
21048
+ if (inputValue === 'C' && state.activeView !== 'conflicts') {
21049
+ return [{ type: 'startCreatePullRequest' }];
21050
+ }
21051
+ // Global `L` — generate the changelog for the current branch and
21052
+ // push the dedicated `changelog` view. Scoped to history and branches
21053
+ // — those are the natural "where am I, what landed here recently"
21054
+ // entry points. Avoids polluting every view's global namespace; the
21055
+ // changelog is reachable from anywhere via `g L` (added in keymap).
21056
+ if (inputValue === 'L' &&
21057
+ (state.activeView === 'history' || state.activeView === 'branches')) {
21058
+ return [{ type: 'startChangelogView' }];
21059
+ }
20657
21060
  // `c` on a stash diff cherry-picks the file under the cursor —
20658
21061
  // materializes that single path from the stash into the working tree
20659
21062
  // (`git checkout <stashRef> -- <path>`). Routed through the y-confirm
@@ -20837,6 +21240,24 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
20837
21240
  events.push(action({ type: 'commitCompose', action: { type: 'setEditing', value: true } }));
20838
21241
  return events;
20839
21242
  }
21243
+ // Capital `E` — open the commit draft in $EDITOR (or $VISUAL). Companion
21244
+ // to lowercase `e` which activates inline editing inside the panel:
21245
+ // `e` for quick tweaks in-place, `E` for "I want the full power of my
21246
+ // editor — syntax highlighting, multi-line nav, paste buffers, etc."
21247
+ // The runtime callback handles the temp-file write, editor session,
21248
+ // and read-back; the input handler emits a single event the
21249
+ // dispatcher routes there. As with lowercase `e`, fires from status
21250
+ // and diff views too (auto-pushes into compose first), since those
21251
+ // are the natural entry points to commit-message work.
21252
+ if (inputValue === 'E' &&
21253
+ (state.activeView === 'status' || state.activeView === 'diff' || state.activeView === 'compose')) {
21254
+ const events = [];
21255
+ if (state.activeView !== 'compose') {
21256
+ events.push(action({ type: 'pushView', value: 'compose' }));
21257
+ }
21258
+ events.push({ type: 'openComposeInEditor' });
21259
+ return events;
21260
+ }
20840
21261
  if (inputValue === 'c' &&
20841
21262
  (state.activeView === 'status' || state.activeView === 'diff' || state.activeView === 'compose')) {
20842
21263
  const events = [];
@@ -21901,6 +22322,12 @@ function stageConflictResolved(git, path) {
21901
22322
  return runAction$1(() => git.raw(['add', '--', path]), `Staged ${path} (marked resolved)`);
21902
22323
  }
21903
22324
 
22325
+ function parseCreatedPullRequestUrl(output) {
22326
+ return output
22327
+ .split('\n')
22328
+ .map((line) => line.trim())
22329
+ .find((line) => line.startsWith('https://'));
22330
+ }
21904
22331
  async function runGhAction(runner, args, successMessage) {
21905
22332
  try {
21906
22333
  return successMessage(await runner(args));
@@ -21912,6 +22339,34 @@ async function runGhAction(runner, args, successMessage) {
21912
22339
  };
21913
22340
  }
21914
22341
  }
22342
+ function buildCreatePullRequestArgs(input) {
22343
+ const args = [
22344
+ 'pr',
22345
+ 'create',
22346
+ '--base',
22347
+ input.base,
22348
+ '--head',
22349
+ input.head,
22350
+ '--title',
22351
+ input.title,
22352
+ '--body',
22353
+ input.body,
22354
+ ];
22355
+ if (input.draft) {
22356
+ args.push('--draft');
22357
+ }
22358
+ return args;
22359
+ }
22360
+ function createPullRequest(input, runner = defaultGhRunner) {
22361
+ return runGhAction(runner, buildCreatePullRequestArgs(input), (output) => {
22362
+ const url = parseCreatedPullRequestUrl(output);
22363
+ return {
22364
+ ok: true,
22365
+ message: url ? `Created pull request: ${url}` : 'Created pull request',
22366
+ url,
22367
+ };
22368
+ });
22369
+ }
21915
22370
  function isPullRequestMergeStrategy(value) {
21916
22371
  return value === 'merge' || value === 'squash' || value === 'rebase';
21917
22372
  }
@@ -23517,6 +23972,121 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
23517
23972
  }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Branches', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
23518
23973
  }
23519
23974
 
23975
+ /**
23976
+ * Changelog surface — full-screen view that renders LLM-generated
23977
+ * release notes for the current branch. Reached via `L` from history
23978
+ * or branches; rendered as a real surface (not an input prompt) so the
23979
+ * content gets proper scroll, editing, yank, and create-PR follow-ups.
23980
+ *
23981
+ * Replaces the input-prompt-in-sidebar implementation from #906 (PR
23982
+ * feedback: cramped, no useful navigation, hotkeys invisible).
23983
+ *
23984
+ * Display states:
23985
+ * - loading : spinner + "generating changelog vs main…"
23986
+ * - ready : full text with scroll, header showing branch + base +
23987
+ * cache age, footer hints driven by the keymap
23988
+ * - error : error message + "press r to retry"
23989
+ *
23990
+ * View-local bindings (also reflected in footer hints + help):
23991
+ * - j/k scroll line
23992
+ * - pgup/pgdn scroll page
23993
+ * - y yank to clipboard
23994
+ * - E open in $EDITOR (write-back updates view + cache)
23995
+ * - c create-PR seeded with this content
23996
+ * - r regenerate (force-refresh, skip cache)
23997
+ * - </Esc pop back to prior view
23998
+ *
23999
+ * Caching: state.changelogCache is keyed by branch name. Re-entering
24000
+ * the view for the same branch hits the cache (no LLM call); switching
24001
+ * branches naturally produces a fresh generation. `r` is the explicit
24002
+ * "I want fresh output right now" knob.
24003
+ */
24004
+ /**
24005
+ * Pluralization-free relative-time string for cache age. Coarse on
24006
+ * purpose — exact seconds don't help, but "5 minutes ago" vs "2 hours
24007
+ * ago" tells the user whether the cached content might be stale.
24008
+ */
24009
+ function formatCacheAge(generatedAt, now) {
24010
+ const diffMs = Math.max(0, now - generatedAt);
24011
+ const sec = Math.floor(diffMs / 1000);
24012
+ if (sec < 5)
24013
+ return 'just now';
24014
+ if (sec < 60)
24015
+ return `${sec}s ago`;
24016
+ const min = Math.floor(sec / 60);
24017
+ if (min < 60)
24018
+ return `${min}m ago`;
24019
+ const hr = Math.floor(min / 60);
24020
+ if (hr < 24)
24021
+ return `${hr}h ago`;
24022
+ const day = Math.floor(hr / 24);
24023
+ return `${day}d ago`;
24024
+ }
24025
+ function renderChangelogSurface(h, components, state, _context, _contextStatus, bodyRows, width, theme) {
24026
+ const { Box, Text } = components;
24027
+ const focused = state.focus === 'commits';
24028
+ const view = state.changelogView;
24029
+ // Reserve rows for the header (1) + cache hint line (1) + 1 for
24030
+ // borders. Body fills the rest. Min of 4 so even ultra-short terminals
24031
+ // don't collapse to negative space.
24032
+ const listRows = Math.max(4, bodyRows - 3);
24033
+ const maxLineWidth = Math.max(20, width - 4);
24034
+ const headerLeft = view.branch
24035
+ ? `Changelog: ${view.branch}${view.baseLabel ? ` (${view.baseLabel})` : ''}`
24036
+ : 'Changelog';
24037
+ let headerRight = '';
24038
+ let lines;
24039
+ if (view.status === 'loading') {
24040
+ headerRight = 'generating…';
24041
+ lines = [
24042
+ h(Text, { key: 'changelog-loading', dimColor: true }, `Generating changelog ${view.baseLabel ? `(${view.baseLabel})` : ''}…`),
24043
+ h(Text, { key: 'changelog-loading-hint', dimColor: true }, ''),
24044
+ h(Text, { key: 'changelog-loading-hint-2', dimColor: true }, 'Esc cancels and returns to the previous view.'),
24045
+ ];
24046
+ }
24047
+ else if (view.status === 'error') {
24048
+ headerRight = 'error';
24049
+ lines = [
24050
+ h(Text, { key: 'changelog-error', color: 'red' }, `Changelog generation failed.`),
24051
+ h(Text, { key: 'changelog-error-msg', dimColor: true }, view.error || 'No additional detail.'),
24052
+ h(Text, { key: 'changelog-error-hint', dimColor: true }, ''),
24053
+ h(Text, { key: 'changelog-error-retry', dimColor: true }, 'Press `r` to retry, `<` / Esc to go back.'),
24054
+ ];
24055
+ }
24056
+ else if (view.status === 'ready' && view.text) {
24057
+ const allLines = view.text.split('\n');
24058
+ const totalLines = allLines.length;
24059
+ const scrollOffset = Math.min(view.scrollOffset, Math.max(0, totalLines - 1));
24060
+ const visible = allLines.slice(scrollOffset, scrollOffset + listRows);
24061
+ const cached = view.branch ? state.changelogCache[view.branch] : undefined;
24062
+ const ageHint = cached ? formatCacheAge(cached.generatedAt, Date.now()) : 'just now';
24063
+ headerRight = `${scrollOffset + 1}–${Math.min(totalLines, scrollOffset + listRows)} / ${totalLines} · ${ageHint}`;
24064
+ lines = visible.length === 0
24065
+ ? [h(Text, { key: 'changelog-empty', dimColor: true }, '(empty changelog)')]
24066
+ : visible.map((line, offset) => h(Text, {
24067
+ key: `changelog-line-${scrollOffset + offset}`,
24068
+ dimColor: false,
24069
+ }, truncateCells(line || ' ', maxLineWidth)));
24070
+ }
24071
+ else {
24072
+ // 'idle' — view was pushed but loading hasn't started yet. Should
24073
+ // be a single-frame transient; we render the same loading copy so
24074
+ // there's no jarring "empty" frame.
24075
+ headerRight = '';
24076
+ lines = [
24077
+ h(Text, { key: 'changelog-idle', dimColor: true }, 'Preparing changelog…'),
24078
+ ];
24079
+ }
24080
+ return h(Box, {
24081
+ borderColor: focusBorderColor(theme, focused),
24082
+ borderStyle: theme.borderStyle,
24083
+ flexDirection: 'column',
24084
+ flexShrink: 0,
24085
+ paddingX: 1,
24086
+ width,
24087
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle(headerLeft, focused)), h(Text, { dimColor: true }, headerRight)), ...lines);
24088
+ }
24089
+
23520
24090
  /**
23521
24091
  * Compose surface — the in-TUI commit-message composer. Combines a
23522
24092
  * summary line, a body field, and a state-line footer; an inline
@@ -25520,6 +26090,9 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
25520
26090
  if (state.activeView === 'conflicts') {
25521
26091
  return renderConflictsSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
25522
26092
  }
26093
+ if (state.activeView === 'changelog') {
26094
+ return renderChangelogSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
26095
+ }
25523
26096
  return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits);
25524
26097
  }
25525
26098
 
@@ -27374,6 +27947,292 @@ function LogInkApp(deps) {
27374
27947
  });
27375
27948
  dispatch({ type: 'setStatus', value: result.message });
27376
27949
  }, [dispatch]);
27950
+ // `C` keystroke handler — start the create-pull-request flow. Resolves
27951
+ // the head + base branches from the live context, runs
27952
+ // `coco changelog --branch <base>` (via `runPullRequestBodyWorkflow`)
27953
+ // to seed a title + body, then opens a multi-line input prompt
27954
+ // pre-filled with that content for the user to edit before submission.
27955
+ //
27956
+ // On submit, the workflow handler `'create-pr'` parses the prompt
27957
+ // value (line 1 = title, lines 2+ = body) and runs
27958
+ // `createPullRequest({ base, head, title, body })`. If anything in the
27959
+ // pre-flight goes sideways (no current branch, no provider, gh CLI
27960
+ // missing) we surface the failure on the status line and skip the
27961
+ // prompt entirely — better than opening a prompt the user can't
27962
+ // actually submit successfully.
27963
+ const startCreatePullRequest = React.useCallback(async () => {
27964
+ const head = context.branches?.currentBranch || context.provider?.currentBranch;
27965
+ if (!head) {
27966
+ dispatch({ type: 'setStatus', value: 'No current branch to create a PR from.' });
27967
+ return;
27968
+ }
27969
+ const defaultBranch = context.provider?.repository.defaultBranch;
27970
+ if (!defaultBranch) {
27971
+ dispatch({
27972
+ type: 'setStatus',
27973
+ value: 'No default branch detected. Set origin/HEAD or ensure main/master exists locally.',
27974
+ });
27975
+ return;
27976
+ }
27977
+ if (head === defaultBranch) {
27978
+ dispatch({ type: 'setStatus', value: `Current branch is ${defaultBranch}; check out a feature branch first.` });
27979
+ return;
27980
+ }
27981
+ if (context.pullRequest?.currentPullRequest || context.provider?.currentPullRequest) {
27982
+ const existing = context.pullRequest?.currentPullRequest || context.provider?.currentPullRequest;
27983
+ dispatch({
27984
+ type: 'setStatus',
27985
+ value: existing
27986
+ ? `PR #${existing.number} already open for ${head}. Use the PR view to manage it.`
27987
+ : `A pull request is already open for ${head}.`,
27988
+ });
27989
+ return;
27990
+ }
27991
+ dispatch({ type: 'setStatus', value: `generating PR body from changelog (vs ${defaultBranch})…` });
27992
+ const body = await runPullRequestBodyWorkflow({ baseBranch: defaultBranch });
27993
+ // Fallback shape when the changelog generation fails — open the
27994
+ // prompt with empty title + body rather than aborting, so the user
27995
+ // can still author the PR manually. The status line surfaces why
27996
+ // we couldn't pre-fill.
27997
+ const initialTitle = body.title || head.replace(/^(feat|fix|chore|docs|refactor|test)\//, '').replace(/[-_]/g, ' ');
27998
+ const initialBody = body.body || '';
27999
+ const initial = initialBody ? `${initialTitle}\n\n${initialBody}` : initialTitle;
28000
+ if (!body.ok) {
28001
+ dispatch({ type: 'setStatus', value: `PR body generation failed: ${body.message}. Edit manually.` });
28002
+ }
28003
+ else {
28004
+ dispatch({ type: 'setStatus', value: 'PR body drafted — review and Ctrl+D to submit.' });
28005
+ }
28006
+ dispatch({
28007
+ type: 'openInputPrompt',
28008
+ kind: 'create-pr',
28009
+ label: `Create PR: ${head} → ${defaultBranch} (line 1 title · rest body · Enter newline · Ctrl+D submit)`,
28010
+ initial,
28011
+ multiline: true,
28012
+ });
28013
+ }, [
28014
+ context.branches?.currentBranch,
28015
+ context.provider?.currentBranch,
28016
+ context.provider?.currentPullRequest,
28017
+ context.provider?.repository.defaultBranch,
28018
+ context.pullRequest?.currentPullRequest,
28019
+ dispatch,
28020
+ ]);
28021
+ // Copy an arbitrary string to the system clipboard. Distinct from
28022
+ // `yankFromActiveView` which derives the value from the current view
28023
+ // — this one takes the value as an explicit event payload, used by
28024
+ // the changelog view's `y` keystroke (and a candidate for future
28025
+ // "copy this" surfaces). Surfaces a status confirming what landed
28026
+ // in clipboard.
28027
+ const yankText = React.useCallback(async (value, label) => {
28028
+ const clipboard = clipboardRunner || defaultClipboardRunner;
28029
+ if (!value) {
28030
+ dispatch({ type: 'setStatus', value: `Nothing to copy — ${label} is empty.` });
28031
+ return;
28032
+ }
28033
+ try {
28034
+ await clipboard(value);
28035
+ dispatch({ type: 'setStatus', value: `Copied ${label} to clipboard.` });
28036
+ }
28037
+ catch (error) {
28038
+ dispatch({
28039
+ type: 'setStatus',
28040
+ value: `Copy failed (${label}): ${error.message}`,
28041
+ });
28042
+ }
28043
+ }, [clipboardRunner, dispatch]);
28044
+ // `L` keystroke handler — generate (or recall from cache) a changelog
28045
+ // for the current branch and push the dedicated `changelog` surface
28046
+ // to display it. The view renders the full text in the main panel
28047
+ // (not cramped into an input prompt), with its own keymap for scroll,
28048
+ // yank, $EDITOR, create-PR, and regenerate.
28049
+ //
28050
+ // Caching: `state.changelogCache` is keyed by branch name. On `L`,
28051
+ // we check the cache first and reuse if hit (no LLM call); the user
28052
+ // presses `r` from inside the view to force a regenerate. Switching
28053
+ // branches naturally produces a fresh generation since the cache key
28054
+ // changes.
28055
+ //
28056
+ // Surface lifecycle: we push the `changelog` view BEFORE awaiting the
28057
+ // workflow, so the user sees a loading state instead of a blank
28058
+ // history view while the LLM runs. On error, we keep the view pushed
28059
+ // and render the error there (with `r` to retry) instead of bailing
28060
+ // back to history with a status-line message that may scroll past.
28061
+ const startChangelogView = React.useCallback(async (options = {}) => {
28062
+ const head = context.branches?.currentBranch || context.provider?.currentBranch;
28063
+ if (!head) {
28064
+ dispatch({ type: 'setStatus', value: 'No current branch — check out a branch first.' });
28065
+ return;
28066
+ }
28067
+ const defaultBranch = context.provider?.repository.defaultBranch;
28068
+ // The changelog command will fall back to its own defaults when no
28069
+ // branch arg is passed, but being explicit about the base is more
28070
+ // honest about what the user is seeing. With the local default-
28071
+ // branch fallback in providerData (#912), `defaultBranch` is
28072
+ // populated even for non-GitHub / offline scenarios — we only fall
28073
+ // through to `--since-last-tag` when truly nothing resolves.
28074
+ const argv = defaultBranch && head !== defaultBranch
28075
+ ? { branch: defaultBranch }
28076
+ : { sinceLastTag: true };
28077
+ const baseLabel = defaultBranch && head !== defaultBranch
28078
+ ? `vs ${defaultBranch}`
28079
+ : 'since last tag';
28080
+ // Cache hit — skip the LLM, push view with ready content. The
28081
+ // generated-at timestamp on the cache entry drives the "(cached, N
28082
+ // ago)" hint in the header, so the user knows whether to press `r`.
28083
+ const cached = !options.force ? state.changelogCache[head] : undefined;
28084
+ if (cached) {
28085
+ dispatch({ type: 'pushView', value: 'changelog' });
28086
+ dispatch({
28087
+ type: 'setChangelogReady',
28088
+ branch: head,
28089
+ baseLabel: cached.baseLabel,
28090
+ text: cached.text,
28091
+ });
28092
+ dispatch({
28093
+ type: 'setStatus',
28094
+ value: `Changelog loaded from cache (${cached.baseLabel}). r to regenerate.`,
28095
+ });
28096
+ return;
28097
+ }
28098
+ // No cache (or force=true via `r`) — push view with loading state,
28099
+ // then run the workflow.
28100
+ dispatch({ type: 'pushView', value: 'changelog' });
28101
+ dispatch({ type: 'setChangelogLoading', branch: head, baseLabel });
28102
+ dispatch({ type: 'setStatus', value: `generating changelog (${baseLabel})…` });
28103
+ const result = await runChangelogTextWorkflow(argv);
28104
+ if (!result.ok || !result.text) {
28105
+ dispatch({
28106
+ type: 'setChangelogError',
28107
+ branch: head,
28108
+ baseLabel,
28109
+ error: result.message,
28110
+ });
28111
+ dispatch({ type: 'setStatus', value: `Changelog failed: ${result.message}` });
28112
+ return;
28113
+ }
28114
+ dispatch({
28115
+ type: 'setChangelogReady',
28116
+ branch: head,
28117
+ baseLabel,
28118
+ text: result.text,
28119
+ });
28120
+ dispatch({
28121
+ type: 'setStatus',
28122
+ value: 'Changelog ready — y yank · E $EDITOR · c PR · r regen · < back.',
28123
+ });
28124
+ }, [
28125
+ context.branches?.currentBranch,
28126
+ context.provider?.currentBranch,
28127
+ context.provider?.repository.defaultBranch,
28128
+ dispatch,
28129
+ state.changelogCache,
28130
+ ]);
28131
+ // `r` keystroke inside the changelog view — re-run generation
28132
+ // ignoring any cached result. Thin wrapper since the underlying
28133
+ // logic in `startChangelogView` already supports the force path.
28134
+ const regenerateChangelog = React.useCallback(() => {
28135
+ void startChangelogView({ force: true });
28136
+ }, [startChangelogView]);
28137
+ // `y` keystroke inside the changelog view — yank the current text
28138
+ // to the system clipboard. Pulled from view state rather than from
28139
+ // wherever the cursor is (no per-row selection on this surface).
28140
+ const yankChangelog = React.useCallback(() => {
28141
+ const text = state.changelogView.text;
28142
+ if (!text) {
28143
+ dispatch({ type: 'setStatus', value: 'No changelog text to copy.' });
28144
+ return;
28145
+ }
28146
+ void yankText(text, 'changelog');
28147
+ }, [dispatch, state.changelogView.text, yankText]);
28148
+ // `E` keystroke inside the changelog view — open the current text in
28149
+ // $EDITOR / $VISUAL, read it back, update view + cache. Mirrors the
28150
+ // compose `E` flow (#913) but on the changelog-view state slice.
28151
+ // After save, `setChangelogText` updates both view and cache so the
28152
+ // edits persist across view re-entry.
28153
+ const openChangelogInEditor = React.useCallback(() => {
28154
+ const current = state.changelogView.text;
28155
+ if (current === undefined) {
28156
+ dispatch({ type: 'setStatus', value: 'Changelog not loaded yet — wait for generation.' });
28157
+ return;
28158
+ }
28159
+ let dir;
28160
+ try {
28161
+ dir = fs$1.mkdtempSync(path__namespace$1.join(os$1.tmpdir(), 'coco-changelog-'));
28162
+ }
28163
+ catch (error) {
28164
+ dispatch({
28165
+ type: 'setStatus',
28166
+ value: `Failed to create temp file for editor: ${error.message}`,
28167
+ });
28168
+ return;
28169
+ }
28170
+ const file = path__namespace$1.join(dir, 'CHANGELOG.md');
28171
+ try {
28172
+ fs$1.writeFileSync(file, current, 'utf8');
28173
+ }
28174
+ catch (error) {
28175
+ dispatch({
28176
+ type: 'setStatus',
28177
+ value: `Failed to seed temp file: ${error.message}`,
28178
+ });
28179
+ try {
28180
+ fs$1.rmSync(dir, { recursive: true, force: true });
28181
+ }
28182
+ catch { /* ignore */ }
28183
+ return;
28184
+ }
28185
+ const editorEnv = process.env.VISUAL || process.env.EDITOR || 'vi';
28186
+ const editorArgs = editorEnv.trim().split(/\s+/).filter(Boolean);
28187
+ const editor = editorArgs[0] || 'vi';
28188
+ const editorPrefixArgs = editorArgs.slice(1);
28189
+ const out = process.stdout;
28190
+ const stdin = process.stdin;
28191
+ const ENTER_ALT = '\x1b[?1049h';
28192
+ const EXIT_ALT = '\x1b[?1049l';
28193
+ const SHOW_CURSOR = '\x1b[?25h';
28194
+ const HIDE_CURSOR = '\x1b[?25l';
28195
+ let editorOk = false;
28196
+ try {
28197
+ stdin.setRawMode?.(false);
28198
+ out.write(`${SHOW_CURSOR}${EXIT_ALT}`);
28199
+ const result = node_child_process.spawnSync(editor, [...editorPrefixArgs, file], { stdio: 'inherit' });
28200
+ if (result.error) {
28201
+ dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}` });
28202
+ }
28203
+ else if (result.signal) {
28204
+ dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}` });
28205
+ }
28206
+ else if (typeof result.status === 'number' && result.status !== 0) {
28207
+ dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}` });
28208
+ }
28209
+ else {
28210
+ editorOk = true;
28211
+ }
28212
+ }
28213
+ finally {
28214
+ out.write(`${ENTER_ALT}${HIDE_CURSOR}`);
28215
+ stdin.setRawMode?.(true);
28216
+ resumeRef?.current?.();
28217
+ }
28218
+ if (editorOk) {
28219
+ try {
28220
+ const content = fs$1.readFileSync(file, 'utf8');
28221
+ dispatch({ type: 'setChangelogText', text: content });
28222
+ dispatch({ type: 'setStatus', value: 'Changelog updated from editor.' });
28223
+ }
28224
+ catch (error) {
28225
+ dispatch({
28226
+ type: 'setStatus',
28227
+ value: `Failed to read back edited changelog: ${error.message}`,
28228
+ });
28229
+ }
28230
+ }
28231
+ try {
28232
+ fs$1.rmSync(dir, { recursive: true, force: true });
28233
+ }
28234
+ catch { /* ignore */ }
28235
+ }, [dispatch, resumeRef, state.changelogView.text]);
27377
28236
  // Open a file in $EDITOR (or $VISUAL) by suspending Ink's hold on the
27378
28237
  // terminal, spawning the editor synchronously inheriting stdio, then
27379
28238
  // restoring the alt screen + raw mode and forcing a re-render. The
@@ -27432,6 +28291,116 @@ function LogInkApp(deps) {
27432
28291
  // refresh so the file row reflects the new staged/unstaged state.
27433
28292
  void refreshWorktreeContext({ silent: true });
27434
28293
  }, [dispatch, refreshWorktreeContext, resumeRef]);
28294
+ // `E` keystroke handler — open the current commit draft in $EDITOR
28295
+ // (or $VISUAL), then read the file back and update the compose state
28296
+ // with the saved content. Mirrors the suspend → spawn → resume
28297
+ // terminal dance of `openInEditor` but operates on an in-memory
28298
+ // draft (round-tripped through a temp file) rather than a worktree
28299
+ // file. Useful when the inline compose editor isn't enough — long
28300
+ // bodies, markdown highlighting, paste from elsewhere, etc.
28301
+ //
28302
+ // Empty drafts are still written to the temp file so the user gets
28303
+ // a blank canvas; the read-back uses `setDraft` which splits content
28304
+ // into summary + body via `splitCommitDraft`, so the new content
28305
+ // re-populates both fields correctly regardless of which one was
28306
+ // active before.
28307
+ const openComposeInEditor = React.useCallback(() => {
28308
+ // Build the current draft text the same way `createManualCommit`
28309
+ // would — single string, blank line between summary and body.
28310
+ // Round-tripping through this format keeps the parse symmetric:
28311
+ // the editor sees what a real commit message would look like, and
28312
+ // `splitCommitDraft` on the way back reverses it cleanly.
28313
+ const composeState = state.commitCompose;
28314
+ const draft = formatCommitComposeMessage(composeState.summary, composeState.body);
28315
+ // Temp dir + file. mkdtemp is cleaned up at the end regardless of
28316
+ // editor success/failure (`finally` block below). `.md` extension
28317
+ // helps editors pick up markdown highlighting — most commit-
28318
+ // message workflows treat the body as markdown-ish.
28319
+ let dir;
28320
+ try {
28321
+ dir = fs$1.mkdtempSync(path__namespace$1.join(os$1.tmpdir(), 'coco-compose-'));
28322
+ }
28323
+ catch (error) {
28324
+ dispatch({
28325
+ type: 'setStatus',
28326
+ value: `Failed to create temp file for editor: ${error.message}`,
28327
+ });
28328
+ return;
28329
+ }
28330
+ const file = path__namespace$1.join(dir, 'COMMIT_EDITMSG.md');
28331
+ try {
28332
+ fs$1.writeFileSync(file, draft, 'utf8');
28333
+ }
28334
+ catch (error) {
28335
+ dispatch({
28336
+ type: 'setStatus',
28337
+ value: `Failed to seed temp file: ${error.message}`,
28338
+ });
28339
+ try {
28340
+ fs$1.rmSync(dir, { recursive: true, force: true });
28341
+ }
28342
+ catch { /* ignore */ }
28343
+ return;
28344
+ }
28345
+ const editorEnv = process.env.VISUAL || process.env.EDITOR || 'vi';
28346
+ const editorArgs = editorEnv.trim().split(/\s+/).filter(Boolean);
28347
+ const editor = editorArgs[0] || 'vi';
28348
+ const editorPrefixArgs = editorArgs.slice(1);
28349
+ const out = process.stdout;
28350
+ const stdin = process.stdin;
28351
+ const ENTER_ALT = '\x1b[?1049h';
28352
+ const EXIT_ALT = '\x1b[?1049l';
28353
+ const SHOW_CURSOR = '\x1b[?25h';
28354
+ const HIDE_CURSOR = '\x1b[?25l';
28355
+ let editorOk = false;
28356
+ try {
28357
+ stdin.setRawMode?.(false);
28358
+ out.write(`${SHOW_CURSOR}${EXIT_ALT}`);
28359
+ const result = node_child_process.spawnSync(editor, [...editorPrefixArgs, file], { stdio: 'inherit' });
28360
+ if (result.error) {
28361
+ dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}` });
28362
+ }
28363
+ else if (result.signal) {
28364
+ dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}` });
28365
+ }
28366
+ else if (typeof result.status === 'number' && result.status !== 0) {
28367
+ dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}` });
28368
+ }
28369
+ else {
28370
+ editorOk = true;
28371
+ }
28372
+ }
28373
+ finally {
28374
+ out.write(`${ENTER_ALT}${HIDE_CURSOR}`);
28375
+ stdin.setRawMode?.(true);
28376
+ resumeRef?.current?.();
28377
+ }
28378
+ // Read the (possibly edited) file back and update compose state.
28379
+ // We only do this when the editor exited cleanly — a crash / kill
28380
+ // shouldn't blow away the user's draft. The setDraft action
28381
+ // re-splits into summary + body via splitCommitDraft.
28382
+ if (editorOk) {
28383
+ try {
28384
+ const content = fs$1.readFileSync(file, 'utf8');
28385
+ dispatch({ type: 'commitCompose', action: { type: 'setDraft', value: content } });
28386
+ dispatch({ type: 'setStatus', value: 'Commit draft updated from editor.' });
28387
+ }
28388
+ catch (error) {
28389
+ dispatch({
28390
+ type: 'setStatus',
28391
+ value: `Failed to read back edited draft: ${error.message}`,
28392
+ });
28393
+ }
28394
+ }
28395
+ // Always clean up the temp dir — even on failure paths above. We
28396
+ // don't want abandoned coco-compose-* directories accumulating in
28397
+ // /tmp across sessions. Best-effort; ignore errors (e.g. file
28398
+ // already removed by the user from inside their editor).
28399
+ try {
28400
+ fs$1.rmSync(dir, { recursive: true, force: true });
28401
+ }
28402
+ catch { /* ignore */ }
28403
+ }, [dispatch, resumeRef, state.commitCompose]);
27435
28404
  // Resolve the destructive-action target from the live filtered+sorted
27436
28405
  // list the user is looking at, run the action against it, surface the
27437
28406
  // result on the status line, and silently refresh so the deleted item
@@ -27859,6 +28828,32 @@ function LogInkApp(deps) {
27859
28828
  // — input prompts validate before they reach here, but the
27860
28829
  // strategy guard stays as a defensive belt-and-suspenders since
27861
28830
  // a future palette path could call us with a raw value.
28831
+ 'create-pr': async () => {
28832
+ // The input-prompt submit handler validates non-empty title
28833
+ // already; this is the defensive belt-and-suspenders for
28834
+ // future palette callers passing in a raw payload.
28835
+ const text = (payload || '').trim();
28836
+ if (!text) {
28837
+ return { ok: false, message: 'Pull request title is required (first line of the prompt).' };
28838
+ }
28839
+ const lines = text.split('\n');
28840
+ const title = lines[0].trim();
28841
+ if (!title) {
28842
+ return { ok: false, message: 'Pull request title cannot be blank.' };
28843
+ }
28844
+ // Body: lines 2+, with the leading blank line tolerated. Empty
28845
+ // body is allowed — GitHub renders an empty PR body fine.
28846
+ const body = lines.slice(1).join('\n').replace(/^\n+/, '').trimEnd();
28847
+ const head = context.branches?.currentBranch || context.provider?.currentBranch;
28848
+ const base = context.provider?.repository.defaultBranch;
28849
+ if (!head) {
28850
+ return { ok: false, message: 'No current branch detected.' };
28851
+ }
28852
+ if (!base) {
28853
+ return { ok: false, message: 'No default branch detected. Configure the GitHub remote.' };
28854
+ }
28855
+ return createPullRequest({ base, head, title, body });
28856
+ },
27862
28857
  'merge-pr': async () => {
27863
28858
  const strategy = (payload || 'merge').toLowerCase();
27864
28859
  if (!isPullRequestMergeStrategy(strategy)) {
@@ -28335,6 +29330,11 @@ function LogInkApp(deps) {
28335
29330
  : state.diffSource === 'commit'
28336
29331
  ? filePreview?.hunks
28337
29332
  : undefined,
29333
+ // Line count of the changelog text, used by the changelog view's
29334
+ // j/k/PgUp/PgDn scroll bindings to clamp `pageChangelog` deltas.
29335
+ // Computed from view state rather than threaded through context
29336
+ // because the surface owns its own content — no external loader.
29337
+ changelogLineCount: state.changelogView.text?.split('\n').length,
28338
29338
  }).forEach((event) => {
28339
29339
  if (event.type === 'exit') {
28340
29340
  exit();
@@ -28360,6 +29360,27 @@ function LogInkApp(deps) {
28360
29360
  else if (event.type === 'runAiCommitDraft') {
28361
29361
  void runAiCommitDraft();
28362
29362
  }
29363
+ else if (event.type === 'startCreatePullRequest') {
29364
+ void startCreatePullRequest();
29365
+ }
29366
+ else if (event.type === 'startChangelogView') {
29367
+ void startChangelogView();
29368
+ }
29369
+ else if (event.type === 'regenerateChangelog') {
29370
+ regenerateChangelog();
29371
+ }
29372
+ else if (event.type === 'yankChangelog') {
29373
+ yankChangelog();
29374
+ }
29375
+ else if (event.type === 'openChangelogInEditor') {
29376
+ openChangelogInEditor();
29377
+ }
29378
+ else if (event.type === 'openComposeInEditor') {
29379
+ openComposeInEditor();
29380
+ }
29381
+ else if (event.type === 'yankText') {
29382
+ void yankText(event.value, event.label);
29383
+ }
28363
29384
  else if (event.type === 'runWorkflowAction') {
28364
29385
  void runWorkflowAction(event.id, event.payload);
28365
29386
  }