git-coco 0.59.1 → 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.59.1";
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';
@@ -18903,9 +19165,17 @@ async function getStashOverview(git) {
18903
19165
  // %gd — stash reflog selector (stash@{N})
18904
19166
  // %H — stash commit hash
18905
19167
  // %P — space-separated parent hashes (first = base, see StashEntry.baseHash)
18906
- // %ci — committer date, ISO format
19168
+ // %cI — committer date, strict ISO 8601
18907
19169
  // %gs — reflog subject ("WIP on main: <subject>")
18908
- const stashes = parseStashList(await git.raw(['stash', 'list', '--date=iso', '--format=%gd%x1f%H%x1f%P%x1f%ci%x1f%gs']));
19170
+ //
19171
+ // NOTE: we deliberately do NOT pass `--date=iso`. That flag rewrites the
19172
+ // `%gd` selector from the index form (`stash@{0}`) into a timestamp
19173
+ // (`stash@{2026-06-03 17:29:23 -0400}`), which is noisy in the list, eats
19174
+ // row width, and — critically — breaks `renameStash`, which parses the
19175
+ // `stash@{N}` index out of the ref. `%cI` gives a strict-ISO date that's
19176
+ // independent of `--date`, so we get both a clean index ref and a
19177
+ // parseable date.
19178
+ const stashes = parseStashList(await git.raw(['stash', 'list', '--format=%gd%x1f%H%x1f%P%x1f%cI%x1f%gs']));
18909
19179
  return {
18910
19180
  stashes: await Promise.all(stashes.map(async (stash) => ({
18911
19181
  ...stash,
@@ -20574,7 +20844,7 @@ function applyCommitComposeAction(state, action) {
20574
20844
  loading: false,
20575
20845
  streamingPreview: undefined,
20576
20846
  pendingAiDraft: action.value,
20577
- message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
20847
+ message: 'AI draft ready. Press Enter (or R) to replace your text, or Esc to keep what you have.',
20578
20848
  details: undefined,
20579
20849
  };
20580
20850
  }
@@ -20623,7 +20893,7 @@ function applyCommitComposeAction(state, action) {
20623
20893
  loading: false,
20624
20894
  streamingPreview: undefined,
20625
20895
  pendingAiDraft: action.value,
20626
- message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
20896
+ message: 'AI draft ready. Press Enter (or R) to replace your text, or Esc to keep what you have.',
20627
20897
  details: undefined,
20628
20898
  };
20629
20899
  case 'acceptPendingAiDraft':
@@ -22179,6 +22449,25 @@ function getLogInkWorkflowActions() {
22179
22449
  kind: 'destructive',
22180
22450
  requiresConfirmation: true,
22181
22451
  },
22452
+ {
22453
+ // Palette-only create variants (empty `key`): no global hotkey to
22454
+ // collide with `S` / `gZ`, reachable from `:`. Both stash a quick
22455
+ // WIP entry with the requested scope.
22456
+ id: 'stash-staged',
22457
+ key: '',
22458
+ label: 'Stash staged only',
22459
+ description: 'Stash just the staged (index) changes — `git stash push --staged`.',
22460
+ kind: 'normal',
22461
+ requiresConfirmation: false,
22462
+ },
22463
+ {
22464
+ id: 'stash-keep-index',
22465
+ key: '',
22466
+ label: 'Stash keeping index',
22467
+ description: 'Stash everything but leave the index intact for an immediate commit — `git stash push --keep-index`.',
22468
+ kind: 'normal',
22469
+ requiresConfirmation: false,
22470
+ },
22182
22471
  {
22183
22472
  id: 'remove-worktree',
22184
22473
  key: 'W',
@@ -22690,6 +22979,13 @@ const LOG_INK_KEY_BINDINGS = [
22690
22979
  description: 'Push the stash view (gz; gs is reserved for status).',
22691
22980
  contexts: ['normal'],
22692
22981
  },
22982
+ {
22983
+ id: 'createStash',
22984
+ keys: ['gZ'],
22985
+ label: 'stash changes',
22986
+ description: 'Stash all changes (tracked + untracked) with an optional message — works from any view, including status/diff/compose. Empty message creates a quick WIP stash.',
22987
+ contexts: ['normal'],
22988
+ },
22693
22989
  {
22694
22990
  id: 'navigateWorktrees',
22695
22991
  keys: ['gw'],
@@ -22934,6 +23230,13 @@ const LOG_INK_KEY_BINDINGS = [
22934
23230
  description: 'Create a lightweight tag at the cursored commit.',
22935
23231
  contexts: ['history'],
22936
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
+ },
22937
23240
  {
22938
23241
  id: 'themePicker',
22939
23242
  keys: ['gC'],
@@ -22962,6 +23265,20 @@ const LOG_INK_KEY_BINDINGS = [
22962
23265
  description: 'Add the cursored file or folder to .gitignore (pick a pattern).',
22963
23266
  contexts: ['status'],
22964
23267
  },
23268
+ {
23269
+ id: 'stageAll',
23270
+ keys: ['A'],
23271
+ label: 'stage all',
23272
+ description: 'Stage every change in the worktree (git add -A).',
23273
+ contexts: ['status', 'compose'],
23274
+ },
23275
+ {
23276
+ id: 'stagePathspec',
23277
+ keys: ['+'],
23278
+ label: 'stage paths',
23279
+ description: 'Stage files matching a typed pathspec (. / src/ / *.ts / a list).',
23280
+ contexts: ['status', 'compose'],
23281
+ },
22965
23282
  {
22966
23283
  id: 'viewChangelog',
22967
23284
  keys: ['L'],
@@ -23035,6 +23352,19 @@ const GLOBAL_BINDING_IDS = [
23035
23352
  'navigateBack',
23036
23353
  ];
23037
23354
  const NORMAL_GLOBAL_HINTS = ['g jump', '< back', '? help', ': cmds', 'q quit'];
23355
+ /**
23356
+ * Narrow single-pane footer budget (#1135). On terminals below the
23357
+ * single-pane breakpoint the pane switcher (`tab: …`, ~29 cells) plus
23358
+ * the snap-back / peek affordance already claim most of an 80-cell row,
23359
+ * so the per-view hint tail and the global cluster are trimmed to what
23360
+ * fits without clipping — the switcher is the orientation anchor and
23361
+ * must stay whole. The dropped bindings remain one `?` (help) away.
23362
+ *
23363
+ * - keep only the first view hint (the most actionable for the view)
23364
+ * - shrink the global cluster to the two recovery essentials
23365
+ */
23366
+ const SINGLE_PANE_GLOBAL_HINTS = ['? help', 'q quit'];
23367
+ const SINGLE_PANE_VIEW_HINT_LIMIT = 1;
23038
23368
  /**
23039
23369
  * Per-binding category mapping. Used to subdivide the help overlay's
23040
23370
  * Global and view sections into named clusters so users don't face a
@@ -23055,6 +23385,9 @@ const BINDING_CATEGORY_BY_ID = {
23055
23385
  openProjectConfig: 'view',
23056
23386
  openGlobalConfig: 'view',
23057
23387
  gitignoreFile: 'mutate',
23388
+ stageAll: 'mutate',
23389
+ stagePathspec: 'mutate',
23390
+ createStash: 'mutate',
23058
23391
  quit: 'essentials',
23059
23392
  refresh: 'essentials',
23060
23393
  navigateBack: 'essentials',
@@ -23206,18 +23539,20 @@ function formatLogInkBreadcrumb(viewStack) {
23206
23539
  if (viewStack.length === 1 && viewStack[0] === 'history') {
23207
23540
  return '';
23208
23541
  }
23209
- // Trailing back-hint (P2.5) reminds the user how to walk back when
23210
- // they're nested deeper than the root view.
23211
- return `${viewStack.join(' › ')} ← <`;
23542
+ // Pure location breadcrumb no trailing back-hint. The footer's
23543
+ // global `< back` hint already names the walk-back key, so repeating
23544
+ // `← <` on every nested view was redundant header chrome (TUI audit).
23545
+ return viewStack.join(' › ');
23212
23546
  }
23213
23547
  /**
23214
23548
  * Render the nested-repo navigation stack (#931) as a breadcrumb suitable
23215
23549
  * for the chrome header. Returns an empty string for a root-only stack
23216
23550
  * so the header stays compact when nothing has been pushed.
23217
23551
  *
23218
- * The trailing `← esc` reminds the user that Esc is the way out — same
23219
- * shape as the view breadcrumb's `← <` so the two read consistently.
23220
- * The repo breadcrumb shows in addition to the view breadcrumb when
23552
+ * The trailing `← esc` reminds the user that Esc (not `<`) pops the
23553
+ * repo stack a distinct key from the footer's global `< back`, so
23554
+ * unlike the view breadcrumb (pure location) the repo crumb keeps its
23555
+ * hint. The repo breadcrumb shows in addition to the view breadcrumb when
23221
23556
  * both stacks are non-trivial; the chrome layer is responsible for
23222
23557
  * laying them out side by side.
23223
23558
  *
@@ -23260,7 +23595,53 @@ function combineLogInkBreadcrumbSegments(repoCrumb, viewCrumb) {
23260
23595
  }
23261
23596
  return '';
23262
23597
  }
23598
+ /**
23599
+ * Single-pane pane switcher hint, e.g. `tab: [sidebar] main inspector`.
23600
+ * The active pane (derived from focus: sidebar → sidebar, detail →
23601
+ * inspector, otherwise main) is bracketed so the user can see which of
23602
+ * the three panes Tab will move them away from. Surfaced only on narrow
23603
+ * terminals where the other two panes aren't on screen.
23604
+ */
23605
+ function singlePaneSwitcherHint(focus) {
23606
+ const active = focus === 'sidebar' ? 'sidebar' : focus === 'detail' ? 'inspector' : 'main';
23607
+ const label = (pane) => (pane === active ? `[${pane}]` : pane);
23608
+ return `tab: ${label('sidebar')} ${label('main')} ${label('inspector')}`;
23609
+ }
23263
23610
  function getLogInkFooterHints(options) {
23611
+ const hints = computeLogInkFooterHints(options);
23612
+ // While peeking the sidebar (#1135 v2) the footer shows the snap-back
23613
+ // affordance instead of the switcher — the user is mid-glance, not
23614
+ // navigating, so `v`/Esc returning to main is the relevant action. The
23615
+ // view-hint tail + globals are trimmed to fit the narrow row (see
23616
+ // SINGLE_PANE_GLOBAL_HINTS).
23617
+ if (options.peeking) {
23618
+ return {
23619
+ contextual: ['v/esc → main', ...hints.contextual.slice(0, SINGLE_PANE_VIEW_HINT_LIMIT)],
23620
+ global: SINGLE_PANE_GLOBAL_HINTS,
23621
+ };
23622
+ }
23623
+ // On narrow terminals only one pane is on screen, so prepend a Tab
23624
+ // pane switcher for orientation. The caller (footer) only sets
23625
+ // `singlePane` in the plain per-pane states — while an overlay or
23626
+ // filter owns the screen the visible pane is forced (or input is
23627
+ // captured) and Tab does something else, so the switcher is
23628
+ // suppressed there to avoid showing a pane that isn't active. From the
23629
+ // main / inspector pane we also surface `v peek` so the momentary
23630
+ // sidebar glance is discoverable. The full per-view hint cluster +
23631
+ // global cluster don't fit alongside the switcher at the 80-col floor,
23632
+ // so both are trimmed (the dropped keys stay reachable via `?`).
23633
+ if (options.singlePane) {
23634
+ const lead = options.focus === 'sidebar'
23635
+ ? [singlePaneSwitcherHint(options.focus)]
23636
+ : [singlePaneSwitcherHint(options.focus), 'v peek'];
23637
+ return {
23638
+ contextual: [...lead, ...hints.contextual.slice(0, SINGLE_PANE_VIEW_HINT_LIMIT)],
23639
+ global: SINGLE_PANE_GLOBAL_HINTS,
23640
+ };
23641
+ }
23642
+ return hints;
23643
+ }
23644
+ function computeLogInkFooterHints(options) {
23264
23645
  if (options.pendingKey) {
23265
23646
  const continuations = getLogInkChordContinuations(options.pendingKey);
23266
23647
  if (continuations.length > 0) {
@@ -23377,7 +23758,7 @@ function getLogInkFooterHints(options) {
23377
23758
  }
23378
23759
  if (options.activeView === 'status') {
23379
23760
  return {
23380
- contextual: ['↑/↓ files', 'enter diff', 'space stage', 'z revert', 'i ignore', 'e/c compose', 'y yank'],
23761
+ contextual: ['↑/↓ files', 'enter hunks', 'space stage', 'A stage all', 'z revert', 'e/c compose'],
23381
23762
  global: NORMAL_GLOBAL_HINTS,
23382
23763
  };
23383
23764
  }
@@ -23389,16 +23770,19 @@ function getLogInkFooterHints(options) {
23389
23770
  const splitToggleHint = options.diffViewMode === 'split' ? 'd unified' : 'd split';
23390
23771
  if (options.diffSource === 'stash') {
23391
23772
  return {
23392
- contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'H apply hunk', 'o edit', splitToggleHint, 'y yank', 'esc back'],
23773
+ contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'H apply hunk', splitToggleHint, 'esc back'],
23393
23774
  global: NORMAL_GLOBAL_HINTS,
23394
23775
  };
23395
23776
  }
23396
23777
  if (options.diffSource === 'commit') {
23397
23778
  // Commit-diff explore: read-only diff, but `c` cherry-picks the
23398
23779
  // cursored file from the commit into the worktree, and `H`
23399
- // (or `gH` for index) applies just the cursored hunk.
23780
+ // (or `gH` for index) applies just the cursored hunk. `j/k`
23781
+ // line-scroll the diff body; `[`/`]` jump between hunks — the
23782
+ // footer labels match the actual handlers (commit diff has no
23783
+ // per-file `[/]` jump; that's the stash diff).
23400
23784
  return {
23401
- contextual: ['j/k hunks', '[/] file', 'c cherry-pick', 'H apply hunk', splitToggleHint, 'y/Y yank', 'esc back'],
23785
+ contextual: ['j/k lines', '[/] hunk', 'c cherry-pick', 'H apply hunk', splitToggleHint, 'esc back'],
23402
23786
  global: NORMAL_GLOBAL_HINTS,
23403
23787
  };
23404
23788
  }
@@ -23411,14 +23795,17 @@ function getLogInkFooterHints(options) {
23411
23795
  global: NORMAL_GLOBAL_HINTS,
23412
23796
  };
23413
23797
  }
23798
+ // Worktree (staging) diff. The hunk is the unit of action: ↑/↓ walk
23799
+ // hunks, space stages/unstages the selected one, a stages the whole
23800
+ // file, z discards the hunk.
23414
23801
  return {
23415
- contextual: ['j/k hunks', 'space stage', 'z revert', 'o edit', 'e/c compose', 'y yank'],
23802
+ contextual: ['↑/↓ hunk', 'space stage', 'a stage file', 'z discard', 'o edit', 'esc back'],
23416
23803
  global: NORMAL_GLOBAL_HINTS,
23417
23804
  };
23418
23805
  }
23419
23806
  if (options.activeView === 'compose') {
23420
23807
  return {
23421
- contextual: ['e edit', 'E $EDITOR', 'c commit', 'S split', 'I AI draft', 'gs hunks', 'esc back'],
23808
+ contextual: ['e edit', 'c commit', 'A stage all', '+ stage…', 'S split', 'I AI draft', 'esc back'],
23422
23809
  global: NORMAL_GLOBAL_HINTS,
23423
23810
  };
23424
23811
  }
@@ -23448,7 +23835,7 @@ function getLogInkFooterHints(options) {
23448
23835
  }
23449
23836
  if (options.activeView === 'stash') {
23450
23837
  return {
23451
- contextual: ['↑/↓ stashes', 'enter diff', 'a apply', 'p pop', 'X drop', 'y yank'],
23838
+ contextual: ['↑/↓ stashes', 'enter diff', 'a/A apply', 'p pop', 'R rename', 'b branch', 'X drop · u undo'],
23452
23839
  global: NORMAL_GLOBAL_HINTS,
23453
23840
  };
23454
23841
  }
@@ -23577,6 +23964,48 @@ function getLogInkHelpSections(options) {
23577
23964
  },
23578
23965
  ];
23579
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
+ }
23580
24009
  function bindingToPaletteCommand(binding) {
23581
24010
  return {
23582
24011
  id: binding.id,
@@ -24839,7 +25268,7 @@ function topOfStack(stack) {
24839
25268
  }
24840
25269
  function withPushedView(state, value) {
24841
25270
  if (topOfStack(state.viewStack) === value) {
24842
- return { ...state, pendingKey: undefined };
25271
+ return { ...state, peekReturnFocus: undefined, pendingKey: undefined };
24843
25272
  }
24844
25273
  const viewStack = [...state.viewStack, value];
24845
25274
  return {
@@ -24862,12 +25291,15 @@ function withPushedView(state, value) {
24862
25291
  compareHead: value === 'diff' ? state.compareHead : undefined,
24863
25292
  pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
24864
25293
  statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
25294
+ // Changing the view is a deliberate destination — cancel any pending
25295
+ // peek return so the user isn't snapped back afterward.
25296
+ peekReturnFocus: undefined,
24865
25297
  pendingKey: undefined,
24866
25298
  };
24867
25299
  }
24868
25300
  function withPoppedView(state) {
24869
25301
  if (state.viewStack.length <= 1) {
24870
- return { ...state, pendingKey: undefined };
25302
+ return { ...state, peekReturnFocus: undefined, pendingKey: undefined };
24871
25303
  }
24872
25304
  const viewStack = state.viewStack.slice(0, -1);
24873
25305
  const next = topOfStack(viewStack);
@@ -24894,6 +25326,8 @@ function withPoppedView(state) {
24894
25326
  compareHead: next === 'diff' ? state.compareHead : undefined,
24895
25327
  pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
24896
25328
  statusGroupHeaderFocused: next === 'status' ? state.statusGroupHeaderFocused : false,
25329
+ // Backing out is a deliberate navigation — cancel any peek return.
25330
+ peekReturnFocus: undefined,
24897
25331
  pendingKey: undefined,
24898
25332
  };
24899
25333
  }
@@ -25006,7 +25440,7 @@ function withPoppedRepoFrame(state) {
25006
25440
  }
25007
25441
  function withReplacedView(state, value) {
25008
25442
  if (topOfStack(state.viewStack) === value) {
25009
- return { ...state, pendingKey: undefined };
25443
+ return { ...state, peekReturnFocus: undefined, pendingKey: undefined };
25010
25444
  }
25011
25445
  const viewStack = [...state.viewStack.slice(0, -1), value];
25012
25446
  return {
@@ -25020,6 +25454,9 @@ function withReplacedView(state, value) {
25020
25454
  compareHead: value === 'diff' ? state.compareHead : undefined,
25021
25455
  pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
25022
25456
  statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
25457
+ // Changing the view is a deliberate destination — cancel any pending
25458
+ // peek return so the user isn't snapped back afterward.
25459
+ peekReturnFocus: undefined,
25023
25460
  pendingKey: undefined,
25024
25461
  };
25025
25462
  }
@@ -25082,7 +25519,39 @@ function replaceRows(state, rows) {
25082
25519
  }
25083
25520
  function appendRows(state, rows) {
25084
25521
  const selected = getSelectedInkCommit(state);
25085
- 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
+ }
25086
25555
  const seen = new Set();
25087
25556
  const commits = getCommitRows(nextRows).filter((commit) => {
25088
25557
  if (seen.has(commit.hash)) {
@@ -25174,6 +25643,7 @@ function createLogInkState(rows, options = {}) {
25174
25643
  fullGraph: options.fullGraph ?? true,
25175
25644
  showHelp: false,
25176
25645
  helpScrollOffset: 0,
25646
+ showViewKeys: false,
25177
25647
  showCommandPalette: false,
25178
25648
  workflowActionId: undefined,
25179
25649
  pendingConfirmationId: undefined,
@@ -25253,6 +25723,9 @@ function applyLogInkAction(state, action) {
25253
25723
  // from 'commits' should always land back on a real file when
25254
25724
  // the user returns.
25255
25725
  statusGroupHeaderFocused: false,
25726
+ // Explicit focus cycle cancels a pending peek return — the
25727
+ // user has taken manual control of the focus.
25728
+ peekReturnFocus: undefined,
25256
25729
  pendingKey: undefined,
25257
25730
  };
25258
25731
  case 'focusPrevious':
@@ -25261,6 +25734,7 @@ function applyLogInkAction(state, action) {
25261
25734
  focus: cycleValue(FOCUS_ORDER, state.focus, -1),
25262
25735
  sidebarHeaderFocused: false,
25263
25736
  statusGroupHeaderFocused: false,
25737
+ peekReturnFocus: undefined,
25264
25738
  pendingKey: undefined,
25265
25739
  };
25266
25740
  case 'move':
@@ -25675,6 +26149,22 @@ function applyLogInkAction(state, action) {
25675
26149
  pendingKey: undefined,
25676
26150
  };
25677
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
+ }
25678
26168
  case 'navigateOpenDiffForCommit': {
25679
26169
  const next = withPushedView(state, 'diff');
25680
26170
  const filteredCommits = state.filteredCommits;
@@ -25758,8 +26248,35 @@ function applyLogInkAction(state, action) {
25758
26248
  // the status view — clear when focus moves away so a
25759
26249
  // re-entry starts on a real file.
25760
26250
  statusGroupHeaderFocused: action.value === 'commits' ? state.statusGroupHeaderFocused : false,
26251
+ // An explicit focus set cancels a pending peek return.
26252
+ peekReturnFocus: undefined,
25761
26253
  pendingKey: undefined,
25762
26254
  };
26255
+ case 'togglePeek': {
26256
+ // Peek = "focus the sidebar with a return ticket." Closing returns
26257
+ // to the stashed focus; opening (only from a non-sidebar pane)
26258
+ // stashes the current focus and jumps to the sidebar. The render
26259
+ // layer needs no special case — `focus: 'sidebar'` already drives
26260
+ // the single-pane layout to show the sidebar full-width.
26261
+ if (state.peekReturnFocus !== undefined) {
26262
+ return {
26263
+ ...state,
26264
+ focus: state.peekReturnFocus,
26265
+ peekReturnFocus: undefined,
26266
+ sidebarHeaderFocused: false,
26267
+ pendingKey: undefined,
26268
+ };
26269
+ }
26270
+ if (state.focus === 'sidebar') {
26271
+ return state;
26272
+ }
26273
+ return {
26274
+ ...state,
26275
+ focus: 'sidebar',
26276
+ peekReturnFocus: state.focus,
26277
+ pendingKey: undefined,
26278
+ };
26279
+ }
25763
26280
  case 'setPendingKey':
25764
26281
  return {
25765
26282
  ...state,
@@ -25844,6 +26361,7 @@ function applyLogInkAction(state, action) {
25844
26361
  showCommandPalette: false,
25845
26362
  showHelp: false,
25846
26363
  helpScrollOffset: 0,
26364
+ showViewKeys: false,
25847
26365
  pendingKey: undefined,
25848
26366
  };
25849
26367
  case 'toggleGraph':
@@ -25862,9 +26380,24 @@ function applyLogInkAction(state, action) {
25862
26380
  // than picking up where the user last scrolled.
25863
26381
  helpScrollOffset: 0,
25864
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,
25865
26387
  pendingKey: undefined,
25866
26388
  };
25867
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
+ };
25868
26401
  case 'scrollHelp':
25869
26402
  // No upper-bound clamp here — the renderer caps the offset
25870
26403
  // against the actual content height at render time. The
@@ -25881,6 +26414,7 @@ function applyLogInkAction(state, action) {
25881
26414
  showCommandPalette: opening,
25882
26415
  showHelp: false,
25883
26416
  helpScrollOffset: 0,
26417
+ showViewKeys: false,
25884
26418
  // Reset palette interaction state on every open/close so the next
25885
26419
  // session starts from a clean slate.
25886
26420
  paletteFilter: '',
@@ -25928,8 +26462,9 @@ function applyLogInkAction(state, action) {
25928
26462
  return {
25929
26463
  ...state,
25930
26464
  showThemePicker: opening,
25931
- // 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.
25932
26466
  showHelp: false,
26467
+ showViewKeys: false,
25933
26468
  showCommandPalette: false,
25934
26469
  themePickerFilter: '',
25935
26470
  themePickerIndex: 0,
@@ -26626,6 +27161,12 @@ function getLogInkPaletteExecuteEvents(command, state) {
26626
27161
  return [action({ type: 'toggleGraph' })];
26627
27162
  case 'navigateHome':
26628
27163
  return [action({ type: 'navigateHome' })];
27164
+ case 'createStash':
27165
+ return [action({
27166
+ type: 'openInputPrompt',
27167
+ kind: 'create-stash',
27168
+ label: 'Stash message (empty = WIP)',
27169
+ })];
26629
27170
  case 'navigateStatus':
26630
27171
  return [action({ type: 'pushView', value: 'status' })];
26631
27172
  case 'navigateDiff':
@@ -26723,6 +27264,10 @@ function getLogInkPaletteExecuteEvents(command, state) {
26723
27264
  // Palette closes on execute (toggleCommandPalette runs first), then
26724
27265
  // this opens the theme picker.
26725
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' })];
26726
27271
  case 'openProjectConfig':
26727
27272
  return [{ type: 'openConfigInEditor', scope: 'project' }];
26728
27273
  case 'openGlobalConfig':
@@ -26731,6 +27276,14 @@ function getLogInkPaletteExecuteEvents(command, state) {
26731
27276
  // Runtime resolves the cursored worktree file and opens the picker
26732
27277
  // (no-ops with a warning when there's no file under the cursor).
26733
27278
  return [{ type: 'openGitignorePicker' }];
27279
+ case 'stageAll':
27280
+ return [{ type: 'runWorkflowAction', id: 'stage-all' }];
27281
+ case 'stagePathspec':
27282
+ return [action({
27283
+ type: 'openInputPrompt',
27284
+ kind: 'stage-pathspec',
27285
+ label: 'Stage pathspec (e.g. `.`, `src/`, `*.ts`, or a space-separated list)',
27286
+ })];
26734
27287
  case 'workflowDeleteBranch':
26735
27288
  case 'workflowDeleteTag':
26736
27289
  case 'workflowDropStash':
@@ -26821,6 +27374,15 @@ function submitInputPrompt(state) {
26821
27374
  if (!state.inputPrompt)
26822
27375
  return [];
26823
27376
  const value = state.inputPrompt.value.trim();
27377
+ // create-stash allows an EMPTY value → quick WIP stash (git supplies its
27378
+ // own "WIP on <branch>" subject). Handled before the generic empty guard
27379
+ // so an empty stash prompt commits a WIP stash instead of bouncing.
27380
+ if (state.inputPrompt.kind === 'create-stash') {
27381
+ return [
27382
+ { type: 'runWorkflowAction', id: 'create-stash', payload: value },
27383
+ action({ type: 'closeInputPrompt' }),
27384
+ ];
27385
+ }
26824
27386
  if (!value) {
26825
27387
  return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel', kind: 'warning' })];
26826
27388
  }
@@ -26830,6 +27392,12 @@ function submitInputPrompt(state) {
26830
27392
  action({ type: 'closeInputPrompt' }),
26831
27393
  ];
26832
27394
  }
27395
+ if (state.inputPrompt.kind === 'stage-pathspec') {
27396
+ return [
27397
+ { type: 'runWorkflowAction', id: 'stage-pathspec', payload: value },
27398
+ action({ type: 'closeInputPrompt' }),
27399
+ ];
27400
+ }
26833
27401
  if (state.inputPrompt.kind === 'reset-mode') {
26834
27402
  const mode = value.toLowerCase();
26835
27403
  if (mode !== 'soft' && mode !== 'mixed' && mode !== 'hard') {
@@ -27056,7 +27624,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27056
27624
  // draft was pending should see the original `R` / Esc semantics of
27057
27625
  // wherever they are now.
27058
27626
  if (state.activeView === 'compose' && state.commitCompose.pendingAiDraft) {
27059
- if (inputValue === 'R' && !key.ctrl && !key.meta) {
27627
+ // `R` or `Enter` accept the swap (the AI draft becomes the new
27628
+ // content); `Enter` is the natural "yes, use it" confirmation.
27629
+ if ((inputValue === 'R' && !key.ctrl && !key.meta) || key.return) {
27060
27630
  return [action({ type: 'commitCompose', action: { type: 'acceptPendingAiDraft' } })];
27061
27631
  }
27062
27632
  if (key.escape) {
@@ -27426,6 +27996,26 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27426
27996
  }
27427
27997
  return [];
27428
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
+ }
27429
28019
  // #879 item 4 — Esc cancels an in-flight bisect-start wizard. Runs
27430
28020
  // BEFORE the generic `popView` so we both clear the wizard state
27431
28021
  // and walk back to the bisect view in one keystroke. Without this
@@ -27442,6 +28032,15 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27442
28032
  }
27443
28033
  return events;
27444
28034
  }
28035
+ // #1135 v2 — while peeking the sidebar, Esc or the peek key (`v`)
28036
+ // snaps back to the pane the user came from. Placed before the
28037
+ // generic Esc → popView so a peek glance returns to main rather than
28038
+ // walking the view stack. Every other key falls through to normal
28039
+ // handling (focus is on the sidebar during a peek), so ←/→ and ↑/↓
28040
+ // browse the sidebar and keep the peek open until an explicit exit.
28041
+ if (state.peekReturnFocus !== undefined && (key.escape || inputValue === 'v')) {
28042
+ return [action({ type: 'togglePeek' })];
28043
+ }
27445
28044
  if (key.escape && state.viewStack.length > 1) {
27446
28045
  return [action({ type: 'popView' })];
27447
28046
  }
@@ -27460,6 +28059,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27460
28059
  }
27461
28060
  return [{ type: 'exit' }];
27462
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
+ }
27463
28073
  if (inputValue === '?') {
27464
28074
  return [action({ type: 'toggleHelp' })];
27465
28075
  }
@@ -27508,6 +28118,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27508
28118
  action({ type: 'setStatus', value: 'jumped to stash' }),
27509
28119
  ];
27510
28120
  }
28121
+ // `gZ` chord: stash all changes from ANY view — including status / diff /
28122
+ // compose, where bare `S` is claimed by the commit-split flow. Mnemonic
28123
+ // pair with `gz` (jump to the stash *view*). Opens the same message
28124
+ // prompt; an empty message creates a quick WIP stash.
28125
+ if (state.pendingKey === 'g' && inputValue === 'Z') {
28126
+ return [action({
28127
+ type: 'openInputPrompt',
28128
+ kind: 'create-stash',
28129
+ label: 'Stash message (empty = WIP)',
28130
+ })];
28131
+ }
27511
28132
  if (state.pendingKey === 'g' && inputValue === 'w') {
27512
28133
  return [
27513
28134
  action({ type: 'pushView', value: 'worktrees' }),
@@ -27871,6 +28492,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27871
28492
  if (key.tab) {
27872
28493
  return [action({ type: key.shift ? 'focusPrevious' : 'focusNext' })];
27873
28494
  }
28495
+ // #1135 v2 — `v` peeks the sidebar from the main / inspector pane on
28496
+ // narrow (single-pane) terminals: a momentary glance that snaps back
28497
+ // with `v` / Esc (handled above once peeking). No-op in the three-pane
28498
+ // layout (every pane is already on screen) and from the sidebar itself.
28499
+ if (inputValue === 'v' && context.singlePane && state.focus !== 'sidebar') {
28500
+ return [action({ type: 'togglePeek' })];
28501
+ }
27874
28502
  // ←/→ on the sidebar switch tabs (Status ↔ Branches ↔ Tags ↔
27875
28503
  // Stashes ↔ Worktrees) — the horizontal axis is "between tabs", the
27876
28504
  // vertical axis (↑/↓ below) is "within the active tab's items".
@@ -27956,10 +28584,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27956
28584
  fileCount: context.worktreeFileCount,
27957
28585
  })];
27958
28586
  }
27959
- // Diff view: j/k scrolls the visible diff one line. Hunk navigation
27960
- // moved to ]/[ so single-hunk files (longer than the preview pane)
27961
- // can scroll bidirectionally instead of getting pinned to a hunk
27962
- // anchor.
28587
+ // Worktree (staging) diff: ↑/↓ move between hunks the hunk is the
28588
+ // unit you stage, so the cursor walks hunks (auto-scrolling to the
28589
+ // selected one). Single-hunk files fall through to line-scroll so a
28590
+ // long lone hunk stays readable; `[`/`]` remain hunk-jump aliases.
28591
+ if (state.activeView === 'diff' && (context.worktreeHunkOffsets?.length ?? 0) > 1) {
28592
+ return [action({
28593
+ type: 'jumpWorktreeHunk',
28594
+ delta: -1,
28595
+ hunkOffsets: context.worktreeHunkOffsets,
28596
+ })];
28597
+ }
27963
28598
  if (state.activeView === 'diff' && context.worktreeDiffLineCount) {
27964
28599
  return [action({
27965
28600
  type: 'pageWorktreeDiff',
@@ -28074,6 +28709,15 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
28074
28709
  fileCount: context.worktreeFileCount,
28075
28710
  })];
28076
28711
  }
28712
+ // Worktree (staging) diff: ↓ walks to the next hunk (see the ↑
28713
+ // handler). Multi-hunk only; single-hunk files line-scroll.
28714
+ if (state.activeView === 'diff' && (context.worktreeHunkOffsets?.length ?? 0) > 1) {
28715
+ return [action({
28716
+ type: 'jumpWorktreeHunk',
28717
+ delta: 1,
28718
+ hunkOffsets: context.worktreeHunkOffsets,
28719
+ })];
28720
+ }
28077
28721
  if (state.activeView === 'diff' && context.worktreeDiffLineCount) {
28078
28722
  return [action({
28079
28723
  type: 'pageWorktreeDiff',
@@ -28480,6 +29124,25 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
28480
29124
  if (inputValue === 'p' && isStashActionTarget(state) && context.stashCount) {
28481
29125
  return [{ type: 'runWorkflowAction', id: 'pop-stash' }];
28482
29126
  }
29127
+ // `A` applies restoring the staged/unstaged split (`git stash apply
29128
+ // --index`) — distinct from `a` (plain apply).
29129
+ if (inputValue === 'A' && isStashActionTarget(state) && context.stashCount) {
29130
+ return [{ type: 'runWorkflowAction', id: 'apply-stash-index' }];
29131
+ }
29132
+ // `b` turns the cursored stash into a new branch (`git stash branch`).
29133
+ if (inputValue === 'b' && isStashActionTarget(state) && context.stashCount) {
29134
+ return [action({ type: 'openInputPrompt', kind: 'stash-branch', label: 'New branch from stash' })];
29135
+ }
29136
+ // `R` renames the cursored stash (store-under-new-message + drop old).
29137
+ if (inputValue === 'R' && isStashActionTarget(state) && context.stashCount) {
29138
+ return [action({ type: 'openInputPrompt', kind: 'rename-stash', label: 'Rename stash' })];
29139
+ }
29140
+ // `u` undoes the last drop. Gated on the view, NOT the count, so it
29141
+ // still works right after you drop your only stash (the list is empty
29142
+ // but the dropped commit is recoverable by hash).
29143
+ if (inputValue === 'u' && isStashActionTarget(state)) {
29144
+ return [{ type: 'runWorkflowAction', id: 'undo-drop-stash' }];
29145
+ }
28483
29146
  // Per-view tag action: `P` pushes the selected tag to origin. Letter
28484
29147
  // is scoped to the tags target so it doesn't collide with `p` for
28485
29148
  // pop-stash. Note: this also takes precedence over the global
@@ -28708,7 +29371,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
28708
29371
  return [action({
28709
29372
  type: 'openInputPrompt',
28710
29373
  kind: 'create-stash',
28711
- label: 'Stash message',
29374
+ label: 'Stash message (empty = WIP)',
28712
29375
  })];
28713
29376
  }
28714
29377
  // `o` opens the file under the cursor in $EDITOR. Available on the
@@ -28977,9 +29640,35 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
28977
29640
  if (inputValue === ' ' && state.activeView === 'status' && context.worktreeFileCount) {
28978
29641
  return [{ type: 'toggleSelectedFileStage' }];
28979
29642
  }
29643
+ // `A` — stage everything (git add -A); `+` — stage by typed pathspec.
29644
+ // Both available from the status AND compose views so you can stage
29645
+ // without leaving the message editor.
29646
+ if (inputValue === 'A' && (state.activeView === 'status' || state.activeView === 'compose')) {
29647
+ return [{ type: 'runWorkflowAction', id: 'stage-all' }];
29648
+ }
29649
+ if (inputValue === '+' && (state.activeView === 'status' || state.activeView === 'compose')) {
29650
+ return [action({
29651
+ type: 'openInputPrompt',
29652
+ kind: 'stage-pathspec',
29653
+ label: 'Stage pathspec (e.g. `.`, `src/`, `*.ts`, or a space-separated list)',
29654
+ })];
29655
+ }
28980
29656
  if (inputValue === ' ' && state.activeView === 'diff' && context.worktreeHunkOffsets?.length) {
28981
29657
  return [{ type: 'toggleSelectedHunkStage' }];
28982
29658
  }
29659
+ // Worktree diff with no hunks (a new/untracked file) — `space` stages
29660
+ // the whole file, since there's nothing to partial-stage.
29661
+ if (inputValue === ' ' &&
29662
+ state.activeView === 'diff' &&
29663
+ state.diffSource === 'worktree' &&
29664
+ !context.worktreeHunkOffsets?.length) {
29665
+ return [{ type: 'toggleSelectedFileStage' }];
29666
+ }
29667
+ // `a` stages/unstages the WHOLE current file from the staging diff —
29668
+ // an escape hatch out of hunk-by-hunk back to all-or-nothing.
29669
+ if (inputValue === 'a' && state.activeView === 'diff' && state.diffSource === 'worktree') {
29670
+ return [{ type: 'toggleSelectedFileStage' }];
29671
+ }
28983
29672
  if (inputValue === 'z' && state.activeView === 'status' && context.worktreeFileCount) {
28984
29673
  return [action({ type: 'setPendingMutationConfirmation', value: 'revert-file' })];
28985
29674
  }
@@ -29754,21 +30443,22 @@ const INSPECTOR_TABBED_BELOW_ROWS = 28;
29754
30443
  * wide >= 160 — plenty of room; keep absolute dates
29755
30444
  * normal >= 120 — relative dates save 8-ish cells without hiding info
29756
30445
  * tight >= 100 — drop date entirely; subject + refs are the priority
29757
- * rail < 100 — even with side panels collapsed the row is tight;
29758
- * stack to two lines and rail the side panels at rest
30446
+ * rail < 100 — history rows stack to two lines; the UI also drops
30447
+ * to single-pane mode (see `LAYOUT_SINGLE_PANE_BELOW`)
29759
30448
  */
29760
30449
  const LAYOUT_TIGHT_BELOW = 120;
29761
30450
  const LAYOUT_NORMAL_BELOW = 160;
29762
30451
  const LAYOUT_RAIL_BELOW = 100;
29763
30452
  /**
29764
- * Fixed cell width for a railed side panel. Just wide enough for a
29765
- * 1-cell icon + a 2-3 digit count after subtracting border (2) and
29766
- * padding (2). Going narrower clips the count; going wider defeats
29767
- * the purpose of railing in the first place.
30453
+ * Width below which the three-panel layout can't tile without starving
30454
+ * every pane, so the UI shows exactly one full-width pane (the focused
30455
+ * one) and Tab cycles which pane is visible. Coincides with the `rail`
30456
+ * density breakpoint single-pane mode replaces the old 8-cell icon
30457
+ * rails that used to render at this width.
29768
30458
  */
29769
- const LAYOUT_RAIL_PANEL_WIDTH = 8;
30459
+ const LAYOUT_SINGLE_PANE_BELOW = LAYOUT_RAIL_BELOW;
29770
30460
  const SIDEBAR_AT_REST_BY_TIER = {
29771
- rail: { min: 22, max: 28, fraction: 0.24 }, // unused — rail collapses to LAYOUT_RAIL_PANEL_WIDTH
30461
+ rail: { min: 22, max: 28, fraction: 0.24 }, // unused at rest single-pane mode overrides the width
29772
30462
  tight: { min: 22, max: 28, fraction: 0.24 },
29773
30463
  normal: { min: 22, max: 30, fraction: 0.22 },
29774
30464
  wide: { min: 28, max: 32, fraction: 0.20 },
@@ -29787,14 +30477,25 @@ function getLogInkLayout(input) {
29787
30477
  : columns >= LAYOUT_RAIL_BELOW
29788
30478
  ? 'tight'
29789
30479
  : 'rail';
29790
- // Rail collapse: only happens at the narrowest tier, and only for
29791
- // the panel that does NOT currently hold focus AND is not being
29792
- // commandeered by the help overlay. Focus always wins pressing
29793
- // tab to the sidebar pops it back open even on an 80-cell terminal
29794
- // so the user can actually use it. The help overlay also wins for
29795
- // the inspector since that's where its descriptions render.
29796
- const sidebarRailed = density === 'rail' && !input.sidebarFocused;
29797
- const inspectorRailed = density === 'rail' && !input.inspectorFocused && !input.helpOverlayActive;
30480
+ // Below the single-pane breakpoint the three-panel layout can't tile
30481
+ // without starving every pane, so we show exactly one full-width pane
30482
+ // the focused one and Tab cycles which pane is visible. This
30483
+ // replaces the retired 8-cell icon rails (an 8-cell stub showed a tab
30484
+ // glyph + count and nothing actionable).
30485
+ const singlePane = columns < LAYOUT_SINGLE_PANE_BELOW;
30486
+ // Which pane shows in single-pane mode. Defaults to the focused pane
30487
+ // (focus and visibility coalesce, so the existing Tab focus cycle
30488
+ // drives it). An active overlay can force a specific pane via
30489
+ // `forcedPane` so its surface isn't hidden behind whatever pane focus
30490
+ // points at.
30491
+ const focusPane = input.sidebarFocused
30492
+ ? 'sidebar'
30493
+ : input.inspectorFocused
30494
+ ? 'inspector'
30495
+ : 'main';
30496
+ const visiblePane = singlePane
30497
+ ? input.forcedPane ?? focusPane
30498
+ : focusPane;
29798
30499
  // Inspector width — at rest 20-32 cells (~22% of width), focused
29799
30500
  // 36-60 cells (~40% of width). Narrow rest state keeps the commit
29800
30501
  // graph dominant; focus expansion gives the inspector room for long
@@ -29806,42 +30507,48 @@ function getLogInkLayout(input) {
29806
30507
  // "Move focus...". Capped at 100 cells so a wide terminal doesn't
29807
30508
  // waste an absurd amount of horizontal space on the cheat sheet.
29808
30509
  //
29809
- // Rail collapse wins over the at-rest range but loses to focus and
29810
- // to the help overlay both of those represent deliberate user
29811
- // intent to read the panel.
30510
+ // (In single-pane mode these three-panel widths are recomputed below
30511
+ // so the visible pane gets the full terminal.)
29812
30512
  const detailWidth = input.helpOverlayActive
29813
30513
  ? Math.max(60, Math.min(100, Math.floor(columns * 0.50)))
29814
30514
  : input.inspectorFocused
29815
30515
  ? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
29816
- : inspectorRailed
29817
- ? LAYOUT_RAIL_PANEL_WIDTH
29818
- : Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
30516
+ : Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
29819
30517
  // Sidebar at rest is tier-aware (see `SIDEBAR_AT_REST_BY_TIER`):
29820
30518
  // tight stays compact (22-28), normal shrinks slightly (22-30),
29821
30519
  // wide grows naturally (28-48) so the side panel doesn't get pinned
29822
30520
  // at an arbitrary cap on big terminals while the main panel hogs
29823
30521
  // 80% of the width. Focused: 32-50 cells (~36% of width),
29824
30522
  // regardless of tier — deliberate user intent to read the sidebar
29825
- // deserves the extra width. Rail mode (narrow terminal, unfocused)
29826
- // collapses to a fixed 8-cell strip with tab glyphs only.
30523
+ // deserves the extra width.
29827
30524
  const sidebarWidth = input.sidebarFocused
29828
30525
  ? Math.max(32, Math.min(50, Math.floor(columns * 0.36)))
29829
- : sidebarRailed
29830
- ? LAYOUT_RAIL_PANEL_WIDTH
29831
- : calcSidebarAtRestWidth(columns, density);
30526
+ : calcSidebarAtRestWidth(columns, density);
30527
+ // Single-pane mode: exactly one pane renders, full-width; the other
30528
+ // two are hidden (width 0), not railed. Above the breakpoint the
30529
+ // three panels tile flush across the terminal.
30530
+ const paneWidths = singlePane
30531
+ ? {
30532
+ sidebarWidth: visiblePane === 'sidebar' ? columns : 0,
30533
+ mainPanelWidth: visiblePane === 'main' ? columns : 0,
30534
+ detailWidth: visiblePane === 'inspector' ? columns : 0,
30535
+ }
30536
+ : {
30537
+ sidebarWidth,
30538
+ mainPanelWidth: Math.max(20, columns - sidebarWidth - detailWidth),
30539
+ detailWidth,
30540
+ };
29832
30541
  return {
29833
30542
  bodyRows: Math.max(8, rows - 5),
29834
30543
  columns,
29835
- detailWidth,
29836
- mainPanelWidth: Math.max(20, columns - sidebarWidth - detailWidth),
29837
30544
  rows,
29838
- sidebarWidth,
29839
30545
  tooSmall: columns < LOG_INK_MIN_COLUMNS || rows < LOG_INK_MIN_ROWS,
29840
30546
  inspectorTabbed: rows < INSPECTOR_TABBED_BELOW_ROWS,
29841
30547
  density,
29842
- sidebarRailed,
29843
- inspectorRailed,
30548
+ singlePane,
30549
+ visiblePane,
29844
30550
  historyRowMode: density === 'rail' ? 'stacked' : 'single',
30551
+ ...paneWidths,
29845
30552
  };
29846
30553
  }
29847
30554
 
@@ -30345,7 +31052,7 @@ function fetchBranch(git, branch) {
30345
31052
  if (!branch.upstream || !branch.remote) {
30346
31053
  return Promise.resolve({
30347
31054
  ok: false,
30348
- 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.`,
30349
31056
  });
30350
31057
  }
30351
31058
  // `branch.upstream` is the short form (e.g. `origin/main`); the
@@ -30383,7 +31090,7 @@ function pullBranch(git, branch, currentBranchName) {
30383
31090
  if (!branch.upstream || !branch.remote) {
30384
31091
  return Promise.resolve({
30385
31092
  ok: false,
30386
- 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.`,
30387
31094
  });
30388
31095
  }
30389
31096
  // Current branch — defer to the in-place workflow.
@@ -30890,6 +31597,45 @@ async function highlightDiffCode(filePath, lines) {
30890
31597
  return result;
30891
31598
  }
30892
31599
 
31600
+ /**
31601
+ * Humanize raw AI-provider / LangChain error strings into a short,
31602
+ * actionable line for the compose surface.
31603
+ *
31604
+ * The underlying errors are verbose and developer-facing — e.g.
31605
+ * `executeChain: Chain execution failed: 429 You exceeded your current
31606
+ * quota …`. We classify the common failure modes (rate limit, auth,
31607
+ * network, context length) into a concise message that tells the user
31608
+ * what happened and what to do, and fall back to the original (trimmed)
31609
+ * text for anything we don't recognize. Pure + tested.
31610
+ */
31611
+ function humanizeAiError(raw) {
31612
+ const message = (raw || '').trim();
31613
+ if (!message)
31614
+ return 'AI request failed.';
31615
+ const lower = message.toLowerCase();
31616
+ // Rate limit / quota — the 429 in the screenshot.
31617
+ if (/\b429\b/.test(message) || /rate.?limit|too many requests|exceeded your current quota|quota/i.test(lower)) {
31618
+ return 'Rate limited by your AI provider (429) — too many requests or quota exceeded. Wait a moment, then press I to retry.';
31619
+ }
31620
+ // Auth / API key problems.
31621
+ if (/\b401\b|\b403\b/.test(message) || /unauthor|forbidden|invalid api key|incorrect api key|no api key|authentication/i.test(lower)) {
31622
+ return 'AI provider rejected the request — check your API key (run `coco init`, or press gK to edit the global config).';
31623
+ }
31624
+ // Context window overflow.
31625
+ if (/context length|maximum context|too many tokens|reduce the length|context_length_exceeded/i.test(lower)) {
31626
+ return 'The staged diff is too large for the model’s context window — stage fewer changes (or split the commit) and retry with I.';
31627
+ }
31628
+ // Network / connectivity.
31629
+ if (/etimedout|econnreset|enotfound|econnrefused|network error|fetch failed|socket hang up|timeout/i.test(lower)) {
31630
+ return 'Network error reaching the AI provider — check your connection, then press I to retry.';
31631
+ }
31632
+ // Unknown: strip the noisy `executeChain: Chain execution failed:`
31633
+ // prefix if present so the meaningful part leads, and keep it to one
31634
+ // line so it doesn't blow out the panel.
31635
+ const stripped = message.replace(/^.*?chain execution failed:\s*/i, '').trim() || message;
31636
+ return stripped.split('\n')[0];
31637
+ }
31638
+
30893
31639
  async function runAction$4(action, successMessage) {
30894
31640
  try {
30895
31641
  await action();
@@ -30933,15 +31679,104 @@ async function runAction$3(action, successMessage) {
30933
31679
  };
30934
31680
  }
30935
31681
  }
30936
- function createStash(git, message) {
31682
+ function createStash(git, message, options = {}) {
30937
31683
  const trimmedMessage = message.trim();
30938
- if (!trimmedMessage) {
30939
- return Promise.resolve({
30940
- ok: false,
30941
- message: 'Stash cancelled: empty message.',
30942
- });
31684
+ const args = ['stash', 'push'];
31685
+ // `--staged` is index-only, so untracked / `--keep-index` don't apply;
31686
+ // every other mode includes untracked (`-u`). `--keep-index` leaves the
31687
+ // index populated for an immediate follow-up commit.
31688
+ if (options.stagedOnly) {
31689
+ args.push('--staged');
30943
31690
  }
30944
- return runAction$3(() => git.raw(['stash', 'push', '-u', '-m', trimmedMessage]), `Created stash: ${trimmedMessage}`);
31691
+ else {
31692
+ args.push('-u');
31693
+ if (options.keepIndex)
31694
+ args.push('--keep-index');
31695
+ }
31696
+ if (trimmedMessage)
31697
+ args.push('-m', trimmedMessage);
31698
+ const paths = options.pathspec?.trim();
31699
+ if (paths)
31700
+ args.push('--', ...paths.split(/\s+/));
31701
+ const what = options.stagedOnly
31702
+ ? 'staged changes'
31703
+ : paths
31704
+ ? `“${paths}”`
31705
+ : options.keepIndex
31706
+ ? 'changes (index kept)'
31707
+ : '';
31708
+ const success = trimmedMessage
31709
+ ? `Created stash: ${trimmedMessage}`
31710
+ : what
31711
+ ? `Stashed ${what}`
31712
+ : 'Created WIP stash';
31713
+ return runAction$3(() => git.raw(args), success);
31714
+ }
31715
+ /**
31716
+ * Apply a stash while restoring the original staged/unstaged split via
31717
+ * `--index`. Faithfully reinstates what was staged at stash time; git
31718
+ * errors (surfaced to the user) if the index can no longer be replayed,
31719
+ * in which case plain `applyStash` is the fallback.
31720
+ */
31721
+ function applyStashKeepIndex(git, stash) {
31722
+ return runAction$3(() => git.raw(['stash', 'apply', '--index', stash.ref]), `Applied ${stash.ref} (index restored)`);
31723
+ }
31724
+ /**
31725
+ * Create a new branch from a stash's base commit, apply the stash onto
31726
+ * it, and drop the stash on success — `git stash branch`. The canonical
31727
+ * recovery when a stash no longer applies cleanly onto the current
31728
+ * branch (the branch starts at the exact commit the stash was made on).
31729
+ */
31730
+ function stashBranch(git, stash, branchName) {
31731
+ const trimmed = branchName.trim();
31732
+ if (!trimmed) {
31733
+ return Promise.resolve({ ok: false, message: 'Cancelled: empty branch name.' });
31734
+ }
31735
+ return runAction$3(() => git.raw(['stash', 'branch', trimmed, stash.ref]), `Created branch ${trimmed} from ${stash.ref}`);
31736
+ }
31737
+ /**
31738
+ * Rename a stash. Git has no native rename, so: drop the original entry,
31739
+ * then re-store the SAME commit under the new message.
31740
+ *
31741
+ * Order matters — and it's the OPPOSITE of what you'd guess. `git stash
31742
+ * store` SILENTLY NO-OPS when the commit is already referenced in the
31743
+ * stash reflog (verified empirically), so storing first does nothing and
31744
+ * a follow-up drop removes the wrong entry. Dropping first removes the
31745
+ * reflog reference (the commit object survives), so the subsequent
31746
+ * `store` actually re-adds it — landing at `stash@{0}` with the new
31747
+ * message. The commit is captured by hash beforehand, so the drop→store
31748
+ * window can't lose it.
31749
+ */
31750
+ function renameStash(git, stash, newMessage) {
31751
+ const trimmed = newMessage.trim();
31752
+ if (!trimmed) {
31753
+ return Promise.resolve({ ok: false, message: 'Rename cancelled: empty message.' });
31754
+ }
31755
+ if (!stash.hash) {
31756
+ return Promise.resolve({ ok: false, message: 'Cannot rename: stash commit hash unavailable.' });
31757
+ }
31758
+ // Preserve git's `On <branch>: <subject>` convention so the renamed
31759
+ // stash keeps its origin-branch context. The list + inspector parse the
31760
+ // branch out of that prefix (`parseStashSubject`); a bare message would
31761
+ // render `on <unknown>`. Falls back to the bare message when the branch
31762
+ // is unknown so we never store a misleading `On <unknown>:`.
31763
+ const branch = stash.branch && stash.branch !== '<unknown>' ? stash.branch : '';
31764
+ const storedMessage = branch ? `On ${branch}: ${trimmed}` : trimmed;
31765
+ return runAction$3(async () => {
31766
+ await git.raw(['stash', 'drop', stash.ref]);
31767
+ await git.raw(['stash', 'store', '-m', storedMessage, stash.hash]);
31768
+ }, `Renamed ${stash.ref} → ${trimmed}`);
31769
+ }
31770
+ /**
31771
+ * Re-store a previously dropped stash by its commit hash — the undo for
31772
+ * a `dropStash`. The dropped stash's commit stays in the object database
31773
+ * until git gc, so storing it back recreates the entry (at `stash@{0}`).
31774
+ */
31775
+ function restoreStash(git, hash, message) {
31776
+ if (!hash) {
31777
+ return Promise.resolve({ ok: false, message: 'Nothing to restore.' });
31778
+ }
31779
+ return runAction$3(() => git.raw(['stash', 'store', '-m', message || 'restored stash', hash]), 'Restored dropped stash');
30945
31780
  }
30946
31781
  function applyStash(git, stash) {
30947
31782
  return runAction$3(() => git.raw(['stash', 'apply', stash.ref]), `Applied ${stash.ref}`);
@@ -31563,13 +32398,14 @@ async function getPullRequestList(git, filter = {}, runner = defaultGhRunner) {
31563
32398
  message: 'No GitHub remote detected.',
31564
32399
  };
31565
32400
  }
31566
- if (!(await isGhAuthenticated(runner))) {
32401
+ const ghStatus = await getGhStatus(runner);
32402
+ if (ghStatus.kind !== 'ok') {
31567
32403
  return {
31568
32404
  available: true,
31569
32405
  authenticated: false,
31570
32406
  repository,
31571
32407
  filter,
31572
- message: 'GitHub CLI is missing or not authenticated.',
32408
+ message: describeGhStatus(ghStatus),
31573
32409
  };
31574
32410
  }
31575
32411
  try {
@@ -31814,6 +32650,28 @@ function unstageAllFiles(git, files) {
31814
32650
  }
31815
32651
  return runAction(() => git.raw(['restore', '--staged', '--', ...files.map((file) => file.path)]), `Unstaged ${files.length} ${files.length === 1 ? 'file' : 'files'}`);
31816
32652
  }
32653
+ /**
32654
+ * Stage everything in the worktree — modifications, new files, and
32655
+ * deletions — in one shot (`git add -A`). The `A` hotkey + the `:`
32656
+ * palette's "stage all" both route here.
32657
+ */
32658
+ function stageAll(git) {
32659
+ return runAction(() => git.raw(['add', '-A']), 'Staged all changes');
32660
+ }
32661
+ /**
32662
+ * Stage files matching one or more git pathspecs (`git add -- <spec…>`).
32663
+ * Powers the typed "stage…" prompt (`+`): the user types a path, a
32664
+ * directory, a glob like `*.ts`, or a space-separated list, and git's
32665
+ * own pathspec matching does the rest. Args are passed directly (no
32666
+ * shell), so the globs are interpreted by git, not the shell.
32667
+ */
32668
+ function stagePathspec(git, pathspec) {
32669
+ const specs = pathspec.trim().split(/\s+/).filter(Boolean);
32670
+ if (specs.length === 0) {
32671
+ return Promise.resolve({ ok: false, message: 'Enter a pathspec to stage (e.g. . or src/ or *.ts).' });
32672
+ }
32673
+ return runAction(() => git.raw(['add', '--', ...specs]), `Staged ${specs.join(' ')}`);
32674
+ }
31817
32675
 
31818
32676
  function hunkHeader(hunk) {
31819
32677
  return `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`;
@@ -32390,7 +33248,7 @@ function buildLoadedHashSet(commits) {
32390
33248
  * 5a.7 of #890. Two-row layout introduced post-0.54.2; per-kind
32391
33249
  * colors + glyphs added in the same pass.
32392
33250
  */
32393
- function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFrame = 0) {
33251
+ function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFrame = 0, singlePane = false) {
32394
33252
  const { Box, Text } = components;
32395
33253
  // Sidebar item count drives the per-tab footer hints — when items are
32396
33254
  // present the footer surfaces in-sidebar ops (checkout / apply / pop /
@@ -32404,6 +33262,24 @@ function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFr
32404
33262
  default: return undefined;
32405
33263
  }
32406
33264
  })();
33265
+ // The single-pane pane switcher only makes sense in the plain
33266
+ // per-pane states. While an overlay or filter owns the screen the
33267
+ // visible pane is forced (split-plan → main; help / palette / theme /
33268
+ // gitignore / input prompt / confirmation / chord → inspector) or
33269
+ // input is captured, and Tab does something else — so the switcher
33270
+ // would point at a pane that isn't on screen. Suppress it then. Mirror
33271
+ // of the runtime's `forcedPane` derivation in `app.ts`.
33272
+ const overlayForcesPane = Boolean(state.splitPlan ||
33273
+ state.showHelp ||
33274
+ state.showViewKeys ||
33275
+ state.showCommandPalette ||
33276
+ state.showThemePicker ||
33277
+ state.gitignorePicker ||
33278
+ state.inputPrompt ||
33279
+ state.pendingConfirmationId ||
33280
+ state.pendingMutationConfirmation ||
33281
+ state.pendingKey ||
33282
+ state.filterMode);
32407
33283
  const hints = getLogInkFooterHints({
32408
33284
  activeView: state.activeView,
32409
33285
  diffSource: state.diffSource,
@@ -32417,6 +33293,12 @@ function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFr
32417
33293
  sidebarItemCount,
32418
33294
  compareBaseSet: Boolean(state.compareBase),
32419
33295
  splitPlanStatus: state.splitPlan?.status,
33296
+ singlePane: singlePane && !overlayForcesPane,
33297
+ // Peeking (#1135 v2) is a single-pane glance with focus on the
33298
+ // sidebar; the footer shows `v/esc → main` instead of the switcher.
33299
+ // Suppressed under an overlay (which owns the footer) just like the
33300
+ // switcher.
33301
+ peeking: Boolean(state.peekReturnFocus) && singlePane && !overlayForcesPane,
32420
33302
  });
32421
33303
  // Real status messages always win; idle tips only fill the slot when it
32422
33304
  // would otherwise be empty.
@@ -32912,7 +33794,10 @@ function sidebarTabCount(tab, context) {
32912
33794
  * Header chip builder. Turns the workstation's title-bar state into an
32913
33795
  * ordered list of small visually-distinct chips:
32914
33796
  *
32915
- * coco · gfargo/coco · ⎇ main · ✓ clean · ⊘ no PR · [NORMAL]
33797
+ * coco · gfargo/coco · ⎇ main · ✓ clean · [NORMAL]
33798
+ *
33799
+ * The PR chip is appended only when a pull request exists (#1133); there
33800
+ * is no "no PR" placeholder chip.
32916
33801
  *
32917
33802
  * Pre-refactor the title bar concatenated every segment into a single
32918
33803
  * Text span, which made the eye read the whole thing as one run of
@@ -33008,10 +33893,11 @@ function buildHeaderChips(input) {
33008
33893
  bold: true,
33009
33894
  });
33010
33895
  }
33011
- // PR state. When present, the chip uses the PR-state glyph + a short
33012
- // label ("PR #1234 OPEN" / "PR #1234 DRAFT"). When absent, a muted
33013
- // "no PR" chip so users know the system DID look (vs. the bar just
33014
- // being blank).
33896
+ // PR state. Shown only when a PR actually exists the chip uses the
33897
+ // PR-state glyph + a short label ("PR #1234 OPEN" / "PR #1234 DRAFT").
33898
+ // The old always-on "no PR" chip spent a permanent header segment to
33899
+ // report a negative default state on every screen; dropping it keeps
33900
+ // the state cluster about what *is* true (TUI audit).
33015
33901
  if (input.pullRequest) {
33016
33902
  const prGlyph = getPullRequestStateGlyph({ ...input.pullRequest, isDraft: Boolean(input.pullRequest.isDraft) }, theme);
33017
33903
  const stateLabel = input.pullRequest.isDraft
@@ -33028,15 +33914,6 @@ function buildHeaderChips(input) {
33028
33914
  bold: false,
33029
33915
  });
33030
33916
  }
33031
- else {
33032
- chips.push({
33033
- id: 'pr',
33034
- label: theme.ascii ? '- no PR' : '⊘ no PR',
33035
- color: theme.colors.muted,
33036
- dim: true,
33037
- bold: false,
33038
- });
33039
- }
33040
33917
  // View breadcrumb. Rendered only when there's content (`coco ui`
33041
33918
  // root view → no breadcrumb chip; pushed into a sub-view → chip
33042
33919
  // appears). Comes AFTER PR so the "state" group (app/repo/branch/
@@ -33107,7 +33984,10 @@ function measureHeaderChipsWidth(chips) {
33107
33984
  * Title-bar renderer. Surfaces the workstation's identity + navigation
33108
33985
  * state as a row of small visually-distinct chips:
33109
33986
  *
33110
- * coco · gfargo/coco · ⎇ main · ✓ clean · ⊘ no PR · [NORMAL]
33987
+ * coco · gfargo/coco · ⎇ main · ✓ clean · [NORMAL]
33988
+ *
33989
+ * The PR chip is appended only when a pull request exists (e.g.
33990
+ * `· ⊠ PR #1234 OPEN`); there's no "no PR" placeholder chip.
33111
33991
  *
33112
33992
  * Per-chip color/glyph treatment lets the user scan in chunks ("what
33113
33993
  * app, what repo, what branch, how clean, what PR state, what mode")
@@ -33547,70 +34427,10 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
33547
34427
  return `${marker} ${worktree.branch || worktree.path} ${wstate}`;
33548
34428
  }, 'tab-worktrees', visibleListCount);
33549
34429
  }
33550
- /**
33551
- * Single-letter glyph for a sidebar tab in rail mode. Letters always
33552
- * carry the meaning so this stays useful under ASCII; the rail is too
33553
- * narrow to fit the full tab label. Pairs with `sidebarTabCount` for
33554
- * the trailing count.
33555
- */
33556
- function sidebarTabRailGlyph(tab) {
33557
- switch (tab) {
33558
- case 'status':
33559
- return 'S';
33560
- case 'branches':
33561
- return 'B';
33562
- case 'tags':
33563
- return 'T';
33564
- case 'stashes':
33565
- return '$';
33566
- case 'worktrees':
33567
- return 'W';
33568
- default:
33569
- return '·';
33570
- }
33571
- }
33572
- /**
33573
- * Rail-mode sidebar — shown on terminals < 100 columns when the
33574
- * sidebar does not hold focus. Five vertically stacked tab glyphs
33575
- * with optional counts; the active tab is bracketed. Pressing Tab to
33576
- * focus the sidebar pops it back to the full accordion (the layout
33577
- * un-rails it on focus, this renderer is never called in that case).
33578
- */
33579
- function renderSidebarRail$1(h, components, state, context, width, theme, focused, tabs) {
33580
- const { Box, Text } = components;
33581
- return h(Box, {
33582
- borderColor: focusBorderColor(theme, focused),
33583
- borderStyle: theme.borderStyle,
33584
- flexDirection: 'column',
33585
- width,
33586
- paddingX: 1,
33587
- }, h(Text, { bold: true, dimColor: !focused }, 'Repo'), h(Text, { dimColor: true }, '────'), ...tabs.map((tab) => {
33588
- const isActive = tab === state.sidebarTab;
33589
- const glyph = sidebarTabRailGlyph(tab);
33590
- const count = sidebarTabCount(tab, context);
33591
- // Count fits in 2 cells (rail content area is ~4 cells); 99+
33592
- // collapses to `+` so we never overflow.
33593
- const countText = count === undefined
33594
- ? ''
33595
- : count > 99
33596
- ? '+'
33597
- : String(count);
33598
- const body = isActive ? `[${glyph}]` : ` ${glyph} `;
33599
- const text = countText ? `${body}${countText}` : body;
33600
- return h(Text, {
33601
- key: `rail-${tab}`,
33602
- bold: isActive,
33603
- dimColor: !isActive,
33604
- }, text);
33605
- }));
33606
- }
33607
- function renderSidebar$1(h, components, state, context, contextStatus, width, bodyRows, theme, railed = false) {
34430
+ function renderSidebar$1(h, components, state, context, contextStatus, width, bodyRows, theme) {
33608
34431
  const { Box, Text } = components;
33609
34432
  const focused = state.focus === 'sidebar';
33610
34433
  const tabs = getLogInkSidebarTabs();
33611
- if (railed) {
33612
- return renderSidebarRail$1(h, components, state, context, width, theme, focused, tabs);
33613
- }
33614
34434
  // Accordion layout — every tab's title is visible on its own line, but
33615
34435
  // only the active tab expands its content underneath. Switching tabs
33616
34436
  // (1-5 / [/]) collapses the previous and expands the next.
@@ -33669,7 +34489,8 @@ function renderSidebar$1(h, components, state, context, contextStatus, width, bo
33669
34489
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
33670
34490
  * of #890. No behavior change.
33671
34491
  */
33672
- function renderBisectSurface(h, components, state, context, contextStatus, candidateDetail, candidateLoading, bodyRows, width, theme) {
34492
+ function renderBisectSurface(ctx, candidateDetail, candidateLoading) {
34493
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
33673
34494
  const { Box, Text } = components;
33674
34495
  const focused = state.focus === 'commits';
33675
34496
  const loading = isLogInkContextKeyLoading(contextStatus, 'bisect');
@@ -33993,7 +34814,8 @@ function formatLogInkGitHubNoRemote({ resource, }) {
33993
34814
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
33994
34815
  * of #890. No behavior change.
33995
34816
  */
33996
- function renderBranchesSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
34817
+ function renderBranchesSurface(ctx) {
34818
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
33997
34819
  const { Box, Text } = components;
33998
34820
  const focused = state.focus === 'commits';
33999
34821
  const branches = context.branches;
@@ -34139,7 +34961,8 @@ function formatCacheAge(generatedAt, now) {
34139
34961
  const day = Math.floor(hr / 24);
34140
34962
  return `${day}d ago`;
34141
34963
  }
34142
- function renderChangelogSurface(h, components, state, _context, _contextStatus, bodyRows, width, theme) {
34964
+ function renderChangelogSurface(ctx) {
34965
+ const { h, components, state, bodyRows, width, theme } = ctx;
34143
34966
  const { Box, Text } = components;
34144
34967
  const focused = state.focus === 'commits';
34145
34968
  const view = state.changelogView;
@@ -34331,7 +35154,8 @@ function renderStreamingPreviewLines(h, components, preview, width, theme) {
34331
35154
  }, `${prefix}${line}`);
34332
35155
  });
34333
35156
  }
34334
- function renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme, spinnerFrame = 0) {
35157
+ function renderComposeSurface(ctx, spinnerFrame = 0) {
35158
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
34335
35159
  const { Box, Text } = components;
34336
35160
  const compose = state.commitCompose;
34337
35161
  const focused = state.focus === 'commits';
@@ -34352,8 +35176,16 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
34352
35176
  const bodyVisualLines = compose.body
34353
35177
  ? compose.body.split('\n').flatMap((line) => wrapCells(line, bodyTextWidth)).slice(0, bodyRowsAvailable)
34354
35178
  : ['<empty>'];
34355
- const summaryVisualLines = wrapCells(`${compose.summary || '<empty>'}${summaryCursor}`, Math.max(8, width - 11) // "Summary " (9) + 2 chrome = 11
34356
- );
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;
34357
35189
  // State-line cycles through three modes (#881 phase 3 added the
34358
35190
  // loading variant): editing copy when the user is typing, cancel
34359
35191
  // hint when an AI draft is generating, default guidance otherwise.
@@ -34373,6 +35205,52 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
34373
35205
  const noStagedHint = !isLogInkContextKeyLoading(contextStatus, 'worktree')
34374
35206
  ? formatLogInkComposeEmpty({ hasStaged: hasStagedFiles })
34375
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
+ });
34376
35254
  return h(Box, {
34377
35255
  borderColor: focusBorderColor(theme, focused),
34378
35256
  borderStyle: theme.borderStyle,
@@ -34380,20 +35258,7 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
34380
35258
  flexShrink: 0,
34381
35259
  paddingX: 1,
34382
35260
  width,
34383
- }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Compose commit', focused)), h(Text, { dimColor: true }, statusLine)), h(Text, undefined, ''), h(Text, {
34384
- bold: compose.field === 'summary' && compose.editing,
34385
- }, `Summary ${summaryVisualLines[0] || ''}`), ...summaryVisualLines.slice(1).map((line, index) => h(Text, {
34386
- key: `compose-summary-${index}`,
34387
- bold: compose.field === 'summary' && compose.editing,
34388
- }, ` ${line}`)), h(Text, undefined, ''), h(Text, {
34389
- bold: compose.field === 'body' && compose.editing,
34390
- }, 'Body'), ...bodyVisualLines.map((line, index) => {
34391
- const isLast = index === bodyVisualLines.length - 1;
34392
- return h(Text, {
34393
- key: `compose-body-${index}`,
34394
- dimColor: line === '<empty>',
34395
- }, ` ${line}${bodyCursor && isLast ? bodyCursor : ''}`);
34396
- }),
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),
34397
35262
  // Loading indicator + post-action message belong inline with the draft
34398
35263
  // (they describe what just happened to the fields above). The state-
34399
35264
  // line ("Editing — Enter switches summary↔body…" / "Press e to edit
@@ -34444,7 +35309,8 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
34444
35309
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.3
34445
35310
  * of #890. No behavior change.
34446
35311
  */
34447
- function renderConflictsSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
35312
+ function renderConflictsSurface(ctx) {
35313
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
34448
35314
  const { Box, Text } = components;
34449
35315
  const focused = state.focus === 'commits';
34450
35316
  const loading = isLogInkContextKeyLoading(contextStatus, 'operation');
@@ -34913,6 +35779,50 @@ function renderDiffLine(h, Text, line, theme, syntaxSpans, maxCells, key) {
34913
35779
  return h(Text, { key }, h(Text, { key: `${key}-m`, color: markerColor }, marker), ...children);
34914
35780
  }
34915
35781
 
35782
+ /** The hunk index owning `absLine`, or -1 for pre-hunk header/label rows. */
35783
+ function hunkIndexForLine(absLine, hunkOffsets) {
35784
+ let index = -1;
35785
+ for (let k = 0; k < hunkOffsets.length; k++) {
35786
+ if (hunkOffsets[k] <= absLine)
35787
+ index = k;
35788
+ else
35789
+ break;
35790
+ }
35791
+ return index;
35792
+ }
35793
+ function renderWorktreeDiffBody(h, components, params) {
35794
+ const { Box, Text } = components;
35795
+ const { lines, offset, visibleRows, width, theme, syntaxSpans, hunkOffsets, hunks, selectedIndex, keyPrefix } = params;
35796
+ const headerSet = new Set(hunkOffsets);
35797
+ const accent = theme.noColor ? undefined : theme.colors.accent;
35798
+ const added = theme.noColor ? undefined : theme.colors.gitAdded;
35799
+ const codeWidth = Math.max(8, width - 5); // 2 chrome + 1 gutter + slack
35800
+ const visible = lines.slice(offset, offset + visibleRows);
35801
+ return visible.map((line, i) => {
35802
+ const abs = offset + i;
35803
+ const key = `${keyPrefix}-${abs}`;
35804
+ const hunkIndex = hunkIndexForLine(abs, hunkOffsets);
35805
+ const hunk = hunkIndex >= 0 ? hunks[hunkIndex] : undefined;
35806
+ const isSelected = hunkIndex >= 0 && hunkIndex === selectedIndex;
35807
+ const isStaged = hunk?.state === 'staged';
35808
+ const bar = isSelected ? '▎' : ' ';
35809
+ // `@@` header row — badge + (dim) hunk position, emphasized when selected.
35810
+ if (headerSet.has(abs)) {
35811
+ const badge = theme.ascii ? (isStaged ? '[x] ' : '[ ] ') : (isStaged ? '● ' : '○ ');
35812
+ const badgeColor = theme.noColor ? undefined : isStaged ? added : theme.colors.muted;
35813
+ return h(Box, { key, flexDirection: 'row' }, h(Text, { color: accent }, bar), h(Text, { color: badgeColor, bold: isSelected }, badge), h(Text, { bold: isSelected, color: isSelected ? accent : (theme.noColor ? undefined : theme.colors.muted) }, truncateCells(line, codeWidth)));
35814
+ }
35815
+ // Body / context / pre-hunk lines.
35816
+ // A staged hunk that ISN'T selected renders dim ("done", out of
35817
+ // focus); the selected hunk and unstaged hunks keep full diff +
35818
+ // syntax coloring via renderDiffLine so the focus stays vivid.
35819
+ const content = isStaged && !isSelected && hunkIndex >= 0
35820
+ ? h(Text, { key: `${key}-c`, dimColor: true }, truncateCells(line, codeWidth))
35821
+ : renderDiffLine(h, Text, line, theme, syntaxSpans, codeWidth, `${key}-c`);
35822
+ return h(Box, { key, flexDirection: 'row' }, h(Text, { color: accent }, bar), content);
35823
+ });
35824
+ }
35825
+
34916
35826
  /**
34917
35827
  * Diff surface — the unified or side-by-side diff view. Four sources
34918
35828
  * route through here, disambiguated by `state.diffSource`:
@@ -34935,7 +35845,9 @@ function renderDiffLine(h, Text, line, theme, syntaxSpans, maxCells, key) {
34935
35845
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.4
34936
35846
  * of #890. No behavior change.
34937
35847
  */
34938
- function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme, syntaxSpans) {
35848
+ function renderDiffSurface(ctx, diff) {
35849
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
35850
+ const { worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, syntaxSpans, } = diff;
34939
35851
  const { Box, Text } = components;
34940
35852
  const focused = state.focus === 'commits';
34941
35853
  const worktree = context.worktree;
@@ -35135,7 +36047,22 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
35135
36047
  }
35136
36048
  const diffLines = worktreeDiff?.lines || [];
35137
36049
  const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
36050
+ const totalHunks = worktreeHunks?.hunks.length ?? 0;
36051
+ const stagedHunks = worktreeHunks?.hunks.filter((hunk) => hunk.state === 'staged').length ?? 0;
35138
36052
  const visibleDiffLines = diffLines.slice(state.worktreeDiffOffset, state.worktreeDiffOffset + visibleRows);
36053
+ // Hunk-position line: badge + selected hunk's state + a staged/total
36054
+ // progress count, so the user always sees how far through staging they
36055
+ // are. Untracked/new files have no hunks — point them at whole-file
36056
+ // staging instead of a dead-end "no hunks" message.
36057
+ const hunkHeaderLine = worktreeHunksLoading
36058
+ ? 'Hunks loading…'
36059
+ : worktreeDiff?.untracked
36060
+ ? (theme.ascii ? 'New file — press space to stage it whole.' : '✚ New file — press space to stage it whole.')
36061
+ : totalHunks
36062
+ ? `Hunk ${state.selectedWorktreeHunkIndex + 1}/${totalHunks} · ${selectedHunk?.state === 'staged'
36063
+ ? (theme.ascii ? '[x] staged' : '● staged')
36064
+ : (theme.ascii ? '[ ] unstaged' : '○ unstaged')} · ${stagedHunks}/${totalHunks} staged`
36065
+ : 'No stageable hunks for this file.';
35139
36066
  const headerLines = isLogInkContextKeyLoading(contextStatus, 'worktree')
35140
36067
  ? ['Loading file context...']
35141
36068
  : worktreeDiffLoading
@@ -35144,11 +36071,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
35144
36071
  ? [
35145
36072
  // File path is already shown in the panel title bar (right) —
35146
36073
  // no redundant "Selected file:" line here.
35147
- worktreeHunksLoading
35148
- ? 'Hunks loading...'
35149
- : worktreeHunks?.hunks.length
35150
- ? `Hunk ${state.selectedWorktreeHunkIndex + 1}/${worktreeHunks.hunks.length} ${selectedHunk?.state || ''}`
35151
- : 'No stageable hunks for this file.',
36074
+ hunkHeaderLine,
35152
36075
  `Lines ${Math.min(state.worktreeDiffOffset + 1, diffLines.length || 1)}-${Math.min(state.worktreeDiffOffset + visibleDiffLines.length, diffLines.length)}/${diffLines.length}`,
35153
36076
  '',
35154
36077
  ]
@@ -35163,11 +36086,26 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
35163
36086
  flexShrink: 0,
35164
36087
  paddingX: 1,
35165
36088
  width,
35166
- }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Diff', focused)), h(Text, { dimColor: true }, worktreeFile ? worktreeFile.path : 'no file')), ...headerLines.map((line, index) => h(Text, {
36089
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Diff', focused)),
36090
+ // Use the path of the file actually being diffed (the grouped/visible
36091
+ // selection feeds the loaded diff) — `worktreeFile` indexes the raw,
36092
+ // ungrouped file list and can name a different file than the diff body.
36093
+ h(Text, { dimColor: true }, worktreeDiff?.filePath || worktreeFile?.path || 'no file')), ...headerLines.map((line, index) => h(Text, {
35167
36094
  key: `diff-surface-header-${index}`,
35168
36095
  dimColor: index > 0,
35169
36096
  }, truncateCells(line, 140))), ...(showDiffLines
35170
- ? visibleDiffLines.map((line, index) => renderDiffLine(h, Text, line, theme, syntaxSpans, 140, `diff-surface-line-${state.worktreeDiffOffset + index}`))
36097
+ ? renderWorktreeDiffBody(h, components, {
36098
+ lines: diffLines,
36099
+ offset: state.worktreeDiffOffset,
36100
+ visibleRows,
36101
+ width,
36102
+ theme,
36103
+ syntaxSpans,
36104
+ hunkOffsets: worktreeDiff?.hunkOffsets || [],
36105
+ hunks: worktreeHunks?.hunks || [],
36106
+ selectedIndex: state.selectedWorktreeHunkIndex,
36107
+ keyPrefix: 'diff-surface-line',
36108
+ })
35171
36109
  : []));
35172
36110
  }
35173
36111
 
@@ -36351,7 +37289,8 @@ function renderRemoteOpLoader(h, components, state, width, bodyRows, theme, focu
36351
37289
  height: innerHeight,
36352
37290
  }, h(Text, { color: accent, bold: true }, `${spinner} ${op.label}`), h(Text, undefined, ''), h(Text, { color: accent }, track), h(Text, undefined, ''), h(Text, { dimColor: true }, 'Talking to the remote — history refreshes automatically.')));
36353
37291
  }
36354
- function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled = false, now = getRenderNow(), spinnerFrame = 0) {
37292
+ function renderHistoryPanel(ctx, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled = false, now = getRenderNow(), spinnerFrame = 0) {
37293
+ const { h, components, state, context, bodyRows, width, theme } = ctx;
36355
37294
  const { Box, Text } = components;
36356
37295
  const focused = state.focus === 'commits';
36357
37296
  // Remote op in flight (fetch / pull / push) → swap the commit list
@@ -36657,6 +37596,54 @@ function renderChordOverlay(h, components, state, width, theme, focused) {
36657
37596
  paddingX: 1,
36658
37597
  }, ...lines);
36659
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
+ }
36660
37647
  function renderHelpPanel(h, components, state, width, theme, focused, bodyRows = 0) {
36661
37648
  const { Box, Text } = components;
36662
37649
  // Build the full list of body rows (everything below the title).
@@ -37053,7 +38040,8 @@ function matchesIssueFilter(issue, filter) {
37053
38040
  ...(issue.assignees || []),
37054
38041
  ], filter);
37055
38042
  }
37056
- function renderIssuesTriageSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
38043
+ function renderIssuesTriageSurface(ctx) {
38044
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
37057
38045
  const { Box, Text } = components;
37058
38046
  const focused = state.focus === 'commits';
37059
38047
  const overview = context.issueList;
@@ -37335,7 +38323,8 @@ function formatPullRequestStateLine(pr) {
37335
38323
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
37336
38324
  * of #890. No behavior change.
37337
38325
  */
37338
- function renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
38326
+ function renderPullRequestSurface(ctx) {
38327
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
37339
38328
  const { Box, Text } = components;
37340
38329
  const focused = state.focus === 'commits';
37341
38330
  const loading = isLogInkContextKeyLoading(contextStatus, 'pullRequest');
@@ -37508,7 +38497,8 @@ function matchesPullRequestFilter(pr, filter) {
37508
38497
  ...(pr.assignees || []),
37509
38498
  ], filter);
37510
38499
  }
37511
- function renderPullRequestTriageSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
38500
+ function renderPullRequestTriageSurface(ctx) {
38501
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
37512
38502
  const { Box, Text } = components;
37513
38503
  const focused = state.focus === 'commits';
37514
38504
  const overview = context.pullRequestList;
@@ -37624,7 +38614,8 @@ function renderPullRequestTriageSurface(h, components, state, context, contextSt
37624
38614
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
37625
38615
  * of #890. No behavior change.
37626
38616
  */
37627
- function renderReflogSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
38617
+ function renderReflogSurface(ctx) {
38618
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
37628
38619
  const { Box, Text } = components;
37629
38620
  const focused = state.focus === 'commits';
37630
38621
  const loading = isLogInkContextKeyLoading(contextStatus, 'reflog');
@@ -37695,7 +38686,8 @@ function renderReflogSurface(h, components, state, context, contextStatus, bodyR
37695
38686
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
37696
38687
  * of #890. No behavior change.
37697
38688
  */
37698
- function renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
38689
+ function renderStashSurface(ctx) {
38690
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
37699
38691
  const { Box, Text } = components;
37700
38692
  const focused = state.focus === 'commits';
37701
38693
  const loading = isLogInkContextKeyLoading(contextStatus, 'stashes');
@@ -37713,6 +38705,11 @@ function renderStashSurface(h, components, state, context, contextStatus, bodyRo
37713
38705
  : `${stashes.length}/${allStashes.length} stashes${filterLabel}`;
37714
38706
  const emptyLabel = formatLogInkStashEmpty({ filter: state.filter });
37715
38707
  const loadingLabel = formatLogInkLoading({ resource: 'stashes' });
38708
+ const now = getRenderNow();
38709
+ // Available width for a row: box width minus the 2-cell horizontal
38710
+ // padding. Truncate to it (with a small floor) instead of a magic 140
38711
+ // so the richer meta degrades gracefully on narrow terminals.
38712
+ const rowWidth = Math.max(20, width - 2);
37716
38713
  const lines = loading
37717
38714
  ? [h(Text, { key: 'stash-loading', dimColor: true }, loadingLabel)]
37718
38715
  : stashes.length === 0
@@ -37721,11 +38718,25 @@ function renderStashSurface(h, components, state, context, contextStatus, bodyRo
37721
38718
  const index = startIndex + offset;
37722
38719
  const isSelected = index === selected;
37723
38720
  const cursor = isSelected ? '>' : ' ';
38721
+ // Surface the metadata the StashEntry already carries — origin
38722
+ // branch, file count, and relative age — between the ref and the
38723
+ // message, so the list answers "which stash is this?" without an
38724
+ // Enter→diff round trip.
38725
+ const age = formatCompactRelativeDate(stash.date, now);
38726
+ const fileCount = stash.files.length;
38727
+ const meta = [
38728
+ stash.branch ? `on ${stash.branch}` : '',
38729
+ fileCount > 0 ? `${fileCount} file${fileCount === 1 ? '' : 's'}` : '',
38730
+ age,
38731
+ ].filter(Boolean).join(' · ');
38732
+ const rowText = meta
38733
+ ? `${cursor} ${stash.ref.padEnd(11)} ${meta} ${stash.message}`
38734
+ : `${cursor} ${stash.ref.padEnd(11)} ${stash.message}`;
37724
38735
  return h(Text, {
37725
38736
  key: `stash-${index}`,
37726
38737
  bold: isSelected,
37727
38738
  dimColor: !isSelected,
37728
- }, truncateCells(`${cursor} ${stash.ref.padEnd(12)} ${stash.message}`, 140));
38739
+ }, truncateCells(rowText, rowWidth));
37729
38740
  });
37730
38741
  const stashHasMoreAbove = startIndex > 0 && stashes.length > 0;
37731
38742
  const stashHasMoreBelow = startIndex + listRows < stashes.length;
@@ -37785,7 +38796,8 @@ function formatStatusFilterMask(mask) {
37785
38796
  active.push('untracked');
37786
38797
  return active.join(' + ') || 'none';
37787
38798
  }
37788
- function renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
38799
+ function renderStatusSurface(ctx) {
38800
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
37789
38801
  const { Box, Text } = components;
37790
38802
  const focused = state.focus === 'commits';
37791
38803
  const worktree = context.worktree;
@@ -37932,7 +38944,8 @@ function flagColor(flag, theme) {
37932
38944
  return theme.colors.danger;
37933
38945
  return undefined;
37934
38946
  }
37935
- function renderSubmodulesSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
38947
+ function renderSubmodulesSurface(ctx) {
38948
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
37936
38949
  const { Box, Text } = components;
37937
38950
  const focused = state.focus === 'commits';
37938
38951
  const loading = isLogInkContextKeyLoading(contextStatus, 'submodules');
@@ -38071,7 +39084,8 @@ function formatHyperlink(text, url, env = process.env) {
38071
39084
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
38072
39085
  * of #890. No behavior change.
38073
39086
  */
38074
- function renderTagsSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
39087
+ function renderTagsSurface(ctx) {
39088
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
38075
39089
  const { Box, Text } = components;
38076
39090
  const focused = state.focus === 'commits';
38077
39091
  const loading = isLogInkContextKeyLoading(contextStatus, 'tags');
@@ -38153,7 +39167,8 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
38153
39167
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
38154
39168
  * of #890. No behavior change.
38155
39169
  */
38156
- function renderWorktreesSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
39170
+ function renderWorktreesSurface(ctx) {
39171
+ const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
38157
39172
  const { Box, Text } = components;
38158
39173
  const focused = state.focus === 'commits';
38159
39174
  const loading = isLogInkContextKeyLoading(contextStatus, 'worktreeList');
@@ -38217,7 +39232,12 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
38217
39232
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
38218
39233
  * of #890. No behavior change.
38219
39234
  */
38220
- function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, spinnerFrame, density, rowMode, dateBucketingEnabled, syntaxSpans) {
39235
+ function renderMainPanel(surface, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, hasMoreCommits, loadingMoreCommits, spinnerFrame, density, rowMode, dateBucketingEnabled, syntaxSpans) {
39236
+ // The universal render values now arrive bundled (#1136); only the
39237
+ // few raw values the dispatcher itself touches (split-plan overlay,
39238
+ // activeView switch) are destructured here. Surfaces receive `surface`
39239
+ // directly plus their own slices.
39240
+ const { h, components, state, bodyRows, width, theme } = surface;
38221
39241
  // Split-plan overlay (#907 polish): renders in the MAIN panel (not
38222
39242
  // detail) when active, because the content — multiple commit groups
38223
39243
  // with file lists, rationale, hunks — needs the full center width
@@ -38229,51 +39249,66 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
38229
39249
  return renderSplitPlanOverlay(h, components, state, width, bodyRows, theme, true, spinnerFrame);
38230
39250
  }
38231
39251
  if (state.activeView === 'status') {
38232
- return renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
39252
+ return renderStatusSurface(surface);
38233
39253
  }
38234
39254
  if (state.activeView === 'diff') {
38235
- return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme, syntaxSpans);
39255
+ const diffData = {
39256
+ worktreeDiff,
39257
+ worktreeDiffLoading,
39258
+ worktreeHunks,
39259
+ worktreeHunksLoading,
39260
+ filePreview,
39261
+ filePreviewLoading,
39262
+ commitDiffHunkOffsets,
39263
+ selectedDetailFile,
39264
+ stashDiffLines,
39265
+ stashDiffLoading,
39266
+ compareDiffLines,
39267
+ compareDiffLoading,
39268
+ syntaxSpans,
39269
+ };
39270
+ return renderDiffSurface(surface, diffData);
38236
39271
  }
38237
39272
  if (state.activeView === 'compose') {
38238
- return renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme, spinnerFrame);
39273
+ return renderComposeSurface(surface, spinnerFrame);
38239
39274
  }
38240
39275
  if (state.activeView === 'branches') {
38241
- return renderBranchesSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
39276
+ return renderBranchesSurface(surface);
38242
39277
  }
38243
39278
  if (state.activeView === 'tags') {
38244
- return renderTagsSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
39279
+ return renderTagsSurface(surface);
38245
39280
  }
38246
39281
  if (state.activeView === 'reflog') {
38247
- return renderReflogSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
39282
+ return renderReflogSurface(surface);
38248
39283
  }
38249
39284
  if (state.activeView === 'bisect') {
38250
- return renderBisectSurface(h, components, state, context, contextStatus, bisectCandidateDetail, bisectCandidateLoading, bodyRows, width, theme);
39285
+ return renderBisectSurface(surface, bisectCandidateDetail, bisectCandidateLoading);
38251
39286
  }
38252
39287
  if (state.activeView === 'stash') {
38253
- return renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
39288
+ return renderStashSurface(surface);
38254
39289
  }
38255
39290
  if (state.activeView === 'worktrees') {
38256
- return renderWorktreesSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
39291
+ return renderWorktreesSurface(surface);
38257
39292
  }
38258
39293
  if (state.activeView === 'submodules') {
38259
- return renderSubmodulesSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
39294
+ return renderSubmodulesSurface(surface);
38260
39295
  }
38261
39296
  if (state.activeView === 'pull-request') {
38262
- return renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
39297
+ return renderPullRequestSurface(surface);
38263
39298
  }
38264
39299
  if (state.activeView === 'pull-request-triage') {
38265
- return renderPullRequestTriageSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
39300
+ return renderPullRequestTriageSurface(surface);
38266
39301
  }
38267
39302
  if (state.activeView === 'issues') {
38268
- return renderIssuesTriageSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
39303
+ return renderIssuesTriageSurface(surface);
38269
39304
  }
38270
39305
  if (state.activeView === 'conflicts') {
38271
- return renderConflictsSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
39306
+ return renderConflictsSurface(surface);
38272
39307
  }
38273
39308
  if (state.activeView === 'changelog') {
38274
- return renderChangelogSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
39309
+ return renderChangelogSurface(surface);
38275
39310
  }
38276
- return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled, undefined, spinnerFrame);
39311
+ return renderHistoryPanel(surface, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled, undefined, spinnerFrame);
38277
39312
  }
38278
39313
 
38279
39314
  /**
@@ -39146,21 +40181,16 @@ function renderCommitPanel(h, components, state, context, contextStatus, width,
39146
40181
  const bodyVisualLines = bodyHasContent
39147
40182
  ? compose.body.split('\n').flatMap((line) => wrapCells(line, bodyTextWidth)).slice(0, 12)
39148
40183
  : ['<empty>'];
39149
- const summaryWrapped = wrapCells(`${compose.summary || '<empty>'}${summaryCursor}`, bodyTextWidth);
39150
- const summaryFirst = `${compose.field === 'summary' && compose.editing ? '>' : ' '} Summary: ${summaryWrapped[0] || ''}`;
39151
- const summaryRest = summaryWrapped.slice(1).map((line) => ` ${line}`);
39152
- const headerLines = [
39153
- statusLine,
39154
- '',
39155
- summaryFirst,
39156
- ...summaryRest,
39157
- `${compose.field === 'body' && compose.editing ? '>' : ' '} Body:`,
39158
- ...bodyVisualLines.map((line, index) => {
39159
- const isLast = index === bodyVisualLines.length - 1;
39160
- return ` ${line}${bodyCursor && isLast ? bodyCursor : ''}`;
39161
- }),
39162
- '',
39163
- ];
40184
+ const hasSummary = Boolean(compose.summary);
40185
+ const summaryMarker = compose.field === 'summary' && compose.editing ? '>' : ' ';
40186
+ const bodyMarker = compose.field === 'body' && compose.editing ? '>' : ' ';
40187
+ // The generated subject is the thing the user is looking for — render
40188
+ // it bold + accent so it pops out of the inspector instead of blending
40189
+ // into the dim label/body text. The `Summary:` label stays dim.
40190
+ const summaryLabel = `${summaryMarker} Summary: `;
40191
+ const summaryColor = hasSummary && !theme.noColor ? theme.colors.accent : undefined;
40192
+ const summaryValueWidth = Math.max(4, width - 4 - cellWidth(summaryLabel));
40193
+ const summaryWrapped = wrapCells(`${compose.summary || '<empty>'}${summaryCursor}`, summaryValueWidth);
39164
40194
  const trailerLines = [
39165
40195
  ...(compose.message ? ['', compose.message] : []),
39166
40196
  ...(compose.details || []).map((line) => ` ${line}`),
@@ -39174,10 +40204,26 @@ function renderCommitPanel(h, components, state, context, contextStatus, width,
39174
40204
  flexDirection: 'column',
39175
40205
  width,
39176
40206
  paddingX: 1,
39177
- }, h(Text, { bold: true }, panelTitle('Commit', focused)), ...headerLines.map((line, index) => h(Text, {
39178
- key: `commit-header-${index}`,
39179
- dimColor: index < 2 || line.startsWith(' ') || line === '<empty>',
39180
- }, truncateCells(line, width - 4))),
40207
+ }, h(Text, { bold: true }, panelTitle('Commit', focused)), h(Text, { key: 'commit-status', dimColor: true }, truncateCells(statusLine, width - 4)), h(Text, { key: 'commit-spacer-1' }, ''),
40208
+ // Summary: dim label + the subject value emphasized so it's easy to spot.
40209
+ h(Text, { key: 'commit-summary' }, h(Text, { dimColor: true }, summaryLabel), h(Text, {
40210
+ bold: hasSummary,
40211
+ color: summaryColor,
40212
+ dimColor: !hasSummary,
40213
+ }, summaryWrapped[0] || '<empty>')), ...summaryWrapped.slice(1).map((line, index) => h(Text, {
40214
+ key: `commit-summary-rest-${index}`,
40215
+ bold: true,
40216
+ color: summaryColor,
40217
+ }, truncateCells(`${' '.repeat(cellWidth(summaryLabel))}${line}`, width - 4))), h(Text, {
40218
+ key: 'commit-body-label',
40219
+ dimColor: !(compose.field === 'body' && compose.editing),
40220
+ }, truncateCells(`${bodyMarker} Body:`, width - 4)), ...bodyVisualLines.map((line, index) => {
40221
+ const isLast = index === bodyVisualLines.length - 1;
40222
+ return h(Text, {
40223
+ key: `commit-body-${index}`,
40224
+ dimColor: true,
40225
+ }, truncateCells(` ${line}${bodyCursor && isLast ? bodyCursor : ''}`, width - 4));
40226
+ }), h(Text, { key: 'commit-spacer-2' }, ''),
39181
40227
  // Loading indicator + commit result/details stay inline with the body
39182
40228
  // (they describe what just happened to the fields above). The action
39183
40229
  // hint ("e edit | c commit | I AI draft") moves to the bottom of the
@@ -39268,44 +40314,20 @@ function renderPullRequestTriagePreviewPanel(h, components, state, context, cont
39268
40314
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
39269
40315
  * of #890. No behavior change.
39270
40316
  */
39271
- /**
39272
- * Rail-mode inspector — shown on terminals < 100 columns when the
39273
- * detail panel does not hold focus. The full inspector (commit body,
39274
- * file list, actions) does not survive truncation to ~4 content cells
39275
- * so we collapse to a stack with the panel label and the selected
39276
- * commit's shortHash. Focus pops the panel back to its expanded
39277
- * widths via the layout, so this renderer is only reached at rest.
39278
- *
39279
- * Help / overlay states are still handled by their own renderers
39280
- * above; this short-circuit only kicks in for the regular "view the
39281
- * commit" cases.
39282
- */
39283
- function renderInspectorRail(h, components, state, detail, width, theme, focused) {
39284
- const { Box, Text } = components;
39285
- // Prefer the loaded detail's hash (canonical) but fall back to the
39286
- // selected list row's shortHash so the rail isn't blank on the
39287
- // first render before getCommitDetail resolves.
39288
- const selectedRow = getSelectedInkCommit(state);
39289
- const hashText = detail?.hash.slice(0, 4)
39290
- ?? selectedRow?.shortHash.slice(0, 4)
39291
- ?? '····';
39292
- return h(Box, {
39293
- borderColor: focusBorderColor(theme, focused),
39294
- borderStyle: theme.borderStyle,
39295
- flexDirection: 'column',
39296
- width,
39297
- paddingX: 1,
39298
- }, h(Text, { bold: true, dimColor: !focused }, panelTitle('Insp', focused)), h(Text, { dimColor: true }, '────'), h(Text, { color: theme.noColor ? undefined : theme.colors.accent }, hashText));
39299
- }
39300
- function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, railed = false, bodyRows = 0) {
40317
+ function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, bodyRows = 0) {
39301
40318
  const focused = state.focus === 'detail';
39302
40319
  // Overlays (help / palette / input / confirmation / chord) take
39303
- // precedence over rail because they always claim the panel's width
39304
- // via the help-overlay layout branch — and railing those would
39305
- // defeat their whole purpose (the user is reading them).
40320
+ // precedence over every per-view surface because they claim the
40321
+ // panel's full width via the help-overlay layout branch.
39306
40322
  if (state.showHelp) {
39307
40323
  return renderHelpPanel(h, components, state, width, theme, focused, bodyRows);
39308
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
+ }
39309
40331
  if (state.showCommandPalette) {
39310
40332
  return renderCommandPalette(h, components, state, width, theme, focused);
39311
40333
  }
@@ -39334,15 +40356,6 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
39334
40356
  if (state.pendingKey && !state.splitPlan) {
39335
40357
  return renderChordOverlay(h, components, state, width, theme, focused);
39336
40358
  }
39337
- // Rail mode applies only after every overlay above has had its say
39338
- // — those would all be unreadable at 4 cells of content. The layout
39339
- // also clears `railed` whenever the inspector takes focus, so we
39340
- // can safely short-circuit the per-view dispatch here without
39341
- // worrying about hiding the panel from a user who's actively
39342
- // reading it.
39343
- if (railed) {
39344
- return renderInspectorRail(h, components, state, detail, width, theme, focused);
39345
- }
39346
40359
  // The synthetic "(+) new commit" row routes the inspector through the
39347
40360
  // worktree summary so the user sees what's staged / unstaged at a glance
39348
40361
  // — same surface as the compose view's right panel.
@@ -39392,6 +40405,43 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
39392
40405
  return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, focused);
39393
40406
  }
39394
40407
 
40408
+ /**
40409
+ * Runtime React Context for the workstation (#1136).
40410
+ *
40411
+ * The render layer currently drills `state` / `dispatch` / `theme` /
40412
+ * `layout` / `context` through every `render*Surface` signature, so
40413
+ * adding a feature repeatedly means threading one more value through
40414
+ * `app → mainPanel → render<View>Surface`. This Context is the single
40415
+ * place those five values live; surfaces read what they need from it
40416
+ * instead of receiving 10–15 positional props.
40417
+ *
40418
+ * Why a factory (`getLogInkRuntimeContext(React)`) instead of a plain
40419
+ * module-level `React.createContext(...)`: the workstation never
40420
+ * statically imports React. `ink` + `react` are ESM-only and loaded via
40421
+ * dynamicImport at boot (see `inkRuntime.ts`), so the rest of the
40422
+ * codebase compiles without bundling them. The Context object must be
40423
+ * built from that same runtime React instance — the one that renders
40424
+ * the tree and the one a consumer's `useContext` reads from have to be
40425
+ * identical. There is exactly one React instance per process, so we
40426
+ * lazily create the Context on first use and cache it; `LogInkApp`'s
40427
+ * provider and (in later PRs) the surface consumers all share the one
40428
+ * identity.
40429
+ */
40430
+ let cachedContext = null;
40431
+ /**
40432
+ * Lazily create (and thereafter return) the process-wide
40433
+ * `LogInkRuntimeContext`, bound to the runtime React instance. Pass the
40434
+ * same `React` the tree is rendered with — `LogInkApp` uses `deps.React`;
40435
+ * tests use the statically-imported `react`.
40436
+ */
40437
+ function getLogInkRuntimeContext(React) {
40438
+ if (!cachedContext) {
40439
+ cachedContext = React.createContext(null);
40440
+ cachedContext.displayName = 'LogInkRuntimeContext';
40441
+ }
40442
+ return cachedContext;
40443
+ }
40444
+
39395
40445
  /**
39396
40446
  * Resolve + scaffold the coco config files the workstation can open in
39397
40447
  * `$EDITOR` (the `gk` / `gK` chords and their command-palette entries).
@@ -39875,6 +40925,10 @@ function LogInkApp(deps) {
39875
40925
  const loadingMoreCommitsRef = React.useRef(false);
39876
40926
  const loadMoreRequestRef = React.useRef(0);
39877
40927
  const mountedRef = React.useRef(true);
40928
+ // Last dropped stash {hash, message}, captured before `drop-stash` runs
40929
+ // so `undo-drop-stash` can re-store it. The dropped commit survives in
40930
+ // the object DB until gc, so the hash is enough to bring it back.
40931
+ const lastDroppedStashRef = React.useRef(null);
39878
40932
  // P4.3 — idle tip rotation. tickIndex 0 ⇒ no tip; the hook bumps it after
39879
40933
  // a grace window of empty statusMessage and then on a steady cadence, so
39880
40934
  // the footer surfaces a different hint every interval until the user does
@@ -40179,6 +41233,10 @@ function LogInkApp(deps) {
40179
41233
  worktree,
40180
41234
  }), issuedAtDepth);
40181
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;
40182
41240
  }, [git, runtimes.length, setContext, setContextStatus]);
40183
41241
  // Live refresh: watch .git metadata + the working tree root and reload
40184
41242
  // context when something changes outside the TUI (editor save, external
@@ -41041,7 +42099,14 @@ function LogInkApp(deps) {
41041
42099
  // and see the pre-commit log (same silent-failure shape as
41042
42100
  // the split-apply case caught in this PR).
41043
42101
  await refreshHistoryRows();
41044
- 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 });
41045
42110
  }
41046
42111
  }, [
41047
42112
  context.worktree?.stagedCount,
@@ -41118,11 +42183,15 @@ function LogInkApp(deps) {
41118
42183
  dispatch({ type: 'setStatus', value: 'AI draft ready for editing', kind: 'success' });
41119
42184
  return;
41120
42185
  }
42186
+ // Humanize provider errors (rate limit / auth / context / network)
42187
+ // into a short actionable line; success-but-no-draft keeps its
42188
+ // message as-is.
42189
+ const composeMessage = result.ok ? result.message : humanizeAiError(result.message);
41121
42190
  dispatch({
41122
42191
  type: 'commitCompose',
41123
- action: { type: 'setResult', message: result.message, details: result.details },
42192
+ action: { type: 'setResult', message: composeMessage, details: result.details },
41124
42193
  });
41125
- dispatch({ type: 'setStatus', value: result.message });
42194
+ dispatch({ type: 'setStatus', value: composeMessage, kind: result.ok ? undefined : 'error' });
41126
42195
  }
41127
42196
  catch (error) {
41128
42197
  // Audit finding #3: defensive recovery for unexpected throws
@@ -42093,8 +43162,20 @@ function LogInkApp(deps) {
42093
43162
  const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
42094
43163
  if (!stash)
42095
43164
  return { ok: false, message: 'No stash selected' };
43165
+ // Remember the dropped commit so `u` can undo it.
43166
+ if (stash.hash)
43167
+ lastDroppedStashRef.current = { hash: stash.hash, message: stash.message };
42096
43168
  return dropStash(git, stash);
42097
43169
  },
43170
+ 'undo-drop-stash': async () => {
43171
+ const dropped = lastDroppedStashRef.current;
43172
+ if (!dropped)
43173
+ return { ok: false, message: 'Nothing to undo — no stash dropped this session' };
43174
+ const result = await restoreStash(git, dropped.hash, dropped.message);
43175
+ if (result.ok)
43176
+ lastDroppedStashRef.current = null;
43177
+ return result;
43178
+ },
42098
43179
  'apply-stash': async () => {
42099
43180
  const all = context.stashes?.stashes || [];
42100
43181
  const visible = state.filter
@@ -42105,6 +43186,16 @@ function LogInkApp(deps) {
42105
43186
  return { ok: false, message: 'No stash selected' };
42106
43187
  return applyStash(git, stash);
42107
43188
  },
43189
+ 'apply-stash-index': async () => {
43190
+ const all = context.stashes?.stashes || [];
43191
+ const visible = state.filter
43192
+ ? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
43193
+ : all;
43194
+ const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
43195
+ if (!stash)
43196
+ return { ok: false, message: 'No stash selected' };
43197
+ return applyStashKeepIndex(git, stash);
43198
+ },
42108
43199
  'pop-stash': async () => {
42109
43200
  const all = context.stashes?.stashes || [];
42110
43201
  const visible = state.filter
@@ -42115,6 +43206,26 @@ function LogInkApp(deps) {
42115
43206
  return { ok: false, message: 'No stash selected' };
42116
43207
  return popStash(git, stash);
42117
43208
  },
43209
+ 'rename-stash': async () => {
43210
+ const all = context.stashes?.stashes || [];
43211
+ const visible = state.filter
43212
+ ? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
43213
+ : all;
43214
+ const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
43215
+ if (!stash)
43216
+ return { ok: false, message: 'No stash selected' };
43217
+ return renameStash(git, stash, payload ?? '');
43218
+ },
43219
+ 'stash-branch': async () => {
43220
+ const all = context.stashes?.stashes || [];
43221
+ const visible = state.filter
43222
+ ? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
43223
+ : all;
43224
+ const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
43225
+ if (!stash)
43226
+ return { ok: false, message: 'No stash selected' };
43227
+ return stashBranch(git, stash, payload ?? '');
43228
+ },
42118
43229
  'bisect-good': async () => {
42119
43230
  if (!context.bisect?.active)
42120
43231
  return { ok: false, message: 'No bisect in progress' };
@@ -42525,11 +43636,12 @@ function LogInkApp(deps) {
42525
43636
  return deleteRemoteTag(git, tag.name);
42526
43637
  },
42527
43638
  'create-stash': async () => {
42528
- const message = payload?.trim();
42529
- if (!message)
42530
- return { ok: false, message: 'Stash message required' };
42531
- return createStash(git, message);
43639
+ // Empty is allowed — createStash turns it into a quick WIP stash
43640
+ // (git's own `WIP on <branch>` subject). Naming is optional.
43641
+ return createStash(git, payload ?? '');
42532
43642
  },
43643
+ 'stash-staged': async () => createStash(git, payload ?? '', { stagedOnly: true }),
43644
+ 'stash-keep-index': async () => createStash(git, payload ?? '', { keepIndex: true }),
42533
43645
  // #783 — full PR action panel handlers. Each wraps the matching
42534
43646
  // pullRequestActions verb. Strategy / body arrives via `payload`
42535
43647
  // — input prompts validate before they reach here, but the
@@ -42777,6 +43889,8 @@ function LogInkApp(deps) {
42777
43889
  const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'untracked');
42778
43890
  return stageAllFiles(git, files);
42779
43891
  },
43892
+ 'stage-all': async () => stageAll(git),
43893
+ 'stage-pathspec': async () => stagePathspec(git, payload || ''),
42780
43894
  };
42781
43895
  const handler = handlers[id];
42782
43896
  if (!handler) {
@@ -42807,6 +43921,16 @@ function LogInkApp(deps) {
42807
43921
  'checkout-branch',
42808
43922
  'continue-operation',
42809
43923
  'pull-current-branch',
43924
+ // Fetch / pull / push bring in new commits and move
43925
+ // remote-tracking refs (origin/main, ahead/behind) — refresh the
43926
+ // graph so they appear instead of staying pinned to the pre-sync
43927
+ // state. (A successful push advances the local origin/<branch>
43928
+ // ref, so the chip should hop to the pushed commit.)
43929
+ 'fetch-remotes',
43930
+ 'fetch-selected-branch',
43931
+ 'pull-selected-branch',
43932
+ 'push-current-branch',
43933
+ 'push-selected-branch',
42810
43934
  'cherry-pick-commit',
42811
43935
  'revert-commit',
42812
43936
  'reset-hard-to-commit',
@@ -42861,6 +43985,11 @@ function LogInkApp(deps) {
42861
43985
  if (result?.ok && id === 'add-to-gitignore') {
42862
43986
  await refreshWorktreeContext();
42863
43987
  }
43988
+ // Stage-all / stage-pathspec change staged/unstaged counts — refresh
43989
+ // the worktree so the status list + compose summary reflect it.
43990
+ if (result?.ok && (id === 'stage-all' || id === 'stage-pathspec')) {
43991
+ await refreshWorktreeContext();
43992
+ }
42864
43993
  if (result?.ok && id === 'drop-stash') {
42865
43994
  // Explicit worktree refresh in case the dropped stash carried
42866
43995
  // untracked-file state that's now collected.
@@ -43444,6 +44573,11 @@ function LogInkApp(deps) {
43444
44573
  ? findStashFileForOffset(stashDiffFiles, state.diffPreviewOffset)?.path
43445
44574
  : undefined;
43446
44575
  getLogInkInputEvents(state, inputValue, key, {
44576
+ // Narrow terminals show one pane at a time (#1135) — gates the `v`
44577
+ // peek key. Derived the same way the layout does, since `layout`
44578
+ // is computed later in the render path (not in this callback).
44579
+ singlePane: (windowSize.columns || process.stdout.columns || LOG_INK_DEFAULT_COLUMNS) <
44580
+ LAYOUT_SINGLE_PANE_BELOW,
43447
44581
  detailFileCount: detail?.files.length,
43448
44582
  previewLineCount: diffPreviewLineCount,
43449
44583
  worktreeDiffLineCount: worktreeDiff?.lines.length,
@@ -43568,7 +44702,14 @@ function LogInkApp(deps) {
43568
44702
  exit();
43569
44703
  }
43570
44704
  else if (event.type === 'refreshContext') {
44705
+ // The user-initiated refresh (`r`) refreshes BOTH the metadata
44706
+ // context (branches/tags/worktree) AND the commit rows. Without
44707
+ // the row re-fetch the history graph stays pinned to whatever
44708
+ // commits existed at boot — new commits (made in another
44709
+ // terminal, or remote commits brought in by a fetch) never
44710
+ // appear until relaunch, which reads as "the history is stuck."
43571
44711
  void refreshContext();
44712
+ void refreshHistoryRows();
43572
44713
  }
43573
44714
  else if (event.type === 'toggleSelectedFileStage') {
43574
44715
  void toggleSelectedFileStage();
@@ -43667,6 +44808,25 @@ function LogInkApp(deps) {
43667
44808
  }
43668
44809
  });
43669
44810
  });
44811
+ // In single-pane mode (narrow terminals) only one pane renders, so an
44812
+ // active overlay must pull its own pane into view rather than stay
44813
+ // hidden behind whatever pane focus points at. The split-plan overlay
44814
+ // lives in the main panel; every other overlay (help / palette / theme
44815
+ // / gitignore / input prompt / confirmation / chord) renders in the
44816
+ // inspector. Ignored above the single-pane breakpoint (all panes show).
44817
+ const forcedPane = state.splitPlan
44818
+ ? 'main'
44819
+ : state.showHelp ||
44820
+ state.showViewKeys ||
44821
+ state.showCommandPalette ||
44822
+ state.showThemePicker ||
44823
+ state.gitignorePicker ||
44824
+ state.inputPrompt ||
44825
+ state.pendingConfirmationId ||
44826
+ state.pendingMutationConfirmation ||
44827
+ state.pendingKey
44828
+ ? 'inspector'
44829
+ : undefined;
43670
44830
  // Layout depends on focus (sidebar grows when focused), so it's
43671
44831
  // computed here — after state is in scope but before the render path.
43672
44832
  const layout = getLogInkLayout({
@@ -43675,7 +44835,22 @@ function LogInkApp(deps) {
43675
44835
  sidebarFocused: state.focus === 'sidebar',
43676
44836
  inspectorFocused: state.focus === 'detail',
43677
44837
  helpOverlayActive: state.showHelp,
44838
+ forcedPane,
43678
44839
  });
44840
+ // Runtime Context provider (#1136). Bundles the five most-drilled
44841
+ // values so surfaces can read them from context instead of receiving
44842
+ // them as positional props. No consumers yet — this PR only installs
44843
+ // the provider at the root; the surface families migrate in later PRs.
44844
+ // A Context.Provider renders its children transparently (no host
44845
+ // output), so wrapping the tree is behavior-preserving.
44846
+ const RuntimeContext = getLogInkRuntimeContext(React);
44847
+ const runtimeContextValue = {
44848
+ state,
44849
+ dispatch,
44850
+ theme,
44851
+ layout,
44852
+ context,
44853
+ };
43679
44854
  if (layout.tooSmall) {
43680
44855
  return h(Box, {
43681
44856
  flexDirection: 'column',
@@ -43689,7 +44864,35 @@ function LogInkApp(deps) {
43689
44864
  if (showOnboarding) {
43690
44865
  return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
43691
44866
  }
43692
- return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader$1(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar$1(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme, layout.sidebarRailed), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits, spinnerFrame, layout.density, layout.historyRowMode, Boolean(dateBucketingEnabled), diffSyntaxSpans), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme, layout.inspectorRailed, layout.bodyRows)), renderFooter$1(h, { Box, Text }, state, context, theme, idleTip, spinnerFrame));
44867
+ // Panel renderers are thunks so single-pane mode can build only the
44868
+ // visible pane — the main-panel render in particular is expensive, so
44869
+ // we don't want to invoke the two hidden ones just to drop them.
44870
+ const sidebarPanel = () => renderSidebar$1(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme);
44871
+ const mainSurface = {
44872
+ h,
44873
+ components: { Box, Text },
44874
+ state,
44875
+ context,
44876
+ contextStatus,
44877
+ bodyRows: layout.bodyRows,
44878
+ width: layout.mainPanelWidth,
44879
+ theme,
44880
+ };
44881
+ const mainPanel = () => renderMainPanel(mainSurface, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, hasMoreCommits, loadingMoreCommits, spinnerFrame, layout.density, layout.historyRowMode, Boolean(dateBucketingEnabled), diffSyntaxSpans);
44882
+ const detailPanel = () => renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme, layout.bodyRows);
44883
+ // Single-pane mode (narrow terminals): exactly one full-width pane,
44884
+ // chosen by `layout.visiblePane`; Tab cycles which one. Above the
44885
+ // breakpoint all three tile side by side as before.
44886
+ const bodyPanels = layout.singlePane
44887
+ ? [
44888
+ layout.visiblePane === 'sidebar'
44889
+ ? sidebarPanel()
44890
+ : layout.visiblePane === 'inspector'
44891
+ ? detailPanel()
44892
+ : mainPanel(),
44893
+ ]
44894
+ : [sidebarPanel(), mainPanel(), detailPanel()];
44895
+ return h(RuntimeContext.Provider, { value: runtimeContextValue }, h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader$1(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, ...bodyPanels), renderFooter$1(h, { Box, Text }, state, context, theme, idleTip, spinnerFrame, layout.singlePane)));
43693
44896
  }
43694
44897
 
43695
44898
  /**
@@ -44427,6 +45630,7 @@ var prs = {
44427
45630
  desc: 'List GitHub pull requests for the current repository (read-only triage)',
44428
45631
  builder: builder$4,
44429
45632
  handler: commandExecutor(handler$3),
45633
+ options: options$4,
44430
45634
  };
44431
45635
 
44432
45636
  const RecapLlmResponseSchema = objectType({
@@ -44506,8 +45710,7 @@ const handler$2 = async (argv, logger) => {
44506
45710
  const summaryService = resolveDynamicService(config, 'summarize');
44507
45711
  const model = recapService.model;
44508
45712
  if (config.service.authentication.type !== 'None' && !key) {
44509
- logger.log(`No API Key found. 🗝️🚪`, { color: 'red' });
44510
- commandExit(1);
45713
+ handleMissingApiKey(logger, config, { command: 'recap' });
44511
45714
  }
44512
45715
  const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
44513
45716
  const llm = getLlm(provider, model, { ...config, service: recapService });
@@ -45095,8 +46298,7 @@ const handler$1 = async (argv, logger) => {
45095
46298
  const summaryService = resolveDynamicService(config, argv.branch ? 'largeDiff' : 'summarize');
45096
46299
  const model = reviewService.model;
45097
46300
  if (config.service.authentication.type !== 'None' && !key) {
45098
- logger.log(`No API Key found. 🗝️🚪`, { color: 'red' });
45099
- commandExit(1);
46301
+ handleMissingApiKey(logger, config, { command: 'review' });
45100
46302
  }
45101
46303
  const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
45102
46304
  const llm = getLlm(provider, model, { ...config, service: reviewService });
@@ -45809,6 +47011,54 @@ async function getWorkspacePullRequestCounts(repoPaths, options = {}) {
45809
47011
  return { authenticated: true, counts };
45810
47012
  }
45811
47013
 
47014
+ /**
47015
+ * Clone a remote repository into a local path — the runtime side of the
47016
+ * workspace surface's `c` (clone) flow.
47017
+ *
47018
+ * `deriveRepoName` is pure (and tested) so the UI can pre-fill the
47019
+ * destination as `<cwd>/<name>` the moment a URL is typed; `cloneRepo`
47020
+ * does the filesystem-touching work and reports a friendly result.
47021
+ */
47022
+ /**
47023
+ * Infer the repository folder name from a clone URL or SSH spec:
47024
+ * git@github.com:gfargo/coco.git → coco
47025
+ * https://github.com/gfargo/coco → coco
47026
+ * https://example.com/a/b/c.git/ → c
47027
+ * Falls back to `repo` when nothing usable can be parsed.
47028
+ */
47029
+ function deriveRepoName(url) {
47030
+ const trimmed = url.trim().replace(/\/+$/, '').replace(/\.git$/i, '');
47031
+ if (!trimmed)
47032
+ return 'repo';
47033
+ // Split on both `/` and `:` so `host:owner/name` SSH specs work.
47034
+ const segment = trimmed.split(/[/:]/).filter(Boolean).pop() || '';
47035
+ return segment || 'repo';
47036
+ }
47037
+ /**
47038
+ * Clone `url` into `targetPath`. Refuses to clobber an existing path so
47039
+ * a typo never overwrites a directory. Network / auth failures surface
47040
+ * git's own message (trimmed to one line).
47041
+ */
47042
+ async function cloneRepo(url, targetPath) {
47043
+ const remote = url.trim();
47044
+ const dest = targetPath.trim();
47045
+ if (!remote)
47046
+ return { ok: false, message: 'Enter a remote URL to clone.' };
47047
+ if (!dest)
47048
+ return { ok: false, message: 'Enter a destination path.' };
47049
+ if (fs.existsSync(dest)) {
47050
+ return { ok: false, message: `${dest} already exists — choose another path.` };
47051
+ }
47052
+ try {
47053
+ await simpleGit().clone(remote, dest);
47054
+ return { ok: true, message: `Cloned into ${dest}` };
47055
+ }
47056
+ catch (error) {
47057
+ const raw = error instanceof Error ? error.message : String(error);
47058
+ return { ok: false, message: `Clone failed: ${raw.split('\n')[0]}` };
47059
+ }
47060
+ }
47061
+
45812
47062
  function resolveStoreDir(subdir) {
45813
47063
  const xdg = process.env.XDG_CACHE_HOME;
45814
47064
  const root = xdg && xdg.trim().length > 0 ? xdg : path$1.join(os$1.homedir(), '.cache');
@@ -46051,6 +47301,7 @@ function createWorkspaceState(init) {
46051
47301
  showThemePicker: false,
46052
47302
  themePickerFilter: '',
46053
47303
  themePickerIndex: 0,
47304
+ helpScrollOffset: 0,
46054
47305
  knownRepoPaths: init.knownRepoPaths ?? [],
46055
47306
  pullRequestFetching: [],
46056
47307
  };
@@ -46216,10 +47467,17 @@ function applyWorkspaceAction(state, action) {
46216
47467
  return { ...state, status: action.status };
46217
47468
  }
46218
47469
  case 'toggle-help': {
46219
- return { ...state, showHelp: !state.showHelp, showOnboarding: false };
47470
+ // Always reopen at the top picking up the last scroll position
47471
+ // is more surprising than predictable for a reference overlay.
47472
+ return { ...state, showHelp: !state.showHelp, helpScrollOffset: 0, showOnboarding: false };
46220
47473
  }
46221
47474
  case 'close-help': {
46222
- return { ...state, showHelp: false };
47475
+ return { ...state, showHelp: false, helpScrollOffset: 0 };
47476
+ }
47477
+ case 'scroll-help': {
47478
+ // Floor-clamp at 0 only; the renderer ceiling-clamps against the
47479
+ // real content height so `j` past the end sticks at the last row.
47480
+ return { ...state, helpScrollOffset: Math.max(0, state.helpScrollOffset + action.delta) };
46223
47481
  }
46224
47482
  case 'toggle-theme-picker': {
46225
47483
  return {
@@ -46549,6 +47807,7 @@ function buildWorkspaceListWindow(state, options = { rows: 20 }) {
46549
47807
  const all = buildWorkspaceListRows(state, {
46550
47808
  width: options.width,
46551
47809
  spinnerTick: options.spinnerTick,
47810
+ now: options.now,
46552
47811
  });
46553
47812
  const visibleCount = Math.max(1, options.rows);
46554
47813
  if (all.length <= visibleCount) {
@@ -46672,10 +47931,11 @@ function buildWorkspaceHeaderChips(state, options = { focusLabel: 'List' }) {
46672
47931
  // The contextual slot drops bindings users can find via the help
46673
47932
  // overlay (arrow keys, tab); the global slot is the safety net so
46674
47933
  // `? help` and `q quit` never disappear.
46675
- const LIST_CONTEXTUAL = ['s sort', '/ filter', 'r/R refresh', 'a add', 'd remove'];
47934
+ const LIST_CONTEXTUAL = ['s sort', '/ filter', 'r/R refresh', 'a add', 'c clone', 'd remove'];
46676
47935
  const SIDEBAR_CONTEXTUAL = ['↑/↓ cycle tab', 'enter open'];
46677
47936
  const FILTER_CONTEXTUAL = ['type to filter', 'enter apply', 'esc cancel'];
46678
47937
  const ADD_REPO_CONTEXTUAL = ['type path', 'tab to complete', 'enter to add', 'esc to cancel'];
47938
+ const CLONE_REPO_CONTEXTUAL = ['enter URL', 'enter → destination', 'enter to clone', 'esc to cancel'];
46679
47939
  const CONFIRM_DELETE_CONTEXTUAL = ['y confirm', 'any other key cancels'];
46680
47940
  const GLOBAL_HINTS = ['? help', 'q quit'];
46681
47941
  function contextualHintsFor(focus) {
@@ -46686,6 +47946,8 @@ function contextualHintsFor(focus) {
46686
47946
  return FILTER_CONTEXTUAL;
46687
47947
  case 'add-repo':
46688
47948
  return ADD_REPO_CONTEXTUAL;
47949
+ case 'clone-repo':
47950
+ return CLONE_REPO_CONTEXTUAL;
46689
47951
  case 'confirm-delete':
46690
47952
  return CONFIRM_DELETE_CONTEXTUAL;
46691
47953
  case 'list':
@@ -46700,6 +47962,7 @@ function buildWorkspaceFooter(state) {
46700
47962
  // is open and showing them would be misleading.
46701
47963
  const isModal = state.focus === 'filter' ||
46702
47964
  state.focus === 'add-repo' ||
47965
+ state.focus === 'clone-repo' ||
46703
47966
  state.focus === 'confirm-delete';
46704
47967
  const global = isModal ? [] : GLOBAL_HINTS;
46705
47968
  const allHints = [...contextual, ...global];
@@ -46761,6 +48024,7 @@ function buildWorkspaceHelpSections() {
46761
48024
  { glyph: '⟳', keys: 'r', description: 'Refresh all repos (discovery + PR counts)' },
46762
48025
  { glyph: '⟲', keys: 'R', description: 'Refresh just the cursored repo (faster)' },
46763
48026
  { glyph: '+', keys: 'a', description: 'Add a repo via path prompt (tab-completes)' },
48027
+ { glyph: '⬇', keys: 'c', description: 'Clone a remote repo (defaults into the launch directory)' },
46764
48028
  { glyph: '✕', keys: 'd', description: 'Remove the cursored repo from the known-repos store' },
46765
48029
  ],
46766
48030
  },
@@ -46778,7 +48042,7 @@ function buildWorkspaceOnboarding(state) {
46778
48042
  : undefined,
46779
48043
  populatedHint: empty
46780
48044
  ? undefined
46781
- : 'Press `enter` to open a repo · `?` for the full keymap · `a` to add a repo by path.',
48045
+ : 'Press `enter` to open a repo · `a` to add by path · `c` to clone · `?` for the full keymap.',
46782
48046
  };
46783
48047
  }
46784
48048
 
@@ -47074,6 +48338,7 @@ function renderListBody(deps, width, height) {
47074
48338
  width,
47075
48339
  rows: listRows,
47076
48340
  spinnerTick: deps.spinnerTick,
48341
+ now: deps.now,
47077
48342
  });
47078
48343
  const visibleRepos = selectVisibleRepos(state);
47079
48344
  const filterChip = state.filter
@@ -47109,7 +48374,7 @@ function renderListBody(deps, width, height) {
47109
48374
  function renderHelpRow(deps, row, glyphWidth, keysWidth, key) {
47110
48375
  const { React, ink, theme } = deps;
47111
48376
  const { Box, Text } = ink;
47112
- return React.createElement(Box, { key, flexDirection: 'row' }, React.createElement(Box, { width: glyphWidth, flexShrink: 0 }, React.createElement(Text, { color: theme.noColor ? undefined : theme.colors.accent, bold: true }, ` ${row.glyph ?? ' '} `)), React.createElement(Box, { width: keysWidth, flexShrink: 0 }, React.createElement(Text, { color: theme.noColor ? undefined : theme.colors.success, bold: true }, row.keys)), React.createElement(Text, null, row.description));
48377
+ return React.createElement(Box, { key, flexShrink: 0, flexDirection: 'row' }, React.createElement(Box, { width: glyphWidth, flexShrink: 0 }, React.createElement(Text, { color: theme.noColor ? undefined : theme.colors.accent, bold: true }, ` ${row.glyph ?? ' '} `)), React.createElement(Box, { width: keysWidth, flexShrink: 0 }, React.createElement(Text, { color: theme.noColor ? undefined : theme.colors.success, bold: true }, row.keys)), React.createElement(Text, null, row.description));
47113
48378
  }
47114
48379
  function renderHelpOverlay(deps) {
47115
48380
  if (!deps.state.showHelp) {
@@ -47122,34 +48387,68 @@ function renderHelpOverlay(deps) {
47122
48387
  // Columns: glyph cell (4 cells) · keys (padded to longest) · description.
47123
48388
  const glyphWidth = 4;
47124
48389
  const keysWidth = Math.max(14, allRows.reduce((acc, row) => Math.max(acc, row.keys.length), 0) + 4);
47125
- const children = [];
47126
- // Title bar accent-tinged, matches the chip-style header on the
47127
- // main surface so the help reads as the same app, just a different
47128
- // panel.
47129
- children.push(React.createElement(Box, { key: 'title', flexDirection: 'row', justifyContent: 'space-between' }, React.createElement(Box, { key: 'title-left', flexDirection: 'row' }, React.createElement(Text, { bold: true, color: theme.noColor ? undefined : theme.colors.accent }, ' ? coco workspace'), React.createElement(Text, { dimColor: true }, ' keymap · '), React.createElement(Text, { dimColor: true }, `${allRows.length} bindings`)), React.createElement(Text, { dimColor: true }, 'esc / ? to close ')));
47130
- children.push(React.createElement(Text, { key: 'title-sep', dimColor: true }, ''));
47131
- // Sections each gets a title in accent, optional subtitle dim,
47132
- // then its rows, then a blank line.
48390
+ // Body lines — every scrollable row below the pinned title. Built as
48391
+ // a flat list (section title optional subtitle rows → inter-section
48392
+ // spacer) so we can window it against the available height. Each entry
48393
+ // is `flexShrink: 0` so Ink never crushes rows on top of each other
48394
+ // when the keymap is taller than the panel (which used to collapse the
48395
+ // title and the first category onto the same line).
48396
+ const body = [];
47133
48397
  sections.forEach((section, sIndex) => {
47134
- children.push(React.createElement(Text, {
48398
+ body.push(React.createElement(Text, {
47135
48399
  key: `section-${sIndex}-title`,
47136
48400
  bold: true,
47137
48401
  color: theme.noColor ? undefined : theme.colors.muted,
47138
48402
  }, section.title.toUpperCase()));
47139
48403
  if (section.subtitle) {
47140
- children.push(React.createElement(Text, { key: `section-${sIndex}-subtitle`, dimColor: true }, ` ${section.subtitle}`));
48404
+ body.push(React.createElement(Text, { key: `section-${sIndex}-subtitle`, dimColor: true }, ` ${section.subtitle}`));
47141
48405
  }
47142
48406
  section.rows.forEach((row, rIndex) => {
47143
- children.push(renderHelpRow(deps, row, glyphWidth, keysWidth, `row-${sIndex}-${rIndex}`));
48407
+ body.push(renderHelpRow(deps, row, glyphWidth, keysWidth, `row-${sIndex}-${rIndex}`));
47144
48408
  });
47145
48409
  if (sIndex < sections.length - 1) {
47146
- children.push(React.createElement(Text, { key: `section-${sIndex}-spacer` }, ''));
48410
+ body.push(React.createElement(Text, { key: `section-${sIndex}-spacer` }, ''));
47147
48411
  }
47148
48412
  });
48413
+ // Vertical budget: the overlay shares the column with the header
48414
+ // (3 rows) and footer (FOOTER_HEIGHT). Its own chrome eats the border
48415
+ // (2), the pinned title (1) and the title/body separator (1). Whatever
48416
+ // is left is the window we slide the body through.
48417
+ const HEADER_ROWS = 3;
48418
+ const overlayChromeRows = 4;
48419
+ const visibleRows = Math.max(4, deps.rows - HEADER_ROWS - FOOTER_HEIGHT - overlayChromeRows);
48420
+ // Ceiling-clamp the offset here (the reducer only floors at 0) so
48421
+ // scrolling past the end sticks at the last row instead of revealing
48422
+ // blank space.
48423
+ const maxOffset = Math.max(0, body.length - visibleRows);
48424
+ const offset = Math.min(deps.state.helpScrollOffset, maxOffset);
48425
+ const children = [];
48426
+ // Title bar — accent-tinged, matches the chip-style header on the
48427
+ // main surface so the help reads as the same app, just a different
48428
+ // panel. Pinned above the scrolling body.
48429
+ children.push(React.createElement(Box, { key: 'title', flexShrink: 0, flexDirection: 'row', justifyContent: 'space-between' }, React.createElement(Box, { key: 'title-left', flexDirection: 'row' }, React.createElement(Text, { bold: true, color: theme.noColor ? undefined : theme.colors.accent }, ' ? coco workspace'), React.createElement(Text, { dimColor: true }, ' keymap · '), React.createElement(Text, { dimColor: true }, `${allRows.length} bindings`)), React.createElement(Text, { dimColor: true }, 'esc / ? to close ')));
48430
+ children.push(React.createElement(Text, { key: 'title-sep', dimColor: true }, ''));
48431
+ // "more above" / "more below" hints each consume a window row so they
48432
+ // don't push body content off-screen. Mirrors the `coco ui` overlay.
48433
+ let windowSize = visibleRows;
48434
+ const hasMoreAbove = offset > 0;
48435
+ if (hasMoreAbove) {
48436
+ windowSize -= 1;
48437
+ children.push(React.createElement(Text, { key: 'more-above', dimColor: true }, ' ↑ more above (j/k or ↑/↓ to scroll)'));
48438
+ }
48439
+ const hasMoreBelow = offset + windowSize < body.length;
48440
+ if (hasMoreBelow) {
48441
+ windowSize -= 1;
48442
+ }
48443
+ children.push(...body.slice(offset, offset + windowSize));
48444
+ if (hasMoreBelow) {
48445
+ children.push(React.createElement(Text, { key: 'more-below', dimColor: true }, ' ↓ more below (j/k or ↑/↓ to scroll)'));
48446
+ }
47149
48447
  return React.createElement(Box, {
47150
48448
  borderColor: focusBorderColor(theme, true),
47151
48449
  borderStyle: theme.borderStyle,
47152
48450
  flexDirection: 'column',
48451
+ flexShrink: 0,
47153
48452
  paddingX: 1,
47154
48453
  }, ...children);
47155
48454
  }
@@ -47200,6 +48499,29 @@ function renderAddRepoPrompt(deps) {
47200
48499
  ? React.createElement(Text, { color: toneColor('dim', theme) }, completionLine)
47201
48500
  : null);
47202
48501
  }
48502
+ function renderCloneRepoPrompt(deps) {
48503
+ if (deps.state.focus !== 'clone-repo') {
48504
+ return null;
48505
+ }
48506
+ const { React, ink, theme, cloneUrl, cloneTarget, cloneField, cloneCompletion, cloning } = deps;
48507
+ const { Box, Text } = ink;
48508
+ const urlActive = cloneField === 'url' && !cloning;
48509
+ const targetActive = cloneField === 'target' && !cloning;
48510
+ const completionLine = cloneCompletion.completions.slice(0, 8).join(' ');
48511
+ const hint = cloning
48512
+ ? 'Cloning… this can take a moment for large repos.'
48513
+ : cloneField === 'url'
48514
+ ? 'Paste a remote URL (https or git@…), then enter for the destination.'
48515
+ : 'Edit the destination · tab to complete · enter to clone · esc to cancel';
48516
+ return React.createElement(Box, {
48517
+ borderColor: focusBorderColor(theme, true),
48518
+ borderStyle: theme.borderStyle,
48519
+ flexDirection: 'column',
48520
+ paddingX: 1,
48521
+ }, React.createElement(Text, { bold: true }, 'Clone a repository'), React.createElement(Text, { color: urlActive ? undefined : toneColor('dim', theme) }, ` URL: ${cloneUrl}${urlActive ? '_' : ''}`), React.createElement(Text, { color: targetActive ? undefined : toneColor('dim', theme) }, ` Into: ${cloneTarget}${targetActive ? '_' : ''}`), React.createElement(Text, { dimColor: true }, hint), completionLine && targetActive
48522
+ ? React.createElement(Text, { color: toneColor('dim', theme) }, completionLine)
48523
+ : null);
48524
+ }
47203
48525
  const FOOTER_HEIGHT = 4; // 2 borders + hint row + status row
47204
48526
  function renderFooter(deps) {
47205
48527
  const { React, ink, state, theme } = deps;
@@ -47241,8 +48563,10 @@ function computeBodyHeight(deps) {
47241
48563
  const FOOTER_ROWS = FOOTER_HEIGHT;
47242
48564
  const onboardingRows = buildWorkspaceOnboarding(deps.state).show ? 5 : 0;
47243
48565
  const addRepoRows = deps.state.focus === 'add-repo' ? 5 : 0;
48566
+ // Clone modal is one row taller (URL + Into + hint + completion).
48567
+ const cloneRows = deps.state.focus === 'clone-repo' ? 6 : 0;
47244
48568
  const confirmRows = deps.state.focus === 'confirm-delete' ? 5 : 0;
47245
- const reserved = HEADER_ROWS + FOOTER_ROWS + onboardingRows + addRepoRows + confirmRows;
48569
+ const reserved = HEADER_ROWS + FOOTER_ROWS + onboardingRows + addRepoRows + cloneRows + confirmRows;
47246
48570
  return Math.max(8, deps.rows - reserved);
47247
48571
  }
47248
48572
  function renderWorkspaceApp(deps) {
@@ -47264,7 +48588,7 @@ function renderWorkspaceApp(deps) {
47264
48588
  return React.createElement(Box, { flexDirection: 'column', height: rootHeight }, renderHeader(deps), renderThemePickerOverlay(React.createElement, { Box: ink.Box, Text: ink.Text }, deps.state.themePickerFilter, deps.state.themePickerIndex, bodyWidth, deps.theme, true), renderFooter(deps));
47265
48589
  }
47266
48590
  const bodyHeight = computeBodyHeight(deps);
47267
- return React.createElement(Box, { flexDirection: 'column', height: rootHeight }, renderHeader(deps), React.createElement(Box, { flexDirection: 'row', height: bodyHeight }, renderSidebar(deps, bodyHeight), renderListBody(deps, bodyWidth - sidebarWidthFor(deps) - 2, bodyHeight)), renderOnboardingBanner(deps), renderAddRepoPrompt(deps), renderConfirmDelete(deps), renderFooter(deps));
48591
+ return React.createElement(Box, { flexDirection: 'column', height: rootHeight }, renderHeader(deps), React.createElement(Box, { flexDirection: 'row', height: bodyHeight }, renderSidebar(deps, bodyHeight), renderListBody(deps, bodyWidth - sidebarWidthFor(deps) - 2, bodyHeight)), renderOnboardingBanner(deps), renderAddRepoPrompt(deps), renderCloneRepoPrompt(deps), renderConfirmDelete(deps), renderFooter(deps));
47268
48592
  }
47269
48593
 
47270
48594
  /**
@@ -47301,6 +48625,21 @@ function resolveWorkspaceInput(input, key, state) {
47301
48625
  if (key.escape || input === '?' || input === 'q') {
47302
48626
  return { kind: 'action', action: { type: 'close-help' } };
47303
48627
  }
48628
+ // The keymap is taller than the panel on short terminals — let
48629
+ // j/k/↑/↓ and ctrl+d/u scroll the windowed body. Mirrors the
48630
+ // `coco ui` help overlay.
48631
+ if (key.downArrow || input === 'j') {
48632
+ return { kind: 'action', action: { type: 'scroll-help', delta: 1 } };
48633
+ }
48634
+ if (key.upArrow || input === 'k') {
48635
+ return { kind: 'action', action: { type: 'scroll-help', delta: -1 } };
48636
+ }
48637
+ if (key.ctrl && input === 'd') {
48638
+ return { kind: 'action', action: { type: 'scroll-help', delta: 10 } };
48639
+ }
48640
+ if (key.ctrl && input === 'u') {
48641
+ return { kind: 'action', action: { type: 'scroll-help', delta: -10 } };
48642
+ }
47304
48643
  return { kind: 'noop' };
47305
48644
  }
47306
48645
  // Theme picker is modal (like `coco ui`'s gC): type to filter, ↑/↓ to
@@ -47351,6 +48690,14 @@ function resolveWorkspaceInput(input, key, state) {
47351
48690
  // can drive the path-completion prompt.
47352
48691
  return { kind: 'noop' };
47353
48692
  }
48693
+ if (state.focus === 'clone-repo') {
48694
+ if (key.escape) {
48695
+ return { kind: 'action', action: { type: 'set-focus', focus: 'list' } };
48696
+ }
48697
+ // Enter/Tab/printable keys drive the URL + destination prompt in the
48698
+ // runtime (it owns the two-field state + path completion).
48699
+ return { kind: 'noop' };
48700
+ }
47354
48701
  // Confirm-delete is modal: only `y` confirms, anything else cancels.
47355
48702
  if (state.focus === 'confirm-delete') {
47356
48703
  if (input === 'y' || input === 'Y') {
@@ -47445,6 +48792,9 @@ function resolveWorkspaceInput(input, key, state) {
47445
48792
  if (input === 'a') {
47446
48793
  return { kind: 'add-repo' };
47447
48794
  }
48795
+ if (input === 'c') {
48796
+ return { kind: 'clone-repo' };
48797
+ }
47448
48798
  if (input === 'd') {
47449
48799
  return { kind: 'request-delete' };
47450
48800
  }
@@ -47999,6 +49349,19 @@ function WorkspaceInkApp(props) {
47999
49349
  const [filterDraft, setFilterDraft] = React.useState('');
48000
49350
  const [addRepoDraft, setAddRepoDraft] = React.useState('~/');
48001
49351
  const [addRepoCompletion, setAddRepoCompletion] = React.useState(() => completePath('~/'));
49352
+ // Clone-repo modal (`c`). Two fields: the remote URL and the
49353
+ // destination path. `cloneField` tracks which is active; `cloneTarget`
49354
+ // auto-derives `<cwd>/<repo-name>` from the URL until the user edits it
49355
+ // (`cloneTargetEdited`). `cloning` blocks input + shows a spinner while
49356
+ // `git clone` runs. The boot cwd is captured once at mount so it stays
49357
+ // the directory the workspace launched in even after drill-in.
49358
+ const bootCwdRef = React.useRef(process.cwd());
49359
+ const [cloneUrl, setCloneUrl] = React.useState('');
49360
+ const [cloneTarget, setCloneTarget] = React.useState('');
49361
+ const [cloneField, setCloneField] = React.useState('url');
49362
+ const [cloneTargetEdited, setCloneTargetEdited] = React.useState(false);
49363
+ const [cloneCompletion, setCloneCompletion] = React.useState(() => completePath('~/'));
49364
+ const [cloning, setCloning] = React.useState(false);
48002
49365
  // Tick counter for the per-row PR-fetch spinner. Bumped on a
48003
49366
  // setInterval that only runs while at least one row is mid-fetch
48004
49367
  // (see effect below) so idle workspaces don't burn CPU on animation
@@ -48048,6 +49411,18 @@ function WorkspaceInkApp(props) {
48048
49411
  addRepoDraftRef.current = addRepoDraft;
48049
49412
  const addRepoCompletionRef = React.useRef(addRepoCompletion);
48050
49413
  addRepoCompletionRef.current = addRepoCompletion;
49414
+ const cloneUrlRef = React.useRef(cloneUrl);
49415
+ cloneUrlRef.current = cloneUrl;
49416
+ const cloneTargetRef = React.useRef(cloneTarget);
49417
+ cloneTargetRef.current = cloneTarget;
49418
+ const cloneFieldRef = React.useRef(cloneField);
49419
+ cloneFieldRef.current = cloneField;
49420
+ const cloneTargetEditedRef = React.useRef(cloneTargetEdited);
49421
+ cloneTargetEditedRef.current = cloneTargetEdited;
49422
+ const cloneCompletionRef = React.useRef(cloneCompletion);
49423
+ cloneCompletionRef.current = cloneCompletion;
49424
+ const cloningRef = React.useRef(cloning);
49425
+ cloningRef.current = cloning;
48051
49426
  // Background discovery + PR-count refresh on mount.
48052
49427
  React.useEffect(() => {
48053
49428
  let cancelled = false;
@@ -48280,6 +49655,60 @@ function WorkspaceInkApp(props) {
48280
49655
  });
48281
49656
  }
48282
49657
  }, [addRepoDraft, dispatch, props]);
49658
+ // Default destination for a clone URL: `<bootCwd>/<repo-name>`.
49659
+ const cloneTargetFor = React.useCallback((url) => {
49660
+ return path$1.join(bootCwdRef.current, deriveRepoName(url));
49661
+ }, []);
49662
+ const openClone = React.useCallback(() => {
49663
+ setCloneUrl('');
49664
+ setCloneTarget('');
49665
+ setCloneField('url');
49666
+ setCloneTargetEdited(false);
49667
+ setCloneCompletion(completePath(`${bootCwdRef.current}/`));
49668
+ dispatch({ type: 'set-focus', focus: 'clone-repo' });
49669
+ }, [dispatch]);
49670
+ const commitClone = React.useCallback(async () => {
49671
+ const url = cloneUrlRef.current.trim();
49672
+ const target = expandHomePrefix(cloneTargetRef.current.trim().replace(/\/+$/, ''));
49673
+ if (!url) {
49674
+ dispatch({ type: 'set-status', status: 'Enter a remote URL.' });
49675
+ return;
49676
+ }
49677
+ if (!target) {
49678
+ dispatch({ type: 'set-status', status: 'Enter a destination path.' });
49679
+ return;
49680
+ }
49681
+ setCloning(true);
49682
+ dispatch({ type: 'set-status', status: `Cloning ${deriveRepoName(url)}…` });
49683
+ const result = await cloneRepo(url, target);
49684
+ if (unmountedRef.current)
49685
+ return;
49686
+ setCloning(false);
49687
+ if (!result.ok) {
49688
+ // Keep the modal open so the user can fix the URL / path and retry.
49689
+ dispatch({ type: 'set-status', status: result.message });
49690
+ return;
49691
+ }
49692
+ const updated = appendKnownRepo(target);
49693
+ dispatch({ type: 'replace-known-repos', paths: updated });
49694
+ dispatch({ type: 'set-focus', focus: 'list' });
49695
+ dispatch({ type: 'set-status', status: result.message });
49696
+ dispatch({ type: 'set-loading', loading: true });
49697
+ try {
49698
+ const merged = mergeKnownRepos(props.knownRepos, readKnownRepos());
49699
+ const overview = await props.loadOverview(props.roots, merged);
49700
+ writeCachedWorkspace(props.roots, overview);
49701
+ dispatch({ type: 'replace-overview', overview });
49702
+ dispatch({ type: 'anchor-cursor-by-path', path: target });
49703
+ }
49704
+ catch (err) {
49705
+ dispatch({ type: 'set-loading', loading: false });
49706
+ dispatch({
49707
+ type: 'set-status',
49708
+ status: err instanceof Error ? err.message : 'Refresh failed.',
49709
+ });
49710
+ }
49711
+ }, [dispatch, props]);
48283
49712
  // Callback refs so the stable input handler can reach the latest
48284
49713
  // closure without taking them in deps.
48285
49714
  const commitAddRepoRef = React.useRef(commitAddRepo);
@@ -48292,6 +49721,10 @@ function WorkspaceInkApp(props) {
48292
49721
  refreshRowRef.current = refreshRow;
48293
49722
  const openAddRepoRef = React.useRef(openAddRepo);
48294
49723
  openAddRepoRef.current = openAddRepo;
49724
+ const openCloneRef = React.useRef(openClone);
49725
+ openCloneRef.current = openClone;
49726
+ const commitCloneRef = React.useRef(commitClone);
49727
+ commitCloneRef.current = commitClone;
48295
49728
  const requestDeleteRef = React.useRef(requestDelete);
48296
49729
  requestDeleteRef.current = requestDelete;
48297
49730
  const confirmDeleteRef = React.useRef(confirmDelete);
@@ -48374,6 +49807,73 @@ function WorkspaceInkApp(props) {
48374
49807
  }
48375
49808
  return;
48376
49809
  }
49810
+ if (state.focus === 'clone-repo') {
49811
+ // While the clone is running, swallow everything except Esc
49812
+ // (which is a no-op here — the clone is already in flight).
49813
+ if (cloningRef.current)
49814
+ return;
49815
+ if (key.escape) {
49816
+ dispatch({ type: 'set-focus', focus: 'list' });
49817
+ return;
49818
+ }
49819
+ const field = cloneFieldRef.current;
49820
+ const url = cloneUrlRef.current;
49821
+ const target = cloneTargetRef.current;
49822
+ const targetEdited = cloneTargetEditedRef.current;
49823
+ if (key.return) {
49824
+ if (field === 'url') {
49825
+ if (!url.trim()) {
49826
+ dispatch({ type: 'set-status', status: 'Enter a remote URL.' });
49827
+ return;
49828
+ }
49829
+ // Advance to the (pre-filled, editable) destination field.
49830
+ const derived = targetEdited ? target : cloneTargetFor(url);
49831
+ setCloneTarget(derived);
49832
+ setCloneCompletion(completePath(derived));
49833
+ setCloneField('target');
49834
+ return;
49835
+ }
49836
+ void commitCloneRef.current();
49837
+ return;
49838
+ }
49839
+ if (key.tab && field === 'target') {
49840
+ const next = applyTabCompletion(target, cloneCompletionRef.current);
49841
+ setCloneTarget(next);
49842
+ setCloneTargetEdited(true);
49843
+ setCloneCompletion(completePath(next));
49844
+ return;
49845
+ }
49846
+ if (key.backspace || key.delete) {
49847
+ if (field === 'url') {
49848
+ const next = url.slice(0, -1);
49849
+ setCloneUrl(next);
49850
+ if (!targetEdited)
49851
+ setCloneTarget(next ? cloneTargetFor(next) : '');
49852
+ }
49853
+ else {
49854
+ const next = target.slice(0, -1);
49855
+ setCloneTarget(next);
49856
+ setCloneTargetEdited(true);
49857
+ setCloneCompletion(completePath(next || '~/'));
49858
+ }
49859
+ return;
49860
+ }
49861
+ if (rawInput && !key.ctrl && !key.meta) {
49862
+ if (field === 'url') {
49863
+ const next = url + rawInput;
49864
+ setCloneUrl(next);
49865
+ if (!targetEdited)
49866
+ setCloneTarget(cloneTargetFor(next));
49867
+ }
49868
+ else {
49869
+ const next = target + rawInput;
49870
+ setCloneTarget(next);
49871
+ setCloneTargetEdited(true);
49872
+ setCloneCompletion(completePath(next));
49873
+ }
49874
+ }
49875
+ return;
49876
+ }
48377
49877
  // Ctrl+C → quit, since we disabled Ink's built-in ctrl+c exit.
48378
49878
  // Handled here (rather than in the pure resolver) because the
48379
49879
  // resolver doesn't have a notion of "raw key with ctrl flag" for
@@ -48414,6 +49914,9 @@ function WorkspaceInkApp(props) {
48414
49914
  case 'add-repo':
48415
49915
  openAddRepoRef.current();
48416
49916
  break;
49917
+ case 'clone-repo':
49918
+ openCloneRef.current();
49919
+ break;
48417
49920
  case 'request-delete':
48418
49921
  requestDeleteRef.current();
48419
49922
  break;
@@ -48463,6 +49966,11 @@ function WorkspaceInkApp(props) {
48463
49966
  filterDraft,
48464
49967
  addRepoDraft,
48465
49968
  addRepoCompletion,
49969
+ cloneUrl,
49970
+ cloneTarget,
49971
+ cloneField,
49972
+ cloneCompletion,
49973
+ cloning,
48466
49974
  columns,
48467
49975
  rows,
48468
49976
  spinnerTick,