git-coco 0.60.0 → 0.62.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.60.0";
81
+ const BUILD_VERSION = "0.62.0";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -309,10 +309,19 @@ class LangChainExecutionError extends LangChainError {
309
309
  }
310
310
  /**
311
311
  * Authentication-related errors (missing API keys, invalid credentials, etc.)
312
+ *
313
+ * Carries `provider` + `endpoint` context so the formatter (in
314
+ * `commandExecutor`) can render provider-specific recovery hints
315
+ * ("set OPENAI_API_KEY", "run `gh auth login`", etc.) instead of the
316
+ * generic "verify your API key" copy. Mirrors the shape of
317
+ * `LangChainNetworkError` so call sites can hand the same fields to
318
+ * either constructor depending on which condition fired.
312
319
  */
313
320
  class LangChainAuthenticationError extends LangChainError {
314
- constructor(message, context) {
315
- super(message, context);
321
+ constructor(message, provider, endpoint, context) {
322
+ super(message, { ...context, provider, endpoint });
323
+ this.provider = provider;
324
+ this.endpoint = endpoint;
316
325
  }
317
326
  }
318
327
  /**
@@ -450,21 +459,27 @@ function getDefaultServiceApiKey(config) {
450
459
  const requiresAuth = provider === 'openai' || provider === 'anthropic';
451
460
  if (service.authentication.type === 'APIKey') {
452
461
  const apiKey = service.authentication.credentials?.apiKey;
462
+ // `endpoint` is optional on some service variants (Ollama / OpenAI-
463
+ // compatible) and absent on others (managed OpenAI / Anthropic).
464
+ // Read defensively so we still attach it when present.
465
+ const endpoint = service.endpoint;
453
466
  if (requiresAuth && (!apiKey || apiKey.trim() === '')) {
454
- throw new LangChainAuthenticationError(`getDefaultServiceApiKey: API key is required for ${provider} provider but not provided`, { provider, authenticationType: service.authentication.type });
467
+ throw new LangChainAuthenticationError(`getDefaultServiceApiKey: API key is required for ${provider} provider but not provided`, provider, endpoint, { authenticationType: service.authentication.type });
455
468
  }
456
469
  return apiKey || '';
457
470
  }
458
471
  if (service.authentication.type === 'OAuth') {
459
472
  const token = service.authentication.credentials?.token;
473
+ const endpoint = service.endpoint;
460
474
  if (requiresAuth && (!token || token.trim() === '')) {
461
- throw new LangChainAuthenticationError(`getDefaultServiceApiKey: OAuth token is required for ${provider} provider but not provided`, { provider, authenticationType: service.authentication.type });
475
+ throw new LangChainAuthenticationError(`getDefaultServiceApiKey: OAuth token is required for ${provider} provider but not provided`, provider, endpoint, { authenticationType: service.authentication.type });
462
476
  }
463
477
  return token || '';
464
478
  }
465
479
  if (service.authentication.type === 'None') {
466
480
  if (requiresAuth) {
467
- throw new LangChainAuthenticationError(`getDefaultServiceApiKey: ${provider} provider requires authentication but 'None' was configured`, { provider, authenticationType: service.authentication.type });
481
+ const endpoint = service.endpoint;
482
+ throw new LangChainAuthenticationError(`getDefaultServiceApiKey: ${provider} provider requires authentication but 'None' was configured`, provider, endpoint, { authenticationType: service.authentication.type });
468
483
  }
469
484
  return '';
470
485
  }
@@ -2597,18 +2612,48 @@ function formatNetworkError(error, logger) {
2597
2612
  logger.log(' • Verify the service endpoint is correct', { color: 'white' });
2598
2613
  logger.log(' • Ensure the LLM service is running and accessible', { color: 'white' });
2599
2614
  }
2615
+ logger.log(' • Run `coco doctor` to verify your configured provider + endpoint', { color: 'white' });
2600
2616
  logger.verbose(`\nOriginal error: ${error.message}`, { color: 'gray' });
2601
2617
  }
2602
2618
  /**
2603
- * Formats an authentication error with helpful information
2619
+ * Formats an authentication error with provider-aware troubleshooting.
2620
+ *
2621
+ * Pre-MEDIUM-8 the formatter was generic — "verify your API key,
2622
+ * check it hasn't expired" — because the error class didn't carry
2623
+ * any provider context. Now that `LangChainAuthenticationError`
2624
+ * carries `provider` + `endpoint` (mirroring `LangChainNetworkError`),
2625
+ * we can name the env var the user actually needs to set and route
2626
+ * Ollama / OpenAI-compatible / managed-provider users through the
2627
+ * right next step.
2604
2628
  */
2605
2629
  function formatAuthenticationError(error, logger) {
2630
+ const provider = error.provider || 'LLM service';
2631
+ const endpoint = error.endpoint;
2606
2632
  logger.log('\nFailed to execute command', { color: 'yellow' });
2607
- logger.log('\nError: Authentication failed', { color: 'red' });
2633
+ logger.log(`\nError: Authentication failed${error.provider ? ` for ${provider}` : ''}`, { color: 'red' });
2634
+ if (endpoint) {
2635
+ logger.log(` Endpoint: ${endpoint}`, { color: 'red' });
2636
+ }
2608
2637
  logger.log('\nTroubleshooting:', { color: 'cyan' });
2609
- logger.log(' • Verify your API key is correct', { color: 'white' });
2610
- logger.log(' • Check that your API key has not expired', { color: 'white' });
2611
- logger.log(' • Ensure the API key is set in your environment or config', { color: 'white' });
2638
+ logger.log(' • Verify your API key is correct and has not expired', { color: 'white' });
2639
+ // Provider-specific env var hint when we know the provider.
2640
+ if (provider === 'openai' || provider === 'OpenAI') {
2641
+ logger.log(' • Set `OPENAI_API_KEY` in your shell or `service.authentication.credentials.apiKey` in config', { color: 'white' });
2642
+ }
2643
+ else if (provider === 'anthropic' || provider === 'Anthropic') {
2644
+ logger.log(' • Set `ANTHROPIC_API_KEY` in your shell or `service.authentication.credentials.apiKey` in config', { color: 'white' });
2645
+ }
2646
+ else if (provider === 'ollama' || provider === 'Ollama') {
2647
+ logger.log(' • Ollama usually does not need a key — check `service.endpoint` and that `ollama serve` is running', { color: 'white' });
2648
+ }
2649
+ else if (provider === 'openai-compatible') {
2650
+ logger.log(' • OpenAI-compatible endpoints need both `service.endpoint` and a valid API key', { color: 'white' });
2651
+ }
2652
+ else {
2653
+ logger.log(' • Ensure the API key is set in your environment or config', { color: 'white' });
2654
+ }
2655
+ logger.log(' • Run `coco init` to (re)configure your provider + key', { color: 'white' });
2656
+ logger.log(' • Run `coco doctor` to inspect the active config sources', { color: 'white' });
2612
2657
  logger.verbose(`\nOriginal error: ${error.message}`, { color: 'gray' });
2613
2658
  }
2614
2659
  /**
@@ -3578,8 +3623,7 @@ const handler$b = async (argv, logger) => {
3578
3623
  const result = clearDiffSummaryCache(repoPath);
3579
3624
  if (!result.ok) {
3580
3625
  logger.log(chalk.red(`Failed to clear diff-summary cache at ${cachePath}`));
3581
- process.exitCode = 1;
3582
- return;
3626
+ commandExit(1, 'cache clear failed');
3583
3627
  }
3584
3628
  if (result.removed) {
3585
3629
  logger.log(chalk.green(`Cleared diff-summary cache at ${cachePath}`));
@@ -3619,8 +3663,7 @@ const handler$b = async (argv, logger) => {
3619
3663
  if (interactive) {
3620
3664
  const picked = await promptLanguageSelection(logger);
3621
3665
  if (!picked) {
3622
- process.exitCode = 1;
3623
- return;
3666
+ commandExit(1, 'cache prefetch cancelled');
3624
3667
  }
3625
3668
  resolved = picked;
3626
3669
  }
@@ -3637,7 +3680,7 @@ const handler$b = async (argv, logger) => {
3637
3680
  `${chalk.dim(`${result.alreadyCached.length} already cached`)} · ` +
3638
3681
  `${chalk.red(`${result.failed.length} failed`)}`);
3639
3682
  if (result.failed.length > 0) {
3640
- process.exitCode = 1;
3683
+ commandExit(1, `cache prefetch failed for ${result.failed.length} language(s)`);
3641
3684
  }
3642
3685
  return;
3643
3686
  }
@@ -3670,12 +3713,12 @@ const handler$b = async (argv, logger) => {
3670
3713
  }
3671
3714
  logger.log(chalk.red(`Unknown cache subcommand: ${subcommand}`));
3672
3715
  logger.log(chalk.dim('Use one of: clear, info, parsers, prefetch, clear-parsers, clear-github'));
3673
- process.exitCode = 1;
3716
+ commandExit(1, `unknown cache subcommand: ${subcommand}`);
3674
3717
  };
3675
3718
 
3676
3719
  var cache = {
3677
3720
  command: command$b,
3678
- desc: 'Manage the diff-summary cache (clear, info)',
3721
+ desc: 'Manage coco caches (clear, info, parsers, prefetch, github)',
3679
3722
  builder: builder$b,
3680
3723
  handler: commandExecutor(handler$b),
3681
3724
  };
@@ -8944,6 +8987,124 @@ async function generateAndReviewLoop({ label, factory, parser, noResult, agent,
8944
8987
  return result;
8945
8988
  }
8946
8989
 
8990
+ /**
8991
+ * Centralised glyph + label vocabulary for diagnostic / status copy.
8992
+ *
8993
+ * Before this module each surface (commandExecutor, doctor, footer,
8994
+ * cache, issues, prs, commit-hook flow) picked its own marks for
8995
+ * pass / warn / fail / info — `✓` here, `✔` there, `✖` vs `✗`. Users
8996
+ * couldn't lean on a consistent visual signal to scan output, and the
8997
+ * audit flagged it as one of the bigger inconsistencies in the
8998
+ * codebase.
8999
+ *
9000
+ * The vocabulary mirrors what Linux package managers + git-aware
9001
+ * tools converge on (`pacman`, `apt`, `nala`, `npm doctor`, etc.) —
9002
+ * green check / red fail / yellow warn / blue info. ASCII fallbacks
9003
+ * are first-class so dumb terminals (TERM=dumb / vt100) still render
9004
+ * a meaningful prefix.
9005
+ *
9006
+ * Conventions:
9007
+ * - Status glyphs (PASS / FAIL / WARN / INFO) — for diagnostic
9008
+ * output, command exit, doctor severity, footer message kinds.
9009
+ * Colour-coded variants live alongside as `*_COLORED` helpers
9010
+ * so callers can use either depending on context.
9011
+ * - Action glyphs (BULLET, ARROW) — for indented hint lines and
9012
+ * "next step" callouts.
9013
+ * - Domain glyphs (CHECK_RUN_*, DECISION_*) — keep their own
9014
+ * vocabularies (PR reviews, status checks) because their
9015
+ * semantic shape doesn't map cleanly onto pass/fail/warn/info.
9016
+ *
9017
+ * Use `pickGlyph(unicode, ascii, isAscii)` when you need to honor
9018
+ * `theme.ascii` mode in a single call site.
9019
+ */
9020
+ /**
9021
+ * Status-severity glyph set. Same vocabulary as the workstation
9022
+ * footer's `kind` field (info / warning / error / success / loading)
9023
+ * plus `pass` for the doctor / "no problem" case.
9024
+ */
9025
+ const GLYPHS = {
9026
+ pass: '✓',
9027
+ fail: '✖',
9028
+ warn: '⚠',
9029
+ info: 'ℹ',
9030
+ bullet: '•'};
9031
+ /**
9032
+ * Theme-tinted helpers for terminal output. These return chalk-wrapped
9033
+ * strings so callers don't repeat the `chalk.<color>(GLYPHS.<key>)`
9034
+ * pattern. Each maps to the canonical colour the codebase uses for
9035
+ * that severity:
9036
+ *
9037
+ * - PASS → green
9038
+ * - FAIL → red
9039
+ * - WARN → yellow
9040
+ * - INFO → blue
9041
+ *
9042
+ * Doctor's `SEVERITY_ICON` lookup is the canonical example — it now
9043
+ * delegates here so the colours stay in sync if the theme palette
9044
+ * shifts in the future.
9045
+ */
9046
+ const PASS = () => chalk.green(GLYPHS.pass);
9047
+ const FAIL = () => chalk.red(GLYPHS.fail);
9048
+ const WARN = () => chalk.yellow(GLYPHS.warn);
9049
+ const INFO = () => chalk.blue(GLYPHS.info);
9050
+
9051
+ /**
9052
+ * Maps each provider to the env var users should set + the kebab-case
9053
+ * provider label used in the recovery copy. `coco init` and `coco
9054
+ * doctor` both reference these names; keeping the lookup in one place
9055
+ * makes the messages stay aligned when a new provider lands.
9056
+ */
9057
+ const PROVIDER_ENV_VARS = {
9058
+ openai: { envVar: 'OPENAI_API_KEY', label: 'OpenAI' },
9059
+ anthropic: { envVar: 'ANTHROPIC_API_KEY', label: 'Anthropic' },
9060
+ ollama: { envVar: 'OLLAMA_API_KEY', label: 'Ollama' },
9061
+ 'openai-compatible': { envVar: 'OPENAI_API_KEY', label: 'OpenAI-compatible' },
9062
+ };
9063
+ /**
9064
+ * Print a structured "missing API key" message + exit non-zero.
9065
+ *
9066
+ * Replaces the old `No API Key found. 🗝️🚪` one-liner that used to live
9067
+ * inline in commit / changelog / recap / review handlers. Centralised
9068
+ * because:
9069
+ *
9070
+ * 1. The message names the env var the user actually needs to set
9071
+ * (different per provider) — that was the single biggest gap in
9072
+ * the prior message.
9073
+ * 2. It surfaces the configured provider + model so the user can tell
9074
+ * which of their providers tripped the check (useful when running
9075
+ * with dynamic model routing).
9076
+ * 3. It points at `coco init` and `coco doctor` as the recovery
9077
+ * paths, mirroring the discoverability cue every other modern CLI
9078
+ * uses for first-run config errors.
9079
+ *
9080
+ * Throws `CommandExitError(1)` via `commandExit` — callers do NOT need
9081
+ * to handle the return value.
9082
+ */
9083
+ function handleMissingApiKey(logger, config, options) {
9084
+ const provider = config.service?.provider || 'unknown';
9085
+ const model = config.service?.model || 'unknown';
9086
+ const providerInfo = PROVIDER_ENV_VARS[provider] || {
9087
+ envVar: 'PROVIDER_API_KEY',
9088
+ label: provider,
9089
+ };
9090
+ const lines = [
9091
+ `${FAIL()} ${chalk.bold('Missing API key')} for ${chalk.cyan(providerInfo.label)} (model: ${chalk.cyan(model)})`,
9092
+ '',
9093
+ `${chalk.bold('Next step')} — set up an API key one of these ways:`,
9094
+ ` ${chalk.dim(GLYPHS.bullet)} Run ${chalk.cyan('coco init')} to walk through provider + key setup`,
9095
+ ` ${chalk.dim(GLYPHS.bullet)} Export ${chalk.cyan(providerInfo.envVar)} in your shell`,
9096
+ ` ${chalk.dim(GLYPHS.bullet)} Add the key to ${chalk.cyan('.coco.config.json')} or ${chalk.cyan('~/.gitconfig')} (under ${chalk.cyan('[coco]')})`,
9097
+ '',
9098
+ `${chalk.dim('Run')} ${chalk.cyan('coco doctor')} ${chalk.dim('to diagnose the active config sources.')}`,
9099
+ ];
9100
+ for (const line of lines) {
9101
+ logger.log(line);
9102
+ }
9103
+ // Tag the exit message with the failing command so process supervisors
9104
+ // / CI logs can grep for it without parsing the full body.
9105
+ commandExit(1, `${options.command}: missing API key for ${providerInfo.label}`);
9106
+ }
9107
+
8947
9108
  const logSuccess = () => {
8948
9109
  console.log(chalk.green(chalk.bold('\nAll set! 🦾🤖')));
8949
9110
  };
@@ -14436,8 +14597,7 @@ const handler$a = async (argv, logger) => {
14436
14597
  commandExit(1);
14437
14598
  }
14438
14599
  if (config.service.authentication.type !== 'None' && !key) {
14439
- logger.log(`No API Key found. 🗝️🚪`, { color: 'red' });
14440
- commandExit(1);
14600
+ handleMissingApiKey(logger, config, { command: 'changelog' });
14441
14601
  }
14442
14602
  const llm = getLlm(provider, model, { ...config, service: changelogService });
14443
14603
  const summaryLlm = getLlm(provider, summaryService.model, { ...config, service: summaryService });
@@ -16333,8 +16493,7 @@ const handler$9 = async (argv, logger) => {
16333
16493
  const splitService = resolveDynamicService(config, 'commitSplit');
16334
16494
  const model = commitService.model;
16335
16495
  if (config.service.authentication.type !== 'None' && !key) {
16336
- logger.log(`No API Key found. 🗝️🚪`, { color: 'red' });
16337
- commandExit(1);
16496
+ handleMissingApiKey(logger, config, { command: 'commit' });
16338
16497
  }
16339
16498
  const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
16340
16499
  const llm = getLlm(provider, model, { ...config, service: commitService });
@@ -17064,9 +17223,9 @@ function checkProjectConfigFile(diagnostics) {
17064
17223
  }
17065
17224
 
17066
17225
  const SEVERITY_ICON = {
17067
- error: chalk.red('✖'),
17068
- warn: chalk.yellow('⚠'),
17069
- info: chalk.blue('ℹ'),
17226
+ error: FAIL(),
17227
+ warn: WARN(),
17228
+ info: INFO(),
17070
17229
  };
17071
17230
  const SEVERITY_LABEL = {
17072
17231
  error: chalk.red('error'),
@@ -17085,10 +17244,10 @@ function formatSourceInfo(sources) {
17085
17244
  for (const source of sources) {
17086
17245
  const label = SOURCE_LABELS[source.source] || source.source;
17087
17246
  if (source.path) {
17088
- lines.push(` ${chalk.green('✓')} ${label} ${chalk.dim(`(${source.path})`)}`);
17247
+ lines.push(` ${PASS()} ${label} ${chalk.dim(`(${source.path})`)}`);
17089
17248
  }
17090
17249
  else {
17091
- lines.push(` ${chalk.green('✓')} ${label}`);
17250
+ lines.push(` ${PASS()} ${label}`);
17092
17251
  }
17093
17252
  }
17094
17253
  return lines;
@@ -17125,7 +17284,7 @@ const handler$8 = async (argv, logger) => {
17125
17284
  // Run diagnostics
17126
17285
  const diagnostics = runDiagnostics(config);
17127
17286
  if (diagnostics.length === 0) {
17128
- logger.log(chalk.green('✓ No issues found. Your configuration looks good!'));
17287
+ logger.log(chalk.green(`${PASS()} No issues found. Your configuration looks good!`));
17129
17288
  return;
17130
17289
  }
17131
17290
  const errors = diagnostics.filter((d) => d.severity === 'error');
@@ -17178,7 +17337,7 @@ const handler$8 = async (argv, logger) => {
17178
17337
  const raw = JSON.parse(fs__namespace.readFileSync(configPath, 'utf-8'));
17179
17338
  for (const diagnostic of fixable) {
17180
17339
  diagnostic.autoFix(raw);
17181
- logger.log(chalk.green(` Fixed: ${diagnostic.message}`));
17340
+ logger.log(chalk.green(` ${PASS()} Fixed: ${diagnostic.message}`));
17182
17341
  }
17183
17342
  // Ensure $schema is present
17184
17343
  if (!raw.$schema) {
@@ -17197,6 +17356,15 @@ const handler$8 = async (argv, logger) => {
17197
17356
  logger.log(chalk.dim(`${fixable.length} issue(s) can be auto-fixed. Run \`coco doctor --fix\` to apply.`));
17198
17357
  }
17199
17358
  }
17359
+ // Exit non-zero when error-severity diagnostics were surfaced so CI
17360
+ // pipelines can gate on `coco doctor` without parsing its stdout.
17361
+ // Warnings + infos still exit clean — they're informational, not
17362
+ // blockers. Auto-fixed errors keep the non-zero exit so the CI run
17363
+ // surfaces "we patched something for you, please commit it" rather
17364
+ // than masquerading as a passing check.
17365
+ if (errors.length > 0) {
17366
+ commandExit(1, `${errors.length} doctor error(s)`);
17367
+ }
17200
17368
  };
17201
17369
 
17202
17370
  var doctor = {
@@ -17204,6 +17372,7 @@ var doctor = {
17204
17372
  desc: 'Check your coco configuration for common issues and suggest fixes',
17205
17373
  builder: builder$8,
17206
17374
  handler: commandExecutor(handler$8),
17375
+ options: options$8,
17207
17376
  };
17208
17377
 
17209
17378
  const command$7 = 'init';
@@ -17583,11 +17752,7 @@ const handler$7 = async (argv, logger) => {
17583
17752
  // writes the project config to X, not the launcher's cwd. The
17584
17753
  // chdir has to happen before getProjectConfigFilePath resolves
17585
17754
  // its target path (it reads process.cwd).
17586
- //
17587
- // `InitArgv` is `Argv<InitOptions>['argv']` which yargs types as a
17588
- // union including Promise — pass just the `repo` field as a plain
17589
- // object so the helper's narrow signature stays clean.
17590
- applyRepoCwd({ repo: argv.repo });
17755
+ applyRepoCwd(argv);
17591
17756
  const options = loadConfig(argv);
17592
17757
  logger.log(LOGO);
17593
17758
  let scope = options?.scope;
@@ -17722,6 +17887,44 @@ const handler$7 = async (argv, logger) => {
17722
17887
  await installCommitlintPackages(scope, logger);
17723
17888
  }
17724
17889
  logger.log(`\ninit successful! 🦾🤖🎉`, { color: 'green' });
17890
+ // Post-write verification — run the same check `coco doctor` runs
17891
+ // so the user finds out about typos / structural issues now,
17892
+ // before their first `coco commit`. Re-load from disk so we
17893
+ // verify the persisted config (not the in-memory shape we just
17894
+ // built), which catches transcription bugs in the appenders.
17895
+ try {
17896
+ const persistedConfig = loadConfig({});
17897
+ const diagnostics = runDiagnostics(persistedConfig);
17898
+ const errors = diagnostics.filter((d) => d.severity === 'error');
17899
+ const warnings = diagnostics.filter((d) => d.severity === 'warn');
17900
+ if (errors.length === 0 && warnings.length === 0) {
17901
+ logger.log(`${PASS()} Verified: no issues found in your new config.`, { color: 'green' });
17902
+ }
17903
+ else {
17904
+ if (errors.length > 0) {
17905
+ logger.log(`${FAIL()} ${errors.length} error(s) found in the persisted config:`, { color: 'red' });
17906
+ for (const diagnostic of errors) {
17907
+ logger.log(` ${chalk.red(diagnostic.message)}`);
17908
+ }
17909
+ }
17910
+ if (warnings.length > 0) {
17911
+ logger.log(`${WARN()} ${warnings.length} warning(s) found in the persisted config:`, { color: 'yellow' });
17912
+ for (const diagnostic of warnings) {
17913
+ logger.log(` ${chalk.yellow(diagnostic.message)}`);
17914
+ }
17915
+ }
17916
+ logger.log(`${chalk.dim('Run')} ${chalk.cyan('coco doctor')} ${chalk.dim('for the full diagnostic report.')}`);
17917
+ }
17918
+ }
17919
+ catch (verifyError) {
17920
+ // Verification is a polish step, not a blocker. If it crashes
17921
+ // (e.g. config file written to a path the loader can't reach
17922
+ // from the current cwd), fall through to a hint instead of
17923
+ // failing the whole init flow — the config is on disk and
17924
+ // the user can run `coco doctor` themselves.
17925
+ logger.log(`${chalk.dim('Skipped post-init verification:')} ${verifyError.message}`, { color: 'gray' });
17926
+ logger.log(`${chalk.dim('Run')} ${chalk.cyan('coco doctor')} ${chalk.dim('to verify your config manually.')}`);
17927
+ }
17725
17928
  }
17726
17929
  else {
17727
17930
  logger.log('\ninit cancelled.', { color: 'yellow' });
@@ -17763,7 +17966,7 @@ async function installCommitlintPackages(scope, logger) {
17763
17966
 
17764
17967
  var init = {
17765
17968
  command: command$7,
17766
- desc: 'install & configure coco globally or for the current project',
17969
+ desc: 'Install & configure coco globally or for the current project',
17767
17970
  builder: builder$7,
17768
17971
  handler: commandExecutor(handler$7),
17769
17972
  options: options$7,
@@ -17847,19 +18050,76 @@ async function getGitHubRepository(git) {
17847
18050
  return url ? parseGitHubRemoteUrl$1(url) : undefined;
17848
18051
  }
17849
18052
  /**
17850
- * Probe `gh auth status` and return whether the GitHub CLI is
17851
- * installed AND authenticated. Used by every data fetcher to short-
17852
- * circuit before issuing real API calls — keeps the failure-mode
17853
- * messaging consistent ("CLI missing or not authenticated") instead
17854
- * of leaking through as a generic spawn error.
18053
+ * Probe `gh auth status` and return a structured status describing
18054
+ * exactly which of the failure modes is in play. Used by every data
18055
+ * fetcher to short-circuit before issuing real API calls — and now
18056
+ * lets the caller surface a tailored recovery hint per failure mode
18057
+ * instead of one catch-all message.
18058
+ *
18059
+ * Distinguishing the modes:
18060
+ * - ENOENT (`gh: command not found`) → `not-installed`
18061
+ * - `gh auth status` exits non-zero with stderr matching the
18062
+ * "not logged into" / "authentication required" pattern →
18063
+ * `not-authenticated`
18064
+ * - Anything else (permission denied on the binary, timeout, etc.)
18065
+ * → `unknown` with the underlying error message attached for
18066
+ * diagnostic display.
17855
18067
  */
17856
- async function isGhAuthenticated(runner) {
18068
+ async function getGhStatus(runner) {
17857
18069
  try {
17858
18070
  await runner(['auth', 'status', '--hostname', 'github.com']);
17859
- return true;
18071
+ return { kind: 'ok' };
17860
18072
  }
17861
- catch {
17862
- return false;
18073
+ catch (error) {
18074
+ const err = error;
18075
+ // ENOENT = the binary itself is missing. exec/spawn surfaces this
18076
+ // as either `code === 'ENOENT'` (Node's spawn error code) or a
18077
+ // message containing "ENOENT". Either form is unambiguous.
18078
+ if (err.code === 'ENOENT' || (err.message && err.message.includes('ENOENT'))) {
18079
+ return { kind: 'not-installed' };
18080
+ }
18081
+ // gh exits non-zero from `auth status` when the user isn't logged
18082
+ // in. The message body contains "not logged into" or "logged in
18083
+ // failed" depending on the gh version. Both patterns are stable
18084
+ // enough to gate on without scope-locking to a specific gh
18085
+ // release.
18086
+ const stderr = err.stderr || err.message || '';
18087
+ if (/not logged into|authentication.*required|you are not/i.test(stderr)) {
18088
+ return { kind: 'not-authenticated', detail: stderr.trim().split('\n')[0] };
18089
+ }
18090
+ // Anything else — permission denied, timeout, etc. Surface the
18091
+ // raw message so the user can read it; treat as unavailable.
18092
+ return { kind: 'unknown', detail: err.message || 'gh auth status failed' };
18093
+ }
18094
+ }
18095
+ /**
18096
+ * Backwards-compatible boolean wrapper around `getGhStatus`. Kept so
18097
+ * existing callers (data loaders, sidebar fetchers) don't all have to
18098
+ * migrate at once. New call sites should use `getGhStatus` directly
18099
+ * to access the discriminated failure modes.
18100
+ */
18101
+ async function isGhAuthenticated(runner) {
18102
+ const status = await getGhStatus(runner);
18103
+ return status.kind === 'ok';
18104
+ }
18105
+ /**
18106
+ * Render a user-facing recovery hint for a non-`ok` gh status. Used by
18107
+ * `commands/issues` / `commands/prs` / pull-request workflow surfaces
18108
+ * so every "gh is unavailable" message tells the user the exact next
18109
+ * step. Keeps the wording in sync across surfaces — if a user runs
18110
+ * `coco prs` and `coco issues` back to back, the same broken state
18111
+ * surfaces the same fix.
18112
+ */
18113
+ function describeGhStatus(status) {
18114
+ switch (status.kind) {
18115
+ case 'ok':
18116
+ return 'GitHub CLI is installed and authenticated.';
18117
+ case 'not-installed':
18118
+ return 'GitHub CLI (`gh`) is not installed. Install it from https://cli.github.com/ and run `gh auth login`.';
18119
+ case 'not-authenticated':
18120
+ return `GitHub CLI is installed but not authenticated. Run \`gh auth login\` (scopes: \`repo\`, \`read:org\`).${status.detail ? ` Details: ${status.detail}` : ''}`;
18121
+ case 'unknown':
18122
+ return `GitHub CLI returned an unexpected error: ${status.detail}. Try \`gh auth status\` directly to diagnose.`;
17863
18123
  }
17864
18124
  }
17865
18125
 
@@ -18051,13 +18311,14 @@ async function getIssueList(git, filter = {}, runner = defaultGhRunner) {
18051
18311
  message: 'No GitHub remote detected.',
18052
18312
  };
18053
18313
  }
18054
- if (!(await isGhAuthenticated(runner))) {
18314
+ const ghStatus = await getGhStatus(runner);
18315
+ if (ghStatus.kind !== 'ok') {
18055
18316
  return {
18056
18317
  available: true,
18057
18318
  authenticated: false,
18058
18319
  repository,
18059
18320
  filter,
18060
- message: 'GitHub CLI is missing or not authenticated.',
18321
+ message: describeGhStatus(ghStatus),
18061
18322
  };
18062
18323
  }
18063
18324
  try {
@@ -18169,6 +18430,7 @@ var issues = {
18169
18430
  desc: 'List GitHub issues for the current repository (read-only triage)',
18170
18431
  builder: builder$6,
18171
18432
  handler: commandExecutor(handler$6),
18433
+ options: options$6,
18172
18434
  };
18173
18435
 
18174
18436
  const command$5 = 'log';
@@ -22188,6 +22450,18 @@ function getLogInkWorkflowActions() {
22188
22450
  kind: 'destructive',
22189
22451
  requiresConfirmation: true,
22190
22452
  },
22453
+ {
22454
+ // No key binding — this is raised by the runtime as a second
22455
+ // confirmation when a safe `delete-branch` (`git branch -d`) is
22456
+ // rejected for an unmerged branch. Reachable from the `:` palette
22457
+ // too, as an explicit force-delete that still gates on y-confirm.
22458
+ id: 'force-delete-branch',
22459
+ key: '',
22460
+ label: 'Force-delete branch',
22461
+ description: 'Force-delete the selected branch even if it is not fully merged (git branch -D).',
22462
+ kind: 'destructive',
22463
+ requiresConfirmation: true,
22464
+ },
22191
22465
  {
22192
22466
  id: 'delete-tag',
22193
22467
  key: 'T',
@@ -22985,6 +23259,13 @@ const LOG_INK_KEY_BINDINGS = [
22985
23259
  description: 'Create a lightweight tag at the cursored commit.',
22986
23260
  contexts: ['history'],
22987
23261
  },
23262
+ {
23263
+ id: 'viewKeys',
23264
+ keys: ['g?'],
23265
+ label: 'keys',
23266
+ description: 'Show the single-key actions available in the current view (which-key strip).',
23267
+ contexts: ['normal'],
23268
+ },
22988
23269
  {
22989
23270
  id: 'themePicker',
22990
23271
  keys: ['gC'],
@@ -23712,6 +23993,48 @@ function getLogInkHelpSections(options) {
23712
23993
  },
23713
23994
  ];
23714
23995
  }
23996
+ /**
23997
+ * True when a key string is a single, bare printable key (e.g. `c`, `R`,
23998
+ * `[`) rather than a chord (`gh`, `gg`) or a named special key (`up`,
23999
+ * `page down`). Used by the which-key view-keys strip, which surfaces only
24000
+ * the single-key overloads — the chord set already has its own overlay.
24001
+ */
24002
+ function isBareSingleKey(key) {
24003
+ return key.length === 1 && key !== ' ';
24004
+ }
24005
+ /**
24006
+ * Single-key bindings available in the current view (#1137). Powers the
24007
+ * `g?` which-key strip: the per-view counterpart to the `g`-chord overlay.
24008
+ *
24009
+ * Sourced entirely from `LOG_INK_KEY_BINDINGS` (no duplicated key data) and
24010
+ * filtered the same way the help overlay's "This view" section is — by
24011
+ * `contexts` against the active view + focus — then narrowed to bindings
24012
+ * that expose at least one bare single key. Globals (`q`, `?`, `/`, `:`, …)
24013
+ * are excluded: they're always available and already live in the footer and
24014
+ * onboarding tour, so the strip stays focused on the deliberate per-view
24015
+ * overloads (`c`, `R`, `a`, `m`, `S`, `[`/`]`, …) the keymap guard protects.
24016
+ *
24017
+ * Sorted by the first bare key for stable, scannable output.
24018
+ */
24019
+ function getLogInkViewKeyBindings(options) {
24020
+ return LOG_INK_KEY_BINDINGS
24021
+ .filter((binding) => !GLOBAL_BINDING_IDS.includes(binding.id) &&
24022
+ bindingMatchesViewContext(binding, options) &&
24023
+ binding.keys.some(isBareSingleKey))
24024
+ .sort((a, b) => {
24025
+ const aKey = a.keys.find(isBareSingleKey) ?? '';
24026
+ const bKey = b.keys.find(isBareSingleKey) ?? '';
24027
+ return aKey.localeCompare(bKey);
24028
+ });
24029
+ }
24030
+ /**
24031
+ * Format only the bare single keys of a binding for the view-keys strip
24032
+ * (e.g. `['up', 'k']` → `k`). Named/chord keys are dropped — the strip is
24033
+ * about the single-key affordance, and the full key list lives in `?` help.
24034
+ */
24035
+ function formatBindingBareKeys(binding) {
24036
+ return binding.keys.filter(isBareSingleKey).join(' / ');
24037
+ }
23715
24038
  function bindingToPaletteCommand(binding) {
23716
24039
  return {
23717
24040
  id: binding.id,
@@ -24855,6 +25178,15 @@ function formatSortIndicator(mode, options = {}) {
24855
25178
  return `${options.ascii ? 'v' : '▼'} ${mode}`;
24856
25179
  }
24857
25180
 
25181
+ /**
25182
+ * True when `pending` (a `state.pendingDeletion`) targets this exact row.
25183
+ * Shared by every deletable surface + the sidebar so the spinner-swap
25184
+ * test is identical everywhere. Takes the field value (not the whole
25185
+ * state) so it can live next to the type without a forward reference.
25186
+ */
25187
+ function isPendingDeletion(pending, kind, id) {
25188
+ return pending?.kind === kind && pending.id === id;
25189
+ }
24858
25190
  const DEFAULT_CHANGELOG_VIEW_STATE = {
24859
25191
  status: 'idle',
24860
25192
  scrollOffset: 0,
@@ -25225,7 +25557,39 @@ function replaceRows(state, rows) {
25225
25557
  }
25226
25558
  function appendRows(state, rows) {
25227
25559
  const selected = getSelectedInkCommit(state);
25228
- const nextRows = [...state.rows, ...rows];
25560
+ // Dedup the merged row list by commit hash so the graph renderer —
25561
+ // which windows directly over `state.rows` (toFullGraphItems →
25562
+ // expandRowsWithSpacers) — and the selection list (deduped commits)
25563
+ // agree on one canonical, duplicate-free row order. Overlapping
25564
+ // appends, notably the anchored `loadCommitContext` page that
25565
+ // re-walks history from the tip, otherwise stack the newest commits
25566
+ // below the oldest ones already loaded. The renderer then shows the
25567
+ // initial commit directly above HEAD and the cursor can scroll
25568
+ // forever through the duplicated tail — the history graph "looping
25569
+ // back on itself". Drop graph-only topology rows that trail a dropped
25570
+ // duplicate commit too, since they describe that duplicate's lanes
25571
+ // and would otherwise dangle.
25572
+ const seenHashes = new Set();
25573
+ const nextRows = [];
25574
+ let droppingTrailingGraph = false;
25575
+ for (const row of [...state.rows, ...rows]) {
25576
+ if (row.type === 'commit') {
25577
+ if (seenHashes.has(row.hash)) {
25578
+ droppingTrailingGraph = true;
25579
+ continue;
25580
+ }
25581
+ seenHashes.add(row.hash);
25582
+ droppingTrailingGraph = false;
25583
+ nextRows.push(row);
25584
+ continue;
25585
+ }
25586
+ // Graph-only topology row: keep it unless it trails a just-dropped
25587
+ // duplicate commit (then it belongs to the duplicate page's lanes).
25588
+ if (droppingTrailingGraph) {
25589
+ continue;
25590
+ }
25591
+ nextRows.push(row);
25592
+ }
25229
25593
  const seen = new Set();
25230
25594
  const commits = getCommitRows(nextRows).filter((commit) => {
25231
25595
  if (seen.has(commit.hash)) {
@@ -25317,6 +25681,7 @@ function createLogInkState(rows, options = {}) {
25317
25681
  fullGraph: options.fullGraph ?? true,
25318
25682
  showHelp: false,
25319
25683
  helpScrollOffset: 0,
25684
+ showViewKeys: false,
25320
25685
  showCommandPalette: false,
25321
25686
  workflowActionId: undefined,
25322
25687
  pendingConfirmationId: undefined,
@@ -25822,6 +26187,22 @@ function applyLogInkAction(state, action) {
25822
26187
  pendingKey: undefined,
25823
26188
  };
25824
26189
  }
26190
+ case 'returnFromCommit': {
26191
+ // After a successful commit we leave the compose view automatically.
26192
+ // Where to: a still-dirty tree the user was staging from returns to
26193
+ // the Status view so they can finish the rest; an otherwise-complete
26194
+ // commit returns to the History view, where the new commit now shows.
26195
+ // We pop frames one at a time (reusing withPoppedView) so sidebar-tab
26196
+ // and diff-state restoration stays identical to manual Esc/back —
26197
+ // this also unwinds an intermediate `diff` frame (status → diff →
26198
+ // compose) back to the status frame it sits under.
26199
+ const target = action.stillDirty && state.viewStack.includes('status') ? 'status' : HOME_VIEW;
26200
+ let next = state;
26201
+ while (next.viewStack.length > 1 && topOfStack(next.viewStack) !== target) {
26202
+ next = withPoppedView(next);
26203
+ }
26204
+ return { ...next, pendingKey: undefined };
26205
+ }
25825
26206
  case 'navigateOpenDiffForCommit': {
25826
26207
  const next = withPushedView(state, 'diff');
25827
26208
  const filteredCommits = state.filteredCommits;
@@ -26011,6 +26392,10 @@ function applyLogInkAction(state, action) {
26011
26392
  workflowActionId: action.value ? undefined : state.workflowActionId,
26012
26393
  pendingKey: undefined,
26013
26394
  };
26395
+ case 'setPendingDeletion':
26396
+ // Pure marker for the in-flight delete; touches nothing else so the
26397
+ // list keeps rendering normally underneath the one spinner'd row.
26398
+ return { ...state, pendingDeletion: action.value };
26014
26399
  case 'toggleFilterMode':
26015
26400
  return {
26016
26401
  ...state,
@@ -26018,6 +26403,7 @@ function applyLogInkAction(state, action) {
26018
26403
  showCommandPalette: false,
26019
26404
  showHelp: false,
26020
26405
  helpScrollOffset: 0,
26406
+ showViewKeys: false,
26021
26407
  pendingKey: undefined,
26022
26408
  };
26023
26409
  case 'toggleGraph':
@@ -26036,9 +26422,24 @@ function applyLogInkAction(state, action) {
26036
26422
  // than picking up where the user last scrolled.
26037
26423
  helpScrollOffset: 0,
26038
26424
  showCommandPalette: false,
26425
+ // Opening full help supersedes the compact view-keys strip — this
26426
+ // is the progressive-disclosure step (`?` from the strip expands
26427
+ // to the full categorized help, #1137).
26428
+ showViewKeys: false,
26039
26429
  pendingKey: undefined,
26040
26430
  };
26041
26431
  }
26432
+ case 'toggleViewKeys':
26433
+ return {
26434
+ ...state,
26435
+ showViewKeys: !state.showViewKeys,
26436
+ // The view-keys strip is mutually exclusive with the other
26437
+ // overlays; opening it closes anything else that was showing.
26438
+ showHelp: false,
26439
+ helpScrollOffset: 0,
26440
+ showCommandPalette: false,
26441
+ pendingKey: undefined,
26442
+ };
26042
26443
  case 'scrollHelp':
26043
26444
  // No upper-bound clamp here — the renderer caps the offset
26044
26445
  // against the actual content height at render time. The
@@ -26055,6 +26456,7 @@ function applyLogInkAction(state, action) {
26055
26456
  showCommandPalette: opening,
26056
26457
  showHelp: false,
26057
26458
  helpScrollOffset: 0,
26459
+ showViewKeys: false,
26058
26460
  // Reset palette interaction state on every open/close so the next
26059
26461
  // session starts from a clean slate.
26060
26462
  paletteFilter: '',
@@ -26102,8 +26504,9 @@ function applyLogInkAction(state, action) {
26102
26504
  return {
26103
26505
  ...state,
26104
26506
  showThemePicker: opening,
26105
- // Only one overlay at a time — close help / palette on open.
26507
+ // Only one overlay at a time — close help / palette / view-keys on open.
26106
26508
  showHelp: false,
26509
+ showViewKeys: false,
26107
26510
  showCommandPalette: false,
26108
26511
  themePickerFilter: '',
26109
26512
  themePickerIndex: 0,
@@ -26903,6 +27306,10 @@ function getLogInkPaletteExecuteEvents(command, state) {
26903
27306
  // Palette closes on execute (toggleCommandPalette runs first), then
26904
27307
  // this opens the theme picker.
26905
27308
  return [action({ type: 'toggleThemePicker' })];
27309
+ case 'viewKeys':
27310
+ // Palette closes on execute (toggleCommandPalette runs first), then
27311
+ // this opens the per-view which-key strip (#1137).
27312
+ return [action({ type: 'toggleViewKeys' })];
26906
27313
  case 'openProjectConfig':
26907
27314
  return [{ type: 'openConfigInEditor', scope: 'project' }];
26908
27315
  case 'openGlobalConfig':
@@ -27631,6 +28038,26 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27631
28038
  }
27632
28039
  return [];
27633
28040
  }
28041
+ // #1137 — the `g?` which-key strip. While it's open the keyboard is
28042
+ // claimed (mirrors the help overlay) so a stray keystroke can't drop
28043
+ // the user into a per-view action they didn't mean to trigger. Esc
28044
+ // closes; `?` is the progressive-disclosure step up to the full
28045
+ // categorized help; `q` still quits. Everything else is swallowed —
28046
+ // the user peeks, dismisses, then presses the key they came for.
28047
+ if (state.showViewKeys) {
28048
+ if (key.escape) {
28049
+ return [action({ type: 'toggleViewKeys' })];
28050
+ }
28051
+ if (inputValue === '?') {
28052
+ // Expand the compact strip into the full help overlay. `toggleHelp`
28053
+ // clears `showViewKeys` so the two never render at once.
28054
+ return [action({ type: 'toggleHelp' })];
28055
+ }
28056
+ if (inputValue === 'q') {
28057
+ return [{ type: 'exit' }];
28058
+ }
28059
+ return [];
28060
+ }
27634
28061
  // #879 item 4 — Esc cancels an in-flight bisect-start wizard. Runs
27635
28062
  // BEFORE the generic `popView` so we both clear the wizard state
27636
28063
  // and walk back to the bisect view in one keystroke. Without this
@@ -27674,6 +28101,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27674
28101
  }
27675
28102
  return [{ type: 'exit' }];
27676
28103
  }
28104
+ // `g?` chord (#1137) — open the per-view which-key strip. Placed
28105
+ // BEFORE the bare `?` (full help) check below so the chord is read as
28106
+ // a unit: with `g` pending, `?` opens the view-keys strip rather than
28107
+ // toggling full help. Surfaces automatically in the `g` which-key menu
28108
+ // because its key is a two-char `g`-prefixed binding.
28109
+ if (state.pendingKey === 'g' && inputValue === '?') {
28110
+ return [
28111
+ action({ type: 'setPendingKey', value: undefined }),
28112
+ action({ type: 'toggleViewKeys' }),
28113
+ ];
28114
+ }
27677
28115
  if (inputValue === '?') {
27678
28116
  return [action({ type: 'toggleHelp' })];
27679
28117
  }
@@ -29582,6 +30020,24 @@ const SPINNER_TICK_MS = 80;
29582
30020
  function pickSpinnerFrame(tick) {
29583
30021
  return SPINNER_FRAMES[Math.max(0, tick) % SPINNER_FRAMES.length];
29584
30022
  }
30023
+ /**
30024
+ * ASCII-safe spinner frames for `NO_COLOR` / ASCII terminals where the
30025
+ * braille dots either don't render or look like noise. The four-frame
30026
+ * `|/-\` cycle is the classic terminal spinner and reads as motion in
30027
+ * any encoding.
30028
+ */
30029
+ const ASCII_SPINNER_FRAMES = ['|', '/', '-', '\\'];
30030
+ /**
30031
+ * Inline per-item pending glyph — used in place of (or appended to) a
30032
+ * list row's status icon while that row's mutation (a delete) is in
30033
+ * flight. Braille spinner normally; the ASCII cycle under `ascii`
30034
+ * themes so the indicator survives `NO_COLOR` / dumb terminals.
30035
+ */
30036
+ function inlineSpinnerGlyph(tick, ascii) {
30037
+ return ascii
30038
+ ? ASCII_SPINNER_FRAMES[Math.max(0, tick) % ASCII_SPINNER_FRAMES.length]
30039
+ : pickSpinnerFrame(tick);
30040
+ }
29585
30041
 
29586
30042
  /**
29587
30043
  * Build the initial `LogInkContextStatus` for a freshly-created frame
@@ -30536,7 +30992,7 @@ function createBranch(git, branchName, startPoint) {
30536
30992
  function renameBranch(git, oldName, newName) {
30537
30993
  return runAction$5(() => git.raw(['branch', '-m', oldName, newName]), `Renamed ${oldName} to ${newName}`);
30538
30994
  }
30539
- function deleteBranch(git, branch) {
30995
+ function deleteBranch(git, branch, force = false) {
30540
30996
  if (branch.type !== 'local') {
30541
30997
  return Promise.resolve({
30542
30998
  ok: false,
@@ -30549,7 +31005,18 @@ function deleteBranch(git, branch) {
30549
31005
  message: 'Cannot delete the current branch.',
30550
31006
  });
30551
31007
  }
30552
- return runAction$5(() => git.raw(['branch', '-d', branch.shortName]), `Deleted branch ${branch.shortName}`);
31008
+ // `-d` is the safe delete (refuses unmerged branches); `-D` forces it.
31009
+ // The TUI starts with `-d` and only escalates to `-D` after the user
31010
+ // confirms a second time on the "not fully merged" error.
31011
+ return runAction$5(() => git.raw(['branch', force ? '-D' : '-d', branch.shortName]), force ? `Force-deleted branch ${branch.shortName}` : `Deleted branch ${branch.shortName}`);
31012
+ }
31013
+ /**
31014
+ * True when a failed `git branch -d` was rejected specifically because the
31015
+ * branch isn't fully merged (the one case worth offering a force-delete
31016
+ * for). Matches git's wording across versions ("not fully merged").
31017
+ */
31018
+ function isBranchNotFullyMergedError(message) {
31019
+ return /not fully merged/i.test(message || '');
30553
31020
  }
30554
31021
  function fetchRemotes(git) {
30555
31022
  return runAction$5(() => git.raw(['fetch', '--all', '--prune']), 'Fetched all remotes');
@@ -30656,7 +31123,7 @@ function fetchBranch(git, branch) {
30656
31123
  if (!branch.upstream || !branch.remote) {
30657
31124
  return Promise.resolve({
30658
31125
  ok: false,
30659
- message: `${branch.shortName} has no upstream — nothing to fetch.`,
31126
+ message: `${branch.shortName} has no upstream — set one with \`git push -u <remote> ${branch.shortName}\` to enable fetch.`,
30660
31127
  });
30661
31128
  }
30662
31129
  // `branch.upstream` is the short form (e.g. `origin/main`); the
@@ -30694,7 +31161,7 @@ function pullBranch(git, branch, currentBranchName) {
30694
31161
  if (!branch.upstream || !branch.remote) {
30695
31162
  return Promise.resolve({
30696
31163
  ok: false,
30697
- message: `${branch.shortName} has no upstream — nothing to pull.`,
31164
+ message: `${branch.shortName} has no upstream — set one with \`git push -u <remote> ${branch.shortName}\` to enable pull.`,
30698
31165
  });
30699
31166
  }
30700
31167
  // Current branch — defer to the in-place workflow.
@@ -32002,13 +32469,14 @@ async function getPullRequestList(git, filter = {}, runner = defaultGhRunner) {
32002
32469
  message: 'No GitHub remote detected.',
32003
32470
  };
32004
32471
  }
32005
- if (!(await isGhAuthenticated(runner))) {
32472
+ const ghStatus = await getGhStatus(runner);
32473
+ if (ghStatus.kind !== 'ok') {
32006
32474
  return {
32007
32475
  available: true,
32008
32476
  authenticated: false,
32009
32477
  repository,
32010
32478
  filter,
32011
- message: 'GitHub CLI is missing or not authenticated.',
32479
+ message: describeGhStatus(ghStatus),
32012
32480
  };
32013
32481
  }
32014
32482
  try {
@@ -32874,6 +33342,7 @@ function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFr
32874
33342
  // of the runtime's `forcedPane` derivation in `app.ts`.
32875
33343
  const overlayForcesPane = Boolean(state.splitPlan ||
32876
33344
  state.showHelp ||
33345
+ state.showViewKeys ||
32877
33346
  state.showCommandPalette ||
32878
33347
  state.showThemePicker ||
32879
33348
  state.gitignorePicker ||
@@ -33955,7 +34424,13 @@ function renderActiveStatusTabContent(h, Text, context, contextStatus, width, th
33955
34424
  * rows so they read as the same severity scale used in the main status
33956
34425
  * surface; every other tab falls through to selectable rows.
33957
34426
  */
33958
- function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme) {
34427
+ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme, spinnerFrame) {
34428
+ // Inline pending-delete glyph: while a row's delete is in flight it
34429
+ // shows this spinner in place of its leading marker (branches /
34430
+ // worktrees) or appended to the row (tags / stashes, which have no
34431
+ // leading status icon). `pending` is the single in-flight target.
34432
+ const pending = state.pendingDeletion;
34433
+ const spin = inlineSpinnerGlyph(spinnerFrame, theme.ascii);
33959
34434
  // Available rows for the active tab's list. The sidebar chrome
33960
34435
  // takes ~10 rows (panel title + spacer + 5 tab headers + 4 inter-tab
33961
34436
  // spacers); the branches tab eats 3 more for its summary header
@@ -33992,7 +34467,12 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
33992
34467
  ];
33993
34468
  return [
33994
34469
  ...headerRows,
33995
- ...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii }).glyph} ${branch.shortName}`, 'tab-branches', visibleListCount),
34470
+ ...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => {
34471
+ const glyph = isPendingDeletion(pending, 'branch', branch.shortName)
34472
+ ? spin
34473
+ : branchRowMarker(branch, { ascii: theme.ascii }).glyph;
34474
+ return `${glyph} ${branch.shortName}`;
34475
+ }, 'tab-branches', visibleListCount),
33996
34476
  ];
33997
34477
  }
33998
34478
  if (tab === 'tags') {
@@ -34003,7 +34483,12 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
34003
34483
  if (tags.length === 0) {
34004
34484
  return [h(Text, { key: 'tab-tags-empty', dimColor: true }, ' No tags found')];
34005
34485
  }
34006
- return renderSelectableSidebarRows(h, Text, tags, state.selectedTagIndex, focused, width, theme, (tag) => `${truncateCells(tag.name, 16)} ${tag.subject}`, 'tab-tags', visibleListCount);
34486
+ return renderSelectableSidebarRows(h, Text, tags, state.selectedTagIndex, focused, width, theme, (tag) => {
34487
+ const base = `${truncateCells(tag.name, 16)} ${tag.subject}`;
34488
+ // Tags have no leading status icon, so the pending spinner is
34489
+ // appended to the row instead of replacing a glyph.
34490
+ return isPendingDeletion(pending, 'tag', tag.name) ? `${base} ${spin}` : base;
34491
+ }, 'tab-tags', visibleListCount);
34007
34492
  }
34008
34493
  if (tab === 'stashes') {
34009
34494
  if (isLogInkContextKeyLoading(contextStatus, 'stashes')) {
@@ -34013,7 +34498,12 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
34013
34498
  if (stashes.length === 0) {
34014
34499
  return [h(Text, { key: 'tab-stashes-empty', dimColor: true }, ' No stashes found')];
34015
34500
  }
34016
- return renderSelectableSidebarRows(h, Text, stashes, state.selectedStashIndex, focused, width, theme, (stash, index) => `@{${index}} ${stash.message || '(no message)'}`, 'tab-stashes', visibleListCount);
34501
+ return renderSelectableSidebarRows(h, Text, stashes, state.selectedStashIndex, focused, width, theme, (stash, index) => {
34502
+ const base = `@{${index}} ${stash.message || '(no message)'}`;
34503
+ // `@{N}` is the stash ref, not a status icon, so append the
34504
+ // spinner rather than replacing it.
34505
+ return isPendingDeletion(pending, 'stash', stash.ref) ? `${base} ${spin}` : base;
34506
+ }, 'tab-stashes', visibleListCount);
34017
34507
  }
34018
34508
  // worktrees
34019
34509
  if (isLogInkContextKeyLoading(contextStatus, 'worktreeList')) {
@@ -34024,12 +34514,14 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
34024
34514
  return [h(Text, { key: 'tab-worktrees-empty', dimColor: true }, ' No linked worktrees')];
34025
34515
  }
34026
34516
  return renderSelectableSidebarRows(h, Text, worktrees, state.selectedWorktreeListIndex, focused, width, theme, (worktree) => {
34027
- const marker = worktree.current ? '*' : ' ';
34517
+ const marker = isPendingDeletion(pending, 'worktree', worktree.path)
34518
+ ? spin
34519
+ : worktree.current ? '*' : ' ';
34028
34520
  const wstate = worktree.dirty ? 'dirty' : 'clean';
34029
34521
  return `${marker} ${worktree.branch || worktree.path} ${wstate}`;
34030
34522
  }, 'tab-worktrees', visibleListCount);
34031
34523
  }
34032
- function renderSidebar$1(h, components, state, context, contextStatus, width, bodyRows, theme) {
34524
+ function renderSidebar$1(h, components, state, context, contextStatus, width, bodyRows, theme, spinnerFrame = 0) {
34033
34525
  const { Box, Text } = components;
34034
34526
  const focused = state.focus === 'sidebar';
34035
34527
  const tabs = getLogInkSidebarTabs();
@@ -34065,7 +34557,7 @@ function renderSidebar$1(h, components, state, context, contextStatus, width, bo
34065
34557
  inverse: headerSelected,
34066
34558
  }, headerText));
34067
34559
  if (isActive) {
34068
- blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme));
34560
+ blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme, spinnerFrame));
34069
34561
  }
34070
34562
  return blocks;
34071
34563
  });
@@ -34416,7 +34908,7 @@ function formatLogInkGitHubNoRemote({ resource, }) {
34416
34908
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
34417
34909
  * of #890. No behavior change.
34418
34910
  */
34419
- function renderBranchesSurface(ctx) {
34911
+ function renderBranchesSurface(ctx, spinnerFrame = 0) {
34420
34912
  const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
34421
34913
  const { Box, Text } = components;
34422
34914
  const focused = state.focus === 'commits';
@@ -34455,7 +34947,14 @@ function renderBranchesSurface(ctx) {
34455
34947
  const isSelected = index === selected;
34456
34948
  const cursor = isSelected ? '>' : ' ';
34457
34949
  const marker = branchRowMarker(branch, { ascii: theme.ascii });
34458
- const markerColor = getBranchRowMarkerColor(marker.kind, theme);
34950
+ // While this branch's delete is in flight, its sync-state marker
34951
+ // is replaced by an inline spinner (accent-coloured) so the row
34952
+ // reads as "deleting" until it vanishes on refresh.
34953
+ const deleting = isPendingDeletion(state.pendingDeletion, 'branch', branch.shortName);
34954
+ const glyph = deleting ? inlineSpinnerGlyph(spinnerFrame, theme.ascii) : marker.glyph;
34955
+ const glyphColor = deleting
34956
+ ? (theme.noColor ? undefined : theme.colors.accent)
34957
+ : getBranchRowMarkerColor(marker.kind, theme);
34459
34958
  const divergence = formatBranchDivergence(branch, { ascii: theme.ascii });
34460
34959
  const lastTouched = formatBranchLastTouched(branch.date, getRenderNow());
34461
34960
  // Split the row into spans so the timestamp stays dim even on the
@@ -34470,7 +34969,7 @@ function renderBranchesSurface(ctx) {
34470
34969
  // Truncate the assembled line to the actual panel width so a
34471
34970
  // narrow inspector / sidebar focus doesn't push branch rows
34472
34971
  // onto a second visual line (#830).
34473
- const fullText = `${cursorAndPad}${marker.glyph}${trailingName}${timestampPadded}${trailingDivergence}`;
34972
+ const fullText = `${cursorAndPad}${glyph}${trailingName}${timestampPadded}${trailingDivergence}`;
34474
34973
  const truncated = truncateCells(fullText, Math.max(20, width - 4));
34475
34974
  // If truncation chopped into the timestamp/divergence portion,
34476
34975
  // fall back to a single Text to keep the visible width honest.
@@ -34493,7 +34992,7 @@ function renderBranchesSurface(ctx) {
34493
34992
  // no-upstream kinds return undefined from
34494
34993
  // `getBranchRowMarkerColor`, so those markers inherit the
34495
34994
  // row's dim and read as quiet chrome.
34496
- h(Text, { color: markerColor, dimColor: markerColor ? false : undefined }, marker.glyph), trailingName, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
34995
+ h(Text, { color: glyphColor, dimColor: glyphColor ? false : undefined }, glyph), trailingName, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
34497
34996
  });
34498
34997
  // Scroll indicators — same "N more above/below" pattern as the
34499
34998
  // sidebar and help overlay so the user knows the list continues.
@@ -34778,8 +35277,16 @@ function renderComposeSurface(ctx, spinnerFrame = 0) {
34778
35277
  const bodyVisualLines = compose.body
34779
35278
  ? compose.body.split('\n').flatMap((line) => wrapCells(line, bodyTextWidth)).slice(0, bodyRowsAvailable)
34780
35279
  : ['<empty>'];
34781
- const summaryVisualLines = wrapCells(`${compose.summary || '<empty>'}${summaryCursor}`, Math.max(8, width - 11) // "Summary " (9) + 2 chrome = 11
34782
- );
35280
+ // Summary now renders on its own indented line under the label (like the
35281
+ // body), so it wraps at the full content width instead of the cramped
35282
+ // "Summary " (9) + chrome budget it had when label and value shared a row.
35283
+ const summaryVisualLines = compose.summary
35284
+ ? compose.summary.split('\n').flatMap((line) => wrapCells(line, bodyTextWidth))
35285
+ : ['<empty>'];
35286
+ // Subject length drives a subtle counter on the Summary label: dim under
35287
+ // 50, warning past the conventional 50-char soft limit, danger past 72.
35288
+ // Counted in code points so multibyte subjects aren't over-counted.
35289
+ const summaryLength = [...compose.summary].length;
34783
35290
  // State-line cycles through three modes (#881 phase 3 added the
34784
35291
  // loading variant): editing copy when the user is typing, cancel
34785
35292
  // hint when an AI draft is generating, default guidance otherwise.
@@ -34799,6 +35306,52 @@ function renderComposeSurface(ctx, spinnerFrame = 0) {
34799
35306
  const noStagedHint = !isLogInkContextKeyLoading(contextStatus, 'worktree')
34800
35307
  ? formatLogInkComposeEmpty({ hasStaged: hasStagedFiles })
34801
35308
  : undefined;
35309
+ // Section header for a field (Summary / Body). The active field's label
35310
+ // carries an arrow marker + the repo's selection highlight (matching the
35311
+ // status surface, see status/index.ts) so the user can see which field
35312
+ // their keystrokes target — even before entering edit mode, and even
35313
+ // under NO_COLOR where the marker + bold/dim carry the signal alone. An
35314
+ // optional length counter (Summary only) trails the label outside the
35315
+ // highlight so its own warning/danger color stays legible.
35316
+ const renderSectionHeader = (name, field, count) => {
35317
+ const active = compose.field === field;
35318
+ const highlight = active && focused && !theme.noColor;
35319
+ const marker = active ? (theme.ascii ? '> ' : '▸ ') : ' ';
35320
+ const badge = active && compose.editing ? ' EDITING' : '';
35321
+ const children = [
35322
+ h(Text, {
35323
+ key: `compose-${field}-label`,
35324
+ bold: active,
35325
+ dimColor: !active,
35326
+ backgroundColor: highlight ? theme.colors.selection : undefined,
35327
+ color: highlight ? theme.colors.selectionForeground : undefined,
35328
+ }, `${marker}${name}${badge}`),
35329
+ ];
35330
+ if (count !== undefined) {
35331
+ const countColor = theme.noColor
35332
+ ? undefined
35333
+ : count > 72
35334
+ ? theme.colors.danger
35335
+ : count > 50
35336
+ ? theme.colors.warning
35337
+ : undefined;
35338
+ children.push(h(Text, {
35339
+ key: `compose-${field}-count`,
35340
+ color: countColor,
35341
+ dimColor: countColor === undefined,
35342
+ }, ` ${count}`));
35343
+ }
35344
+ return h(Box, { key: `compose-${field}-header` }, ...children);
35345
+ };
35346
+ // Content lines for a field — indented two cells under the header, with
35347
+ // the edit cursor parked on the final line when this field is active.
35348
+ const renderSectionContent = (lines, field, cursor) => lines.map((line, index) => {
35349
+ const isLast = index === lines.length - 1;
35350
+ return h(Text, {
35351
+ key: `compose-${field}-${index}`,
35352
+ dimColor: line === '<empty>',
35353
+ }, ` ${line}${cursor && isLast ? cursor : ''}`);
35354
+ });
34802
35355
  return h(Box, {
34803
35356
  borderColor: focusBorderColor(theme, focused),
34804
35357
  borderStyle: theme.borderStyle,
@@ -34806,20 +35359,7 @@ function renderComposeSurface(ctx, spinnerFrame = 0) {
34806
35359
  flexShrink: 0,
34807
35360
  paddingX: 1,
34808
35361
  width,
34809
- }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Compose commit', focused)), h(Text, { dimColor: true }, statusLine)), h(Text, undefined, ''), h(Text, {
34810
- bold: compose.field === 'summary' && compose.editing,
34811
- }, `Summary ${summaryVisualLines[0] || ''}`), ...summaryVisualLines.slice(1).map((line, index) => h(Text, {
34812
- key: `compose-summary-${index}`,
34813
- bold: compose.field === 'summary' && compose.editing,
34814
- }, ` ${line}`)), h(Text, undefined, ''), h(Text, {
34815
- bold: compose.field === 'body' && compose.editing,
34816
- }, 'Body'), ...bodyVisualLines.map((line, index) => {
34817
- const isLast = index === bodyVisualLines.length - 1;
34818
- return h(Text, {
34819
- key: `compose-body-${index}`,
34820
- dimColor: line === '<empty>',
34821
- }, ` ${line}${bodyCursor && isLast ? bodyCursor : ''}`);
34822
- }),
35362
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Compose commit', focused)), h(Text, { dimColor: true }, statusLine)), h(Text, undefined, ''), renderSectionHeader('Summary', 'summary', summaryLength > 0 ? summaryLength : undefined), ...renderSectionContent(summaryVisualLines, 'summary', summaryCursor), h(Text, undefined, ''), renderSectionHeader('Body', 'body'), ...renderSectionContent(bodyVisualLines, 'body', bodyCursor),
34823
35363
  // Loading indicator + post-action message belong inline with the draft
34824
35364
  // (they describe what just happened to the fields above). The state-
34825
35365
  // line ("Editing — Enter switches summary↔body…" / "Press e to edit
@@ -37074,9 +37614,13 @@ function renderConfirmationPanel(h, components, state, width, theme, focused) {
37074
37614
  ? 'You have an unsaved commit draft. Press y to discard it and quit.'
37075
37615
  : state.pendingMutationConfirmation
37076
37616
  ? 'This discards local changes and cannot be undone by Coco.'
37077
- : action?.kind === 'ai'
37078
- ? `AI action requires confirmation. Estimated ${action.estimatedTokens || '<unknown>'} tokens.`
37079
- : 'Destructive Git action requires confirmation.';
37617
+ // Second-stage confirm raised when a safe delete hit an unmerged
37618
+ // branch name the reason so the force isn't a blind "y again".
37619
+ : state.pendingConfirmationId === 'force-delete-branch'
37620
+ ? 'Not fully merged. Force-delete (git branch -D) is irreversible.'
37621
+ : action?.kind === 'ai'
37622
+ ? `AI action requires confirmation. Estimated ${action.estimatedTokens || '<unknown>'} tokens.`
37623
+ : 'Destructive Git action requires confirmation.';
37080
37624
  return h(Box, {
37081
37625
  borderColor: focusBorderColor(theme, focused),
37082
37626
  borderStyle: theme.borderStyle,
@@ -37157,6 +37701,54 @@ function renderChordOverlay(h, components, state, width, theme, focused) {
37157
37701
  paddingX: 1,
37158
37702
  }, ...lines);
37159
37703
  }
37704
+ /**
37705
+ * Which-key view-keys strip (#1137). The per-view counterpart to the
37706
+ * `g`-chord overlay: opened by `g?`, it lists the single-key actions
37707
+ * available in the current view (the deliberate overloads — `c`, `R`,
37708
+ * `a`, `m`, `S`, `[`/`]`, …) with their labels, sourced from
37709
+ * `LOG_INK_KEY_BINDINGS` filtered by the active view + focus.
37710
+ *
37711
+ * Renders in the detail panel slot like the chord overlay. `?` steps up
37712
+ * to the full categorized help; Esc closes.
37713
+ */
37714
+ function renderViewKeysOverlay(h, components, state, width, theme, focused) {
37715
+ const { Box, Text } = components;
37716
+ const bindings = getLogInkViewKeyBindings({
37717
+ activeView: state.activeView,
37718
+ focus: state.focus,
37719
+ });
37720
+ const accent = theme.noColor ? undefined : theme.colors.accent;
37721
+ const lines = [
37722
+ h(Text, { key: 'view-keys-title', bold: true }, panelTitle(`keys · ${state.activeView}`, focused)),
37723
+ h(Text, { key: 'view-keys-spacer' }, ''),
37724
+ ];
37725
+ if (bindings.length === 0) {
37726
+ lines.push(h(Text, {
37727
+ key: 'view-keys-empty',
37728
+ dimColor: true,
37729
+ }, truncateCells('No single-key actions in this view — use ? for the full help.', width - 4)));
37730
+ }
37731
+ else {
37732
+ // Pad keys to the widest entry so labels align into a scannable column.
37733
+ const keyColumn = bindings.reduce((max, binding) => Math.max(max, formatBindingBareKeys(binding).length), 0);
37734
+ for (const binding of bindings) {
37735
+ const keys = formatBindingBareKeys(binding);
37736
+ lines.push(h(Text, { key: `view-keys-${binding.id}` }, h(Text, { color: accent, bold: true }, ` ${keys.padEnd(keyColumn)} `), h(Text, undefined, truncateCells(`${binding.label.padEnd(14)} ${binding.description}`, width - keyColumn - 7))));
37737
+ }
37738
+ }
37739
+ lines.push(h(Text, { key: 'view-keys-foot-spacer' }, ''));
37740
+ lines.push(h(Text, {
37741
+ key: 'view-keys-hint',
37742
+ dimColor: true,
37743
+ }, truncateCells('? full help · esc closes', width - 4)));
37744
+ return h(Box, {
37745
+ borderColor: focusBorderColor(theme, focused),
37746
+ borderStyle: theme.borderStyle,
37747
+ flexDirection: 'column',
37748
+ width,
37749
+ paddingX: 1,
37750
+ }, ...lines);
37751
+ }
37160
37752
  function renderHelpPanel(h, components, state, width, theme, focused, bodyRows = 0) {
37161
37753
  const { Box, Text } = components;
37162
37754
  // Build the full list of body rows (everything below the title).
@@ -38199,7 +38791,7 @@ function renderReflogSurface(ctx) {
38199
38791
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
38200
38792
  * of #890. No behavior change.
38201
38793
  */
38202
- function renderStashSurface(ctx) {
38794
+ function renderStashSurface(ctx, spinnerFrame = 0) {
38203
38795
  const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
38204
38796
  const { Box, Text } = components;
38205
38797
  const focused = state.focus === 'commits';
@@ -38245,11 +38837,18 @@ function renderStashSurface(ctx) {
38245
38837
  const rowText = meta
38246
38838
  ? `${cursor} ${stash.ref.padEnd(11)} ${meta} ${stash.message}`
38247
38839
  : `${cursor} ${stash.ref.padEnd(11)} ${stash.message}`;
38840
+ // The `stash@{N}` ref is an identifier, not a status icon, so a
38841
+ // delete-in-flight appends an accent spinner at the row's end
38842
+ // (2 cells reserved from the width budget).
38843
+ const deleting = isPendingDeletion(state.pendingDeletion, 'stash', stash.ref);
38844
+ const spinnerSpan = deleting
38845
+ ? h(Text, { color: theme.noColor ? undefined : theme.colors.accent, dimColor: false }, ` ${inlineSpinnerGlyph(spinnerFrame, theme.ascii)}`)
38846
+ : null;
38248
38847
  return h(Text, {
38249
38848
  key: `stash-${index}`,
38250
38849
  bold: isSelected,
38251
38850
  dimColor: !isSelected,
38252
- }, truncateCells(rowText, rowWidth));
38851
+ }, truncateCells(rowText, rowWidth - (deleting ? 2 : 0)), spinnerSpan);
38253
38852
  });
38254
38853
  const stashHasMoreAbove = startIndex > 0 && stashes.length > 0;
38255
38854
  const stashHasMoreBelow = startIndex + listRows < stashes.length;
@@ -38597,7 +39196,7 @@ function formatHyperlink(text, url, env = process.env) {
38597
39196
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
38598
39197
  * of #890. No behavior change.
38599
39198
  */
38600
- function renderTagsSurface(ctx) {
39199
+ function renderTagsSurface(ctx, spinnerFrame = 0) {
38601
39200
  const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
38602
39201
  const { Box, Text } = components;
38603
39202
  const focused = state.focus === 'commits';
@@ -38638,13 +39237,20 @@ function renderTagsSurface(ctx) {
38638
39237
  // intact.
38639
39238
  const url = buildRefUrl(context.provider?.repository, tag.name);
38640
39239
  const namePadded = truncateCells(tag.name, tagNameColWidth).padEnd(tagNameColWidth);
38641
- const lineText = truncateCells(`${cursor} ${namePadded} ${tag.subject}`, Math.max(20, width - 4));
39240
+ // Tags have no leading status icon, so a delete-in-flight appends
39241
+ // an accent spinner at the row's end. Reserve its 2 cells from the
39242
+ // truncation budget so it never pushes the row past the panel.
39243
+ const deleting = isPendingDeletion(state.pendingDeletion, 'tag', tag.name);
39244
+ const spinnerSpan = deleting
39245
+ ? h(Text, { color: theme.noColor ? undefined : theme.colors.accent, dimColor: false }, ` ${inlineSpinnerGlyph(spinnerFrame, theme.ascii)}`)
39246
+ : null;
39247
+ const lineText = truncateCells(`${cursor} ${namePadded} ${tag.subject}`, Math.max(20, width - 4 - (deleting ? 2 : 0)));
38642
39248
  if (!url || lineText.indexOf(namePadded) < 0) {
38643
39249
  return h(Text, {
38644
39250
  key: `tag-${index}`,
38645
39251
  bold: isSelected,
38646
39252
  dimColor: !isSelected,
38647
- }, lineText);
39253
+ }, lineText, spinnerSpan);
38648
39254
  }
38649
39255
  const linkStart = lineText.indexOf(namePadded);
38650
39256
  const before = lineText.slice(0, linkStart);
@@ -38653,7 +39259,7 @@ function renderTagsSurface(ctx) {
38653
39259
  key: `tag-${index}`,
38654
39260
  bold: isSelected,
38655
39261
  dimColor: !isSelected,
38656
- }, before, formatHyperlink(namePadded, url), after);
39262
+ }, before, formatHyperlink(namePadded, url), after, spinnerSpan);
38657
39263
  });
38658
39264
  const tagsHasMoreAbove = startIndex > 0 && tags.length > 0;
38659
39265
  const tagsHasMoreBelow = startIndex + listRows < tags.length;
@@ -38680,7 +39286,7 @@ function renderTagsSurface(ctx) {
38680
39286
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
38681
39287
  * of #890. No behavior change.
38682
39288
  */
38683
- function renderWorktreesSurface(ctx) {
39289
+ function renderWorktreesSurface(ctx, spinnerFrame = 0) {
38684
39290
  const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
38685
39291
  const { Box, Text } = components;
38686
39292
  const focused = state.focus === 'commits';
@@ -38716,7 +39322,9 @@ function renderWorktreesSurface(ctx) {
38716
39322
  const index = startIndex + offset;
38717
39323
  const isSelected = index === selected;
38718
39324
  const cursor = isSelected ? '>' : ' ';
38719
- const marker = entry.current ? '*' : ' ';
39325
+ const marker = isPendingDeletion(state.pendingDeletion, 'worktree', entry.path)
39326
+ ? inlineSpinnerGlyph(spinnerFrame, theme.ascii)
39327
+ : entry.current ? '*' : ' ';
38720
39328
  const branchLabel = entry.branch ? entry.branch : entry.head || '<detached>';
38721
39329
  const stateLabel = entry.dirty ? 'dirty' : 'clean';
38722
39330
  const branchPadded = truncateCells(branchLabel, branchColWidth).padEnd(branchColWidth);
@@ -38786,10 +39394,10 @@ function renderMainPanel(surface, worktreeDiff, worktreeDiffLoading, worktreeHun
38786
39394
  return renderComposeSurface(surface, spinnerFrame);
38787
39395
  }
38788
39396
  if (state.activeView === 'branches') {
38789
- return renderBranchesSurface(surface);
39397
+ return renderBranchesSurface(surface, spinnerFrame);
38790
39398
  }
38791
39399
  if (state.activeView === 'tags') {
38792
- return renderTagsSurface(surface);
39400
+ return renderTagsSurface(surface, spinnerFrame);
38793
39401
  }
38794
39402
  if (state.activeView === 'reflog') {
38795
39403
  return renderReflogSurface(surface);
@@ -38798,10 +39406,10 @@ function renderMainPanel(surface, worktreeDiff, worktreeDiffLoading, worktreeHun
38798
39406
  return renderBisectSurface(surface, bisectCandidateDetail, bisectCandidateLoading);
38799
39407
  }
38800
39408
  if (state.activeView === 'stash') {
38801
- return renderStashSurface(surface);
39409
+ return renderStashSurface(surface, spinnerFrame);
38802
39410
  }
38803
39411
  if (state.activeView === 'worktrees') {
38804
- return renderWorktreesSurface(surface);
39412
+ return renderWorktreesSurface(surface, spinnerFrame);
38805
39413
  }
38806
39414
  if (state.activeView === 'submodules') {
38807
39415
  return renderSubmodulesSurface(surface);
@@ -39835,6 +40443,12 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
39835
40443
  if (state.showHelp) {
39836
40444
  return renderHelpPanel(h, components, state, width, theme, focused, bodyRows);
39837
40445
  }
40446
+ // #1137 — the `g?` which-key strip lists the current view's single-key
40447
+ // actions. Checked alongside the other overlays; the reducer keeps it
40448
+ // mutually exclusive with help / palette / pickers.
40449
+ if (state.showViewKeys) {
40450
+ return renderViewKeysOverlay(h, components, state, width, theme, focused);
40451
+ }
39838
40452
  if (state.showCommandPalette) {
39839
40453
  return renderCommandPalette(h, components, state, width, theme, focused);
39840
40454
  }
@@ -40168,6 +40782,53 @@ const REMOTE_OP_LOADERS = {
40168
40782
  'pull-selected-branch': { kind: 'pull', label: 'Pulling branch from remote…' },
40169
40783
  'push-selected-branch': { kind: 'push', label: 'Pushing branch to remote…' },
40170
40784
  };
40785
+ /**
40786
+ * Resolve which list row a delete workflow is about to act on, so the
40787
+ * runner can mark it pending (inline spinner) for the duration of the
40788
+ * git call. Mirrors the cursored-target resolution inside each delete
40789
+ * handler exactly — same sort, same promoted-filter, same selection
40790
+ * index — so the spinner lands on the row that actually gets deleted.
40791
+ * Returns `undefined` for non-delete workflows (and when nothing is
40792
+ * selected), which the runner treats as "no pending marker".
40793
+ */
40794
+ function resolvePendingDeletion(id, state, context) {
40795
+ const { filter } = state;
40796
+ if (id === 'delete-branch' || id === 'force-delete-branch') {
40797
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
40798
+ const visible = filter
40799
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], filter))
40800
+ : all;
40801
+ const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
40802
+ return branch ? { kind: 'branch', id: branch.shortName } : undefined;
40803
+ }
40804
+ if (id === 'delete-tag') {
40805
+ const all = sortTags(context.tags?.tags || [], state.tagSort);
40806
+ const visible = filter
40807
+ ? all.filter((t) => matchesPromotedFilter([t.name, t.subject], filter))
40808
+ : all;
40809
+ const tag = visible[Math.min(state.selectedTagIndex, visible.length - 1)];
40810
+ return tag ? { kind: 'tag', id: tag.name } : undefined;
40811
+ }
40812
+ if (id === 'drop-stash') {
40813
+ const all = context.stashes?.stashes || [];
40814
+ const visible = filter
40815
+ ? all.filter((s) => matchesPromotedFilter([s.ref, s.message], filter))
40816
+ : all;
40817
+ const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
40818
+ return stash ? { kind: 'stash', id: stash.ref } : undefined;
40819
+ }
40820
+ if (id === 'remove-worktree') {
40821
+ const all = context.worktreeList?.worktrees || [];
40822
+ const visible = filter
40823
+ ? all.filter((w) => matchesPromotedFilter([w.path, w.branch || ''], filter))
40824
+ : all;
40825
+ const wt = visible.length
40826
+ ? visible[Math.min(state.selectedWorktreeListIndex, visible.length - 1)]
40827
+ : all[Math.min(state.selectedWorktreeListIndex, Math.max(0, all.length - 1))];
40828
+ return wt ? { kind: 'worktree', id: wt.path } : undefined;
40829
+ }
40830
+ return undefined;
40831
+ }
40171
40832
  function predictNextFilter(action, currentFilter) {
40172
40833
  switch (action.type) {
40173
40834
  case 'appendFilter':
@@ -40484,7 +41145,10 @@ function LogInkApp(deps) {
40484
41145
  state.changelogView.status === 'loading' ||
40485
41146
  state.commitCompose.loading ||
40486
41147
  Boolean(state.remoteOp) ||
40487
- Boolean(state.statusLoading);
41148
+ Boolean(state.statusLoading) ||
41149
+ // Keep the shared spinner ticking while a list-item delete is in
41150
+ // flight so its inline pending glyph animates instead of freezing.
41151
+ Boolean(state.pendingDeletion);
40488
41152
  React.useEffect(() => {
40489
41153
  if (!anyLoading) {
40490
41154
  // Reset to 0 so the next loading state starts from a known
@@ -40740,6 +41404,10 @@ function LogInkApp(deps) {
40740
41404
  worktree,
40741
41405
  }), issuedAtDepth);
40742
41406
  setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'ready'), issuedAtDepth);
41407
+ // Returned so callers needing the *fresh* overview (e.g. post-commit
41408
+ // navigation) can read it directly instead of racing the async
41409
+ // `setContext` update, which won't be visible in their closure.
41410
+ return worktree;
40743
41411
  }, [git, runtimes.length, setContext, setContextStatus]);
40744
41412
  // Live refresh: watch .git metadata + the working tree root and reload
40745
41413
  // context when something changes outside the TUI (editor save, external
@@ -41602,7 +42270,14 @@ function LogInkApp(deps) {
41602
42270
  // and see the pre-commit log (same silent-failure shape as
41603
42271
  // the split-apply case caught in this PR).
41604
42272
  await refreshHistoryRows();
41605
- await refreshWorktreeContext();
42273
+ const worktree = await refreshWorktreeContext();
42274
+ // Leave the compose view automatically: a still-dirty tree returns
42275
+ // to Status (so the user can keep staging), an otherwise-complete
42276
+ // commit returns to History (where the new commit now shows). The
42277
+ // reducer inspects the live viewStack to pick the destination.
42278
+ const stillDirty = Boolean(worktree &&
42279
+ worktree.stagedCount + worktree.unstagedCount + worktree.untrackedCount > 0);
42280
+ dispatch({ type: 'returnFromCommit', stillDirty });
41606
42281
  }
41607
42282
  }, [
41608
42283
  context.worktree?.stagedCount,
@@ -42630,6 +43305,16 @@ function LogInkApp(deps) {
42630
43305
  return { ok: false, message: 'No branch selected' };
42631
43306
  return deleteBranch(git, branch);
42632
43307
  },
43308
+ 'force-delete-branch': async () => {
43309
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
43310
+ const visible = state.filter
43311
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
43312
+ : all;
43313
+ const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
43314
+ if (!branch)
43315
+ return { ok: false, message: 'No branch selected' };
43316
+ return deleteBranch(git, branch, true);
43317
+ },
42633
43318
  'delete-tag': async () => {
42634
43319
  const all = sortTags(context.tags?.tags || [], state.tagSort);
42635
43320
  const visible = state.filter
@@ -43403,9 +44088,26 @@ function LogInkApp(deps) {
43403
44088
  if (remoteOp) {
43404
44089
  dispatch({ type: 'setRemoteOp', value: remoteOp });
43405
44090
  }
44091
+ // Mark the cursored row as deleting so it shows an inline pending
44092
+ // spinner while the git call runs. Cleared in `finally` after the
44093
+ // refresh, so a successful delete hands straight off to the row
44094
+ // vanishing, and a failed one (e.g. an unmerged branch) restores
44095
+ // the row's normal icon alongside the error status.
44096
+ const pendingDeletion = resolvePendingDeletion(id, state, context);
44097
+ if (pendingDeletion) {
44098
+ dispatch({ type: 'setPendingDeletion', value: pendingDeletion });
44099
+ }
43406
44100
  try {
43407
44101
  const result = await handler();
43408
44102
  dispatch({ type: 'setStatus', value: result?.message || 'Workflow action complete' });
44103
+ // A safe `delete-branch` (`git branch -d`) refuses branches that
44104
+ // aren't fully merged. Rather than dead-end on git's raw error, raise
44105
+ // a second y-confirm offering the force-delete (`git branch -D`). The
44106
+ // cursor hasn't moved (the delete failed), so the force handler
44107
+ // re-resolves the same branch.
44108
+ if (id === 'delete-branch' && !result?.ok && isBranchNotFullyMergedError(result?.message)) {
44109
+ dispatch({ type: 'setPendingConfirmation', value: 'force-delete-branch' });
44110
+ }
43409
44111
  // Refresh history rows AS WELL when the workflow could have
43410
44112
  // changed the commits the user sees (#945 follow-up). The
43411
44113
  // workflow IDs below all either create/rewrite local commits or
@@ -43504,6 +44206,12 @@ function LogInkApp(deps) {
43504
44206
  if (remoteOp) {
43505
44207
  dispatch({ type: 'setRemoteOp', value: undefined });
43506
44208
  }
44209
+ // Same guarantee for the per-row delete spinner: clear it whether
44210
+ // the delete succeeded, failed, or the refresh threw, so no row is
44211
+ // left spinning forever.
44212
+ if (pendingDeletion) {
44213
+ dispatch({ type: 'setPendingDeletion', value: undefined });
44214
+ }
43507
44215
  }
43508
44216
  }, [context, dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext,
43509
44217
  state.branchSort, state.filter, state.selectedBranchIndex,
@@ -44313,6 +45021,7 @@ function LogInkApp(deps) {
44313
45021
  const forcedPane = state.splitPlan
44314
45022
  ? 'main'
44315
45023
  : state.showHelp ||
45024
+ state.showViewKeys ||
44316
45025
  state.showCommandPalette ||
44317
45026
  state.showThemePicker ||
44318
45027
  state.gitignorePicker ||
@@ -44362,7 +45071,7 @@ function LogInkApp(deps) {
44362
45071
  // Panel renderers are thunks so single-pane mode can build only the
44363
45072
  // visible pane — the main-panel render in particular is expensive, so
44364
45073
  // we don't want to invoke the two hidden ones just to drop them.
44365
- const sidebarPanel = () => renderSidebar$1(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme);
45074
+ const sidebarPanel = () => renderSidebar$1(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme, spinnerFrame);
44366
45075
  const mainSurface = {
44367
45076
  h,
44368
45077
  components: { Box, Text },
@@ -45125,6 +45834,7 @@ var prs = {
45125
45834
  desc: 'List GitHub pull requests for the current repository (read-only triage)',
45126
45835
  builder: builder$4,
45127
45836
  handler: commandExecutor(handler$3),
45837
+ options: options$4,
45128
45838
  };
45129
45839
 
45130
45840
  const RecapLlmResponseSchema = objectType({
@@ -45204,8 +45914,7 @@ const handler$2 = async (argv, logger) => {
45204
45914
  const summaryService = resolveDynamicService(config, 'summarize');
45205
45915
  const model = recapService.model;
45206
45916
  if (config.service.authentication.type !== 'None' && !key) {
45207
- logger.log(`No API Key found. 🗝️🚪`, { color: 'red' });
45208
- commandExit(1);
45917
+ handleMissingApiKey(logger, config, { command: 'recap' });
45209
45918
  }
45210
45919
  const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
45211
45920
  const llm = getLlm(provider, model, { ...config, service: recapService });
@@ -45793,8 +46502,7 @@ const handler$1 = async (argv, logger) => {
45793
46502
  const summaryService = resolveDynamicService(config, argv.branch ? 'largeDiff' : 'summarize');
45794
46503
  const model = reviewService.model;
45795
46504
  if (config.service.authentication.type !== 'None' && !key) {
45796
- logger.log(`No API Key found. 🗝️🚪`, { color: 'red' });
45797
- commandExit(1);
46505
+ handleMissingApiKey(logger, config, { command: 'review' });
45798
46506
  }
45799
46507
  const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
45800
46508
  const llm = getLlm(provider, model, { ...config, service: reviewService });