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.
@@ -61,7 +61,7 @@ import { pathToFileURL } from 'url';
61
61
  /**
62
62
  * Current build version from package.json
63
63
  */
64
- const BUILD_VERSION = "0.60.0";
64
+ const BUILD_VERSION = "0.61.0";
65
65
 
66
66
  const isInteractive = (config) => {
67
67
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -292,10 +292,19 @@ class LangChainExecutionError extends LangChainError {
292
292
  }
293
293
  /**
294
294
  * Authentication-related errors (missing API keys, invalid credentials, etc.)
295
+ *
296
+ * Carries `provider` + `endpoint` context so the formatter (in
297
+ * `commandExecutor`) can render provider-specific recovery hints
298
+ * ("set OPENAI_API_KEY", "run `gh auth login`", etc.) instead of the
299
+ * generic "verify your API key" copy. Mirrors the shape of
300
+ * `LangChainNetworkError` so call sites can hand the same fields to
301
+ * either constructor depending on which condition fired.
295
302
  */
296
303
  class LangChainAuthenticationError extends LangChainError {
297
- constructor(message, context) {
298
- super(message, context);
304
+ constructor(message, provider, endpoint, context) {
305
+ super(message, { ...context, provider, endpoint });
306
+ this.provider = provider;
307
+ this.endpoint = endpoint;
299
308
  }
300
309
  }
301
310
  /**
@@ -433,21 +442,27 @@ function getDefaultServiceApiKey(config) {
433
442
  const requiresAuth = provider === 'openai' || provider === 'anthropic';
434
443
  if (service.authentication.type === 'APIKey') {
435
444
  const apiKey = service.authentication.credentials?.apiKey;
445
+ // `endpoint` is optional on some service variants (Ollama / OpenAI-
446
+ // compatible) and absent on others (managed OpenAI / Anthropic).
447
+ // Read defensively so we still attach it when present.
448
+ const endpoint = service.endpoint;
436
449
  if (requiresAuth && (!apiKey || apiKey.trim() === '')) {
437
- throw new LangChainAuthenticationError(`getDefaultServiceApiKey: API key is required for ${provider} provider but not provided`, { provider, authenticationType: service.authentication.type });
450
+ throw new LangChainAuthenticationError(`getDefaultServiceApiKey: API key is required for ${provider} provider but not provided`, provider, endpoint, { authenticationType: service.authentication.type });
438
451
  }
439
452
  return apiKey || '';
440
453
  }
441
454
  if (service.authentication.type === 'OAuth') {
442
455
  const token = service.authentication.credentials?.token;
456
+ const endpoint = service.endpoint;
443
457
  if (requiresAuth && (!token || token.trim() === '')) {
444
- throw new LangChainAuthenticationError(`getDefaultServiceApiKey: OAuth token is required for ${provider} provider but not provided`, { provider, authenticationType: service.authentication.type });
458
+ throw new LangChainAuthenticationError(`getDefaultServiceApiKey: OAuth token is required for ${provider} provider but not provided`, provider, endpoint, { authenticationType: service.authentication.type });
445
459
  }
446
460
  return token || '';
447
461
  }
448
462
  if (service.authentication.type === 'None') {
449
463
  if (requiresAuth) {
450
- throw new LangChainAuthenticationError(`getDefaultServiceApiKey: ${provider} provider requires authentication but 'None' was configured`, { provider, authenticationType: service.authentication.type });
464
+ const endpoint = service.endpoint;
465
+ throw new LangChainAuthenticationError(`getDefaultServiceApiKey: ${provider} provider requires authentication but 'None' was configured`, provider, endpoint, { authenticationType: service.authentication.type });
451
466
  }
452
467
  return '';
453
468
  }
@@ -2580,18 +2595,48 @@ function formatNetworkError(error, logger) {
2580
2595
  logger.log(' • Verify the service endpoint is correct', { color: 'white' });
2581
2596
  logger.log(' • Ensure the LLM service is running and accessible', { color: 'white' });
2582
2597
  }
2598
+ logger.log(' • Run `coco doctor` to verify your configured provider + endpoint', { color: 'white' });
2583
2599
  logger.verbose(`\nOriginal error: ${error.message}`, { color: 'gray' });
2584
2600
  }
2585
2601
  /**
2586
- * Formats an authentication error with helpful information
2602
+ * Formats an authentication error with provider-aware troubleshooting.
2603
+ *
2604
+ * Pre-MEDIUM-8 the formatter was generic — "verify your API key,
2605
+ * check it hasn't expired" — because the error class didn't carry
2606
+ * any provider context. Now that `LangChainAuthenticationError`
2607
+ * carries `provider` + `endpoint` (mirroring `LangChainNetworkError`),
2608
+ * we can name the env var the user actually needs to set and route
2609
+ * Ollama / OpenAI-compatible / managed-provider users through the
2610
+ * right next step.
2587
2611
  */
2588
2612
  function formatAuthenticationError(error, logger) {
2613
+ const provider = error.provider || 'LLM service';
2614
+ const endpoint = error.endpoint;
2589
2615
  logger.log('\nFailed to execute command', { color: 'yellow' });
2590
- logger.log('\nError: Authentication failed', { color: 'red' });
2616
+ logger.log(`\nError: Authentication failed${error.provider ? ` for ${provider}` : ''}`, { color: 'red' });
2617
+ if (endpoint) {
2618
+ logger.log(` Endpoint: ${endpoint}`, { color: 'red' });
2619
+ }
2591
2620
  logger.log('\nTroubleshooting:', { color: 'cyan' });
2592
- logger.log(' • Verify your API key is correct', { color: 'white' });
2593
- logger.log(' • Check that your API key has not expired', { color: 'white' });
2594
- logger.log(' • Ensure the API key is set in your environment or config', { color: 'white' });
2621
+ logger.log(' • Verify your API key is correct and has not expired', { color: 'white' });
2622
+ // Provider-specific env var hint when we know the provider.
2623
+ if (provider === 'openai' || provider === 'OpenAI') {
2624
+ logger.log(' • Set `OPENAI_API_KEY` in your shell or `service.authentication.credentials.apiKey` in config', { color: 'white' });
2625
+ }
2626
+ else if (provider === 'anthropic' || provider === 'Anthropic') {
2627
+ logger.log(' • Set `ANTHROPIC_API_KEY` in your shell or `service.authentication.credentials.apiKey` in config', { color: 'white' });
2628
+ }
2629
+ else if (provider === 'ollama' || provider === 'Ollama') {
2630
+ logger.log(' • Ollama usually does not need a key — check `service.endpoint` and that `ollama serve` is running', { color: 'white' });
2631
+ }
2632
+ else if (provider === 'openai-compatible') {
2633
+ logger.log(' • OpenAI-compatible endpoints need both `service.endpoint` and a valid API key', { color: 'white' });
2634
+ }
2635
+ else {
2636
+ logger.log(' • Ensure the API key is set in your environment or config', { color: 'white' });
2637
+ }
2638
+ logger.log(' • Run `coco init` to (re)configure your provider + key', { color: 'white' });
2639
+ logger.log(' • Run `coco doctor` to inspect the active config sources', { color: 'white' });
2595
2640
  logger.verbose(`\nOriginal error: ${error.message}`, { color: 'gray' });
2596
2641
  }
2597
2642
  /**
@@ -3561,8 +3606,7 @@ const handler$b = async (argv, logger) => {
3561
3606
  const result = clearDiffSummaryCache(repoPath);
3562
3607
  if (!result.ok) {
3563
3608
  logger.log(chalk.red(`Failed to clear diff-summary cache at ${cachePath}`));
3564
- process.exitCode = 1;
3565
- return;
3609
+ commandExit(1, 'cache clear failed');
3566
3610
  }
3567
3611
  if (result.removed) {
3568
3612
  logger.log(chalk.green(`Cleared diff-summary cache at ${cachePath}`));
@@ -3602,8 +3646,7 @@ const handler$b = async (argv, logger) => {
3602
3646
  if (interactive) {
3603
3647
  const picked = await promptLanguageSelection(logger);
3604
3648
  if (!picked) {
3605
- process.exitCode = 1;
3606
- return;
3649
+ commandExit(1, 'cache prefetch cancelled');
3607
3650
  }
3608
3651
  resolved = picked;
3609
3652
  }
@@ -3620,7 +3663,7 @@ const handler$b = async (argv, logger) => {
3620
3663
  `${chalk.dim(`${result.alreadyCached.length} already cached`)} · ` +
3621
3664
  `${chalk.red(`${result.failed.length} failed`)}`);
3622
3665
  if (result.failed.length > 0) {
3623
- process.exitCode = 1;
3666
+ commandExit(1, `cache prefetch failed for ${result.failed.length} language(s)`);
3624
3667
  }
3625
3668
  return;
3626
3669
  }
@@ -3653,12 +3696,12 @@ const handler$b = async (argv, logger) => {
3653
3696
  }
3654
3697
  logger.log(chalk.red(`Unknown cache subcommand: ${subcommand}`));
3655
3698
  logger.log(chalk.dim('Use one of: clear, info, parsers, prefetch, clear-parsers, clear-github'));
3656
- process.exitCode = 1;
3699
+ commandExit(1, `unknown cache subcommand: ${subcommand}`);
3657
3700
  };
3658
3701
 
3659
3702
  var cache = {
3660
3703
  command: command$b,
3661
- desc: 'Manage the diff-summary cache (clear, info)',
3704
+ desc: 'Manage coco caches (clear, info, parsers, prefetch, github)',
3662
3705
  builder: builder$b,
3663
3706
  handler: commandExecutor(handler$b),
3664
3707
  };
@@ -8927,6 +8970,124 @@ async function generateAndReviewLoop({ label, factory, parser, noResult, agent,
8927
8970
  return result;
8928
8971
  }
8929
8972
 
8973
+ /**
8974
+ * Centralised glyph + label vocabulary for diagnostic / status copy.
8975
+ *
8976
+ * Before this module each surface (commandExecutor, doctor, footer,
8977
+ * cache, issues, prs, commit-hook flow) picked its own marks for
8978
+ * pass / warn / fail / info — `✓` here, `✔` there, `✖` vs `✗`. Users
8979
+ * couldn't lean on a consistent visual signal to scan output, and the
8980
+ * audit flagged it as one of the bigger inconsistencies in the
8981
+ * codebase.
8982
+ *
8983
+ * The vocabulary mirrors what Linux package managers + git-aware
8984
+ * tools converge on (`pacman`, `apt`, `nala`, `npm doctor`, etc.) —
8985
+ * green check / red fail / yellow warn / blue info. ASCII fallbacks
8986
+ * are first-class so dumb terminals (TERM=dumb / vt100) still render
8987
+ * a meaningful prefix.
8988
+ *
8989
+ * Conventions:
8990
+ * - Status glyphs (PASS / FAIL / WARN / INFO) — for diagnostic
8991
+ * output, command exit, doctor severity, footer message kinds.
8992
+ * Colour-coded variants live alongside as `*_COLORED` helpers
8993
+ * so callers can use either depending on context.
8994
+ * - Action glyphs (BULLET, ARROW) — for indented hint lines and
8995
+ * "next step" callouts.
8996
+ * - Domain glyphs (CHECK_RUN_*, DECISION_*) — keep their own
8997
+ * vocabularies (PR reviews, status checks) because their
8998
+ * semantic shape doesn't map cleanly onto pass/fail/warn/info.
8999
+ *
9000
+ * Use `pickGlyph(unicode, ascii, isAscii)` when you need to honor
9001
+ * `theme.ascii` mode in a single call site.
9002
+ */
9003
+ /**
9004
+ * Status-severity glyph set. Same vocabulary as the workstation
9005
+ * footer's `kind` field (info / warning / error / success / loading)
9006
+ * plus `pass` for the doctor / "no problem" case.
9007
+ */
9008
+ const GLYPHS = {
9009
+ pass: '✓',
9010
+ fail: '✖',
9011
+ warn: '⚠',
9012
+ info: 'ℹ',
9013
+ bullet: '•'};
9014
+ /**
9015
+ * Theme-tinted helpers for terminal output. These return chalk-wrapped
9016
+ * strings so callers don't repeat the `chalk.<color>(GLYPHS.<key>)`
9017
+ * pattern. Each maps to the canonical colour the codebase uses for
9018
+ * that severity:
9019
+ *
9020
+ * - PASS → green
9021
+ * - FAIL → red
9022
+ * - WARN → yellow
9023
+ * - INFO → blue
9024
+ *
9025
+ * Doctor's `SEVERITY_ICON` lookup is the canonical example — it now
9026
+ * delegates here so the colours stay in sync if the theme palette
9027
+ * shifts in the future.
9028
+ */
9029
+ const PASS = () => chalk.green(GLYPHS.pass);
9030
+ const FAIL = () => chalk.red(GLYPHS.fail);
9031
+ const WARN = () => chalk.yellow(GLYPHS.warn);
9032
+ const INFO = () => chalk.blue(GLYPHS.info);
9033
+
9034
+ /**
9035
+ * Maps each provider to the env var users should set + the kebab-case
9036
+ * provider label used in the recovery copy. `coco init` and `coco
9037
+ * doctor` both reference these names; keeping the lookup in one place
9038
+ * makes the messages stay aligned when a new provider lands.
9039
+ */
9040
+ const PROVIDER_ENV_VARS = {
9041
+ openai: { envVar: 'OPENAI_API_KEY', label: 'OpenAI' },
9042
+ anthropic: { envVar: 'ANTHROPIC_API_KEY', label: 'Anthropic' },
9043
+ ollama: { envVar: 'OLLAMA_API_KEY', label: 'Ollama' },
9044
+ 'openai-compatible': { envVar: 'OPENAI_API_KEY', label: 'OpenAI-compatible' },
9045
+ };
9046
+ /**
9047
+ * Print a structured "missing API key" message + exit non-zero.
9048
+ *
9049
+ * Replaces the old `No API Key found. 🗝️🚪` one-liner that used to live
9050
+ * inline in commit / changelog / recap / review handlers. Centralised
9051
+ * because:
9052
+ *
9053
+ * 1. The message names the env var the user actually needs to set
9054
+ * (different per provider) — that was the single biggest gap in
9055
+ * the prior message.
9056
+ * 2. It surfaces the configured provider + model so the user can tell
9057
+ * which of their providers tripped the check (useful when running
9058
+ * with dynamic model routing).
9059
+ * 3. It points at `coco init` and `coco doctor` as the recovery
9060
+ * paths, mirroring the discoverability cue every other modern CLI
9061
+ * uses for first-run config errors.
9062
+ *
9063
+ * Throws `CommandExitError(1)` via `commandExit` — callers do NOT need
9064
+ * to handle the return value.
9065
+ */
9066
+ function handleMissingApiKey(logger, config, options) {
9067
+ const provider = config.service?.provider || 'unknown';
9068
+ const model = config.service?.model || 'unknown';
9069
+ const providerInfo = PROVIDER_ENV_VARS[provider] || {
9070
+ envVar: 'PROVIDER_API_KEY',
9071
+ label: provider,
9072
+ };
9073
+ const lines = [
9074
+ `${FAIL()} ${chalk.bold('Missing API key')} for ${chalk.cyan(providerInfo.label)} (model: ${chalk.cyan(model)})`,
9075
+ '',
9076
+ `${chalk.bold('Next step')} — set up an API key one of these ways:`,
9077
+ ` ${chalk.dim(GLYPHS.bullet)} Run ${chalk.cyan('coco init')} to walk through provider + key setup`,
9078
+ ` ${chalk.dim(GLYPHS.bullet)} Export ${chalk.cyan(providerInfo.envVar)} in your shell`,
9079
+ ` ${chalk.dim(GLYPHS.bullet)} Add the key to ${chalk.cyan('.coco.config.json')} or ${chalk.cyan('~/.gitconfig')} (under ${chalk.cyan('[coco]')})`,
9080
+ '',
9081
+ `${chalk.dim('Run')} ${chalk.cyan('coco doctor')} ${chalk.dim('to diagnose the active config sources.')}`,
9082
+ ];
9083
+ for (const line of lines) {
9084
+ logger.log(line);
9085
+ }
9086
+ // Tag the exit message with the failing command so process supervisors
9087
+ // / CI logs can grep for it without parsing the full body.
9088
+ commandExit(1, `${options.command}: missing API key for ${providerInfo.label}`);
9089
+ }
9090
+
8930
9091
  const logSuccess = () => {
8931
9092
  console.log(chalk.green(chalk.bold('\nAll set! 🦾🤖')));
8932
9093
  };
@@ -14419,8 +14580,7 @@ const handler$a = async (argv, logger) => {
14419
14580
  commandExit(1);
14420
14581
  }
14421
14582
  if (config.service.authentication.type !== 'None' && !key) {
14422
- logger.log(`No API Key found. 🗝️🚪`, { color: 'red' });
14423
- commandExit(1);
14583
+ handleMissingApiKey(logger, config, { command: 'changelog' });
14424
14584
  }
14425
14585
  const llm = getLlm(provider, model, { ...config, service: changelogService });
14426
14586
  const summaryLlm = getLlm(provider, summaryService.model, { ...config, service: summaryService });
@@ -16316,8 +16476,7 @@ const handler$9 = async (argv, logger) => {
16316
16476
  const splitService = resolveDynamicService(config, 'commitSplit');
16317
16477
  const model = commitService.model;
16318
16478
  if (config.service.authentication.type !== 'None' && !key) {
16319
- logger.log(`No API Key found. 🗝️🚪`, { color: 'red' });
16320
- commandExit(1);
16479
+ handleMissingApiKey(logger, config, { command: 'commit' });
16321
16480
  }
16322
16481
  const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
16323
16482
  const llm = getLlm(provider, model, { ...config, service: commitService });
@@ -17047,9 +17206,9 @@ function checkProjectConfigFile(diagnostics) {
17047
17206
  }
17048
17207
 
17049
17208
  const SEVERITY_ICON = {
17050
- error: chalk.red('✖'),
17051
- warn: chalk.yellow('⚠'),
17052
- info: chalk.blue('ℹ'),
17209
+ error: FAIL(),
17210
+ warn: WARN(),
17211
+ info: INFO(),
17053
17212
  };
17054
17213
  const SEVERITY_LABEL = {
17055
17214
  error: chalk.red('error'),
@@ -17068,10 +17227,10 @@ function formatSourceInfo(sources) {
17068
17227
  for (const source of sources) {
17069
17228
  const label = SOURCE_LABELS[source.source] || source.source;
17070
17229
  if (source.path) {
17071
- lines.push(` ${chalk.green('✓')} ${label} ${chalk.dim(`(${source.path})`)}`);
17230
+ lines.push(` ${PASS()} ${label} ${chalk.dim(`(${source.path})`)}`);
17072
17231
  }
17073
17232
  else {
17074
- lines.push(` ${chalk.green('✓')} ${label}`);
17233
+ lines.push(` ${PASS()} ${label}`);
17075
17234
  }
17076
17235
  }
17077
17236
  return lines;
@@ -17108,7 +17267,7 @@ const handler$8 = async (argv, logger) => {
17108
17267
  // Run diagnostics
17109
17268
  const diagnostics = runDiagnostics(config);
17110
17269
  if (diagnostics.length === 0) {
17111
- logger.log(chalk.green('✓ No issues found. Your configuration looks good!'));
17270
+ logger.log(chalk.green(`${PASS()} No issues found. Your configuration looks good!`));
17112
17271
  return;
17113
17272
  }
17114
17273
  const errors = diagnostics.filter((d) => d.severity === 'error');
@@ -17161,7 +17320,7 @@ const handler$8 = async (argv, logger) => {
17161
17320
  const raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
17162
17321
  for (const diagnostic of fixable) {
17163
17322
  diagnostic.autoFix(raw);
17164
- logger.log(chalk.green(` Fixed: ${diagnostic.message}`));
17323
+ logger.log(chalk.green(` ${PASS()} Fixed: ${diagnostic.message}`));
17165
17324
  }
17166
17325
  // Ensure $schema is present
17167
17326
  if (!raw.$schema) {
@@ -17180,6 +17339,15 @@ const handler$8 = async (argv, logger) => {
17180
17339
  logger.log(chalk.dim(`${fixable.length} issue(s) can be auto-fixed. Run \`coco doctor --fix\` to apply.`));
17181
17340
  }
17182
17341
  }
17342
+ // Exit non-zero when error-severity diagnostics were surfaced so CI
17343
+ // pipelines can gate on `coco doctor` without parsing its stdout.
17344
+ // Warnings + infos still exit clean — they're informational, not
17345
+ // blockers. Auto-fixed errors keep the non-zero exit so the CI run
17346
+ // surfaces "we patched something for you, please commit it" rather
17347
+ // than masquerading as a passing check.
17348
+ if (errors.length > 0) {
17349
+ commandExit(1, `${errors.length} doctor error(s)`);
17350
+ }
17183
17351
  };
17184
17352
 
17185
17353
  var doctor = {
@@ -17187,6 +17355,7 @@ var doctor = {
17187
17355
  desc: 'Check your coco configuration for common issues and suggest fixes',
17188
17356
  builder: builder$8,
17189
17357
  handler: commandExecutor(handler$8),
17358
+ options: options$8,
17190
17359
  };
17191
17360
 
17192
17361
  const command$7 = 'init';
@@ -17566,11 +17735,7 @@ const handler$7 = async (argv, logger) => {
17566
17735
  // writes the project config to X, not the launcher's cwd. The
17567
17736
  // chdir has to happen before getProjectConfigFilePath resolves
17568
17737
  // its target path (it reads process.cwd).
17569
- //
17570
- // `InitArgv` is `Argv<InitOptions>['argv']` which yargs types as a
17571
- // union including Promise — pass just the `repo` field as a plain
17572
- // object so the helper's narrow signature stays clean.
17573
- applyRepoCwd({ repo: argv.repo });
17738
+ applyRepoCwd(argv);
17574
17739
  const options = loadConfig(argv);
17575
17740
  logger.log(LOGO);
17576
17741
  let scope = options?.scope;
@@ -17705,6 +17870,44 @@ const handler$7 = async (argv, logger) => {
17705
17870
  await installCommitlintPackages(scope, logger);
17706
17871
  }
17707
17872
  logger.log(`\ninit successful! 🦾🤖🎉`, { color: 'green' });
17873
+ // Post-write verification — run the same check `coco doctor` runs
17874
+ // so the user finds out about typos / structural issues now,
17875
+ // before their first `coco commit`. Re-load from disk so we
17876
+ // verify the persisted config (not the in-memory shape we just
17877
+ // built), which catches transcription bugs in the appenders.
17878
+ try {
17879
+ const persistedConfig = loadConfig({});
17880
+ const diagnostics = runDiagnostics(persistedConfig);
17881
+ const errors = diagnostics.filter((d) => d.severity === 'error');
17882
+ const warnings = diagnostics.filter((d) => d.severity === 'warn');
17883
+ if (errors.length === 0 && warnings.length === 0) {
17884
+ logger.log(`${PASS()} Verified: no issues found in your new config.`, { color: 'green' });
17885
+ }
17886
+ else {
17887
+ if (errors.length > 0) {
17888
+ logger.log(`${FAIL()} ${errors.length} error(s) found in the persisted config:`, { color: 'red' });
17889
+ for (const diagnostic of errors) {
17890
+ logger.log(` ${chalk.red(diagnostic.message)}`);
17891
+ }
17892
+ }
17893
+ if (warnings.length > 0) {
17894
+ logger.log(`${WARN()} ${warnings.length} warning(s) found in the persisted config:`, { color: 'yellow' });
17895
+ for (const diagnostic of warnings) {
17896
+ logger.log(` ${chalk.yellow(diagnostic.message)}`);
17897
+ }
17898
+ }
17899
+ logger.log(`${chalk.dim('Run')} ${chalk.cyan('coco doctor')} ${chalk.dim('for the full diagnostic report.')}`);
17900
+ }
17901
+ }
17902
+ catch (verifyError) {
17903
+ // Verification is a polish step, not a blocker. If it crashes
17904
+ // (e.g. config file written to a path the loader can't reach
17905
+ // from the current cwd), fall through to a hint instead of
17906
+ // failing the whole init flow — the config is on disk and
17907
+ // the user can run `coco doctor` themselves.
17908
+ logger.log(`${chalk.dim('Skipped post-init verification:')} ${verifyError.message}`, { color: 'gray' });
17909
+ logger.log(`${chalk.dim('Run')} ${chalk.cyan('coco doctor')} ${chalk.dim('to verify your config manually.')}`);
17910
+ }
17708
17911
  }
17709
17912
  else {
17710
17913
  logger.log('\ninit cancelled.', { color: 'yellow' });
@@ -17746,7 +17949,7 @@ async function installCommitlintPackages(scope, logger) {
17746
17949
 
17747
17950
  var init = {
17748
17951
  command: command$7,
17749
- desc: 'install & configure coco globally or for the current project',
17952
+ desc: 'Install & configure coco globally or for the current project',
17750
17953
  builder: builder$7,
17751
17954
  handler: commandExecutor(handler$7),
17752
17955
  options: options$7,
@@ -17830,19 +18033,76 @@ async function getGitHubRepository(git) {
17830
18033
  return url ? parseGitHubRemoteUrl$1(url) : undefined;
17831
18034
  }
17832
18035
  /**
17833
- * Probe `gh auth status` and return whether the GitHub CLI is
17834
- * installed AND authenticated. Used by every data fetcher to short-
17835
- * circuit before issuing real API calls — keeps the failure-mode
17836
- * messaging consistent ("CLI missing or not authenticated") instead
17837
- * of leaking through as a generic spawn error.
18036
+ * Probe `gh auth status` and return a structured status describing
18037
+ * exactly which of the failure modes is in play. Used by every data
18038
+ * fetcher to short-circuit before issuing real API calls — and now
18039
+ * lets the caller surface a tailored recovery hint per failure mode
18040
+ * instead of one catch-all message.
18041
+ *
18042
+ * Distinguishing the modes:
18043
+ * - ENOENT (`gh: command not found`) → `not-installed`
18044
+ * - `gh auth status` exits non-zero with stderr matching the
18045
+ * "not logged into" / "authentication required" pattern →
18046
+ * `not-authenticated`
18047
+ * - Anything else (permission denied on the binary, timeout, etc.)
18048
+ * → `unknown` with the underlying error message attached for
18049
+ * diagnostic display.
17838
18050
  */
17839
- async function isGhAuthenticated(runner) {
18051
+ async function getGhStatus(runner) {
17840
18052
  try {
17841
18053
  await runner(['auth', 'status', '--hostname', 'github.com']);
17842
- return true;
18054
+ return { kind: 'ok' };
17843
18055
  }
17844
- catch {
17845
- return false;
18056
+ catch (error) {
18057
+ const err = error;
18058
+ // ENOENT = the binary itself is missing. exec/spawn surfaces this
18059
+ // as either `code === 'ENOENT'` (Node's spawn error code) or a
18060
+ // message containing "ENOENT". Either form is unambiguous.
18061
+ if (err.code === 'ENOENT' || (err.message && err.message.includes('ENOENT'))) {
18062
+ return { kind: 'not-installed' };
18063
+ }
18064
+ // gh exits non-zero from `auth status` when the user isn't logged
18065
+ // in. The message body contains "not logged into" or "logged in
18066
+ // failed" depending on the gh version. Both patterns are stable
18067
+ // enough to gate on without scope-locking to a specific gh
18068
+ // release.
18069
+ const stderr = err.stderr || err.message || '';
18070
+ if (/not logged into|authentication.*required|you are not/i.test(stderr)) {
18071
+ return { kind: 'not-authenticated', detail: stderr.trim().split('\n')[0] };
18072
+ }
18073
+ // Anything else — permission denied, timeout, etc. Surface the
18074
+ // raw message so the user can read it; treat as unavailable.
18075
+ return { kind: 'unknown', detail: err.message || 'gh auth status failed' };
18076
+ }
18077
+ }
18078
+ /**
18079
+ * Backwards-compatible boolean wrapper around `getGhStatus`. Kept so
18080
+ * existing callers (data loaders, sidebar fetchers) don't all have to
18081
+ * migrate at once. New call sites should use `getGhStatus` directly
18082
+ * to access the discriminated failure modes.
18083
+ */
18084
+ async function isGhAuthenticated(runner) {
18085
+ const status = await getGhStatus(runner);
18086
+ return status.kind === 'ok';
18087
+ }
18088
+ /**
18089
+ * Render a user-facing recovery hint for a non-`ok` gh status. Used by
18090
+ * `commands/issues` / `commands/prs` / pull-request workflow surfaces
18091
+ * so every "gh is unavailable" message tells the user the exact next
18092
+ * step. Keeps the wording in sync across surfaces — if a user runs
18093
+ * `coco prs` and `coco issues` back to back, the same broken state
18094
+ * surfaces the same fix.
18095
+ */
18096
+ function describeGhStatus(status) {
18097
+ switch (status.kind) {
18098
+ case 'ok':
18099
+ return 'GitHub CLI is installed and authenticated.';
18100
+ case 'not-installed':
18101
+ return 'GitHub CLI (`gh`) is not installed. Install it from https://cli.github.com/ and run `gh auth login`.';
18102
+ case 'not-authenticated':
18103
+ return `GitHub CLI is installed but not authenticated. Run \`gh auth login\` (scopes: \`repo\`, \`read:org\`).${status.detail ? ` Details: ${status.detail}` : ''}`;
18104
+ case 'unknown':
18105
+ return `GitHub CLI returned an unexpected error: ${status.detail}. Try \`gh auth status\` directly to diagnose.`;
17846
18106
  }
17847
18107
  }
17848
18108
 
@@ -18034,13 +18294,14 @@ async function getIssueList(git, filter = {}, runner = defaultGhRunner) {
18034
18294
  message: 'No GitHub remote detected.',
18035
18295
  };
18036
18296
  }
18037
- if (!(await isGhAuthenticated(runner))) {
18297
+ const ghStatus = await getGhStatus(runner);
18298
+ if (ghStatus.kind !== 'ok') {
18038
18299
  return {
18039
18300
  available: true,
18040
18301
  authenticated: false,
18041
18302
  repository,
18042
18303
  filter,
18043
- message: 'GitHub CLI is missing or not authenticated.',
18304
+ message: describeGhStatus(ghStatus),
18044
18305
  };
18045
18306
  }
18046
18307
  try {
@@ -18152,6 +18413,7 @@ var issues = {
18152
18413
  desc: 'List GitHub issues for the current repository (read-only triage)',
18153
18414
  builder: builder$6,
18154
18415
  handler: commandExecutor(handler$6),
18416
+ options: options$6,
18155
18417
  };
18156
18418
 
18157
18419
  const command$5 = 'log';
@@ -22968,6 +23230,13 @@ const LOG_INK_KEY_BINDINGS = [
22968
23230
  description: 'Create a lightweight tag at the cursored commit.',
22969
23231
  contexts: ['history'],
22970
23232
  },
23233
+ {
23234
+ id: 'viewKeys',
23235
+ keys: ['g?'],
23236
+ label: 'keys',
23237
+ description: 'Show the single-key actions available in the current view (which-key strip).',
23238
+ contexts: ['normal'],
23239
+ },
22971
23240
  {
22972
23241
  id: 'themePicker',
22973
23242
  keys: ['gC'],
@@ -23695,6 +23964,48 @@ function getLogInkHelpSections(options) {
23695
23964
  },
23696
23965
  ];
23697
23966
  }
23967
+ /**
23968
+ * True when a key string is a single, bare printable key (e.g. `c`, `R`,
23969
+ * `[`) rather than a chord (`gh`, `gg`) or a named special key (`up`,
23970
+ * `page down`). Used by the which-key view-keys strip, which surfaces only
23971
+ * the single-key overloads — the chord set already has its own overlay.
23972
+ */
23973
+ function isBareSingleKey(key) {
23974
+ return key.length === 1 && key !== ' ';
23975
+ }
23976
+ /**
23977
+ * Single-key bindings available in the current view (#1137). Powers the
23978
+ * `g?` which-key strip: the per-view counterpart to the `g`-chord overlay.
23979
+ *
23980
+ * Sourced entirely from `LOG_INK_KEY_BINDINGS` (no duplicated key data) and
23981
+ * filtered the same way the help overlay's "This view" section is — by
23982
+ * `contexts` against the active view + focus — then narrowed to bindings
23983
+ * that expose at least one bare single key. Globals (`q`, `?`, `/`, `:`, …)
23984
+ * are excluded: they're always available and already live in the footer and
23985
+ * onboarding tour, so the strip stays focused on the deliberate per-view
23986
+ * overloads (`c`, `R`, `a`, `m`, `S`, `[`/`]`, …) the keymap guard protects.
23987
+ *
23988
+ * Sorted by the first bare key for stable, scannable output.
23989
+ */
23990
+ function getLogInkViewKeyBindings(options) {
23991
+ return LOG_INK_KEY_BINDINGS
23992
+ .filter((binding) => !GLOBAL_BINDING_IDS.includes(binding.id) &&
23993
+ bindingMatchesViewContext(binding, options) &&
23994
+ binding.keys.some(isBareSingleKey))
23995
+ .sort((a, b) => {
23996
+ const aKey = a.keys.find(isBareSingleKey) ?? '';
23997
+ const bKey = b.keys.find(isBareSingleKey) ?? '';
23998
+ return aKey.localeCompare(bKey);
23999
+ });
24000
+ }
24001
+ /**
24002
+ * Format only the bare single keys of a binding for the view-keys strip
24003
+ * (e.g. `['up', 'k']` → `k`). Named/chord keys are dropped — the strip is
24004
+ * about the single-key affordance, and the full key list lives in `?` help.
24005
+ */
24006
+ function formatBindingBareKeys(binding) {
24007
+ return binding.keys.filter(isBareSingleKey).join(' / ');
24008
+ }
23698
24009
  function bindingToPaletteCommand(binding) {
23699
24010
  return {
23700
24011
  id: binding.id,
@@ -25208,7 +25519,39 @@ function replaceRows(state, rows) {
25208
25519
  }
25209
25520
  function appendRows(state, rows) {
25210
25521
  const selected = getSelectedInkCommit(state);
25211
- const nextRows = [...state.rows, ...rows];
25522
+ // Dedup the merged row list by commit hash so the graph renderer —
25523
+ // which windows directly over `state.rows` (toFullGraphItems →
25524
+ // expandRowsWithSpacers) — and the selection list (deduped commits)
25525
+ // agree on one canonical, duplicate-free row order. Overlapping
25526
+ // appends, notably the anchored `loadCommitContext` page that
25527
+ // re-walks history from the tip, otherwise stack the newest commits
25528
+ // below the oldest ones already loaded. The renderer then shows the
25529
+ // initial commit directly above HEAD and the cursor can scroll
25530
+ // forever through the duplicated tail — the history graph "looping
25531
+ // back on itself". Drop graph-only topology rows that trail a dropped
25532
+ // duplicate commit too, since they describe that duplicate's lanes
25533
+ // and would otherwise dangle.
25534
+ const seenHashes = new Set();
25535
+ const nextRows = [];
25536
+ let droppingTrailingGraph = false;
25537
+ for (const row of [...state.rows, ...rows]) {
25538
+ if (row.type === 'commit') {
25539
+ if (seenHashes.has(row.hash)) {
25540
+ droppingTrailingGraph = true;
25541
+ continue;
25542
+ }
25543
+ seenHashes.add(row.hash);
25544
+ droppingTrailingGraph = false;
25545
+ nextRows.push(row);
25546
+ continue;
25547
+ }
25548
+ // Graph-only topology row: keep it unless it trails a just-dropped
25549
+ // duplicate commit (then it belongs to the duplicate page's lanes).
25550
+ if (droppingTrailingGraph) {
25551
+ continue;
25552
+ }
25553
+ nextRows.push(row);
25554
+ }
25212
25555
  const seen = new Set();
25213
25556
  const commits = getCommitRows(nextRows).filter((commit) => {
25214
25557
  if (seen.has(commit.hash)) {
@@ -25300,6 +25643,7 @@ function createLogInkState(rows, options = {}) {
25300
25643
  fullGraph: options.fullGraph ?? true,
25301
25644
  showHelp: false,
25302
25645
  helpScrollOffset: 0,
25646
+ showViewKeys: false,
25303
25647
  showCommandPalette: false,
25304
25648
  workflowActionId: undefined,
25305
25649
  pendingConfirmationId: undefined,
@@ -25805,6 +26149,22 @@ function applyLogInkAction(state, action) {
25805
26149
  pendingKey: undefined,
25806
26150
  };
25807
26151
  }
26152
+ case 'returnFromCommit': {
26153
+ // After a successful commit we leave the compose view automatically.
26154
+ // Where to: a still-dirty tree the user was staging from returns to
26155
+ // the Status view so they can finish the rest; an otherwise-complete
26156
+ // commit returns to the History view, where the new commit now shows.
26157
+ // We pop frames one at a time (reusing withPoppedView) so sidebar-tab
26158
+ // and diff-state restoration stays identical to manual Esc/back —
26159
+ // this also unwinds an intermediate `diff` frame (status → diff →
26160
+ // compose) back to the status frame it sits under.
26161
+ const target = action.stillDirty && state.viewStack.includes('status') ? 'status' : HOME_VIEW;
26162
+ let next = state;
26163
+ while (next.viewStack.length > 1 && topOfStack(next.viewStack) !== target) {
26164
+ next = withPoppedView(next);
26165
+ }
26166
+ return { ...next, pendingKey: undefined };
26167
+ }
25808
26168
  case 'navigateOpenDiffForCommit': {
25809
26169
  const next = withPushedView(state, 'diff');
25810
26170
  const filteredCommits = state.filteredCommits;
@@ -26001,6 +26361,7 @@ function applyLogInkAction(state, action) {
26001
26361
  showCommandPalette: false,
26002
26362
  showHelp: false,
26003
26363
  helpScrollOffset: 0,
26364
+ showViewKeys: false,
26004
26365
  pendingKey: undefined,
26005
26366
  };
26006
26367
  case 'toggleGraph':
@@ -26019,9 +26380,24 @@ function applyLogInkAction(state, action) {
26019
26380
  // than picking up where the user last scrolled.
26020
26381
  helpScrollOffset: 0,
26021
26382
  showCommandPalette: false,
26383
+ // Opening full help supersedes the compact view-keys strip — this
26384
+ // is the progressive-disclosure step (`?` from the strip expands
26385
+ // to the full categorized help, #1137).
26386
+ showViewKeys: false,
26022
26387
  pendingKey: undefined,
26023
26388
  };
26024
26389
  }
26390
+ case 'toggleViewKeys':
26391
+ return {
26392
+ ...state,
26393
+ showViewKeys: !state.showViewKeys,
26394
+ // The view-keys strip is mutually exclusive with the other
26395
+ // overlays; opening it closes anything else that was showing.
26396
+ showHelp: false,
26397
+ helpScrollOffset: 0,
26398
+ showCommandPalette: false,
26399
+ pendingKey: undefined,
26400
+ };
26025
26401
  case 'scrollHelp':
26026
26402
  // No upper-bound clamp here — the renderer caps the offset
26027
26403
  // against the actual content height at render time. The
@@ -26038,6 +26414,7 @@ function applyLogInkAction(state, action) {
26038
26414
  showCommandPalette: opening,
26039
26415
  showHelp: false,
26040
26416
  helpScrollOffset: 0,
26417
+ showViewKeys: false,
26041
26418
  // Reset palette interaction state on every open/close so the next
26042
26419
  // session starts from a clean slate.
26043
26420
  paletteFilter: '',
@@ -26085,8 +26462,9 @@ function applyLogInkAction(state, action) {
26085
26462
  return {
26086
26463
  ...state,
26087
26464
  showThemePicker: opening,
26088
- // Only one overlay at a time — close help / palette on open.
26465
+ // Only one overlay at a time — close help / palette / view-keys on open.
26089
26466
  showHelp: false,
26467
+ showViewKeys: false,
26090
26468
  showCommandPalette: false,
26091
26469
  themePickerFilter: '',
26092
26470
  themePickerIndex: 0,
@@ -26886,6 +27264,10 @@ function getLogInkPaletteExecuteEvents(command, state) {
26886
27264
  // Palette closes on execute (toggleCommandPalette runs first), then
26887
27265
  // this opens the theme picker.
26888
27266
  return [action({ type: 'toggleThemePicker' })];
27267
+ case 'viewKeys':
27268
+ // Palette closes on execute (toggleCommandPalette runs first), then
27269
+ // this opens the per-view which-key strip (#1137).
27270
+ return [action({ type: 'toggleViewKeys' })];
26889
27271
  case 'openProjectConfig':
26890
27272
  return [{ type: 'openConfigInEditor', scope: 'project' }];
26891
27273
  case 'openGlobalConfig':
@@ -27614,6 +27996,26 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27614
27996
  }
27615
27997
  return [];
27616
27998
  }
27999
+ // #1137 — the `g?` which-key strip. While it's open the keyboard is
28000
+ // claimed (mirrors the help overlay) so a stray keystroke can't drop
28001
+ // the user into a per-view action they didn't mean to trigger. Esc
28002
+ // closes; `?` is the progressive-disclosure step up to the full
28003
+ // categorized help; `q` still quits. Everything else is swallowed —
28004
+ // the user peeks, dismisses, then presses the key they came for.
28005
+ if (state.showViewKeys) {
28006
+ if (key.escape) {
28007
+ return [action({ type: 'toggleViewKeys' })];
28008
+ }
28009
+ if (inputValue === '?') {
28010
+ // Expand the compact strip into the full help overlay. `toggleHelp`
28011
+ // clears `showViewKeys` so the two never render at once.
28012
+ return [action({ type: 'toggleHelp' })];
28013
+ }
28014
+ if (inputValue === 'q') {
28015
+ return [{ type: 'exit' }];
28016
+ }
28017
+ return [];
28018
+ }
27617
28019
  // #879 item 4 — Esc cancels an in-flight bisect-start wizard. Runs
27618
28020
  // BEFORE the generic `popView` so we both clear the wizard state
27619
28021
  // and walk back to the bisect view in one keystroke. Without this
@@ -27657,6 +28059,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27657
28059
  }
27658
28060
  return [{ type: 'exit' }];
27659
28061
  }
28062
+ // `g?` chord (#1137) — open the per-view which-key strip. Placed
28063
+ // BEFORE the bare `?` (full help) check below so the chord is read as
28064
+ // a unit: with `g` pending, `?` opens the view-keys strip rather than
28065
+ // toggling full help. Surfaces automatically in the `g` which-key menu
28066
+ // because its key is a two-char `g`-prefixed binding.
28067
+ if (state.pendingKey === 'g' && inputValue === '?') {
28068
+ return [
28069
+ action({ type: 'setPendingKey', value: undefined }),
28070
+ action({ type: 'toggleViewKeys' }),
28071
+ ];
28072
+ }
27660
28073
  if (inputValue === '?') {
27661
28074
  return [action({ type: 'toggleHelp' })];
27662
28075
  }
@@ -30639,7 +31052,7 @@ function fetchBranch(git, branch) {
30639
31052
  if (!branch.upstream || !branch.remote) {
30640
31053
  return Promise.resolve({
30641
31054
  ok: false,
30642
- message: `${branch.shortName} has no upstream — nothing to fetch.`,
31055
+ message: `${branch.shortName} has no upstream — set one with \`git push -u <remote> ${branch.shortName}\` to enable fetch.`,
30643
31056
  });
30644
31057
  }
30645
31058
  // `branch.upstream` is the short form (e.g. `origin/main`); the
@@ -30677,7 +31090,7 @@ function pullBranch(git, branch, currentBranchName) {
30677
31090
  if (!branch.upstream || !branch.remote) {
30678
31091
  return Promise.resolve({
30679
31092
  ok: false,
30680
- message: `${branch.shortName} has no upstream — nothing to pull.`,
31093
+ message: `${branch.shortName} has no upstream — set one with \`git push -u <remote> ${branch.shortName}\` to enable pull.`,
30681
31094
  });
30682
31095
  }
30683
31096
  // Current branch — defer to the in-place workflow.
@@ -31985,13 +32398,14 @@ async function getPullRequestList(git, filter = {}, runner = defaultGhRunner) {
31985
32398
  message: 'No GitHub remote detected.',
31986
32399
  };
31987
32400
  }
31988
- if (!(await isGhAuthenticated(runner))) {
32401
+ const ghStatus = await getGhStatus(runner);
32402
+ if (ghStatus.kind !== 'ok') {
31989
32403
  return {
31990
32404
  available: true,
31991
32405
  authenticated: false,
31992
32406
  repository,
31993
32407
  filter,
31994
- message: 'GitHub CLI is missing or not authenticated.',
32408
+ message: describeGhStatus(ghStatus),
31995
32409
  };
31996
32410
  }
31997
32411
  try {
@@ -32857,6 +33271,7 @@ function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFr
32857
33271
  // of the runtime's `forcedPane` derivation in `app.ts`.
32858
33272
  const overlayForcesPane = Boolean(state.splitPlan ||
32859
33273
  state.showHelp ||
33274
+ state.showViewKeys ||
32860
33275
  state.showCommandPalette ||
32861
33276
  state.showThemePicker ||
32862
33277
  state.gitignorePicker ||
@@ -34761,8 +35176,16 @@ function renderComposeSurface(ctx, spinnerFrame = 0) {
34761
35176
  const bodyVisualLines = compose.body
34762
35177
  ? compose.body.split('\n').flatMap((line) => wrapCells(line, bodyTextWidth)).slice(0, bodyRowsAvailable)
34763
35178
  : ['<empty>'];
34764
- const summaryVisualLines = wrapCells(`${compose.summary || '<empty>'}${summaryCursor}`, Math.max(8, width - 11) // "Summary " (9) + 2 chrome = 11
34765
- );
35179
+ // Summary now renders on its own indented line under the label (like the
35180
+ // body), so it wraps at the full content width instead of the cramped
35181
+ // "Summary " (9) + chrome budget it had when label and value shared a row.
35182
+ const summaryVisualLines = compose.summary
35183
+ ? compose.summary.split('\n').flatMap((line) => wrapCells(line, bodyTextWidth))
35184
+ : ['<empty>'];
35185
+ // Subject length drives a subtle counter on the Summary label: dim under
35186
+ // 50, warning past the conventional 50-char soft limit, danger past 72.
35187
+ // Counted in code points so multibyte subjects aren't over-counted.
35188
+ const summaryLength = [...compose.summary].length;
34766
35189
  // State-line cycles through three modes (#881 phase 3 added the
34767
35190
  // loading variant): editing copy when the user is typing, cancel
34768
35191
  // hint when an AI draft is generating, default guidance otherwise.
@@ -34782,6 +35205,52 @@ function renderComposeSurface(ctx, spinnerFrame = 0) {
34782
35205
  const noStagedHint = !isLogInkContextKeyLoading(contextStatus, 'worktree')
34783
35206
  ? formatLogInkComposeEmpty({ hasStaged: hasStagedFiles })
34784
35207
  : undefined;
35208
+ // Section header for a field (Summary / Body). The active field's label
35209
+ // carries an arrow marker + the repo's selection highlight (matching the
35210
+ // status surface, see status/index.ts) so the user can see which field
35211
+ // their keystrokes target — even before entering edit mode, and even
35212
+ // under NO_COLOR where the marker + bold/dim carry the signal alone. An
35213
+ // optional length counter (Summary only) trails the label outside the
35214
+ // highlight so its own warning/danger color stays legible.
35215
+ const renderSectionHeader = (name, field, count) => {
35216
+ const active = compose.field === field;
35217
+ const highlight = active && focused && !theme.noColor;
35218
+ const marker = active ? (theme.ascii ? '> ' : '▸ ') : ' ';
35219
+ const badge = active && compose.editing ? ' EDITING' : '';
35220
+ const children = [
35221
+ h(Text, {
35222
+ key: `compose-${field}-label`,
35223
+ bold: active,
35224
+ dimColor: !active,
35225
+ backgroundColor: highlight ? theme.colors.selection : undefined,
35226
+ color: highlight ? theme.colors.selectionForeground : undefined,
35227
+ }, `${marker}${name}${badge}`),
35228
+ ];
35229
+ if (count !== undefined) {
35230
+ const countColor = theme.noColor
35231
+ ? undefined
35232
+ : count > 72
35233
+ ? theme.colors.danger
35234
+ : count > 50
35235
+ ? theme.colors.warning
35236
+ : undefined;
35237
+ children.push(h(Text, {
35238
+ key: `compose-${field}-count`,
35239
+ color: countColor,
35240
+ dimColor: countColor === undefined,
35241
+ }, ` ${count}`));
35242
+ }
35243
+ return h(Box, { key: `compose-${field}-header` }, ...children);
35244
+ };
35245
+ // Content lines for a field — indented two cells under the header, with
35246
+ // the edit cursor parked on the final line when this field is active.
35247
+ const renderSectionContent = (lines, field, cursor) => lines.map((line, index) => {
35248
+ const isLast = index === lines.length - 1;
35249
+ return h(Text, {
35250
+ key: `compose-${field}-${index}`,
35251
+ dimColor: line === '<empty>',
35252
+ }, ` ${line}${cursor && isLast ? cursor : ''}`);
35253
+ });
34785
35254
  return h(Box, {
34786
35255
  borderColor: focusBorderColor(theme, focused),
34787
35256
  borderStyle: theme.borderStyle,
@@ -34789,20 +35258,7 @@ function renderComposeSurface(ctx, spinnerFrame = 0) {
34789
35258
  flexShrink: 0,
34790
35259
  paddingX: 1,
34791
35260
  width,
34792
- }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Compose commit', focused)), h(Text, { dimColor: true }, statusLine)), h(Text, undefined, ''), h(Text, {
34793
- bold: compose.field === 'summary' && compose.editing,
34794
- }, `Summary ${summaryVisualLines[0] || ''}`), ...summaryVisualLines.slice(1).map((line, index) => h(Text, {
34795
- key: `compose-summary-${index}`,
34796
- bold: compose.field === 'summary' && compose.editing,
34797
- }, ` ${line}`)), h(Text, undefined, ''), h(Text, {
34798
- bold: compose.field === 'body' && compose.editing,
34799
- }, 'Body'), ...bodyVisualLines.map((line, index) => {
34800
- const isLast = index === bodyVisualLines.length - 1;
34801
- return h(Text, {
34802
- key: `compose-body-${index}`,
34803
- dimColor: line === '<empty>',
34804
- }, ` ${line}${bodyCursor && isLast ? bodyCursor : ''}`);
34805
- }),
35261
+ }, 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),
34806
35262
  // Loading indicator + post-action message belong inline with the draft
34807
35263
  // (they describe what just happened to the fields above). The state-
34808
35264
  // line ("Editing — Enter switches summary↔body…" / "Press e to edit
@@ -37140,6 +37596,54 @@ function renderChordOverlay(h, components, state, width, theme, focused) {
37140
37596
  paddingX: 1,
37141
37597
  }, ...lines);
37142
37598
  }
37599
+ /**
37600
+ * Which-key view-keys strip (#1137). The per-view counterpart to the
37601
+ * `g`-chord overlay: opened by `g?`, it lists the single-key actions
37602
+ * available in the current view (the deliberate overloads — `c`, `R`,
37603
+ * `a`, `m`, `S`, `[`/`]`, …) with their labels, sourced from
37604
+ * `LOG_INK_KEY_BINDINGS` filtered by the active view + focus.
37605
+ *
37606
+ * Renders in the detail panel slot like the chord overlay. `?` steps up
37607
+ * to the full categorized help; Esc closes.
37608
+ */
37609
+ function renderViewKeysOverlay(h, components, state, width, theme, focused) {
37610
+ const { Box, Text } = components;
37611
+ const bindings = getLogInkViewKeyBindings({
37612
+ activeView: state.activeView,
37613
+ focus: state.focus,
37614
+ });
37615
+ const accent = theme.noColor ? undefined : theme.colors.accent;
37616
+ const lines = [
37617
+ h(Text, { key: 'view-keys-title', bold: true }, panelTitle(`keys · ${state.activeView}`, focused)),
37618
+ h(Text, { key: 'view-keys-spacer' }, ''),
37619
+ ];
37620
+ if (bindings.length === 0) {
37621
+ lines.push(h(Text, {
37622
+ key: 'view-keys-empty',
37623
+ dimColor: true,
37624
+ }, truncateCells('No single-key actions in this view — use ? for the full help.', width - 4)));
37625
+ }
37626
+ else {
37627
+ // Pad keys to the widest entry so labels align into a scannable column.
37628
+ const keyColumn = bindings.reduce((max, binding) => Math.max(max, formatBindingBareKeys(binding).length), 0);
37629
+ for (const binding of bindings) {
37630
+ const keys = formatBindingBareKeys(binding);
37631
+ 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))));
37632
+ }
37633
+ }
37634
+ lines.push(h(Text, { key: 'view-keys-foot-spacer' }, ''));
37635
+ lines.push(h(Text, {
37636
+ key: 'view-keys-hint',
37637
+ dimColor: true,
37638
+ }, truncateCells('? full help · esc closes', width - 4)));
37639
+ return h(Box, {
37640
+ borderColor: focusBorderColor(theme, focused),
37641
+ borderStyle: theme.borderStyle,
37642
+ flexDirection: 'column',
37643
+ width,
37644
+ paddingX: 1,
37645
+ }, ...lines);
37646
+ }
37143
37647
  function renderHelpPanel(h, components, state, width, theme, focused, bodyRows = 0) {
37144
37648
  const { Box, Text } = components;
37145
37649
  // Build the full list of body rows (everything below the title).
@@ -39818,6 +40322,12 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
39818
40322
  if (state.showHelp) {
39819
40323
  return renderHelpPanel(h, components, state, width, theme, focused, bodyRows);
39820
40324
  }
40325
+ // #1137 — the `g?` which-key strip lists the current view's single-key
40326
+ // actions. Checked alongside the other overlays; the reducer keeps it
40327
+ // mutually exclusive with help / palette / pickers.
40328
+ if (state.showViewKeys) {
40329
+ return renderViewKeysOverlay(h, components, state, width, theme, focused);
40330
+ }
39821
40331
  if (state.showCommandPalette) {
39822
40332
  return renderCommandPalette(h, components, state, width, theme, focused);
39823
40333
  }
@@ -40723,6 +41233,10 @@ function LogInkApp(deps) {
40723
41233
  worktree,
40724
41234
  }), issuedAtDepth);
40725
41235
  setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'ready'), issuedAtDepth);
41236
+ // Returned so callers needing the *fresh* overview (e.g. post-commit
41237
+ // navigation) can read it directly instead of racing the async
41238
+ // `setContext` update, which won't be visible in their closure.
41239
+ return worktree;
40726
41240
  }, [git, runtimes.length, setContext, setContextStatus]);
40727
41241
  // Live refresh: watch .git metadata + the working tree root and reload
40728
41242
  // context when something changes outside the TUI (editor save, external
@@ -41585,7 +42099,14 @@ function LogInkApp(deps) {
41585
42099
  // and see the pre-commit log (same silent-failure shape as
41586
42100
  // the split-apply case caught in this PR).
41587
42101
  await refreshHistoryRows();
41588
- await refreshWorktreeContext();
42102
+ const worktree = await refreshWorktreeContext();
42103
+ // Leave the compose view automatically: a still-dirty tree returns
42104
+ // to Status (so the user can keep staging), an otherwise-complete
42105
+ // commit returns to History (where the new commit now shows). The
42106
+ // reducer inspects the live viewStack to pick the destination.
42107
+ const stillDirty = Boolean(worktree &&
42108
+ worktree.stagedCount + worktree.unstagedCount + worktree.untrackedCount > 0);
42109
+ dispatch({ type: 'returnFromCommit', stillDirty });
41589
42110
  }
41590
42111
  }, [
41591
42112
  context.worktree?.stagedCount,
@@ -44296,6 +44817,7 @@ function LogInkApp(deps) {
44296
44817
  const forcedPane = state.splitPlan
44297
44818
  ? 'main'
44298
44819
  : state.showHelp ||
44820
+ state.showViewKeys ||
44299
44821
  state.showCommandPalette ||
44300
44822
  state.showThemePicker ||
44301
44823
  state.gitignorePicker ||
@@ -45108,6 +45630,7 @@ var prs = {
45108
45630
  desc: 'List GitHub pull requests for the current repository (read-only triage)',
45109
45631
  builder: builder$4,
45110
45632
  handler: commandExecutor(handler$3),
45633
+ options: options$4,
45111
45634
  };
45112
45635
 
45113
45636
  const RecapLlmResponseSchema = objectType({
@@ -45187,8 +45710,7 @@ const handler$2 = async (argv, logger) => {
45187
45710
  const summaryService = resolveDynamicService(config, 'summarize');
45188
45711
  const model = recapService.model;
45189
45712
  if (config.service.authentication.type !== 'None' && !key) {
45190
- logger.log(`No API Key found. 🗝️🚪`, { color: 'red' });
45191
- commandExit(1);
45713
+ handleMissingApiKey(logger, config, { command: 'recap' });
45192
45714
  }
45193
45715
  const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
45194
45716
  const llm = getLlm(provider, model, { ...config, service: recapService });
@@ -45776,8 +46298,7 @@ const handler$1 = async (argv, logger) => {
45776
46298
  const summaryService = resolveDynamicService(config, argv.branch ? 'largeDiff' : 'summarize');
45777
46299
  const model = reviewService.model;
45778
46300
  if (config.service.authentication.type !== 'None' && !key) {
45779
- logger.log(`No API Key found. 🗝️🚪`, { color: 'red' });
45780
- commandExit(1);
46301
+ handleMissingApiKey(logger, config, { command: 'review' });
45781
46302
  }
45782
46303
  const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
45783
46304
  const llm = getLlm(provider, model, { ...config, service: reviewService });