git-coco 0.60.0 → 0.61.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.61.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';
@@ -22985,6 +23247,13 @@ const LOG_INK_KEY_BINDINGS = [
22985
23247
  description: 'Create a lightweight tag at the cursored commit.',
22986
23248
  contexts: ['history'],
22987
23249
  },
23250
+ {
23251
+ id: 'viewKeys',
23252
+ keys: ['g?'],
23253
+ label: 'keys',
23254
+ description: 'Show the single-key actions available in the current view (which-key strip).',
23255
+ contexts: ['normal'],
23256
+ },
22988
23257
  {
22989
23258
  id: 'themePicker',
22990
23259
  keys: ['gC'],
@@ -23712,6 +23981,48 @@ function getLogInkHelpSections(options) {
23712
23981
  },
23713
23982
  ];
23714
23983
  }
23984
+ /**
23985
+ * True when a key string is a single, bare printable key (e.g. `c`, `R`,
23986
+ * `[`) rather than a chord (`gh`, `gg`) or a named special key (`up`,
23987
+ * `page down`). Used by the which-key view-keys strip, which surfaces only
23988
+ * the single-key overloads — the chord set already has its own overlay.
23989
+ */
23990
+ function isBareSingleKey(key) {
23991
+ return key.length === 1 && key !== ' ';
23992
+ }
23993
+ /**
23994
+ * Single-key bindings available in the current view (#1137). Powers the
23995
+ * `g?` which-key strip: the per-view counterpart to the `g`-chord overlay.
23996
+ *
23997
+ * Sourced entirely from `LOG_INK_KEY_BINDINGS` (no duplicated key data) and
23998
+ * filtered the same way the help overlay's "This view" section is — by
23999
+ * `contexts` against the active view + focus — then narrowed to bindings
24000
+ * that expose at least one bare single key. Globals (`q`, `?`, `/`, `:`, …)
24001
+ * are excluded: they're always available and already live in the footer and
24002
+ * onboarding tour, so the strip stays focused on the deliberate per-view
24003
+ * overloads (`c`, `R`, `a`, `m`, `S`, `[`/`]`, …) the keymap guard protects.
24004
+ *
24005
+ * Sorted by the first bare key for stable, scannable output.
24006
+ */
24007
+ function getLogInkViewKeyBindings(options) {
24008
+ return LOG_INK_KEY_BINDINGS
24009
+ .filter((binding) => !GLOBAL_BINDING_IDS.includes(binding.id) &&
24010
+ bindingMatchesViewContext(binding, options) &&
24011
+ binding.keys.some(isBareSingleKey))
24012
+ .sort((a, b) => {
24013
+ const aKey = a.keys.find(isBareSingleKey) ?? '';
24014
+ const bKey = b.keys.find(isBareSingleKey) ?? '';
24015
+ return aKey.localeCompare(bKey);
24016
+ });
24017
+ }
24018
+ /**
24019
+ * Format only the bare single keys of a binding for the view-keys strip
24020
+ * (e.g. `['up', 'k']` → `k`). Named/chord keys are dropped — the strip is
24021
+ * about the single-key affordance, and the full key list lives in `?` help.
24022
+ */
24023
+ function formatBindingBareKeys(binding) {
24024
+ return binding.keys.filter(isBareSingleKey).join(' / ');
24025
+ }
23715
24026
  function bindingToPaletteCommand(binding) {
23716
24027
  return {
23717
24028
  id: binding.id,
@@ -25225,7 +25536,39 @@ function replaceRows(state, rows) {
25225
25536
  }
25226
25537
  function appendRows(state, rows) {
25227
25538
  const selected = getSelectedInkCommit(state);
25228
- const nextRows = [...state.rows, ...rows];
25539
+ // Dedup the merged row list by commit hash so the graph renderer —
25540
+ // which windows directly over `state.rows` (toFullGraphItems →
25541
+ // expandRowsWithSpacers) — and the selection list (deduped commits)
25542
+ // agree on one canonical, duplicate-free row order. Overlapping
25543
+ // appends, notably the anchored `loadCommitContext` page that
25544
+ // re-walks history from the tip, otherwise stack the newest commits
25545
+ // below the oldest ones already loaded. The renderer then shows the
25546
+ // initial commit directly above HEAD and the cursor can scroll
25547
+ // forever through the duplicated tail — the history graph "looping
25548
+ // back on itself". Drop graph-only topology rows that trail a dropped
25549
+ // duplicate commit too, since they describe that duplicate's lanes
25550
+ // and would otherwise dangle.
25551
+ const seenHashes = new Set();
25552
+ const nextRows = [];
25553
+ let droppingTrailingGraph = false;
25554
+ for (const row of [...state.rows, ...rows]) {
25555
+ if (row.type === 'commit') {
25556
+ if (seenHashes.has(row.hash)) {
25557
+ droppingTrailingGraph = true;
25558
+ continue;
25559
+ }
25560
+ seenHashes.add(row.hash);
25561
+ droppingTrailingGraph = false;
25562
+ nextRows.push(row);
25563
+ continue;
25564
+ }
25565
+ // Graph-only topology row: keep it unless it trails a just-dropped
25566
+ // duplicate commit (then it belongs to the duplicate page's lanes).
25567
+ if (droppingTrailingGraph) {
25568
+ continue;
25569
+ }
25570
+ nextRows.push(row);
25571
+ }
25229
25572
  const seen = new Set();
25230
25573
  const commits = getCommitRows(nextRows).filter((commit) => {
25231
25574
  if (seen.has(commit.hash)) {
@@ -25317,6 +25660,7 @@ function createLogInkState(rows, options = {}) {
25317
25660
  fullGraph: options.fullGraph ?? true,
25318
25661
  showHelp: false,
25319
25662
  helpScrollOffset: 0,
25663
+ showViewKeys: false,
25320
25664
  showCommandPalette: false,
25321
25665
  workflowActionId: undefined,
25322
25666
  pendingConfirmationId: undefined,
@@ -25822,6 +26166,22 @@ function applyLogInkAction(state, action) {
25822
26166
  pendingKey: undefined,
25823
26167
  };
25824
26168
  }
26169
+ case 'returnFromCommit': {
26170
+ // After a successful commit we leave the compose view automatically.
26171
+ // Where to: a still-dirty tree the user was staging from returns to
26172
+ // the Status view so they can finish the rest; an otherwise-complete
26173
+ // commit returns to the History view, where the new commit now shows.
26174
+ // We pop frames one at a time (reusing withPoppedView) so sidebar-tab
26175
+ // and diff-state restoration stays identical to manual Esc/back —
26176
+ // this also unwinds an intermediate `diff` frame (status → diff →
26177
+ // compose) back to the status frame it sits under.
26178
+ const target = action.stillDirty && state.viewStack.includes('status') ? 'status' : HOME_VIEW;
26179
+ let next = state;
26180
+ while (next.viewStack.length > 1 && topOfStack(next.viewStack) !== target) {
26181
+ next = withPoppedView(next);
26182
+ }
26183
+ return { ...next, pendingKey: undefined };
26184
+ }
25825
26185
  case 'navigateOpenDiffForCommit': {
25826
26186
  const next = withPushedView(state, 'diff');
25827
26187
  const filteredCommits = state.filteredCommits;
@@ -26018,6 +26378,7 @@ function applyLogInkAction(state, action) {
26018
26378
  showCommandPalette: false,
26019
26379
  showHelp: false,
26020
26380
  helpScrollOffset: 0,
26381
+ showViewKeys: false,
26021
26382
  pendingKey: undefined,
26022
26383
  };
26023
26384
  case 'toggleGraph':
@@ -26036,9 +26397,24 @@ function applyLogInkAction(state, action) {
26036
26397
  // than picking up where the user last scrolled.
26037
26398
  helpScrollOffset: 0,
26038
26399
  showCommandPalette: false,
26400
+ // Opening full help supersedes the compact view-keys strip — this
26401
+ // is the progressive-disclosure step (`?` from the strip expands
26402
+ // to the full categorized help, #1137).
26403
+ showViewKeys: false,
26039
26404
  pendingKey: undefined,
26040
26405
  };
26041
26406
  }
26407
+ case 'toggleViewKeys':
26408
+ return {
26409
+ ...state,
26410
+ showViewKeys: !state.showViewKeys,
26411
+ // The view-keys strip is mutually exclusive with the other
26412
+ // overlays; opening it closes anything else that was showing.
26413
+ showHelp: false,
26414
+ helpScrollOffset: 0,
26415
+ showCommandPalette: false,
26416
+ pendingKey: undefined,
26417
+ };
26042
26418
  case 'scrollHelp':
26043
26419
  // No upper-bound clamp here — the renderer caps the offset
26044
26420
  // against the actual content height at render time. The
@@ -26055,6 +26431,7 @@ function applyLogInkAction(state, action) {
26055
26431
  showCommandPalette: opening,
26056
26432
  showHelp: false,
26057
26433
  helpScrollOffset: 0,
26434
+ showViewKeys: false,
26058
26435
  // Reset palette interaction state on every open/close so the next
26059
26436
  // session starts from a clean slate.
26060
26437
  paletteFilter: '',
@@ -26102,8 +26479,9 @@ function applyLogInkAction(state, action) {
26102
26479
  return {
26103
26480
  ...state,
26104
26481
  showThemePicker: opening,
26105
- // Only one overlay at a time — close help / palette on open.
26482
+ // Only one overlay at a time — close help / palette / view-keys on open.
26106
26483
  showHelp: false,
26484
+ showViewKeys: false,
26107
26485
  showCommandPalette: false,
26108
26486
  themePickerFilter: '',
26109
26487
  themePickerIndex: 0,
@@ -26903,6 +27281,10 @@ function getLogInkPaletteExecuteEvents(command, state) {
26903
27281
  // Palette closes on execute (toggleCommandPalette runs first), then
26904
27282
  // this opens the theme picker.
26905
27283
  return [action({ type: 'toggleThemePicker' })];
27284
+ case 'viewKeys':
27285
+ // Palette closes on execute (toggleCommandPalette runs first), then
27286
+ // this opens the per-view which-key strip (#1137).
27287
+ return [action({ type: 'toggleViewKeys' })];
26906
27288
  case 'openProjectConfig':
26907
27289
  return [{ type: 'openConfigInEditor', scope: 'project' }];
26908
27290
  case 'openGlobalConfig':
@@ -27631,6 +28013,26 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27631
28013
  }
27632
28014
  return [];
27633
28015
  }
28016
+ // #1137 — the `g?` which-key strip. While it's open the keyboard is
28017
+ // claimed (mirrors the help overlay) so a stray keystroke can't drop
28018
+ // the user into a per-view action they didn't mean to trigger. Esc
28019
+ // closes; `?` is the progressive-disclosure step up to the full
28020
+ // categorized help; `q` still quits. Everything else is swallowed —
28021
+ // the user peeks, dismisses, then presses the key they came for.
28022
+ if (state.showViewKeys) {
28023
+ if (key.escape) {
28024
+ return [action({ type: 'toggleViewKeys' })];
28025
+ }
28026
+ if (inputValue === '?') {
28027
+ // Expand the compact strip into the full help overlay. `toggleHelp`
28028
+ // clears `showViewKeys` so the two never render at once.
28029
+ return [action({ type: 'toggleHelp' })];
28030
+ }
28031
+ if (inputValue === 'q') {
28032
+ return [{ type: 'exit' }];
28033
+ }
28034
+ return [];
28035
+ }
27634
28036
  // #879 item 4 — Esc cancels an in-flight bisect-start wizard. Runs
27635
28037
  // BEFORE the generic `popView` so we both clear the wizard state
27636
28038
  // and walk back to the bisect view in one keystroke. Without this
@@ -27674,6 +28076,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27674
28076
  }
27675
28077
  return [{ type: 'exit' }];
27676
28078
  }
28079
+ // `g?` chord (#1137) — open the per-view which-key strip. Placed
28080
+ // BEFORE the bare `?` (full help) check below so the chord is read as
28081
+ // a unit: with `g` pending, `?` opens the view-keys strip rather than
28082
+ // toggling full help. Surfaces automatically in the `g` which-key menu
28083
+ // because its key is a two-char `g`-prefixed binding.
28084
+ if (state.pendingKey === 'g' && inputValue === '?') {
28085
+ return [
28086
+ action({ type: 'setPendingKey', value: undefined }),
28087
+ action({ type: 'toggleViewKeys' }),
28088
+ ];
28089
+ }
27677
28090
  if (inputValue === '?') {
27678
28091
  return [action({ type: 'toggleHelp' })];
27679
28092
  }
@@ -30656,7 +31069,7 @@ function fetchBranch(git, branch) {
30656
31069
  if (!branch.upstream || !branch.remote) {
30657
31070
  return Promise.resolve({
30658
31071
  ok: false,
30659
- message: `${branch.shortName} has no upstream — nothing to fetch.`,
31072
+ message: `${branch.shortName} has no upstream — set one with \`git push -u <remote> ${branch.shortName}\` to enable fetch.`,
30660
31073
  });
30661
31074
  }
30662
31075
  // `branch.upstream` is the short form (e.g. `origin/main`); the
@@ -30694,7 +31107,7 @@ function pullBranch(git, branch, currentBranchName) {
30694
31107
  if (!branch.upstream || !branch.remote) {
30695
31108
  return Promise.resolve({
30696
31109
  ok: false,
30697
- message: `${branch.shortName} has no upstream — nothing to pull.`,
31110
+ message: `${branch.shortName} has no upstream — set one with \`git push -u <remote> ${branch.shortName}\` to enable pull.`,
30698
31111
  });
30699
31112
  }
30700
31113
  // Current branch — defer to the in-place workflow.
@@ -32002,13 +32415,14 @@ async function getPullRequestList(git, filter = {}, runner = defaultGhRunner) {
32002
32415
  message: 'No GitHub remote detected.',
32003
32416
  };
32004
32417
  }
32005
- if (!(await isGhAuthenticated(runner))) {
32418
+ const ghStatus = await getGhStatus(runner);
32419
+ if (ghStatus.kind !== 'ok') {
32006
32420
  return {
32007
32421
  available: true,
32008
32422
  authenticated: false,
32009
32423
  repository,
32010
32424
  filter,
32011
- message: 'GitHub CLI is missing or not authenticated.',
32425
+ message: describeGhStatus(ghStatus),
32012
32426
  };
32013
32427
  }
32014
32428
  try {
@@ -32874,6 +33288,7 @@ function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFr
32874
33288
  // of the runtime's `forcedPane` derivation in `app.ts`.
32875
33289
  const overlayForcesPane = Boolean(state.splitPlan ||
32876
33290
  state.showHelp ||
33291
+ state.showViewKeys ||
32877
33292
  state.showCommandPalette ||
32878
33293
  state.showThemePicker ||
32879
33294
  state.gitignorePicker ||
@@ -34778,8 +35193,16 @@ function renderComposeSurface(ctx, spinnerFrame = 0) {
34778
35193
  const bodyVisualLines = compose.body
34779
35194
  ? compose.body.split('\n').flatMap((line) => wrapCells(line, bodyTextWidth)).slice(0, bodyRowsAvailable)
34780
35195
  : ['<empty>'];
34781
- const summaryVisualLines = wrapCells(`${compose.summary || '<empty>'}${summaryCursor}`, Math.max(8, width - 11) // "Summary " (9) + 2 chrome = 11
34782
- );
35196
+ // Summary now renders on its own indented line under the label (like the
35197
+ // body), so it wraps at the full content width instead of the cramped
35198
+ // "Summary " (9) + chrome budget it had when label and value shared a row.
35199
+ const summaryVisualLines = compose.summary
35200
+ ? compose.summary.split('\n').flatMap((line) => wrapCells(line, bodyTextWidth))
35201
+ : ['<empty>'];
35202
+ // Subject length drives a subtle counter on the Summary label: dim under
35203
+ // 50, warning past the conventional 50-char soft limit, danger past 72.
35204
+ // Counted in code points so multibyte subjects aren't over-counted.
35205
+ const summaryLength = [...compose.summary].length;
34783
35206
  // State-line cycles through three modes (#881 phase 3 added the
34784
35207
  // loading variant): editing copy when the user is typing, cancel
34785
35208
  // hint when an AI draft is generating, default guidance otherwise.
@@ -34799,6 +35222,52 @@ function renderComposeSurface(ctx, spinnerFrame = 0) {
34799
35222
  const noStagedHint = !isLogInkContextKeyLoading(contextStatus, 'worktree')
34800
35223
  ? formatLogInkComposeEmpty({ hasStaged: hasStagedFiles })
34801
35224
  : undefined;
35225
+ // Section header for a field (Summary / Body). The active field's label
35226
+ // carries an arrow marker + the repo's selection highlight (matching the
35227
+ // status surface, see status/index.ts) so the user can see which field
35228
+ // their keystrokes target — even before entering edit mode, and even
35229
+ // under NO_COLOR where the marker + bold/dim carry the signal alone. An
35230
+ // optional length counter (Summary only) trails the label outside the
35231
+ // highlight so its own warning/danger color stays legible.
35232
+ const renderSectionHeader = (name, field, count) => {
35233
+ const active = compose.field === field;
35234
+ const highlight = active && focused && !theme.noColor;
35235
+ const marker = active ? (theme.ascii ? '> ' : '▸ ') : ' ';
35236
+ const badge = active && compose.editing ? ' EDITING' : '';
35237
+ const children = [
35238
+ h(Text, {
35239
+ key: `compose-${field}-label`,
35240
+ bold: active,
35241
+ dimColor: !active,
35242
+ backgroundColor: highlight ? theme.colors.selection : undefined,
35243
+ color: highlight ? theme.colors.selectionForeground : undefined,
35244
+ }, `${marker}${name}${badge}`),
35245
+ ];
35246
+ if (count !== undefined) {
35247
+ const countColor = theme.noColor
35248
+ ? undefined
35249
+ : count > 72
35250
+ ? theme.colors.danger
35251
+ : count > 50
35252
+ ? theme.colors.warning
35253
+ : undefined;
35254
+ children.push(h(Text, {
35255
+ key: `compose-${field}-count`,
35256
+ color: countColor,
35257
+ dimColor: countColor === undefined,
35258
+ }, ` ${count}`));
35259
+ }
35260
+ return h(Box, { key: `compose-${field}-header` }, ...children);
35261
+ };
35262
+ // Content lines for a field — indented two cells under the header, with
35263
+ // the edit cursor parked on the final line when this field is active.
35264
+ const renderSectionContent = (lines, field, cursor) => lines.map((line, index) => {
35265
+ const isLast = index === lines.length - 1;
35266
+ return h(Text, {
35267
+ key: `compose-${field}-${index}`,
35268
+ dimColor: line === '<empty>',
35269
+ }, ` ${line}${cursor && isLast ? cursor : ''}`);
35270
+ });
34802
35271
  return h(Box, {
34803
35272
  borderColor: focusBorderColor(theme, focused),
34804
35273
  borderStyle: theme.borderStyle,
@@ -34806,20 +35275,7 @@ function renderComposeSurface(ctx, spinnerFrame = 0) {
34806
35275
  flexShrink: 0,
34807
35276
  paddingX: 1,
34808
35277
  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
- }),
35278
+ }, 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
35279
  // Loading indicator + post-action message belong inline with the draft
34824
35280
  // (they describe what just happened to the fields above). The state-
34825
35281
  // line ("Editing — Enter switches summary↔body…" / "Press e to edit
@@ -37157,6 +37613,54 @@ function renderChordOverlay(h, components, state, width, theme, focused) {
37157
37613
  paddingX: 1,
37158
37614
  }, ...lines);
37159
37615
  }
37616
+ /**
37617
+ * Which-key view-keys strip (#1137). The per-view counterpart to the
37618
+ * `g`-chord overlay: opened by `g?`, it lists the single-key actions
37619
+ * available in the current view (the deliberate overloads — `c`, `R`,
37620
+ * `a`, `m`, `S`, `[`/`]`, …) with their labels, sourced from
37621
+ * `LOG_INK_KEY_BINDINGS` filtered by the active view + focus.
37622
+ *
37623
+ * Renders in the detail panel slot like the chord overlay. `?` steps up
37624
+ * to the full categorized help; Esc closes.
37625
+ */
37626
+ function renderViewKeysOverlay(h, components, state, width, theme, focused) {
37627
+ const { Box, Text } = components;
37628
+ const bindings = getLogInkViewKeyBindings({
37629
+ activeView: state.activeView,
37630
+ focus: state.focus,
37631
+ });
37632
+ const accent = theme.noColor ? undefined : theme.colors.accent;
37633
+ const lines = [
37634
+ h(Text, { key: 'view-keys-title', bold: true }, panelTitle(`keys · ${state.activeView}`, focused)),
37635
+ h(Text, { key: 'view-keys-spacer' }, ''),
37636
+ ];
37637
+ if (bindings.length === 0) {
37638
+ lines.push(h(Text, {
37639
+ key: 'view-keys-empty',
37640
+ dimColor: true,
37641
+ }, truncateCells('No single-key actions in this view — use ? for the full help.', width - 4)));
37642
+ }
37643
+ else {
37644
+ // Pad keys to the widest entry so labels align into a scannable column.
37645
+ const keyColumn = bindings.reduce((max, binding) => Math.max(max, formatBindingBareKeys(binding).length), 0);
37646
+ for (const binding of bindings) {
37647
+ const keys = formatBindingBareKeys(binding);
37648
+ 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))));
37649
+ }
37650
+ }
37651
+ lines.push(h(Text, { key: 'view-keys-foot-spacer' }, ''));
37652
+ lines.push(h(Text, {
37653
+ key: 'view-keys-hint',
37654
+ dimColor: true,
37655
+ }, truncateCells('? full help · esc closes', width - 4)));
37656
+ return h(Box, {
37657
+ borderColor: focusBorderColor(theme, focused),
37658
+ borderStyle: theme.borderStyle,
37659
+ flexDirection: 'column',
37660
+ width,
37661
+ paddingX: 1,
37662
+ }, ...lines);
37663
+ }
37160
37664
  function renderHelpPanel(h, components, state, width, theme, focused, bodyRows = 0) {
37161
37665
  const { Box, Text } = components;
37162
37666
  // Build the full list of body rows (everything below the title).
@@ -39835,6 +40339,12 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
39835
40339
  if (state.showHelp) {
39836
40340
  return renderHelpPanel(h, components, state, width, theme, focused, bodyRows);
39837
40341
  }
40342
+ // #1137 — the `g?` which-key strip lists the current view's single-key
40343
+ // actions. Checked alongside the other overlays; the reducer keeps it
40344
+ // mutually exclusive with help / palette / pickers.
40345
+ if (state.showViewKeys) {
40346
+ return renderViewKeysOverlay(h, components, state, width, theme, focused);
40347
+ }
39838
40348
  if (state.showCommandPalette) {
39839
40349
  return renderCommandPalette(h, components, state, width, theme, focused);
39840
40350
  }
@@ -40740,6 +41250,10 @@ function LogInkApp(deps) {
40740
41250
  worktree,
40741
41251
  }), issuedAtDepth);
40742
41252
  setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'ready'), issuedAtDepth);
41253
+ // Returned so callers needing the *fresh* overview (e.g. post-commit
41254
+ // navigation) can read it directly instead of racing the async
41255
+ // `setContext` update, which won't be visible in their closure.
41256
+ return worktree;
40743
41257
  }, [git, runtimes.length, setContext, setContextStatus]);
40744
41258
  // Live refresh: watch .git metadata + the working tree root and reload
40745
41259
  // context when something changes outside the TUI (editor save, external
@@ -41602,7 +42116,14 @@ function LogInkApp(deps) {
41602
42116
  // and see the pre-commit log (same silent-failure shape as
41603
42117
  // the split-apply case caught in this PR).
41604
42118
  await refreshHistoryRows();
41605
- await refreshWorktreeContext();
42119
+ const worktree = await refreshWorktreeContext();
42120
+ // Leave the compose view automatically: a still-dirty tree returns
42121
+ // to Status (so the user can keep staging), an otherwise-complete
42122
+ // commit returns to History (where the new commit now shows). The
42123
+ // reducer inspects the live viewStack to pick the destination.
42124
+ const stillDirty = Boolean(worktree &&
42125
+ worktree.stagedCount + worktree.unstagedCount + worktree.untrackedCount > 0);
42126
+ dispatch({ type: 'returnFromCommit', stillDirty });
41606
42127
  }
41607
42128
  }, [
41608
42129
  context.worktree?.stagedCount,
@@ -44313,6 +44834,7 @@ function LogInkApp(deps) {
44313
44834
  const forcedPane = state.splitPlan
44314
44835
  ? 'main'
44315
44836
  : state.showHelp ||
44837
+ state.showViewKeys ||
44316
44838
  state.showCommandPalette ||
44317
44839
  state.showThemePicker ||
44318
44840
  state.gitignorePicker ||
@@ -45125,6 +45647,7 @@ var prs = {
45125
45647
  desc: 'List GitHub pull requests for the current repository (read-only triage)',
45126
45648
  builder: builder$4,
45127
45649
  handler: commandExecutor(handler$3),
45650
+ options: options$4,
45128
45651
  };
45129
45652
 
45130
45653
  const RecapLlmResponseSchema = objectType({
@@ -45204,8 +45727,7 @@ const handler$2 = async (argv, logger) => {
45204
45727
  const summaryService = resolveDynamicService(config, 'summarize');
45205
45728
  const model = recapService.model;
45206
45729
  if (config.service.authentication.type !== 'None' && !key) {
45207
- logger.log(`No API Key found. 🗝️🚪`, { color: 'red' });
45208
- commandExit(1);
45730
+ handleMissingApiKey(logger, config, { command: 'recap' });
45209
45731
  }
45210
45732
  const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
45211
45733
  const llm = getLlm(provider, model, { ...config, service: recapService });
@@ -45793,8 +46315,7 @@ const handler$1 = async (argv, logger) => {
45793
46315
  const summaryService = resolveDynamicService(config, argv.branch ? 'largeDiff' : 'summarize');
45794
46316
  const model = reviewService.model;
45795
46317
  if (config.service.authentication.type !== 'None' && !key) {
45796
- logger.log(`No API Key found. 🗝️🚪`, { color: 'red' });
45797
- commandExit(1);
46318
+ handleMissingApiKey(logger, config, { command: 'review' });
45798
46319
  }
45799
46320
  const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
45800
46321
  const llm = getLlm(provider, model, { ...config, service: reviewService });