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.
@@ -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.48.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 [defaultBranch, currentPullRequest] = await Promise.all([
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
- defaultBranch,
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
  }