git-coco 0.34.0 → 0.35.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.
@@ -37,6 +37,9 @@ import '@langchain/core/utils/env';
37
37
  import '@langchain/core/utils/async_caller';
38
38
  import { encoding_for_model } from 'tiktoken';
39
39
  import { spawn, exec, execFile } from 'child_process';
40
+ import * as fs$1 from 'node:fs';
41
+ import * as os$1 from 'node:os';
42
+ import * as path$1 from 'node:path';
40
43
  import * as readline from 'readline';
41
44
  import readline__default from 'readline';
42
45
  import { promisify } from 'util';
@@ -47,7 +50,7 @@ import { pathToFileURL } from 'url';
47
50
  /**
48
51
  * Current build version from package.json
49
52
  */
50
- const BUILD_VERSION = "0.34.0";
53
+ const BUILD_VERSION = "0.35.0";
51
54
 
52
55
  const isInteractive = (config) => {
53
56
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -528,14 +531,9 @@ const CONFIG_KEYS = Object.keys({
528
531
  prompt: '',
529
532
  });
530
533
 
531
- /**
532
- * Load environment variables
533
- *
534
- * @param {Config} config
535
- * @returns {Config} Updated config
536
- **/
537
- function loadEnvConfig(config) {
534
+ function loadEnvConfig(config, opts) {
538
535
  const envConfig = {};
536
+ let foundAny = false;
539
537
  const envKeys = [
540
538
  ...CONFIG_KEYS,
541
539
  'COCO_SERVICE_PROVIDER',
@@ -570,15 +568,20 @@ function loadEnvConfig(config) {
570
568
  // @ts-ignore
571
569
  envConfig.service = envConfig.service || {};
572
570
  handleServiceEnvVar(envConfig.service, key, envValue);
571
+ foundAny = true;
573
572
  }
574
573
  else {
575
574
  if (key === 'service' || !envValue) {
576
575
  return;
577
576
  }
578
577
  envConfig[key] = envValue;
578
+ foundAny = true;
579
579
  }
580
580
  });
581
- return { ...config, ...removeUndefined(envConfig) };
581
+ const merged = { ...config, ...removeUndefined(envConfig) };
582
+ {
583
+ return { config: merged, active: foundAny };
584
+ }
582
585
  }
583
586
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
584
587
  function handleServiceEnvVar(service, key, value) {
@@ -705,19 +708,15 @@ const appendToEnvFile = async (filePath, config) => {
705
708
  });
706
709
  };
707
710
 
708
- /**
709
- * Load git profile config (from ~/.gitconfig)
710
- *
711
- * @param {Config} config
712
- * @returns {Config} Updated config
713
- **/
714
- function loadGitConfig(config) {
711
+ function loadGitConfig(config, opts) {
715
712
  const gitConfigPath = path.join(os.homedir(), '.gitconfig');
713
+ let foundPath;
716
714
  if (fs.existsSync(gitConfigPath)) {
717
715
  const gitConfigRaw = fs.readFileSync(gitConfigPath, 'utf-8');
718
716
  const gitConfigParsed = ini.parse(gitConfigRaw);
719
717
  let service = config.service;
720
718
  if (gitConfigParsed.coco) {
719
+ foundPath = gitConfigPath;
721
720
  service = {
722
721
  provider: gitConfigParsed.coco?.serviceProvider,
723
722
  model: gitConfigParsed.coco?.serviceModel,
@@ -771,7 +770,10 @@ function loadGitConfig(config) {
771
770
  includeBranchName: gitConfigParsed.coco?.includeBranchName || config.includeBranchName,
772
771
  };
773
772
  }
774
- return removeUndefined(config);
773
+ const cleaned = removeUndefined(config);
774
+ {
775
+ return { config: cleaned, path: foundPath };
776
+ }
775
777
  }
776
778
  /**
777
779
  * Appends the provided configuration to a git config file.
@@ -1011,6 +1013,10 @@ const schema$1 = {
1011
1013
  "theme": {
1012
1014
  "$ref": "#/definitions/LogInkThemeConfig",
1013
1015
  "description": "Theme settings for `coco log -i`."
1016
+ },
1017
+ "idleTips": {
1018
+ "type": "boolean",
1019
+ "description": "Rotate short usage tips through the status line when the TUI has been idle for >10s. Off by default so power users aren't distracted."
1014
1020
  }
1015
1021
  },
1016
1022
  "additionalProperties": false,
@@ -1844,24 +1850,29 @@ const ajv = new Ajv({
1844
1850
  });
1845
1851
 
1846
1852
  const validate = ajv.compile(schema$1);
1847
- /**
1848
- * Load project config
1849
- *
1850
- * @param {Config} config
1851
- * @returns {Config} Updated config
1852
- **/
1853
- function loadProjectJsonConfig(config) {
1854
- if (fs.existsSync('.coco.config.json')) {
1853
+ function loadProjectJsonConfig(config, opts) {
1854
+ // Prefer .coco.json, fall back to .coco.config.json
1855
+ const candidates = ['.coco.json', '.coco.config.json'];
1856
+ let resolvedPath;
1857
+ for (const candidate of candidates) {
1858
+ if (fs.existsSync(candidate)) {
1859
+ resolvedPath = candidate;
1860
+ break;
1861
+ }
1862
+ }
1863
+ if (resolvedPath) {
1855
1864
  // Removing $schema from the project config to avoid validation errors.
1856
1865
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
1857
- const { $schema, ...projectConfig } = JSON.parse(fs.readFileSync('.coco.config.json', 'utf-8'));
1866
+ const { $schema, ...projectConfig } = JSON.parse(fs.readFileSync(resolvedPath, 'utf-8'));
1858
1867
  config = { ...config, ...projectConfig };
1859
1868
  const isProjectConfigValid = validate(config);
1860
1869
  if (!isProjectConfigValid) {
1861
1870
  throw new Error('Invalid project config', { cause: ajv.errorsText(validate.errors) });
1862
1871
  }
1863
1872
  }
1864
- return config;
1873
+ {
1874
+ return { config: config, path: resolvedPath };
1875
+ }
1865
1876
  }
1866
1877
  const appendToProjectJsonConfig = (filePath, config) => {
1867
1878
  if (!fs.existsSync(filePath)) {
@@ -1873,16 +1884,12 @@ const appendToProjectJsonConfig = (filePath, config) => {
1873
1884
  }, null, 2));
1874
1885
  };
1875
1886
 
1876
- /**
1877
- * Load XDG config
1878
- *
1879
- * @param {Config} config
1880
- * @returns {Config} Updated config
1881
- */
1882
- function loadXDGConfig(config) {
1887
+ function loadXDGConfig(config, opts) {
1883
1888
  const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
1884
1889
  const xdgConfigPath = path.join(xdgConfigHome, 'coco', 'config.json');
1890
+ let foundPath;
1885
1891
  if (fs.existsSync(xdgConfigPath)) {
1892
+ foundPath = xdgConfigPath;
1886
1893
  const xdgConfig = JSON.parse(fs.readFileSync(xdgConfigPath, 'utf-8'));
1887
1894
  const service = parseServiceConfig(xdgConfig.service || config.service);
1888
1895
  config = {
@@ -1891,7 +1898,9 @@ function loadXDGConfig(config) {
1891
1898
  service: service
1892
1899
  };
1893
1900
  }
1894
- return config;
1901
+ {
1902
+ return { config: config, path: foundPath };
1903
+ }
1895
1904
  }
1896
1905
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1897
1906
  function parseServiceConfig(service) {
@@ -1935,6 +1944,17 @@ function parseServiceConfig(service) {
1935
1944
  }
1936
1945
  }
1937
1946
 
1947
+ /**
1948
+ * Tracked config sources populated during the last loadConfig call.
1949
+ * Useful for diagnostics (e.g. `coco doctor`).
1950
+ */
1951
+ let _lastConfigSources = [];
1952
+ /**
1953
+ * Returns the config sources detected during the most recent loadConfig call.
1954
+ */
1955
+ function getConfigSources() {
1956
+ return _lastConfigSources;
1957
+ }
1938
1958
  /**
1939
1959
  * Load application config
1940
1960
  *
@@ -1953,14 +1973,28 @@ function parseServiceConfig(service) {
1953
1973
  * @returns {Config} application config
1954
1974
  **/
1955
1975
  function loadConfig(argv = {}) {
1976
+ const sources = [{ source: 'default' }];
1956
1977
  // Default config
1957
1978
  let config = DEFAULT_CONFIG;
1958
1979
  config = loadGitignore(config);
1959
1980
  config = loadIgnore(config);
1960
- config = loadXDGConfig(config);
1961
- config = loadGitConfig(config);
1962
- config = loadProjectJsonConfig(config);
1963
- config = loadEnvConfig(config);
1981
+ const { config: xdgConfig, path: xdgPath } = loadXDGConfig(config);
1982
+ config = xdgConfig;
1983
+ if (xdgPath)
1984
+ sources.push({ source: 'xdg', path: xdgPath });
1985
+ const { config: gitConfig, path: gitPath } = loadGitConfig(config);
1986
+ config = gitConfig;
1987
+ if (gitPath)
1988
+ sources.push({ source: 'git', path: gitPath });
1989
+ const { config: projectConfig, path: projectPath } = loadProjectJsonConfig(config);
1990
+ config = projectConfig;
1991
+ if (projectPath)
1992
+ sources.push({ source: 'project', path: projectPath });
1993
+ const { config: envConfig, active: envActive } = loadEnvConfig(config);
1994
+ config = envConfig;
1995
+ if (envActive)
1996
+ sources.push({ source: 'env' });
1997
+ _lastConfigSources = sources;
1964
1998
  return { ...config, ...argv };
1965
1999
  }
1966
2000
 
@@ -5910,11 +5944,11 @@ const ChangelogResponseSchema = objectType({
5910
5944
  title: stringType(),
5911
5945
  content: stringType(),
5912
5946
  });
5913
- const command$6 = 'changelog';
5947
+ const command$7 = 'changelog';
5914
5948
  /**
5915
5949
  * Command line options via yargs
5916
5950
  */
5917
- const options$6 = {
5951
+ const options$7 = {
5918
5952
  range: {
5919
5953
  type: 'string',
5920
5954
  alias: 'r',
@@ -5961,8 +5995,8 @@ const options$6 = {
5961
5995
  description: 'Toggle interactive mode',
5962
5996
  },
5963
5997
  };
5964
- const builder$6 = (yargs) => {
5965
- return yargs.options(options$6).usage(getCommandUsageHeader(command$6));
5998
+ const builder$7 = (yargs) => {
5999
+ return yargs.options(options$7).usage(getCommandUsageHeader(command$7));
5966
6000
  };
5967
6001
 
5968
6002
  /**
@@ -10829,7 +10863,7 @@ async function processInWaves(items, processor, maxConcurrent = 6) {
10829
10863
  }
10830
10864
  return results;
10831
10865
  }
10832
- const handler$6 = async (argv, logger) => {
10866
+ const handler$7 = async (argv, logger) => {
10833
10867
  const config = loadConfig(argv);
10834
10868
  const git = getRepo();
10835
10869
  const key = getApiKeyForModel(config);
@@ -11054,11 +11088,11 @@ const handler$6 = async (argv, logger) => {
11054
11088
  };
11055
11089
 
11056
11090
  var changelog = {
11057
- command: command$6,
11091
+ command: command$7,
11058
11092
  desc: 'Generate a changelog from current or target branch, provided commit range, or since the last tag.',
11059
- builder: builder$6,
11060
- handler: commandExecutor(handler$6),
11061
- options: options$6,
11093
+ builder: builder$7,
11094
+ handler: commandExecutor(handler$7),
11095
+ options: options$7,
11062
11096
  };
11063
11097
 
11064
11098
  const conventionalTypeRegex = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?!?:/;
@@ -11075,11 +11109,11 @@ const ConventionalCommitMessageResponseSchema = objectType({
11075
11109
  body: stringType().describe("Body of the commit message")
11076
11110
  // .max(280, "Body must be 280 characters or less"),
11077
11111
  }).describe("Object with Conventional Commit message 'title' and 'body' adhering to Conventional Commits specification");
11078
- const command$5 = 'commit';
11112
+ const command$6 = 'commit';
11079
11113
  /**
11080
11114
  * Command line options via yargs
11081
11115
  */
11082
- const options$5 = {
11116
+ const options$6 = {
11083
11117
  i: {
11084
11118
  alias: 'interactive',
11085
11119
  description: 'Toggle interactive mode',
@@ -11151,8 +11185,8 @@ const options$5 = {
11151
11185
  default: false,
11152
11186
  },
11153
11187
  };
11154
- const builder$5 = (yargs) => {
11155
- return yargs.options(options$5).usage(getCommandUsageHeader(command$5));
11188
+ const builder$6 = (yargs) => {
11189
+ return yargs.options(options$6).usage(getCommandUsageHeader(command$6));
11156
11190
  };
11157
11191
 
11158
11192
  /**
@@ -11987,7 +12021,7 @@ async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, })
11987
12021
  return formatCommitSplitPlan(plan);
11988
12022
  }
11989
12023
 
11990
- const handler$5 = async (argv, logger) => {
12024
+ const handler$6 = async (argv, logger) => {
11991
12025
  const git = getRepo();
11992
12026
  const config = loadConfig(argv);
11993
12027
  const key = getApiKeyForModel(config);
@@ -12028,9 +12062,14 @@ const handler$5 = async (argv, logger) => {
12028
12062
  tokenizer,
12029
12063
  llm,
12030
12064
  });
12065
+ const splitMode = INTERACTIVE ? 'interactive' : (config.mode || 'stdout');
12031
12066
  await handleResult({
12032
12067
  result: splitResult,
12033
- mode: config.mode || 'stdout',
12068
+ mode: splitMode,
12069
+ interactiveModeCallback: async (result) => {
12070
+ logger.log(result);
12071
+ logSuccess();
12072
+ },
12034
12073
  });
12035
12074
  logLlmTelemetrySummary(logger, 'commit');
12036
12075
  return;
@@ -12447,11 +12486,407 @@ IMPORTANT RULES:
12447
12486
  };
12448
12487
 
12449
12488
  var commit = {
12450
- command: command$5,
12489
+ command: command$6,
12451
12490
  desc: 'Summarize the staged changes in a commit message.',
12491
+ builder: builder$6,
12492
+ handler: commandExecutor(handler$6),
12493
+ options: options$6,
12494
+ };
12495
+
12496
+ const command$5 = 'doctor';
12497
+ const options$5 = {
12498
+ fix: {
12499
+ description: 'Attempt to auto-fix detected issues and write the updated config',
12500
+ type: 'boolean',
12501
+ default: false,
12502
+ },
12503
+ };
12504
+ const builder$5 = (yargs) => {
12505
+ return yargs.options(options$5).usage(getCommandUsageHeader(command$5));
12506
+ };
12507
+
12508
+ /**
12509
+ * Deprecated or renamed model identifiers that should be updated.
12510
+ */
12511
+ const MODEL_UPGRADES = {
12512
+ 'gpt-4-turbo-preview': 'gpt-4o',
12513
+ 'gpt-4-0125-preview': 'gpt-4o',
12514
+ 'gpt-4-1106-preview': 'gpt-4o',
12515
+ 'gpt-3.5-turbo-0125': 'gpt-4o-mini',
12516
+ 'gpt-3.5-turbo-1106': 'gpt-4o-mini',
12517
+ 'gpt-3.5-turbo-16k': 'gpt-4o-mini',
12518
+ 'claude-3-opus-20240229': 'claude-sonnet-4-0',
12519
+ 'claude-3-sonnet-20240229': 'claude-3-5-sonnet-latest',
12520
+ 'claude-3-haiku-20240307': 'claude-3-5-haiku-latest',
12521
+ };
12522
+ function runDiagnostics(config) {
12523
+ const diagnostics = [];
12524
+ checkServiceBlock(config, diagnostics);
12525
+ checkAuthentication(config, diagnostics);
12526
+ checkModelCurrency(config, diagnostics);
12527
+ checkModeConfig(config, diagnostics);
12528
+ checkDynamicRouting(config, diagnostics);
12529
+ checkTokenLimits(config, diagnostics);
12530
+ checkIgnoredFiles(config, diagnostics);
12531
+ checkProjectConfigFile(diagnostics);
12532
+ return diagnostics;
12533
+ }
12534
+ function checkServiceBlock(config, diagnostics) {
12535
+ if (!config.service) {
12536
+ diagnostics.push({
12537
+ severity: 'error',
12538
+ message: 'No service configuration found. Coco needs an AI provider to generate results.',
12539
+ fix: 'Run `coco init` to set up a provider, or add a "service" block to .coco.config.json.',
12540
+ });
12541
+ return;
12542
+ }
12543
+ if (!config.service.provider) {
12544
+ diagnostics.push({
12545
+ severity: 'error',
12546
+ message: 'No provider set in service config.',
12547
+ fix: 'Set service.provider to "openai", "anthropic", or "ollama".',
12548
+ });
12549
+ }
12550
+ if (!config.service.model) {
12551
+ diagnostics.push({
12552
+ severity: 'error',
12553
+ message: 'No model set in service config.',
12554
+ fix: 'Set service.model to a valid model name (e.g. "gpt-4o") or "dynamic" for task-based routing.',
12555
+ });
12556
+ }
12557
+ }
12558
+ function checkAuthentication(config, diagnostics) {
12559
+ if (!config.service)
12560
+ return;
12561
+ const { provider, authentication } = config.service;
12562
+ if (provider === 'ollama') {
12563
+ if (authentication && authentication.type !== 'None') {
12564
+ diagnostics.push({
12565
+ severity: 'warn',
12566
+ message: 'Ollama does not require authentication. Set authentication.type to "None".',
12567
+ fix: 'Change service.authentication to { "type": "None" }.',
12568
+ autoFix: (raw) => {
12569
+ const svc = raw.service;
12570
+ if (svc) {
12571
+ svc.authentication = { type: 'None' };
12572
+ }
12573
+ },
12574
+ });
12575
+ }
12576
+ const ollamaService = config.service;
12577
+ if (!ollamaService.endpoint) {
12578
+ diagnostics.push({
12579
+ severity: 'warn',
12580
+ message: 'No Ollama endpoint configured. Defaulting to http://localhost:11434.',
12581
+ fix: 'Add service.endpoint: "http://localhost:11434" to your config.',
12582
+ autoFix: (raw) => {
12583
+ const svc = raw.service;
12584
+ if (svc) {
12585
+ svc.endpoint = 'http://localhost:11434';
12586
+ }
12587
+ },
12588
+ });
12589
+ }
12590
+ return;
12591
+ }
12592
+ if (!authentication || authentication.type === 'None') {
12593
+ diagnostics.push({
12594
+ severity: 'error',
12595
+ message: `Provider "${provider}" requires authentication but none is configured.`,
12596
+ fix: `Set service.authentication to { "type": "APIKey", "credentials": { "apiKey": "..." } } or use the OPENAI_API_KEY / ANTHROPIC_API_KEY environment variable.`,
12597
+ });
12598
+ return;
12599
+ }
12600
+ if (authentication.type === 'APIKey') {
12601
+ const key = authentication.credentials?.apiKey;
12602
+ if (!key || key === '•••••••••••••••' || key.trim() === '') {
12603
+ diagnostics.push({
12604
+ severity: 'warn',
12605
+ message: 'API key appears to be a placeholder or empty. Coco may fall back to environment variables.',
12606
+ fix: `Set the API key in your config or via environment variable (OPENAI_API_KEY or ANTHROPIC_API_KEY).`,
12607
+ });
12608
+ }
12609
+ }
12610
+ }
12611
+ function checkModelCurrency(config, diagnostics) {
12612
+ if (!config.service?.model || config.service.model === 'dynamic')
12613
+ return;
12614
+ const model = String(config.service.model);
12615
+ const upgrade = MODEL_UPGRADES[model];
12616
+ if (upgrade) {
12617
+ diagnostics.push({
12618
+ severity: 'warn',
12619
+ message: `Model "${model}" has a newer replacement available: "${upgrade}".`,
12620
+ fix: `Update service.model to "${upgrade}" for better performance and pricing.`,
12621
+ autoFix: (raw) => {
12622
+ const svc = raw.service;
12623
+ if (svc) {
12624
+ svc.model = upgrade;
12625
+ }
12626
+ },
12627
+ });
12628
+ }
12629
+ }
12630
+ function checkModeConfig(config, diagnostics) {
12631
+ if (!config.mode || config.mode === 'stdout') {
12632
+ diagnostics.push({
12633
+ severity: 'info',
12634
+ message: 'Output mode is "stdout". Interactive features like commit split require -i or mode: "interactive".',
12635
+ fix: 'Set "mode": "interactive" in your config, or pass -i when using interactive commands.',
12636
+ });
12637
+ }
12638
+ }
12639
+ function checkDynamicRouting(config, diagnostics) {
12640
+ if (!config.service)
12641
+ return;
12642
+ if (config.service.model === 'dynamic') {
12643
+ if (!config.service.dynamicModelPreference) {
12644
+ diagnostics.push({
12645
+ severity: 'info',
12646
+ message: 'Dynamic model routing is enabled but no preference is set. Defaulting to "balanced".',
12647
+ fix: 'Optionally set service.dynamicModelPreference to "cost", "balanced", or "quality".',
12648
+ });
12649
+ }
12650
+ if (config.service.dynamicModels) {
12651
+ const validTasks = ['summarize', 'commit', 'changelog', 'review', 'recap', 'repair', 'largeDiff'];
12652
+ for (const task of Object.keys(config.service.dynamicModels)) {
12653
+ if (!validTasks.includes(task)) {
12654
+ diagnostics.push({
12655
+ severity: 'warn',
12656
+ message: `Unknown dynamic model task "${task}". Valid tasks: ${validTasks.join(', ')}.`,
12657
+ fix: `Remove or rename the "${task}" key in service.dynamicModels.`,
12658
+ });
12659
+ }
12660
+ }
12661
+ }
12662
+ diagnostics.push({
12663
+ severity: 'info',
12664
+ message: 'Dynamic model routing is active. Coco will select models per task based on your preference.',
12665
+ });
12666
+ }
12667
+ else {
12668
+ diagnostics.push({
12669
+ severity: 'info',
12670
+ message: `Using fixed model "${config.service.model}" for all tasks. Set service.model to "dynamic" to enable per-task model selection.`,
12671
+ });
12672
+ }
12673
+ }
12674
+ function checkTokenLimits(config, diagnostics) {
12675
+ if (!config.service)
12676
+ return;
12677
+ const tokenLimit = config.service.tokenLimit || 2048;
12678
+ if (tokenLimit < 512) {
12679
+ diagnostics.push({
12680
+ severity: 'warn',
12681
+ message: `Token limit (${tokenLimit}) is very low. This may cause truncated or poor-quality results.`,
12682
+ fix: 'Increase service.tokenLimit to at least 1024, ideally 2048 or higher.',
12683
+ autoFix: (raw) => {
12684
+ const svc = raw.service;
12685
+ if (svc) {
12686
+ svc.tokenLimit = 2048;
12687
+ }
12688
+ },
12689
+ });
12690
+ }
12691
+ if (config.service.maxConcurrent && config.service.provider === 'ollama' && config.service.maxConcurrent > 1) {
12692
+ diagnostics.push({
12693
+ severity: 'warn',
12694
+ message: `maxConcurrent is ${config.service.maxConcurrent} but Ollama typically handles one request at a time.`,
12695
+ fix: 'Set service.maxConcurrent to 1 for Ollama.',
12696
+ autoFix: (raw) => {
12697
+ const svc = raw.service;
12698
+ if (svc) {
12699
+ svc.maxConcurrent = 1;
12700
+ }
12701
+ },
12702
+ });
12703
+ }
12704
+ }
12705
+ function checkIgnoredFiles(config, diagnostics) {
12706
+ if (!config.ignoredFiles || config.ignoredFiles.length === 0) {
12707
+ diagnostics.push({
12708
+ severity: 'info',
12709
+ message: 'No custom ignored files configured. Coco uses defaults (package-lock.json, .gitignore contents, etc.).',
12710
+ });
12711
+ }
12712
+ }
12713
+ function checkProjectConfigFile(diagnostics) {
12714
+ const hasNewName = fs.existsSync('.coco.json');
12715
+ const hasLegacyName = fs.existsSync('.coco.config.json');
12716
+ if (!hasNewName && !hasLegacyName) {
12717
+ diagnostics.push({
12718
+ severity: 'info',
12719
+ message: 'No project config file found in the current directory. Using git config, env vars, or defaults.',
12720
+ fix: 'Run `coco init --scope project` to create a project config.',
12721
+ });
12722
+ return;
12723
+ }
12724
+ if (hasLegacyName && !hasNewName) {
12725
+ diagnostics.push({
12726
+ severity: 'info',
12727
+ message: 'Using legacy config filename .coco.config.json. Consider renaming to .coco.json.',
12728
+ fix: 'Rename .coco.config.json to .coco.json. Both filenames are supported, but .coco.json is preferred.',
12729
+ });
12730
+ }
12731
+ const configPath = hasNewName ? '.coco.json' : '.coco.config.json';
12732
+ try {
12733
+ const raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
12734
+ if (!raw.$schema) {
12735
+ diagnostics.push({
12736
+ severity: 'info',
12737
+ message: `Project config (${configPath}) is missing the $schema field. Adding it enables editor autocompletion.`,
12738
+ fix: `Add "$schema": "https://coco.griffen.codes/schema.json" to ${configPath}.`,
12739
+ autoFix: (rawConfig) => {
12740
+ rawConfig.$schema = 'https://coco.griffen.codes/schema.json';
12741
+ },
12742
+ });
12743
+ }
12744
+ }
12745
+ catch {
12746
+ diagnostics.push({
12747
+ severity: 'error',
12748
+ message: `${configPath} contains invalid JSON.`,
12749
+ fix: `Fix the JSON syntax in ${configPath} or regenerate it with \`coco init --scope project\`.`,
12750
+ });
12751
+ }
12752
+ }
12753
+
12754
+ const SEVERITY_ICON = {
12755
+ error: chalk.red('✖'),
12756
+ warn: chalk.yellow('⚠'),
12757
+ info: chalk.blue('ℹ'),
12758
+ };
12759
+ const SEVERITY_LABEL = {
12760
+ error: chalk.red('error'),
12761
+ warn: chalk.yellow('warn'),
12762
+ info: chalk.blue('info'),
12763
+ };
12764
+ const SOURCE_LABELS = {
12765
+ default: 'Built-in defaults',
12766
+ xdg: 'XDG config',
12767
+ git: 'Git config (~/.gitconfig)',
12768
+ project: 'Project config',
12769
+ env: 'Environment variables',
12770
+ };
12771
+ function formatSourceInfo(sources) {
12772
+ const lines = [];
12773
+ for (const source of sources) {
12774
+ const label = SOURCE_LABELS[source.source] || source.source;
12775
+ if (source.path) {
12776
+ lines.push(` ${chalk.green('✓')} ${label} ${chalk.dim(`(${source.path})`)}`);
12777
+ }
12778
+ else {
12779
+ lines.push(` ${chalk.green('✓')} ${label}`);
12780
+ }
12781
+ }
12782
+ return lines;
12783
+ }
12784
+ const handler$5 = async (argv, logger) => {
12785
+ const config = loadConfig(argv);
12786
+ const sources = getConfigSources();
12787
+ logger.log(LOGO);
12788
+ logger.log('');
12789
+ logger.log(chalk.bold('coco doctor') + ' — checking your configuration\n');
12790
+ // Show active config sources
12791
+ logger.log(chalk.bold('Config sources') + chalk.dim(' (lowest → highest precedence):\n'));
12792
+ const sourceLines = formatSourceInfo(sources);
12793
+ for (const line of sourceLines) {
12794
+ logger.log(line);
12795
+ }
12796
+ // Show inactive sources
12797
+ const activeSources = new Set(sources.map((s) => s.source));
12798
+ const allSources = ['xdg', 'git', 'project', 'env'];
12799
+ const inactive = allSources.filter((s) => !activeSources.has(s));
12800
+ if (inactive.length > 0) {
12801
+ logger.log('');
12802
+ for (const source of inactive) {
12803
+ const label = SOURCE_LABELS[source] || source;
12804
+ logger.log(` ${chalk.dim('–')} ${chalk.dim(label)} ${chalk.dim('(not found)')}`);
12805
+ }
12806
+ }
12807
+ logger.log('');
12808
+ // Run diagnostics
12809
+ const diagnostics = runDiagnostics(config);
12810
+ if (diagnostics.length === 0) {
12811
+ logger.log(chalk.green('✓ No issues found. Your configuration looks good!'));
12812
+ return;
12813
+ }
12814
+ const errors = diagnostics.filter((d) => d.severity === 'error');
12815
+ const warnings = diagnostics.filter((d) => d.severity === 'warn');
12816
+ const infos = diagnostics.filter((d) => d.severity === 'info');
12817
+ logger.log(chalk.bold('Diagnostics:\n'));
12818
+ for (const diagnostic of diagnostics) {
12819
+ const icon = SEVERITY_ICON[diagnostic.severity];
12820
+ const label = SEVERITY_LABEL[diagnostic.severity];
12821
+ logger.log(`${icon} ${label}: ${diagnostic.message}`);
12822
+ if (diagnostic.fix) {
12823
+ logger.log(chalk.dim(` → ${diagnostic.fix}`));
12824
+ }
12825
+ logger.log('');
12826
+ }
12827
+ // Summary
12828
+ const parts = [];
12829
+ if (errors.length > 0)
12830
+ parts.push(chalk.red(`${errors.length} error(s)`));
12831
+ if (warnings.length > 0)
12832
+ parts.push(chalk.yellow(`${warnings.length} warning(s)`));
12833
+ if (infos.length > 0)
12834
+ parts.push(chalk.blue(`${infos.length} info(s)`));
12835
+ logger.log(`Found ${parts.join(', ')}.\n`);
12836
+ // Auto-fix
12837
+ if (argv.fix) {
12838
+ const fixable = diagnostics.filter((d) => d.autoFix);
12839
+ if (fixable.length === 0) {
12840
+ logger.log(chalk.dim('No auto-fixable issues found.'));
12841
+ return;
12842
+ }
12843
+ // Find the project config file to write to
12844
+ const projectSource = sources.find((s) => s.source === 'project');
12845
+ const configPath = projectSource?.path;
12846
+ if (!configPath) {
12847
+ logger.log(chalk.yellow('No project config file found. Run `coco init --scope project` first, then re-run `coco doctor --fix`.'));
12848
+ logger.log(chalk.dim(' Auto-fix writes to the project config file (.coco.json or .coco.config.json).'));
12849
+ // If config is coming from git or env, explain
12850
+ const gitSource = sources.find((s) => s.source === 'git');
12851
+ const envSource = sources.find((s) => s.source === 'env');
12852
+ if (gitSource) {
12853
+ logger.log(chalk.dim(` Your config is loaded from ${gitSource.path} — edit that file manually, or create a project config.`));
12854
+ }
12855
+ if (envSource) {
12856
+ logger.log(chalk.dim(' Some config comes from environment variables — update those in your shell profile.'));
12857
+ }
12858
+ return;
12859
+ }
12860
+ try {
12861
+ const raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
12862
+ for (const diagnostic of fixable) {
12863
+ diagnostic.autoFix(raw);
12864
+ logger.log(chalk.green(` ✓ Fixed: ${diagnostic.message}`));
12865
+ }
12866
+ // Ensure $schema is present
12867
+ if (!raw.$schema) {
12868
+ raw.$schema = SCHEMA_PUBLIC_URL;
12869
+ }
12870
+ fs.writeFileSync(configPath, JSON.stringify(raw, null, 2) + '\n');
12871
+ logger.log(chalk.green(`\nWrote ${fixable.length} fix(es) to ${configPath}`));
12872
+ }
12873
+ catch (e) {
12874
+ logger.log(chalk.red(`Failed to apply fixes: ${e.message}`));
12875
+ }
12876
+ }
12877
+ else {
12878
+ const fixable = diagnostics.filter((d) => d.autoFix);
12879
+ if (fixable.length > 0) {
12880
+ logger.log(chalk.dim(`${fixable.length} issue(s) can be auto-fixed. Run \`coco doctor --fix\` to apply.`));
12881
+ }
12882
+ }
12883
+ };
12884
+
12885
+ var doctor = {
12886
+ command: command$5,
12887
+ desc: 'Check your coco configuration for common issues and suggest fixes',
12452
12888
  builder: builder$5,
12453
12889
  handler: commandExecutor(handler$5),
12454
- options: options$5,
12455
12890
  };
12456
12891
 
12457
12892
  const command$4 = 'init';
@@ -12807,7 +13242,11 @@ const questions = {
12807
13242
  message: 'where would you like to store the project config?',
12808
13243
  choices: [
12809
13244
  {
12810
- name: '.coco.config.json',
13245
+ name: '.coco.json (recommended)',
13246
+ value: '.coco.json',
13247
+ },
13248
+ {
13249
+ name: '.coco.config.json (legacy)',
12811
13250
  value: '.coco.config.json',
12812
13251
  },
12813
13252
  {
@@ -13468,10 +13907,11 @@ function applyCommitComposeAction(state, action) {
13468
13907
  details: action.details,
13469
13908
  };
13470
13909
  case 'reset':
13471
- return createCommitComposeState({
13472
- message: state.message,
13473
- details: state.details,
13474
- });
13910
+ // Drop message/details too — the post-commit "Created commit ..."
13911
+ // notification is already on the runtime status line (footer); a
13912
+ // duplicate copy lingering in the Compose panel reads as stale
13913
+ // state once the user starts a fresh draft.
13914
+ return createCommitComposeState();
13475
13915
  default:
13476
13916
  return state;
13477
13917
  }
@@ -13538,56 +13978,291 @@ async function createManualCommit({ git, summary, body, noVerify = false, }) {
13538
13978
  }
13539
13979
  }
13540
13980
 
13541
- function createCommitWorkflowArgv(action) {
13542
- const split = action === 'split-plan' || action === 'split-apply';
13543
- const apply = action === 'split-apply';
13544
- const plan = action === 'split-plan';
13545
- return {
13546
- $0: 'coco',
13547
- _: split ? ['commit', 'split'] : ['commit'],
13548
- interactive: false,
13549
- verbose: false,
13550
- version: false,
13551
- help: false,
13552
- mode: 'stdout',
13553
- openInEditor: false,
13554
- ignoredFiles: [],
13555
- ignoredExtensions: [],
13556
- withPreviousCommits: 0,
13557
- conventional: false,
13558
- includeBranchName: true,
13559
- noVerify: false,
13560
- noDiff: false,
13561
- split,
13562
- plan,
13563
- apply,
13564
- };
13565
- }
13566
- function formatCommitWorkflowMessage(action, output) {
13567
- const normalized = output.trim();
13568
- if (normalized) {
13569
- return normalized.split('\n')[0];
13570
- }
13571
- if (action === 'split-plan') {
13572
- return 'Generated commit split plan.';
13573
- }
13574
- if (action === 'split-apply') {
13575
- return 'Applied commit split plan.';
13576
- }
13577
- return 'Generated commit message.';
13578
- }
13579
- function compactOutputLines$3(output) {
13580
- return output
13581
- .split('\n')
13582
- .map((line) => line.trim())
13583
- .filter(Boolean);
13584
- }
13585
- function formatCommitFailure(error) {
13586
- if (error instanceof PreCommitHookError) {
13587
- const details = compactOutputLines$3(error.hookOutput);
13981
+ const FORMAT_INSTRUCTIONS_TEMPLATE = (schemaDescription) => (`CRITICAL: You must return ONLY a valid JSON object with no additional text, explanations, or markdown formatting.
13982
+
13983
+ REQUIRED JSON FORMAT:
13984
+ ${schemaDescription}
13985
+
13986
+ EXAMPLE (follow this EXACT format - compact JSON on a single line or minimal whitespace):
13987
+ {"title": "feat(auth): add user authentication system", "body": "Implement JWT-based authentication with login and logout functionality. Includes password hashing and session management."}
13988
+
13989
+ IMPORTANT RULES:
13990
+ - Return ONLY the JSON object - NO markdown code blocks, NO backticks, NO extra text
13991
+ - ALL string values MUST be enclosed in double quotes
13992
+ - Use compact JSON format (minimal whitespace) for best compatibility
13993
+ - NO trailing commas
13994
+ - NO comments or additional text outside the JSON
13995
+ - The "title" and "body" values must be properly quoted strings`);
13996
+ /**
13997
+ * Generate a commit message draft with no UI side effects.
13998
+ *
13999
+ * Mirrors the LLM-chain logic from `commit/handler.ts`'s agent callback but
14000
+ * skips the review loop, ora spinners, Inquirer prompts, and stdout writes
14001
+ * that would corrupt the surrounding Ink TUI alt screen. Validation failures
14002
+ * are surfaced as `validationErrors`/`warnings` rather than driving an
14003
+ * interactive retry flow — the TUI can re-invoke or let the user edit.
14004
+ */
14005
+ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: true }), }) {
14006
+ const config = loadConfig(argv);
14007
+ const key = getApiKeyForModel(config);
14008
+ const { provider } = getModelAndProviderFromConfig(config);
14009
+ const commitService = resolveDynamicService(config, 'commit');
14010
+ const summaryService = resolveDynamicService(config, 'summarize');
14011
+ const model = commitService.model;
14012
+ if (config.service.authentication.type !== 'None' && !key) {
13588
14013
  return {
13589
14014
  ok: false,
13590
- message: `Commit blocked by hook: ${details[0] || 'hook failed'}`,
14015
+ draft: '',
14016
+ warnings: [],
14017
+ validationErrors: ['No API key configured for the commit service.'],
14018
+ };
14019
+ }
14020
+ const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
14021
+ const llm = getLlm(provider, model, { ...config, service: commitService });
14022
+ const summaryLlm = getLlm(provider, summaryService.model, {
14023
+ ...config,
14024
+ service: summaryService,
14025
+ });
14026
+ const useConventional = Boolean(config.conventionalCommits || argv.conventional);
14027
+ const changes = await (async () => {
14028
+ if (config.noDiff) {
14029
+ const status = await git.status();
14030
+ return status.files.map((file) => ({
14031
+ filePath: file.path,
14032
+ status: (file.index === 'A' || file.index === '?'
14033
+ ? 'added'
14034
+ : 'modified'),
14035
+ summary: file.path,
14036
+ }));
14037
+ }
14038
+ const result = await getChanges({
14039
+ git,
14040
+ options: {
14041
+ ignoredFiles: config.ignoredFiles || undefined,
14042
+ ignoredExtensions: config.ignoredExtensions || undefined,
14043
+ },
14044
+ });
14045
+ return result.staged;
14046
+ })();
14047
+ if (!changes || changes.length === 0) {
14048
+ return {
14049
+ ok: false,
14050
+ draft: '',
14051
+ warnings: ['No staged changes detected.'],
14052
+ validationErrors: [],
14053
+ };
14054
+ }
14055
+ const summary = config.noDiff
14056
+ ? `Staged files:\n${changes.map((c) => `${c.status}: ${c.filePath}`).join('\n')}`
14057
+ : await fileChangeParser({
14058
+ changes,
14059
+ commit: '--staged',
14060
+ options: createFileChangeParserOptions({
14061
+ command: 'commit',
14062
+ tokenizer,
14063
+ git,
14064
+ llm: summaryLlm,
14065
+ logger,
14066
+ provider,
14067
+ model: String(summaryService.model),
14068
+ service: config.service,
14069
+ }),
14070
+ });
14071
+ if (!summary || !summary.length) {
14072
+ return {
14073
+ ok: false,
14074
+ draft: '',
14075
+ warnings: ['Diff summary was empty after parsing staged changes.'],
14076
+ validationErrors: [],
14077
+ };
14078
+ }
14079
+ const schema = useConventional
14080
+ ? ConventionalCommitMessageResponseSchema
14081
+ : CommitMessageResponseSchema;
14082
+ const promptTemplate = useConventional ? CONVENTIONAL_COMMIT_PROMPT : COMMIT_PROMPT;
14083
+ const prompt = getPrompt({
14084
+ template: config.prompt || promptTemplate.template,
14085
+ variables: promptTemplate.inputVariables,
14086
+ fallback: promptTemplate,
14087
+ });
14088
+ const formatInstructions = FORMAT_INSTRUCTIONS_TEMPLATE(schema.description || '');
14089
+ const additionalContext = argv.additional ? `## Additional Context\n${argv.additional}` : '';
14090
+ let commitHistory = '';
14091
+ const previousCommitsCount = Number(argv.withPreviousCommits || 0);
14092
+ if (previousCommitsCount > 0) {
14093
+ const commitHistoryData = await getPreviousCommits({ git, count: previousCommitsCount });
14094
+ if (commitHistoryData) {
14095
+ commitHistory = `## Commit History\n${commitHistoryData}`;
14096
+ }
14097
+ }
14098
+ const branchName = await getCurrentBranchName({ git });
14099
+ const includeBranchName = argv.includeBranchName !== undefined
14100
+ ? argv.includeBranchName
14101
+ : config.includeBranchName !== false;
14102
+ const branchNameContext = includeBranchName ? `Current git branch name: ${branchName}` : '';
14103
+ const warnings = [];
14104
+ const hasCommitLintConfig = await hasCommitlintConfig();
14105
+ let commitlintRulesContext = '';
14106
+ let validationEnabled = useConventional || hasCommitLintConfig;
14107
+ if (validationEnabled) {
14108
+ const { getCommitlintRulesContext, checkCommitlintAvailability } = await Promise.resolve().then(function () { return commitlintValidator; });
14109
+ const availability = checkCommitlintAvailability();
14110
+ if (!availability.available) {
14111
+ warnings.push(`Skipping commitlint validation: missing packages (${availability.missingPackages.join(', ')}).`);
14112
+ validationEnabled = false;
14113
+ }
14114
+ else {
14115
+ commitlintRulesContext = await getCommitlintRulesContext();
14116
+ }
14117
+ }
14118
+ const baseVariables = {
14119
+ summary,
14120
+ format_instructions: formatInstructions,
14121
+ additional_context: additionalContext,
14122
+ commit_history: commitHistory,
14123
+ branch_name_context: branchNameContext,
14124
+ commitlint_rules_context: commitlintRulesContext,
14125
+ };
14126
+ const maxParsingAttempts = config.service.provider === 'ollama' && 'maxParsingAttempts' in config.service
14127
+ ? config.service.maxParsingAttempts || 3
14128
+ : 3;
14129
+ let lastValidationErrors = [];
14130
+ let validationFeedback = '';
14131
+ let lastDraft = '';
14132
+ // Two attempts max — one initial generation plus one retry that incorporates
14133
+ // commitlint feedback. Beyond that we surface warnings and let the TUI user
14134
+ // edit the draft manually rather than driving an interactive prompt loop.
14135
+ for (let attempt = 1; attempt <= 2; attempt++) {
14136
+ const variables = {
14137
+ ...baseVariables,
14138
+ additional_context: validationFeedback
14139
+ ? `${baseVariables.additional_context}\n\n## Validation Errors from Previous Attempt\nPlease fix the following issues:\n${validationFeedback}`
14140
+ : baseVariables.additional_context,
14141
+ };
14142
+ const budgetedPrompt = await enforcePromptBudget({
14143
+ prompt,
14144
+ variables,
14145
+ tokenizer,
14146
+ maxTokens: config.service.tokenLimit || 2048,
14147
+ });
14148
+ const commitMsg = await executeChainWithSchema(schema, llm, prompt, budgetedPrompt.variables, {
14149
+ logger,
14150
+ tokenizer,
14151
+ metadata: {
14152
+ task: useConventional ? 'commit-message-conventional' : 'commit-message',
14153
+ command: 'commit-draft',
14154
+ provider,
14155
+ model: String(model),
14156
+ },
14157
+ retryOptions: {
14158
+ maxAttempts: maxParsingAttempts,
14159
+ },
14160
+ fallbackParser: (text) => {
14161
+ try {
14162
+ let cleanText = text.trim();
14163
+ const codeBlockMatch = cleanText.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
14164
+ if (codeBlockMatch && codeBlockMatch[1]) {
14165
+ cleanText = codeBlockMatch[1].trim();
14166
+ }
14167
+ const parsed = JSON.parse(cleanText);
14168
+ if (parsed && typeof parsed === 'object' &&
14169
+ typeof parsed.title === 'string' &&
14170
+ typeof parsed.body === 'string' &&
14171
+ parsed.title.length > 0) {
14172
+ return parsed;
14173
+ }
14174
+ }
14175
+ catch {
14176
+ // fall through
14177
+ }
14178
+ return {
14179
+ title: text.split('\n')[0] || 'Auto-generated commit',
14180
+ body: text.split('\n').slice(1).join('\n') || 'Generated commit message',
14181
+ };
14182
+ },
14183
+ });
14184
+ const ticketId = extractTicketIdFromBranchName(branchName);
14185
+ const fullMessage = formatCommitMessage(commitMsg, {
14186
+ append: argv.append,
14187
+ ticketId: ticketId || undefined,
14188
+ appendTicket: argv.appendTicket,
14189
+ });
14190
+ lastDraft = fullMessage;
14191
+ if (!validationEnabled) {
14192
+ return { ok: true, draft: fullMessage, warnings, validationErrors: [] };
14193
+ }
14194
+ const { validateCommitMessage } = await Promise.resolve().then(function () { return commitlintValidator; });
14195
+ const validationResult = await validateCommitMessage(fullMessage);
14196
+ if (validationResult.missingDependencies && validationResult.missingDependencies.length > 0) {
14197
+ warnings.push(`Skipping commitlint validation: missing packages (${validationResult.missingDependencies.join(', ')}).`);
14198
+ return { ok: true, draft: fullMessage, warnings, validationErrors: [] };
14199
+ }
14200
+ if (validationResult.valid) {
14201
+ return { ok: true, draft: fullMessage, warnings, validationErrors: [] };
14202
+ }
14203
+ lastValidationErrors = validationResult.errors;
14204
+ validationFeedback = validationResult.errors.map((error) => `- ${error}`).join('\n');
14205
+ }
14206
+ // Both attempts failed validation — return the latest draft so the user can
14207
+ // hand-edit in the compose surface, plus the validator output for context.
14208
+ return {
14209
+ ok: false,
14210
+ draft: lastDraft,
14211
+ warnings,
14212
+ validationErrors: lastValidationErrors,
14213
+ };
14214
+ }
14215
+
14216
+ function createCommitWorkflowArgv(action) {
14217
+ const split = action === 'split-plan' || action === 'split-apply';
14218
+ const apply = action === 'split-apply';
14219
+ const plan = action === 'split-plan';
14220
+ return {
14221
+ $0: 'coco',
14222
+ _: split ? ['commit', 'split'] : ['commit'],
14223
+ interactive: false,
14224
+ verbose: false,
14225
+ version: false,
14226
+ help: false,
14227
+ mode: 'stdout',
14228
+ openInEditor: false,
14229
+ ignoredFiles: [],
14230
+ ignoredExtensions: [],
14231
+ withPreviousCommits: 0,
14232
+ conventional: false,
14233
+ includeBranchName: true,
14234
+ noVerify: false,
14235
+ noDiff: false,
14236
+ split,
14237
+ plan,
14238
+ apply,
14239
+ };
14240
+ }
14241
+ function formatCommitWorkflowMessage(action, output) {
14242
+ const normalized = output.trim();
14243
+ if (normalized) {
14244
+ return normalized.split('\n')[0];
14245
+ }
14246
+ if (action === 'split-plan') {
14247
+ return 'Generated commit split plan.';
14248
+ }
14249
+ if (action === 'split-apply') {
14250
+ return 'Applied commit split plan.';
14251
+ }
14252
+ return 'Generated commit message.';
14253
+ }
14254
+ function compactOutputLines$3(output) {
14255
+ return output
14256
+ .split('\n')
14257
+ .map((line) => line.trim())
14258
+ .filter(Boolean);
14259
+ }
14260
+ function formatCommitFailure(error) {
14261
+ if (error instanceof PreCommitHookError) {
14262
+ const details = compactOutputLines$3(error.hookOutput);
14263
+ return {
14264
+ ok: false,
14265
+ message: `Commit blocked by hook: ${details[0] || 'hook failed'}`,
13591
14266
  details: details.slice(1, 6),
13592
14267
  };
13593
14268
  }
@@ -13612,7 +14287,7 @@ async function runCommitWorkflow({ action, git = getRepo(), noVerify = false, })
13612
14287
  return true;
13613
14288
  });
13614
14289
  try {
13615
- await handler$5(argv, logger);
14290
+ await handler$6(argv, logger);
13616
14291
  const message = output.trim();
13617
14292
  if (action === 'commit' && message) {
13618
14293
  await createCommit(message, git, undefined, { noVerify: config.noVerify || false });
@@ -13637,41 +14312,45 @@ async function runCommitWorkflow({ action, git = getRepo(), noVerify = false, })
13637
14312
  process.stdout.write = originalWrite;
13638
14313
  }
13639
14314
  }
13640
- async function runCommitDraftWorkflow() {
14315
+ async function runCommitDraftWorkflow(input = {}) {
14316
+ const git = input.git || getRepo();
13641
14317
  const argv = createCommitWorkflowArgv('commit');
13642
14318
  const logger = new Logger({ silent: true });
13643
- const originalWrite = process.stdout.write.bind(process.stdout);
13644
- let output = '';
13645
- process.stdout.write = ((chunk, ...args) => {
13646
- output += typeof chunk === 'string' ? chunk : chunk.toString();
13647
- const callback = args.find((arg) => typeof arg === 'function');
13648
- callback?.();
13649
- return true;
13650
- });
13651
14319
  try {
13652
- await handler$5(argv, logger);
13653
- const draft = output.trim();
14320
+ const result = await generateCommitDraft({ git, argv, logger });
14321
+ const draft = result.draft.trim();
14322
+ if (result.ok && draft) {
14323
+ return {
14324
+ ok: true,
14325
+ message: formatCommitWorkflowMessage('commit', draft),
14326
+ details: result.warnings,
14327
+ draft,
14328
+ };
14329
+ }
14330
+ const failureLines = [
14331
+ ...(result.validationErrors || []),
14332
+ ...(result.warnings || []),
14333
+ ];
13654
14334
  return {
13655
- ok: Boolean(draft),
13656
- message: draft ? formatCommitWorkflowMessage('commit', draft) : 'AI draft was empty.',
14335
+ ok: false,
14336
+ message: failureLines[0] ||
14337
+ (draft ? 'AI draft did not pass commitlint.' : 'AI draft was empty.'),
14338
+ details: failureLines.slice(1, 6),
13657
14339
  draft,
13658
14340
  };
13659
14341
  }
13660
14342
  catch (error) {
13661
14343
  if (isCommandExitError(error)) {
13662
- const lines = compactOutputLines$3(output || error.message);
14344
+ const lines = compactOutputLines$3(error.message);
13663
14345
  return {
13664
14346
  ok: error.code === 0,
13665
14347
  message: lines[0] || error.message,
13666
14348
  details: lines.slice(1, 6),
13667
- draft: output.trim(),
14349
+ draft: '',
13668
14350
  };
13669
14351
  }
13670
14352
  return formatCommitFailure(error);
13671
14353
  }
13672
- finally {
13673
- process.stdout.write = originalWrite;
13674
- }
13675
14354
  }
13676
14355
 
13677
14356
  const LOG_INK_CONTEXT_KEYS = [
@@ -13988,16 +14667,30 @@ const LOG_INK_KEY_BINDINGS = [
13988
14667
  id: 'previousSidebarTab',
13989
14668
  keys: ['['],
13990
14669
  label: 'previous tab',
13991
- description: 'Move to the previous repository sidebar tab.',
14670
+ description: 'Move to the previous repository sidebar tab (outside diff view).',
13992
14671
  contexts: ['sidebar'],
13993
14672
  },
13994
14673
  {
13995
14674
  id: 'nextSidebarTab',
13996
14675
  keys: [']'],
13997
14676
  label: 'next tab',
13998
- description: 'Move to the next repository sidebar tab.',
14677
+ description: 'Move to the next repository sidebar tab (outside diff view).',
13999
14678
  contexts: ['sidebar'],
14000
14679
  },
14680
+ {
14681
+ id: 'previousHunk',
14682
+ keys: ['['],
14683
+ label: 'previous hunk',
14684
+ description: 'Jump to the previous diff hunk in the current diff view.',
14685
+ contexts: ['commits'],
14686
+ },
14687
+ {
14688
+ id: 'nextHunk',
14689
+ keys: [']'],
14690
+ label: 'next hunk',
14691
+ description: 'Jump to the next diff hunk in the current diff view.',
14692
+ contexts: ['commits'],
14693
+ },
14001
14694
  {
14002
14695
  id: 'focusNext',
14003
14696
  keys: ['tab'],
@@ -14124,6 +14817,13 @@ const LOG_INK_KEY_BINDINGS = [
14124
14817
  description: 'Create a commit from staged changes with the current draft.',
14125
14818
  contexts: ['commits'],
14126
14819
  },
14820
+ {
14821
+ id: 'cycleSort',
14822
+ keys: ['s'],
14823
+ label: 'sort',
14824
+ description: 'Cycle the sort mode in branches (name/recent/ahead) or tags (recent/name).',
14825
+ contexts: ['commits'],
14826
+ },
14127
14827
  {
14128
14828
  id: 'help',
14129
14829
  keys: ['?'],
@@ -14153,6 +14853,28 @@ const LOG_INK_KEY_BINDINGS = [
14153
14853
  contexts: ['normal', 'search'],
14154
14854
  },
14155
14855
  ];
14856
+ /**
14857
+ * Surface the second-key continuations for a chord prefix (e.g. `g`)
14858
+ * as a flat list, sourced from the canonical keymap so the help, footer
14859
+ * hint, and which-key overlay all stay in sync. Continuations are sorted
14860
+ * by key for stable, scannable output.
14861
+ */
14862
+ function getLogInkChordContinuations(prefix) {
14863
+ const continuations = [];
14864
+ for (const binding of LOG_INK_KEY_BINDINGS) {
14865
+ for (const keys of binding.keys) {
14866
+ if (keys.length === 2 && keys.startsWith(prefix)) {
14867
+ continuations.push({
14868
+ key: keys.charAt(1),
14869
+ label: binding.label,
14870
+ description: binding.description,
14871
+ });
14872
+ break;
14873
+ }
14874
+ }
14875
+ }
14876
+ return continuations.sort((a, b) => a.key.localeCompare(b.key));
14877
+ }
14156
14878
  /**
14157
14879
  * Bindings considered "global" — always available regardless of which view
14158
14880
  * or pane has focus. Surfaced as a separate group in the help overlay and
@@ -14197,9 +14919,23 @@ function formatLogInkBreadcrumb(viewStack) {
14197
14919
  if (viewStack.length === 1 && viewStack[0] === 'history') {
14198
14920
  return '';
14199
14921
  }
14200
- return viewStack.join(' ');
14922
+ // Trailing back-hint (P2.5) reminds the user how to walk back when
14923
+ // they're nested deeper than the root view.
14924
+ return `${viewStack.join(' › ')} ← <`;
14201
14925
  }
14202
14926
  function getLogInkFooterHints(options) {
14927
+ if (options.pendingKey) {
14928
+ const continuations = getLogInkChordContinuations(options.pendingKey);
14929
+ if (continuations.length > 0) {
14930
+ return {
14931
+ contextual: [
14932
+ `${options.pendingKey} …`,
14933
+ ...continuations.map((entry) => `${entry.key} ${entry.label}`),
14934
+ ],
14935
+ global: ['esc cancel'],
14936
+ };
14937
+ }
14938
+ }
14203
14939
  if (options.filterMode) {
14204
14940
  return {
14205
14941
  contextual: ['enter apply', 'esc cancel', 'ctrl+u clear'],
@@ -14250,13 +14986,13 @@ function getLogInkFooterHints(options) {
14250
14986
  }
14251
14987
  if (options.activeView === 'branches') {
14252
14988
  return {
14253
- contextual: ['↑/↓ branches', 'D delete', 'X checkout', 'enter diff', 'esc back'],
14989
+ contextual: ['↑/↓ branches', 's sort', 'D delete', 'X checkout', 'enter diff'],
14254
14990
  global: NORMAL_GLOBAL_HINTS,
14255
14991
  };
14256
14992
  }
14257
14993
  if (options.activeView === 'tags') {
14258
14994
  return {
14259
- contextual: ['↑/↓ tags', 'T create', 'X push', 'esc back'],
14995
+ contextual: ['↑/↓ tags', 's sort', 'T create', 'X push', 'esc back'],
14260
14996
  global: NORMAL_GLOBAL_HINTS,
14261
14997
  };
14262
14998
  }
@@ -14410,6 +15146,110 @@ function filterLogInkPaletteCommands(commands, filter, recent) {
14410
15146
  .map((entry) => entry.command);
14411
15147
  }
14412
15148
 
15149
+ /**
15150
+ * The chars `git log --graph` emits for branch topology — `*`, `|`, `\`,
15151
+ * `/`, `_`, ` `. ASCII-only output is bulletproof for legacy terminals
15152
+ * but the angles read poorly when many branches overlap.
15153
+ *
15154
+ * `substituteGraphChars` swaps them for box-drawing / geometric Unicode
15155
+ * equivalents when the terminal can render them; falls back to ASCII
15156
+ * under `theme.ascii` (TERM=dumb / vt100) and `theme.noColor` is
15157
+ * orthogonal — the Unicode chars are still rendered, just without color.
15158
+ *
15159
+ * Kept ASCII-only intentionally:
15160
+ * - alphanumerics (commit refs / annotations git sometimes injects)
15161
+ * - parens / brackets (HEAD decoration markers, not part of the graph)
15162
+ * - hyphens / colons (likewise)
15163
+ */
15164
+ const ASCII_TO_UNICODE = {
15165
+ '*': '●',
15166
+ '|': '│',
15167
+ '/': '╱',
15168
+ '\\': '╲',
15169
+ '_': '─',
15170
+ };
15171
+ function substituteGraphChars(graph, options) {
15172
+ if (options.ascii) {
15173
+ return graph;
15174
+ }
15175
+ let output = '';
15176
+ for (const character of graph) {
15177
+ output += ASCII_TO_UNICODE[character] ?? character;
15178
+ }
15179
+ return output;
15180
+ }
15181
+
15182
+ /**
15183
+ * OSC 8 hyperlink helpers for the Ink TUI (P5.1).
15184
+ *
15185
+ * Modern terminals (iTerm2, kitty, WezTerm, Ghostty, recent VS Code, Windows
15186
+ * Terminal, Alacritty, foot) recognise the OSC 8 escape sequence and turn
15187
+ * the wrapped text into a clickable / Cmd-clickable link. Older or
15188
+ * minimal terminals ignore the sequence — but a few of them render the
15189
+ * escape codes as raw garbage instead, so we feature-detect and fall back
15190
+ * to plain text rather than always emitting.
15191
+ *
15192
+ * Sequence: ESC ] 8 ; ; <url> ESC \ <text> ESC ] 8 ; ; ESC \
15193
+ */
15194
+ const ESC = '\u001b';
15195
+ const OSC_PREFIX = `${ESC}]8;;`;
15196
+ const ST = `${ESC}\\`;
15197
+ /**
15198
+ * Detect whether the host terminal will likely render OSC 8 hyperlinks.
15199
+ *
15200
+ * Conservative: only returns true for terminals that have publicly
15201
+ * confirmed support. Unknown terminals fall back to plain text — making
15202
+ * the wrap a no-op rather than risking garbage output. Set
15203
+ * `FORCE_HYPERLINK=1` to override during testing.
15204
+ */
15205
+ function supportsHyperlinks(env = process.env) {
15206
+ if (env.FORCE_HYPERLINK) {
15207
+ return env.FORCE_HYPERLINK !== '0';
15208
+ }
15209
+ // Honor NO_COLOR for parity with our color handling — users who opt out
15210
+ // of color formatting generally want a clean plain-text output too.
15211
+ if (env.NO_COLOR) {
15212
+ return false;
15213
+ }
15214
+ // kitty publishes either of these markers.
15215
+ if (env.KITTY_WINDOW_ID || env.TERM === 'xterm-kitty') {
15216
+ return true;
15217
+ }
15218
+ // Windows Terminal sets WT_SESSION.
15219
+ if (env.WT_SESSION) {
15220
+ return true;
15221
+ }
15222
+ // Ghostty exposes its resources dir.
15223
+ if (env.GHOSTTY_RESOURCES_DIR) {
15224
+ return true;
15225
+ }
15226
+ switch (env.TERM_PROGRAM) {
15227
+ case 'iTerm.app':
15228
+ case 'WezTerm':
15229
+ case 'vscode':
15230
+ case 'ghostty':
15231
+ case 'mintty':
15232
+ case 'Hyper':
15233
+ return true;
15234
+ default:
15235
+ return false;
15236
+ }
15237
+ }
15238
+ /**
15239
+ * Wrap `text` in an OSC 8 hyperlink when the host terminal supports it,
15240
+ * otherwise return the plain text unchanged. Empty / missing url falls
15241
+ * back to the plain text.
15242
+ */
15243
+ function formatHyperlink(text, url, env = process.env) {
15244
+ if (!url) {
15245
+ return text;
15246
+ }
15247
+ if (!supportsHyperlinks(env)) {
15248
+ return text;
15249
+ }
15250
+ return `${OSC_PREFIX}${url}${ST}${text}${OSC_PREFIX}${ST}`;
15251
+ }
15252
+
14413
15253
  function action(actionValue) {
14414
15254
  return {
14415
15255
  type: 'action',
@@ -14460,6 +15300,15 @@ function getLogInkPaletteExecuteEvents(command, state) {
14460
15300
  return [action({ type: 'previousSidebarTab' })];
14461
15301
  case 'nextSidebarTab':
14462
15302
  return [action({ type: 'nextSidebarTab' })];
15303
+ case 'previousHunk':
15304
+ case 'nextHunk':
15305
+ // Palette execution can't reach the live worktree/commit hunk offsets
15306
+ // (those live in runtime state, not the reducer). Surface a hint and
15307
+ // let the user press the keystroke directly in diff view.
15308
+ return [action({
15309
+ type: 'setStatus',
15310
+ value: 'open the diff view and press [ or ] to jump hunks',
15311
+ })];
14463
15312
  case 'focusNext':
14464
15313
  return [action({ type: 'focusNext' })];
14465
15314
  case 'focusPrevious':
@@ -14532,9 +15381,23 @@ function getLogInkPaletteExecuteEvents(command, state) {
14532
15381
  // Aggregate entry; individual workflows are surfaced separately.
14533
15382
  return [];
14534
15383
  case 'quit':
15384
+ if (hasUnsavedComposeDraft(state)) {
15385
+ return [action({ type: 'setPendingMutationConfirmation', value: 'discard-draft' })];
15386
+ }
14535
15387
  return [{ type: 'exit' }];
14536
15388
  case 'clearSearch':
14537
15389
  return [action({ type: 'clearFilter' })];
15390
+ case 'cycleSort':
15391
+ if (state.activeView === 'branches') {
15392
+ return [action({ type: 'cycleBranchSort' })];
15393
+ }
15394
+ if (state.activeView === 'tags') {
15395
+ return [action({ type: 'cycleTagSort' })];
15396
+ }
15397
+ return [action({
15398
+ type: 'setStatus',
15399
+ value: 'Sort cycle is available in the branches and tags views',
15400
+ })];
14538
15401
  default:
14539
15402
  return [];
14540
15403
  }
@@ -14546,8 +15409,24 @@ const SIDEBAR_TAB_BY_NUMBER = {
14546
15409
  '4': 'stashes',
14547
15410
  '5': 'worktrees',
14548
15411
  };
15412
+ /**
15413
+ * Returns true when the compose surface holds an unsaved commit message
15414
+ * (any text in summary or body and no in-flight AI draft). Used by the
15415
+ * quit confirmation flow (P2.3) so users can't lose drafts via a stray
15416
+ * `q` / Ctrl+C.
15417
+ */
15418
+ function hasUnsavedComposeDraft(state) {
15419
+ const compose = state.commitCompose;
15420
+ if (compose.loading) {
15421
+ return false;
15422
+ }
15423
+ return Boolean(compose.summary.trim() || compose.body.trim());
15424
+ }
14549
15425
  function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14550
15426
  if (key.ctrl && inputValue === 'c') {
15427
+ if (hasUnsavedComposeDraft(state) && !state.pendingMutationConfirmation) {
15428
+ return [action({ type: 'setPendingMutationConfirmation', value: 'discard-draft' })];
15429
+ }
14551
15430
  return [{ type: 'exit' }];
14552
15431
  }
14553
15432
  if (state.commitCompose.editing) {
@@ -14579,7 +15458,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14579
15458
  return [];
14580
15459
  }
14581
15460
  if (state.filterMode) {
14582
- if (key.return || key.escape) {
15461
+ if (key.return) {
15462
+ return [action({ type: 'toggleFilterMode' })];
15463
+ }
15464
+ // Two-stage Esc (P2.4 / P4.4): first Esc with a non-empty filter
15465
+ // clears the input but keeps filterMode active so the user can keep
15466
+ // typing; second Esc exits filterMode entirely. Matches vim and
15467
+ // most modal TUIs.
15468
+ if (key.escape) {
15469
+ if (state.filter.length > 0) {
15470
+ return [action({ type: 'clearFilterText' })];
15471
+ }
14583
15472
  return [action({ type: 'toggleFilterMode' })];
14584
15473
  }
14585
15474
  if (key.backspace || key.delete) {
@@ -14602,14 +15491,19 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14602
15491
  action({ type: 'setPendingConfirmation', value: undefined }),
14603
15492
  ];
14604
15493
  }
15494
+ // Destructive + provider workflow actions (delete-branch, delete-tag,
15495
+ // drop-stash, remove-worktree, abort-operation, create-pr, …) defer
15496
+ // to the runtime — it has the live context needed to identify the
15497
+ // selected item and run the right action function.
15498
+ if (workflowAction) {
15499
+ return [
15500
+ { type: 'runWorkflowAction', id: workflowAction.id },
15501
+ action({ type: 'setPendingConfirmation', value: undefined }),
15502
+ ];
15503
+ }
14605
15504
  return [
14606
15505
  action({ type: 'setPendingConfirmation', value: undefined }),
14607
- action({
14608
- type: 'setStatus',
14609
- value: workflowAction
14610
- ? `${workflowAction.label} queued for workflow execution`
14611
- : 'workflow action queued',
14612
- }),
15506
+ action({ type: 'setStatus', value: 'workflow action queued' }),
14613
15507
  ];
14614
15508
  }
14615
15509
  if (inputValue === 'n' || key.escape) {
@@ -14622,6 +15516,12 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14622
15516
  }
14623
15517
  if (state.pendingMutationConfirmation) {
14624
15518
  if (inputValue === 'y') {
15519
+ if (state.pendingMutationConfirmation === 'discard-draft') {
15520
+ return [
15521
+ action({ type: 'setPendingMutationConfirmation', value: undefined }),
15522
+ { type: 'exit' },
15523
+ ];
15524
+ }
14625
15525
  return [
14626
15526
  state.pendingMutationConfirmation === 'revert-hunk'
14627
15527
  ? { type: 'revertSelectedHunk' }
@@ -14630,9 +15530,12 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14630
15530
  ];
14631
15531
  }
14632
15532
  if (inputValue === 'n' || key.escape) {
15533
+ const cancelMessage = state.pendingMutationConfirmation === 'discard-draft'
15534
+ ? 'kept draft — press q again to quit without saving'
15535
+ : 'revert cancelled';
14633
15536
  return [
14634
15537
  action({ type: 'setPendingMutationConfirmation', value: undefined }),
14635
- action({ type: 'setStatus', value: 'revert cancelled' }),
15538
+ action({ type: 'setStatus', value: cancelMessage }),
14636
15539
  ];
14637
15540
  }
14638
15541
  return [];
@@ -14640,6 +15543,11 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14640
15543
  if (state.showCommandPalette) {
14641
15544
  const filtered = filterLogInkPaletteCommands(getLogInkPaletteCommands(), state.paletteFilter, state.paletteRecent);
14642
15545
  if (key.escape) {
15546
+ // Two-stage Esc inside the palette: first Esc with non-empty
15547
+ // input clears the filter; second Esc closes the palette. P2.4.
15548
+ if (state.paletteFilter.length > 0) {
15549
+ return [action({ type: 'clearPaletteFilter' })];
15550
+ }
14643
15551
  return [action({ type: 'toggleCommandPalette' })];
14644
15552
  }
14645
15553
  if (key.return) {
@@ -14686,6 +15594,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14686
15594
  return [action({ type: 'popView' })];
14687
15595
  }
14688
15596
  if (inputValue === 'q') {
15597
+ if (hasUnsavedComposeDraft(state)) {
15598
+ return [action({ type: 'setPendingMutationConfirmation', value: 'discard-draft' })];
15599
+ }
14689
15600
  return [{ type: 'exit' }];
14690
15601
  }
14691
15602
  if (inputValue === '?') {
@@ -14766,13 +15677,51 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14766
15677
  if (inputValue === 'r') {
14767
15678
  return [{ type: 'refreshContext' }];
14768
15679
  }
15680
+ if (inputValue === 's') {
15681
+ if (state.activeView === 'branches') {
15682
+ return [action({ type: 'cycleBranchSort' })];
15683
+ }
15684
+ if (state.activeView === 'tags') {
15685
+ return [action({ type: 'cycleTagSort' })];
15686
+ }
15687
+ // Falls through so other views (history/status/diff/compose/stash) still
15688
+ // see the literal `s` for whatever per-view bindings they may grow.
15689
+ }
14769
15690
  if (inputValue === ':') {
14770
15691
  return [action({ type: 'toggleCommandPalette' })];
14771
15692
  }
14772
15693
  if (inputValue === '[') {
15694
+ if (state.activeView === 'diff' && context.worktreeHunkOffsets?.length) {
15695
+ return [action({
15696
+ type: 'jumpWorktreeHunk',
15697
+ delta: -1,
15698
+ hunkOffsets: context.worktreeHunkOffsets,
15699
+ })];
15700
+ }
15701
+ if (state.activeView === 'diff' && context.commitDiffHunkOffsets?.length) {
15702
+ return [action({
15703
+ type: 'jumpCommitDiffHunk',
15704
+ delta: -1,
15705
+ hunkOffsets: context.commitDiffHunkOffsets,
15706
+ })];
15707
+ }
14773
15708
  return [action({ type: 'previousSidebarTab' })];
14774
15709
  }
14775
15710
  if (inputValue === ']') {
15711
+ if (state.activeView === 'diff' && context.worktreeHunkOffsets?.length) {
15712
+ return [action({
15713
+ type: 'jumpWorktreeHunk',
15714
+ delta: 1,
15715
+ hunkOffsets: context.worktreeHunkOffsets,
15716
+ })];
15717
+ }
15718
+ if (state.activeView === 'diff' && context.commitDiffHunkOffsets?.length) {
15719
+ return [action({
15720
+ type: 'jumpCommitDiffHunk',
15721
+ delta: 1,
15722
+ hunkOffsets: context.commitDiffHunkOffsets,
15723
+ })];
15724
+ }
14776
15725
  return [action({ type: 'nextSidebarTab' })];
14777
15726
  }
14778
15727
  if (SIDEBAR_TAB_BY_NUMBER[inputValue]) {
@@ -14792,11 +15741,22 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14792
15741
  fileCount: context.worktreeFileCount,
14793
15742
  })];
14794
15743
  }
15744
+ // Diff view: j/k scrolls the visible diff one line. Hunk navigation
15745
+ // moved to ]/[ so single-hunk files (longer than the preview pane)
15746
+ // can scroll bidirectionally instead of getting pinned to a hunk
15747
+ // anchor.
14795
15748
  if (state.activeView === 'diff' && context.worktreeDiffLineCount) {
14796
15749
  return [action({
14797
- type: 'jumpWorktreeHunk',
15750
+ type: 'pageWorktreeDiff',
14798
15751
  delta: -1,
14799
- hunkOffsets: context.worktreeHunkOffsets || [],
15752
+ lineCount: context.worktreeDiffLineCount,
15753
+ })];
15754
+ }
15755
+ if (state.activeView === 'diff' && context.previewLineCount) {
15756
+ return [action({
15757
+ type: 'pageDetailPreview',
15758
+ delta: -1,
15759
+ previewLineCount: context.previewLineCount,
14800
15760
  })];
14801
15761
  }
14802
15762
  if (state.activeView === 'branches' && context.branchCount) {
@@ -14808,6 +15768,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14808
15768
  if (state.activeView === 'stash' && context.stashCount) {
14809
15769
  return [action({ type: 'moveStash', delta: -1, count: context.stashCount })];
14810
15770
  }
15771
+ if (state.activeView === 'history' &&
15772
+ state.focus === 'commits' &&
15773
+ state.selectedIndex === 0 &&
15774
+ !state.pendingCommitFocused &&
15775
+ context.worktreeDirty) {
15776
+ return [action({ type: 'focusPendingCommit' })];
15777
+ }
14811
15778
  return [
14812
15779
  action(state.focus === 'sidebar'
14813
15780
  ? { type: 'previousSidebarTab' }
@@ -14815,6 +15782,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14815
15782
  ];
14816
15783
  }
14817
15784
  if (key.downArrow || inputValue === 'j') {
15785
+ if (state.activeView === 'history' && state.pendingCommitFocused) {
15786
+ return [action({ type: 'unfocusPendingCommit' })];
15787
+ }
14818
15788
  if (state.focus === 'detail' && context.detailFileCount) {
14819
15789
  return [action({ type: 'moveDetailFile', delta: 1, fileCount: context.detailFileCount })];
14820
15790
  }
@@ -14827,9 +15797,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14827
15797
  }
14828
15798
  if (state.activeView === 'diff' && context.worktreeDiffLineCount) {
14829
15799
  return [action({
14830
- type: 'jumpWorktreeHunk',
15800
+ type: 'pageWorktreeDiff',
15801
+ delta: 1,
15802
+ lineCount: context.worktreeDiffLineCount,
15803
+ })];
15804
+ }
15805
+ if (state.activeView === 'diff' && context.previewLineCount) {
15806
+ return [action({
15807
+ type: 'pageDetailPreview',
14831
15808
  delta: 1,
14832
- hunkOffsets: context.worktreeHunkOffsets || [],
15809
+ previewLineCount: context.previewLineCount,
14833
15810
  })];
14834
15811
  }
14835
15812
  if (state.activeView === 'branches' && context.branchCount) {
@@ -14855,6 +15832,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14855
15832
  lineCount: context.worktreeDiffLineCount,
14856
15833
  })];
14857
15834
  }
15835
+ if (state.activeView === 'diff' && context.previewLineCount) {
15836
+ return [action({
15837
+ type: 'pageDetailPreview',
15838
+ delta: -8,
15839
+ previewLineCount: context.previewLineCount,
15840
+ })];
15841
+ }
14858
15842
  if (state.focus === 'detail' && context.previewLineCount) {
14859
15843
  return [action({
14860
15844
  type: 'pageDetailPreview',
@@ -14872,6 +15856,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14872
15856
  lineCount: context.worktreeDiffLineCount,
14873
15857
  })];
14874
15858
  }
15859
+ if (state.activeView === 'diff' && context.previewLineCount) {
15860
+ return [action({
15861
+ type: 'pageDetailPreview',
15862
+ delta: 8,
15863
+ previewLineCount: context.previewLineCount,
15864
+ })];
15865
+ }
14875
15866
  if (state.focus === 'detail' && context.previewLineCount) {
14876
15867
  return [action({
14877
15868
  type: 'pageDetailPreview',
@@ -14881,6 +15872,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14881
15872
  }
14882
15873
  return [action({ type: 'page', delta: 10 })];
14883
15874
  }
15875
+ // Enter on the synthetic "(+) new commit" row pushes the status view so
15876
+ // the user can stage/commit. The pending flag is cleared on view push so
15877
+ // popping back lands on the real commit at index 0.
15878
+ if (key.return &&
15879
+ state.activeView === 'history' &&
15880
+ state.pendingCommitFocused) {
15881
+ return [
15882
+ action({ type: 'pushView', value: 'status' }),
15883
+ action({ type: 'setStatus', value: 'staging worktree changes' }),
15884
+ ];
15885
+ }
14884
15886
  if (key.return &&
14885
15887
  state.activeView === 'history' &&
14886
15888
  state.focus === 'commits' &&
@@ -14897,6 +15899,25 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14897
15899
  ];
14898
15900
  }
14899
15901
  }
15902
+ // From the inspector / commit-diff detail panel, Enter opens (or refocuses)
15903
+ // the diff view scoped to the currently-selected commit and file. Lets the
15904
+ // user drive the explore flow entirely from the right panel: j/k picks a
15905
+ // file, Enter opens the diff for it.
15906
+ if (key.return &&
15907
+ state.focus === 'detail' &&
15908
+ (state.activeView === 'history' || state.activeView === 'diff') &&
15909
+ context.detailFileCount &&
15910
+ state.filteredCommits.length > 0) {
15911
+ const selected = state.filteredCommits[state.selectedIndex];
15912
+ if (selected) {
15913
+ return [action({
15914
+ type: 'navigateOpenDiffForCommit',
15915
+ sha: selected.hash,
15916
+ commitIndex: state.selectedIndex,
15917
+ fileIndex: state.selectedFileIndex,
15918
+ })];
15919
+ }
15920
+ }
14900
15921
  if (key.return && state.activeView === 'status' && context.worktreeFileCount) {
14901
15922
  return [action({
14902
15923
  type: 'navigateOpenDiffForWorktreeFile',
@@ -14946,6 +15967,294 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14946
15967
  return [];
14947
15968
  }
14948
15969
 
15970
+ /**
15971
+ * Track whether the user has seen the first-launch onboarding overlay
15972
+ * (P1.3 from #756) via an XDG-friendly marker file. We persist this
15973
+ * outside of `.coco.config.json` so a fresh repo doesn't re-show the
15974
+ * tip when the user already dismissed it elsewhere.
15975
+ *
15976
+ * The marker is touched empty — its existence is the signal. Writes
15977
+ * are best-effort: filesystem failures (read-only $HOME, permissions)
15978
+ * fall back to "already seen" so we never block startup.
15979
+ */
15980
+ const MARKER_BASENAME = 'onboarding.seen';
15981
+ function resolveCacheDir() {
15982
+ const xdg = process.env.XDG_CACHE_HOME;
15983
+ if (xdg && xdg.trim().length > 0) {
15984
+ return path$1.join(xdg, 'coco');
15985
+ }
15986
+ return path$1.join(os$1.homedir(), '.cache', 'coco');
15987
+ }
15988
+ function getOnboardingMarkerPath() {
15989
+ return path$1.join(resolveCacheDir(), MARKER_BASENAME);
15990
+ }
15991
+ function hasSeenOnboarding() {
15992
+ try {
15993
+ return fs$1.existsSync(getOnboardingMarkerPath());
15994
+ }
15995
+ catch {
15996
+ // If we can't even stat the path (sandboxed env, etc.), treat the
15997
+ // user as "seen" so we don't keep showing a panel they can never
15998
+ // dismiss persistently.
15999
+ return true;
16000
+ }
16001
+ }
16002
+ function markOnboardingSeen() {
16003
+ const markerPath = getOnboardingMarkerPath();
16004
+ try {
16005
+ fs$1.mkdirSync(path$1.dirname(markerPath), { recursive: true });
16006
+ fs$1.writeFileSync(markerPath, '');
16007
+ }
16008
+ catch {
16009
+ // Best-effort persistence; swallow.
16010
+ }
16011
+ }
16012
+
16013
+ /**
16014
+ * Promoted-view selection rectification on filter changes (P4.5).
16015
+ *
16016
+ * Without this, the reducer used to snap selectedBranchIndex/Tag/Stash to 0
16017
+ * on every filter keystroke — which kept the cursor in range but lost the
16018
+ * user's place even when the previously-selected item was still in the
16019
+ * filtered result.
16020
+ *
16021
+ * The proper behavior (called out in #756 P4.5):
16022
+ * - If the previously-selected item is still in the filtered list, the
16023
+ * cursor follows it (its new index in the filtered list).
16024
+ * - If it dropped out of the filtered list, snap to result[0].
16025
+ * - If no filter change happened, leave the cursor alone.
16026
+ *
16027
+ * Implementation: the runtime computes a `PromotedSelectionsSnapshot` from
16028
+ * existing context items + the predicted next filter, attaches it to the
16029
+ * filter-mutating action, and the reducer applies the precomputed indexes.
16030
+ *
16031
+ * The rectification is a pure function over (lookup, filteredKeys); see
16032
+ * inkSelectionRectify.test.ts for the cases it covers.
16033
+ */
16034
+ /**
16035
+ * Map (filtered keys, previously-selected key) → new selected index.
16036
+ * Falls back to 0 when the key dropped out or no key was provided —
16037
+ * matching the spec's "snap to result[0]" requirement.
16038
+ *
16039
+ * The runtime is responsible for producing `filteredKeys` using the same
16040
+ * match function the renderer uses (multi-field haystacks per item).
16041
+ */
16042
+ function rectifyPromotedSelectionIndex(filteredKeys, selectedKey) {
16043
+ if (filteredKeys.length === 0) {
16044
+ return 0;
16045
+ }
16046
+ if (!selectedKey) {
16047
+ return 0;
16048
+ }
16049
+ const next = filteredKeys.indexOf(selectedKey);
16050
+ return next < 0 ? 0 : next;
16051
+ }
16052
+
16053
+ const DEFAULT_DEBOUNCE_MS = 250;
16054
+ const DEFAULT_SCHEDULER = {
16055
+ // `callback` is typed `() => void` (a function reference, never a string),
16056
+ // and `ms` is a number, so the eval-injection vector behind DevSkim
16057
+ // DS172411 doesn't apply here.
16058
+ // DevSkim: ignore DS172411
16059
+ setTimeout: (callback, ms) => setTimeout(callback, ms),
16060
+ clearTimeout: (handle) => clearTimeout(handle),
16061
+ };
16062
+ /**
16063
+ * Pure debouncer that coalesces a burst of `trigger` calls into one
16064
+ * `onSettle` invocation. Tracks the highest-severity kind across the
16065
+ * window so a fast sequence of worktree-then-HEAD changes still produces
16066
+ * a single `full` refresh.
16067
+ *
16068
+ * Extracted from the watcher so it's testable without touching `fs.watch`.
16069
+ */
16070
+ function createRefreshDebouncer(options) {
16071
+ const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
16072
+ const scheduler = options.scheduler ?? DEFAULT_SCHEDULER;
16073
+ let timer = null;
16074
+ let pendingKind = null;
16075
+ const onTimerFire = () => {
16076
+ timer = null;
16077
+ const kindToEmit = pendingKind || 'worktree';
16078
+ pendingKind = null;
16079
+ options.onSettle(kindToEmit);
16080
+ };
16081
+ const trigger = (kind) => {
16082
+ pendingKind = pendingKind === 'full' ? 'full' : kind;
16083
+ if (timer !== null) {
16084
+ scheduler.clearTimeout(timer);
16085
+ }
16086
+ // The first arg is a function value defined above (`onTimerFire`),
16087
+ // never a string, so the eval-injection vector that drives
16088
+ // DevSkim DS172411 doesn't apply here. The second arg is also our
16089
+ // own `debounceMs` constant — no caller-supplied data flows in.
16090
+ // DevSkim: ignore DS172411
16091
+ timer = scheduler.setTimeout(onTimerFire, debounceMs);
16092
+ };
16093
+ const close = () => {
16094
+ if (timer !== null) {
16095
+ scheduler.clearTimeout(timer);
16096
+ timer = null;
16097
+ }
16098
+ pendingKind = null;
16099
+ };
16100
+ return { trigger, close };
16101
+ }
16102
+ /**
16103
+ * Watch the repo's `.git` metadata + the working tree root for changes
16104
+ * that should refresh the TUI's repository context. Best-effort: missing
16105
+ * paths or platforms without `fs.watch` support degrade gracefully — the
16106
+ * user can still manually refresh with `r`.
16107
+ *
16108
+ * The watch surface is deliberately narrow:
16109
+ *
16110
+ * - `.git/index` (worktree refresh) — fires on `git add` / `rm` / `commit`
16111
+ * - `.git/HEAD` (full refresh) — fires on branch switches
16112
+ * - `.git/refs/heads` recursively (full refresh) — fires on commits to a
16113
+ * branch tip, branch creation/deletion
16114
+ * - repo root non-recursively (worktree refresh) — picks up top-level
16115
+ * create/delete/rename. Subdirectory unstaged edits do NOT trigger an
16116
+ * auto-refresh; the user can press `r` for those, which keeps watch
16117
+ * overhead negligible on large repos.
16118
+ */
16119
+ function createRefreshWatcher(options) {
16120
+ const debouncer = createRefreshDebouncer({
16121
+ debounceMs: options.debounceMs,
16122
+ onSettle: options.onChange,
16123
+ });
16124
+ const watchers = [];
16125
+ const safeWatch = (pathname, kind, watchOptions = {}) => {
16126
+ try {
16127
+ const watcher = fs$1.watch(pathname, watchOptions, () => debouncer.trigger(kind));
16128
+ // fs.watch errors at runtime (e.g. file removed) shouldn't crash the
16129
+ // TUI — the watcher is best-effort.
16130
+ watcher.on('error', () => { });
16131
+ watchers.push(watcher);
16132
+ }
16133
+ catch {
16134
+ // Path may not exist (fresh repo with no commits yet) or the platform
16135
+ // may not support fs.watch on this entry. Skip silently.
16136
+ }
16137
+ };
16138
+ safeWatch(path$1.join(options.gitDir, 'index'), 'worktree');
16139
+ safeWatch(path$1.join(options.gitDir, 'HEAD'), 'full');
16140
+ safeWatch(path$1.join(options.gitDir, 'refs', 'heads'), 'full', { recursive: true });
16141
+ safeWatch(options.repoRoot, 'worktree');
16142
+ return {
16143
+ close: () => {
16144
+ debouncer.close();
16145
+ for (const watcher of watchers) {
16146
+ try {
16147
+ watcher.close();
16148
+ }
16149
+ catch {
16150
+ // already closed; ignore
16151
+ }
16152
+ }
16153
+ watchers.length = 0;
16154
+ },
16155
+ };
16156
+ }
16157
+
16158
+ /**
16159
+ * Install panic + suspend handlers around an Ink instance so the terminal
16160
+ * never gets left in alt-screen / raw-mode / hidden-cursor state when:
16161
+ *
16162
+ * - an uncaught exception throws past Ink's render loop (P1.4)
16163
+ * - the user hits Ctrl+Z and the kernel raises SIGTSTP (P1.5)
16164
+ *
16165
+ * `dispose()` removes every registered listener so a clean exit doesn't
16166
+ * leak global handlers on subsequent runs.
16167
+ *
16168
+ * The escape sequences here are the standard ANSI/xterm trio:
16169
+ * - `\x1b[?25h` — show the cursor
16170
+ * - `\x1b[?25l` — hide the cursor
16171
+ * - `\x1b[?1049l` — exit alt screen
16172
+ * - `\x1b[?1049h` — enter alt screen
16173
+ */
16174
+ const SHOW_CURSOR = '\x1b[?25h';
16175
+ const HIDE_CURSOR = '\x1b[?25l';
16176
+ const ENTER_ALT_SCREEN = '\x1b[?1049h';
16177
+ const EXIT_ALT_SCREEN = '\x1b[?1049l';
16178
+ const tryWrite = (output, sequence) => {
16179
+ try {
16180
+ output.write(sequence);
16181
+ }
16182
+ catch {
16183
+ // stream may already be closed during shutdown; ignore
16184
+ }
16185
+ };
16186
+ const trySetRawMode = (input, value) => {
16187
+ try {
16188
+ input.setRawMode?.(value);
16189
+ }
16190
+ catch {
16191
+ // stdin may not be a TTY; ignore
16192
+ }
16193
+ };
16194
+ const tryUnmount = (instance) => {
16195
+ try {
16196
+ instance.unmount();
16197
+ }
16198
+ catch {
16199
+ // Ink may have already cleaned up; ignore
16200
+ }
16201
+ };
16202
+ function installTerminalLifecycle(options) {
16203
+ const { input, instance, output } = options;
16204
+ const restoreTerminal = () => {
16205
+ // Belt-and-suspenders: tell Ink to unmount AND write the escape
16206
+ // sequences directly. Ink's unmount handles most cases but we've
16207
+ // seen it leave artifacts when a render is in flight at panic time.
16208
+ tryUnmount(instance);
16209
+ trySetRawMode(input, false);
16210
+ tryWrite(output, `${SHOW_CURSOR}${EXIT_ALT_SCREEN}`);
16211
+ };
16212
+ const handlePanic = (error) => {
16213
+ restoreTerminal();
16214
+ if (options.onPanic) {
16215
+ options.onPanic(error);
16216
+ }
16217
+ else if (error instanceof Error) {
16218
+ process.stderr.write(`\n${error.stack || error.message}\n`);
16219
+ }
16220
+ else {
16221
+ process.stderr.write(`\n${String(error)}\n`);
16222
+ }
16223
+ // Exit with non-zero so callers (CI, scripts) see the failure.
16224
+ process.exit(1);
16225
+ };
16226
+ const onUncaughtException = (error) => handlePanic(error);
16227
+ const onUnhandledRejection = (reason) => handlePanic(reason);
16228
+ // Ctrl+Z: leave the alt screen + restore the cursor + drop raw mode
16229
+ // BEFORE the kernel actually suspends us. We don't unmount Ink — the
16230
+ // tree stays alive so SIGCONT can repaint without re-mounting.
16231
+ const onSigtstp = () => {
16232
+ trySetRawMode(input, false);
16233
+ tryWrite(output, `${SHOW_CURSOR}${EXIT_ALT_SCREEN}`);
16234
+ process.kill(process.pid, 'SIGSTOP');
16235
+ };
16236
+ // Resume: re-enter alt screen + hide cursor + raw mode back on, then
16237
+ // ask the runtime to nudge React so the user lands on a painted screen
16238
+ // instead of an empty alt buffer.
16239
+ const onSigcont = () => {
16240
+ tryWrite(output, `${ENTER_ALT_SCREEN}${HIDE_CURSOR}`);
16241
+ trySetRawMode(input, true);
16242
+ options.onResume?.();
16243
+ };
16244
+ process.on('uncaughtException', onUncaughtException);
16245
+ process.on('unhandledRejection', onUnhandledRejection);
16246
+ process.on('SIGTSTP', onSigtstp);
16247
+ process.on('SIGCONT', onSigcont);
16248
+ return {
16249
+ dispose: () => {
16250
+ process.off('uncaughtException', onUncaughtException);
16251
+ process.off('unhandledRejection', onUnhandledRejection);
16252
+ process.off('SIGTSTP', onSigtstp);
16253
+ process.off('SIGCONT', onSigcont);
16254
+ },
16255
+ };
16256
+ }
16257
+
14949
16258
  const LOG_INK_MIN_COLUMNS = 80;
14950
16259
  const LOG_INK_MIN_ROWS = 24;
14951
16260
  const LOG_INK_DEFAULT_COLUMNS = 120;
@@ -14953,16 +16262,83 @@ const LOG_INK_DEFAULT_ROWS = 40;
14953
16262
  function getLogInkLayout(input) {
14954
16263
  const columns = input.columns || LOG_INK_DEFAULT_COLUMNS;
14955
16264
  const rows = input.rows || LOG_INK_DEFAULT_ROWS;
16265
+ const detailWidth = Math.max(30, Math.min(56, Math.floor(columns * 0.34)));
16266
+ const sidebarWidth = Math.max(22, Math.min(34, Math.floor(columns * 0.24)));
14956
16267
  return {
14957
16268
  bodyRows: Math.max(8, rows - 5),
14958
16269
  columns,
14959
- detailWidth: Math.max(30, Math.min(56, Math.floor(columns * 0.34))),
16270
+ detailWidth,
16271
+ mainPanelWidth: Math.max(20, columns - sidebarWidth - detailWidth),
14960
16272
  rows,
14961
- sidebarWidth: Math.max(22, Math.min(34, Math.floor(columns * 0.24))),
16273
+ sidebarWidth,
14962
16274
  tooSmall: columns < LOG_INK_MIN_COLUMNS || rows < LOG_INK_MIN_ROWS,
14963
16275
  };
14964
16276
  }
14965
16277
 
16278
+ /**
16279
+ * Explicit color-level detection for the Ink TUI (P5.2).
16280
+ *
16281
+ * Chalk already approximates hex colors when the terminal can't render
16282
+ * truecolor — but we want an explicit signal so the catppuccin / gruvbox
16283
+ * presets (which use hex) can fall back to the ANSI-named `default` preset
16284
+ * cleanly on minimal SSH sessions, instead of relying on chalk's
16285
+ * heuristics. Users who set `NO_COLOR` or pick the `monochrome` preset
16286
+ * still get the manual override.
16287
+ *
16288
+ * Levels (matching the chalk taxonomy):
16289
+ * - 'mono' → no ANSI escapes at all (NO_COLOR / TERM=dumb)
16290
+ * - '16' → standard 16-color ANSI palette
16291
+ * - '256' → xterm-256color
16292
+ * - 'truecolor' → 24-bit RGB (COLORTERM=truecolor or known terminals)
16293
+ */
16294
+ function getColorLevel(env = process.env) {
16295
+ if (env.NO_COLOR)
16296
+ return 'mono';
16297
+ switch (env.FORCE_COLOR) {
16298
+ case '0':
16299
+ return 'mono';
16300
+ case '1':
16301
+ return '16';
16302
+ case '2':
16303
+ return '256';
16304
+ case '3':
16305
+ return 'truecolor';
16306
+ }
16307
+ const colorterm = env.COLORTERM?.toLowerCase();
16308
+ if (colorterm === 'truecolor' || colorterm === '24bit') {
16309
+ return 'truecolor';
16310
+ }
16311
+ // Modern terminal emulators that publicly advertise truecolor support.
16312
+ if (env.KITTY_WINDOW_ID || env.TERM === 'xterm-kitty') {
16313
+ return 'truecolor';
16314
+ }
16315
+ if (env.WT_SESSION) {
16316
+ return 'truecolor';
16317
+ }
16318
+ switch (env.TERM_PROGRAM) {
16319
+ case 'iTerm.app':
16320
+ case 'WezTerm':
16321
+ case 'vscode':
16322
+ case 'ghostty':
16323
+ case 'Hyper':
16324
+ return 'truecolor';
16325
+ }
16326
+ if (env.TERM === 'dumb')
16327
+ return 'mono';
16328
+ if (env.TERM?.includes('256color'))
16329
+ return '256';
16330
+ return '16';
16331
+ }
16332
+ const TRUECOLOR_PRESETS = new Set(['catppuccin', 'gruvbox']);
16333
+ /**
16334
+ * `true` when the named preset relies on hex colors that look best under
16335
+ * 24-bit RGB. Used by `createLogInkTheme` to decide whether to downgrade
16336
+ * to the ANSI-named `default` palette on lower-capability terminals.
16337
+ */
16338
+ function presetUsesTrueColor(preset) {
16339
+ return preset !== undefined && TRUECOLOR_PRESETS.has(preset);
16340
+ }
16341
+
14966
16342
  const THEME_PRESET_COLORS = {
14967
16343
  default: {
14968
16344
  accent: 'cyan',
@@ -15017,7 +16393,15 @@ function createLogInkTheme(options = {}) {
15017
16393
  const noColor = (options.noColor ?? Boolean(process.env.NO_COLOR)) ||
15018
16394
  options.preset === 'monochrome';
15019
16395
  const ascii = options.ascii ?? shouldUseAscii(options.term ?? process.env.TERM);
15020
- const preset = options.preset && options.preset !== 'monochrome' ? options.preset : 'default';
16396
+ const requestedPreset = options.preset && options.preset !== 'monochrome' ? options.preset : 'default';
16397
+ // P5.2 — gracefully downgrade hex presets (catppuccin / gruvbox) when
16398
+ // the host terminal can't render truecolor. Chalk approximates hex in
16399
+ // those modes anyway, but the default preset's ANSI-named palette
16400
+ // renders far more faithfully on 16-color terminals.
16401
+ const colorLevel = getColorLevel(options.env ?? process.env);
16402
+ const preset = !noColor && presetUsesTrueColor(requestedPreset) && colorLevel !== 'truecolor'
16403
+ ? 'default'
16404
+ : requestedPreset;
15021
16405
  const colors = noColor
15022
16406
  ? {}
15023
16407
  : {
@@ -15032,6 +16416,313 @@ function createLogInkTheme(options = {}) {
15032
16416
  };
15033
16417
  }
15034
16418
 
16419
+ /**
16420
+ * Iconography helpers for the Ink TUI surfaces.
16421
+ *
16422
+ * Letters always carry the meaning; symbols enhance. Glyphs come from the
16423
+ * Geometric Shapes / Arrows blocks (high-compat Unicode, no emoji), and all
16424
+ * helpers degrade cleanly under `theme.ascii` and `theme.noColor`.
16425
+ */
16426
+ /**
16427
+ * Format a branch's relationship to its upstream.
16428
+ * - no upstream → "no upstream"
16429
+ * - even → "even with <upstream>"
16430
+ * - divergent → "↑<ahead> ↓<behind> <upstream>" (only the non-zero side
16431
+ * is rendered so the line stays tight). ASCII mode falls back to the
16432
+ * legacy `+N/-N` form.
16433
+ */
16434
+ function formatBranchDivergence(branch, options = {}) {
16435
+ if (!branch.upstream) {
16436
+ return 'no upstream';
16437
+ }
16438
+ if (branch.ahead === 0 && branch.behind === 0) {
16439
+ return `even with ${branch.upstream}`;
16440
+ }
16441
+ if (options.ascii) {
16442
+ return `+${branch.ahead}/-${branch.behind} ${branch.upstream}`;
16443
+ }
16444
+ const parts = [];
16445
+ if (branch.ahead > 0)
16446
+ parts.push(`↑${branch.ahead}`);
16447
+ if (branch.behind > 0)
16448
+ parts.push(`↓${branch.behind}`);
16449
+ return `${parts.join(' ')} ${branch.upstream}`;
16450
+ }
16451
+ /**
16452
+ * Single-cell marker shown to the left of a branch name in lists.
16453
+ * `*` = current, `◌` = no upstream (detached from a remote), space otherwise.
16454
+ */
16455
+ function branchRowMarker(branch, options = {}) {
16456
+ if (branch.current)
16457
+ return '*';
16458
+ if (!branch.upstream)
16459
+ return options.ascii ? '?' : '◌';
16460
+ return ' ';
16461
+ }
16462
+ /**
16463
+ * Pick the glyph + color for a PR state badge.
16464
+ * Returns an empty glyph under ASCII mode so the textual state (OPEN /
16465
+ * MERGED / DRAFT / CLOSED) carries the meaning alone.
16466
+ */
16467
+ function getPullRequestStateGlyph(pr, theme) {
16468
+ if (theme.ascii) {
16469
+ return { glyph: '', color: undefined, dim: false };
16470
+ }
16471
+ if (pr.isDraft) {
16472
+ return { glyph: '◇', color: undefined, dim: true };
16473
+ }
16474
+ switch (pr.state.toUpperCase()) {
16475
+ case 'OPEN':
16476
+ return { glyph: '◉', color: theme.colors.success, dim: false };
16477
+ case 'MERGED':
16478
+ return { glyph: '●', color: theme.noColor ? undefined : 'magenta', dim: false };
16479
+ case 'CLOSED':
16480
+ return { glyph: '×', color: theme.colors.danger, dim: false };
16481
+ default:
16482
+ return { glyph: '·', color: undefined, dim: true };
16483
+ }
16484
+ }
16485
+ /**
16486
+ * Color for the leading dot in a status row. `undefined` means "skip the
16487
+ * dot" — under noColor or ascii mode the dot carries no information so the
16488
+ * raw porcelain codes (M / ?? / etc.) and the textual state carry meaning
16489
+ * alone.
16490
+ */
16491
+ function getStageStatusDotColor(state, theme) {
16492
+ if (theme.noColor || theme.ascii)
16493
+ return undefined;
16494
+ switch (state) {
16495
+ case 'unstaged':
16496
+ return theme.colors.danger;
16497
+ case 'staged':
16498
+ return theme.colors.warning;
16499
+ case 'untracked':
16500
+ return theme.colors.muted;
16501
+ default:
16502
+ return undefined;
16503
+ }
16504
+ }
16505
+ const STAGE_STATUS_DOT = '●';
16506
+ /**
16507
+ * Count to show next to a sidebar tab name, or `undefined` when the
16508
+ * underlying data has not loaded yet (so the label renders without a `(N)`
16509
+ * rather than a misleading `(0)`).
16510
+ */
16511
+ function sidebarTabCount(tab, context) {
16512
+ switch (tab) {
16513
+ case 'status':
16514
+ return context.worktree?.files.length;
16515
+ case 'branches':
16516
+ return context.branches?.localBranches.length;
16517
+ case 'tags':
16518
+ return context.tags?.tags.length;
16519
+ case 'stashes':
16520
+ return context.stashes?.stashes.length;
16521
+ case 'worktrees':
16522
+ return context.worktreeList?.worktrees.length;
16523
+ default:
16524
+ return undefined;
16525
+ }
16526
+ }
16527
+
16528
+ /**
16529
+ * Idle status-line tip rotation (P4.3).
16530
+ *
16531
+ * Off by default; opt-in via `logTui.idleTips: true`. The runtime drives a
16532
+ * tick counter that this module turns into a tip — pure mapping so the
16533
+ * cadence + content can be tested without spinning React or timers.
16534
+ *
16535
+ * Convention:
16536
+ * - tickIndex 0 → no tip (initial grace, before the first idle window).
16537
+ * - tickIndex N>0 → IDLE_TIPS[(N - 1) % IDLE_TIPS.length].
16538
+ *
16539
+ * The runtime keeps tickIndex at 0 whenever the user is active or
16540
+ * `state.statusMessage` is non-empty, so the tip only appears during true
16541
+ * idle stretches.
16542
+ */
16543
+ const IDLE_TIPS = [
16544
+ 'press : to search every command',
16545
+ 'g h returns home from anywhere',
16546
+ '/ filters the active view',
16547
+ 'press ? to see the full keymap',
16548
+ 's cycles sort modes in branches and tags',
16549
+ 'gz opens the stash view',
16550
+ '< or esc walks the navigation stack back',
16551
+ ];
16552
+ /**
16553
+ * Threshold (in ms) of idle time before the first tip appears. Picked at 10s
16554
+ * to match the spec in #756 — long enough that an active user never sees
16555
+ * one, short enough to be useful when the user genuinely paused.
16556
+ */
16557
+ const IDLE_TIPS_GRACE_MS = 10000;
16558
+ /** Cadence between subsequent tips in ms. */
16559
+ const IDLE_TIPS_INTERVAL_MS = 8000;
16560
+ function pickIdleTip(tickIndex) {
16561
+ if (tickIndex <= 0)
16562
+ return undefined;
16563
+ if (IDLE_TIPS.length === 0)
16564
+ return undefined;
16565
+ return IDLE_TIPS[(tickIndex - 1) % IDLE_TIPS.length];
16566
+ }
16567
+
16568
+ /**
16569
+ * Preview-pane content formatters for the promoted views (P4.1).
16570
+ *
16571
+ * Each formatter turns an existing context entry into a list of lines the
16572
+ * detail panel renders on the right. Pure — no git calls, no React — so the
16573
+ * shape is easy to assert in unit tests and the renderer stays a simple map
16574
+ * over the result.
16575
+ *
16576
+ * Designed to mirror what `lazygit` / `yazi` show in their preview pane:
16577
+ * the answer to "what am I about to act on" without forcing a checkout / show.
16578
+ */
16579
+ const heading = (text) => ({ text, emphasis: 'heading' });
16580
+ const dim = (text) => ({ text, emphasis: 'dim' });
16581
+ const line = (text) => ({ text });
16582
+ const blank = () => ({ text: '' });
16583
+ function shortHash(hash) {
16584
+ return hash ? hash.slice(0, 7) : '<none>';
16585
+ }
16586
+ /* ------------------------------- branch -------------------------------- */
16587
+ function describeBranchDivergence(branch) {
16588
+ if (branch.ahead === 0 && branch.behind === 0) {
16589
+ return 'in sync';
16590
+ }
16591
+ return `${branch.ahead} ahead, ${branch.behind} behind`;
16592
+ }
16593
+ function formatBranchPreview(branch) {
16594
+ if (!branch) {
16595
+ return [dim('Select a branch to preview.')];
16596
+ }
16597
+ const out = [
16598
+ heading(branch.shortName),
16599
+ blank(),
16600
+ line(`Tip: ${shortHash(branch.hash)}`),
16601
+ line(`Date: ${branch.date || '<unknown>'}`),
16602
+ line(`Subject: ${branch.subject || '<no subject>'}`),
16603
+ blank(),
16604
+ ];
16605
+ if (branch.upstream) {
16606
+ out.push(line(`Upstream: ${branch.upstream}`));
16607
+ out.push(line(`Status: ${describeBranchDivergence(branch)}`));
16608
+ }
16609
+ else {
16610
+ out.push(dim('No upstream tracking.'));
16611
+ }
16612
+ if (branch.current) {
16613
+ out.push(blank());
16614
+ out.push(dim('* current branch'));
16615
+ }
16616
+ return out;
16617
+ }
16618
+ /* --------------------------------- tag --------------------------------- */
16619
+ function formatTagPreview(tag) {
16620
+ if (!tag) {
16621
+ return [dim('Select a tag to preview.')];
16622
+ }
16623
+ return [
16624
+ heading(tag.name),
16625
+ blank(),
16626
+ line(`Commit: ${shortHash(tag.hash)}`),
16627
+ line(`Date: ${tag.date || '<unknown>'}`),
16628
+ blank(),
16629
+ line('Subject:'),
16630
+ line(` ${tag.subject || '<no subject>'}`),
16631
+ ];
16632
+ }
16633
+ function formatStashPreview(stash, options = {}) {
16634
+ if (!stash) {
16635
+ return [dim('Select a stash to preview.')];
16636
+ }
16637
+ const cap = options.fileCap ?? 10;
16638
+ const out = [
16639
+ heading(stash.ref),
16640
+ blank(),
16641
+ line(`On: ${stash.branch || '<unknown>'}`),
16642
+ line(`Commit: ${shortHash(stash.hash)}`),
16643
+ line(`Date: ${stash.date || '<unknown>'}`),
16644
+ blank(),
16645
+ line('Message:'),
16646
+ line(` ${stash.message || '<no message>'}`),
16647
+ ];
16648
+ const files = stash.files || [];
16649
+ if (files.length > 0) {
16650
+ out.push(blank());
16651
+ out.push(line(`Files (${files.length}):`));
16652
+ files.slice(0, cap).forEach((path) => out.push(line(` ${path}`)));
16653
+ if (files.length > cap) {
16654
+ out.push(dim(` … ${files.length - cap} more`));
16655
+ }
16656
+ }
16657
+ else {
16658
+ out.push(blank());
16659
+ out.push(dim('No files in stash.'));
16660
+ }
16661
+ return out;
16662
+ }
16663
+
16664
+ /**
16665
+ * Sort modes for the promoted views (P4.2).
16666
+ *
16667
+ * Pure: takes existing context entries + the active mode, returns a sorted
16668
+ * copy. Tested in isolation; the runtime just calls these helpers.
16669
+ *
16670
+ * Display label uses `▼` (U+25BC) under truecolor / UTF-8 and falls back to
16671
+ * `v` under ASCII. Letters (`recent` / `name` / `ahead`) carry meaning;
16672
+ * shape enhances.
16673
+ */
16674
+ const BRANCH_SORT_MODES = ['name', 'recent', 'ahead'];
16675
+ const DEFAULT_BRANCH_SORT_MODE = 'name';
16676
+ function cycleBranchSort(mode) {
16677
+ const index = BRANCH_SORT_MODES.indexOf(mode);
16678
+ if (index < 0)
16679
+ return BRANCH_SORT_MODES[0];
16680
+ return BRANCH_SORT_MODES[(index + 1) % BRANCH_SORT_MODES.length];
16681
+ }
16682
+ function sortBranches(branches, mode) {
16683
+ const copy = branches.slice();
16684
+ switch (mode) {
16685
+ case 'name':
16686
+ return copy.sort((a, b) => a.shortName.localeCompare(b.shortName));
16687
+ case 'recent':
16688
+ // ISO-shaped dates compare byte-for-byte; descending so the freshest
16689
+ // branch sits at the top.
16690
+ return copy.sort((a, b) => (b.date || '').localeCompare(a.date || '') ||
16691
+ a.shortName.localeCompare(b.shortName));
16692
+ case 'ahead':
16693
+ // ahead-first; ties broken by behind, then by name. Keeps "this branch
16694
+ // has unmerged work" in the user's first scroll.
16695
+ return copy.sort((a, b) => b.ahead - a.ahead || b.behind - a.behind ||
16696
+ a.shortName.localeCompare(b.shortName));
16697
+ default:
16698
+ return copy;
16699
+ }
16700
+ }
16701
+ const TAG_SORT_MODES = ['recent', 'name'];
16702
+ const DEFAULT_TAG_SORT_MODE = 'recent';
16703
+ function cycleTagSort(mode) {
16704
+ const index = TAG_SORT_MODES.indexOf(mode);
16705
+ if (index < 0)
16706
+ return TAG_SORT_MODES[0];
16707
+ return TAG_SORT_MODES[(index + 1) % TAG_SORT_MODES.length];
16708
+ }
16709
+ function sortTags(tags, mode) {
16710
+ const copy = tags.slice();
16711
+ switch (mode) {
16712
+ case 'name':
16713
+ return copy.sort((a, b) => a.name.localeCompare(b.name));
16714
+ case 'recent':
16715
+ return copy.sort((a, b) => (b.date || '').localeCompare(a.date || '') ||
16716
+ a.name.localeCompare(b.name));
16717
+ default:
16718
+ return copy;
16719
+ }
16720
+ }
16721
+ /* ---------------------------- header indicator -------------------------- */
16722
+ function formatSortIndicator(mode, options = {}) {
16723
+ return `${options.ascii ? 'v' : '▼'} ${mode}`;
16724
+ }
16725
+
15035
16726
  /**
15036
16727
  * Empty- and loading-state messages for each TUI surface.
15037
16728
  *
@@ -15126,6 +16817,73 @@ function characterWidth(character) {
15126
16817
  function cellWidth(value) {
15127
16818
  return Array.from(value).reduce((width, character) => width + characterWidth(character), 0);
15128
16819
  }
16820
+ /**
16821
+ * Word-wrap `value` into lines that each fit within `width` cells. Breaks
16822
+ * on whitespace where possible; falls back to mid-word splits when a single
16823
+ * word is wider than the budget. Preserves blank input as a single empty
16824
+ * line so `value.split('\n').flatMap(wrapCells)` round-trips cleanly.
16825
+ */
16826
+ function wrapCells(value, width) {
16827
+ if (width < 1) {
16828
+ return [value];
16829
+ }
16830
+ if (cellWidth(value) <= width) {
16831
+ return [value];
16832
+ }
16833
+ const lines = [];
16834
+ let current = '';
16835
+ let currentWidth = 0;
16836
+ const flush = () => {
16837
+ if (current.length > 0) {
16838
+ lines.push(current);
16839
+ current = '';
16840
+ currentWidth = 0;
16841
+ }
16842
+ };
16843
+ // Tokenize into runs of whitespace + non-whitespace so we can keep word
16844
+ // boundaries when possible.
16845
+ const tokens = value.match(/\s+|\S+/g) || [];
16846
+ for (const token of tokens) {
16847
+ const tokenWidth = cellWidth(token);
16848
+ if (currentWidth + tokenWidth <= width) {
16849
+ current += token;
16850
+ currentWidth += tokenWidth;
16851
+ continue;
16852
+ }
16853
+ if (/^\s+$/.test(token)) {
16854
+ // Drop boundary whitespace at line breaks.
16855
+ flush();
16856
+ continue;
16857
+ }
16858
+ flush();
16859
+ if (tokenWidth <= width) {
16860
+ current = token;
16861
+ currentWidth = tokenWidth;
16862
+ continue;
16863
+ }
16864
+ // Word longer than budget — hard-split into chunks.
16865
+ let remaining = token;
16866
+ while (cellWidth(remaining) > width) {
16867
+ let chunk = '';
16868
+ let chunkWidth = 0;
16869
+ for (const character of Array.from(remaining)) {
16870
+ const charW = characterWidth(character);
16871
+ if (chunkWidth + charW > width)
16872
+ break;
16873
+ chunk += character;
16874
+ chunkWidth += charW;
16875
+ }
16876
+ lines.push(chunk);
16877
+ remaining = remaining.slice(chunk.length);
16878
+ }
16879
+ if (remaining.length > 0) {
16880
+ current = remaining;
16881
+ currentWidth = cellWidth(remaining);
16882
+ }
16883
+ }
16884
+ flush();
16885
+ return lines.length > 0 ? lines : [value];
16886
+ }
15129
16887
  function truncateCells(value, width) {
15130
16888
  if (width < 1) {
15131
16889
  return '';
@@ -15247,6 +17005,8 @@ function withPushedView(state, value) {
15247
17005
  viewStack,
15248
17006
  worktreeDiffOffset: value === 'diff' ? state.worktreeDiffOffset : 0,
15249
17007
  selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
17008
+ diffSource: value === 'diff' ? state.diffSource : undefined,
17009
+ pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
15250
17010
  pendingKey: undefined,
15251
17011
  };
15252
17012
  }
@@ -15262,6 +17022,8 @@ function withPoppedView(state) {
15262
17022
  viewStack,
15263
17023
  worktreeDiffOffset: next === 'diff' ? state.worktreeDiffOffset : 0,
15264
17024
  selectedWorktreeHunkIndex: next === 'diff' ? state.selectedWorktreeHunkIndex : 0,
17025
+ diffSource: next === 'diff' ? state.diffSource : undefined,
17026
+ pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
15265
17027
  pendingKey: undefined,
15266
17028
  };
15267
17029
  }
@@ -15276,17 +17038,35 @@ function withReplacedView(state, value) {
15276
17038
  viewStack,
15277
17039
  worktreeDiffOffset: value === 'diff' ? state.worktreeDiffOffset : 0,
15278
17040
  selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
17041
+ diffSource: value === 'diff' ? state.diffSource : undefined,
17042
+ pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
15279
17043
  pendingKey: undefined,
15280
17044
  };
15281
17045
  }
15282
- function withFilter$1(state, filter) {
17046
+ function withFilter$1(state, filter, promotedSelections) {
15283
17047
  const filteredCommits = filterCommits$1(state.commits, filter);
17048
+ // P4.5: rectify promoted-view selections when the filter changes. Prefer
17049
+ // the runtime-supplied snapshot — which preserves the cursor on the same
17050
+ // item when it's still in the filtered list and only snaps to result[0]
17051
+ // when the previously-selected item dropped out. Falls back to the older
17052
+ // "snap to 0" behavior when no snapshot was provided (test paths,
17053
+ // dispatchers without context).
17054
+ const filterChanged = state.filter !== filter;
17055
+ const branchIndex = promotedSelections?.branchIndex ??
17056
+ (filterChanged ? 0 : state.selectedBranchIndex);
17057
+ const tagIndex = promotedSelections?.tagIndex ??
17058
+ (filterChanged ? 0 : state.selectedTagIndex);
17059
+ const stashIndex = promotedSelections?.stashIndex ??
17060
+ (filterChanged ? 0 : state.selectedStashIndex);
15284
17061
  return {
15285
17062
  ...state,
15286
17063
  filter,
15287
17064
  filteredCommits,
15288
17065
  selectedIndex: clampIndex$1(state.selectedIndex, filteredCommits.length),
15289
17066
  selectedFileIndex: 0,
17067
+ selectedBranchIndex: branchIndex,
17068
+ selectedTagIndex: tagIndex,
17069
+ selectedStashIndex: stashIndex,
15290
17070
  diffPreviewOffset: 0,
15291
17071
  pendingKey: undefined,
15292
17072
  };
@@ -15322,11 +17102,11 @@ function nextHunkOffset(currentOffset, hunkOffsets, delta) {
15322
17102
  return currentOffset;
15323
17103
  }
15324
17104
  if (delta > 0) {
15325
- return hunkOffsets.find((offset) => offset > currentOffset) ||
15326
- hunkOffsets[hunkOffsets.length - 1];
17105
+ const nextOffset = hunkOffsets.find((offset) => offset > currentOffset);
17106
+ return nextOffset === undefined ? currentOffset : nextOffset;
15327
17107
  }
15328
- return [...hunkOffsets].reverse().find((offset) => offset < currentOffset) ||
15329
- hunkOffsets[0];
17108
+ const previousOffset = [...hunkOffsets].reverse().find((offset) => offset < currentOffset);
17109
+ return previousOffset === undefined ? currentOffset : previousOffset;
15330
17110
  }
15331
17111
  function nextHunkIndex(currentOffset, hunkOffsets, delta) {
15332
17112
  const offset = nextHunkOffset(currentOffset, hunkOffsets, delta);
@@ -15351,6 +17131,8 @@ function createLogInkState(rows, options = {}) {
15351
17131
  selectedBranchIndex: 0,
15352
17132
  selectedTagIndex: 0,
15353
17133
  selectedStashIndex: 0,
17134
+ branchSort: DEFAULT_BRANCH_SORT_MODE,
17135
+ tagSort: DEFAULT_TAG_SORT_MODE,
15354
17136
  paletteFilter: '',
15355
17137
  paletteSelectedIndex: 0,
15356
17138
  paletteRecent: [],
@@ -15371,6 +17153,12 @@ function createLogInkState(rows, options = {}) {
15371
17153
  };
15372
17154
  }
15373
17155
  function getSelectedInkCommit(state) {
17156
+ if (state.pendingCommitFocused) {
17157
+ // The cursor is on the synthetic "(+) new commit" row, not a real
17158
+ // commit; callers (detail loaders, diff intents) should treat this as
17159
+ // "no commit selected" and route to the worktree summary instead.
17160
+ return undefined;
17161
+ }
15374
17162
  return state.filteredCommits[state.selectedIndex];
15375
17163
  }
15376
17164
  function applyLogInkAction(state, action) {
@@ -15378,14 +17166,18 @@ function applyLogInkAction(state, action) {
15378
17166
  case 'appendRows':
15379
17167
  return appendRows(state, action.rows);
15380
17168
  case 'appendFilter':
15381
- return withFilter$1(state, `${state.filter}${action.value}`);
17169
+ return withFilter$1(state, `${state.filter}${action.value}`, action.promotedSelections);
15382
17170
  case 'backspaceFilter':
15383
- return withFilter$1(state, state.filter.slice(0, -1));
17171
+ return withFilter$1(state, state.filter.slice(0, -1), action.promotedSelections);
15384
17172
  case 'clearFilter':
15385
17173
  return withFilter$1({
15386
17174
  ...state,
15387
17175
  filterMode: false,
15388
- }, '');
17176
+ }, '', action.promotedSelections);
17177
+ case 'clearFilterText':
17178
+ // Clears the filter input but stays in filterMode so the user can
17179
+ // keep typing. P2.4 / P4.4: pairs with the two-stage Esc semantics.
17180
+ return withFilter$1(state, '', action.promotedSelections);
15389
17181
  case 'commitCompose':
15390
17182
  return {
15391
17183
  ...state,
@@ -15410,6 +17202,24 @@ function applyLogInkAction(state, action) {
15410
17202
  selectedIndex: clampIndex$1(state.selectedIndex + action.delta, state.filteredCommits.length),
15411
17203
  selectedFileIndex: 0,
15412
17204
  diffPreviewOffset: 0,
17205
+ pendingCommitFocused: false,
17206
+ pendingKey: undefined,
17207
+ };
17208
+ case 'focusPendingCommit':
17209
+ return {
17210
+ ...state,
17211
+ pendingCommitFocused: true,
17212
+ selectedFileIndex: 0,
17213
+ diffPreviewOffset: 0,
17214
+ pendingKey: undefined,
17215
+ };
17216
+ case 'unfocusPendingCommit':
17217
+ return {
17218
+ ...state,
17219
+ pendingCommitFocused: false,
17220
+ selectedIndex: 0,
17221
+ selectedFileIndex: 0,
17222
+ diffPreviewOffset: 0,
15413
17223
  pendingKey: undefined,
15414
17224
  };
15415
17225
  case 'moveDetailFile':
@@ -15446,12 +17256,29 @@ function applyLogInkAction(state, action) {
15446
17256
  selectedStashIndex: clampIndex$1(state.selectedStashIndex + action.delta, action.count),
15447
17257
  pendingKey: undefined,
15448
17258
  };
17259
+ case 'cycleBranchSort':
17260
+ return {
17261
+ ...state,
17262
+ branchSort: cycleBranchSort(state.branchSort),
17263
+ // Snap to the top of the (newly ordered) list so the user always
17264
+ // sees what's now most relevant under the new mode.
17265
+ selectedBranchIndex: 0,
17266
+ pendingKey: undefined,
17267
+ };
17268
+ case 'cycleTagSort':
17269
+ return {
17270
+ ...state,
17271
+ tagSort: cycleTagSort(state.tagSort),
17272
+ selectedTagIndex: 0,
17273
+ pendingKey: undefined,
17274
+ };
15449
17275
  case 'moveToBottom':
15450
17276
  return {
15451
17277
  ...state,
15452
17278
  selectedIndex: clampIndex$1(state.filteredCommits.length - 1, state.filteredCommits.length),
15453
17279
  selectedFileIndex: 0,
15454
17280
  diffPreviewOffset: 0,
17281
+ pendingCommitFocused: false,
15455
17282
  pendingKey: undefined,
15456
17283
  };
15457
17284
  case 'moveToTop':
@@ -15460,6 +17287,7 @@ function applyLogInkAction(state, action) {
15460
17287
  selectedIndex: 0,
15461
17288
  selectedFileIndex: 0,
15462
17289
  diffPreviewOffset: 0,
17290
+ pendingCommitFocused: false,
15463
17291
  pendingKey: undefined,
15464
17292
  };
15465
17293
  case 'nextSidebarTab':
@@ -15495,6 +17323,12 @@ function applyLogInkAction(state, action) {
15495
17323
  selectedWorktreeHunkIndex: nextHunkIndex(state.worktreeDiffOffset, action.hunkOffsets, action.delta),
15496
17324
  pendingKey: undefined,
15497
17325
  };
17326
+ case 'jumpCommitDiffHunk':
17327
+ return {
17328
+ ...state,
17329
+ diffPreviewOffset: nextHunkOffset(state.diffPreviewOffset, action.hunkOffsets, action.delta),
17330
+ pendingKey: undefined,
17331
+ };
15498
17332
  case 'previousSidebarTab':
15499
17333
  return {
15500
17334
  ...state,
@@ -15502,7 +17336,7 @@ function applyLogInkAction(state, action) {
15502
17336
  pendingKey: undefined,
15503
17337
  };
15504
17338
  case 'setFilter':
15505
- return withFilter$1(state, action.value);
17339
+ return withFilter$1(state, action.value, action.promotedSelections);
15506
17340
  case 'setActiveView':
15507
17341
  return withReplacedView(state, action.value);
15508
17342
  case 'pushView':
@@ -15521,6 +17355,7 @@ function applyLogInkAction(state, action) {
15521
17355
  viewStack: [HOME_VIEW],
15522
17356
  worktreeDiffOffset: 0,
15523
17357
  selectedWorktreeHunkIndex: 0,
17358
+ pendingCommitFocused: false,
15524
17359
  pendingKey: undefined,
15525
17360
  };
15526
17361
  }
@@ -15532,8 +17367,9 @@ function applyLogInkAction(state, action) {
15532
17367
  return {
15533
17368
  ...next,
15534
17369
  selectedIndex: clampIndex$1(selectedIndex, filteredCommits.length),
15535
- selectedFileIndex: 0,
17370
+ selectedFileIndex: Math.max(0, action.fileIndex ?? 0),
15536
17371
  diffPreviewOffset: 0,
17372
+ diffSource: 'commit',
15537
17373
  };
15538
17374
  }
15539
17375
  case 'navigateOpenDiffForWorktreeFile': {
@@ -15543,6 +17379,7 @@ function applyLogInkAction(state, action) {
15543
17379
  selectedWorktreeFileIndex: Math.max(0, action.fileIndex),
15544
17380
  selectedWorktreeHunkIndex: 0,
15545
17381
  worktreeDiffOffset: 0,
17382
+ diffSource: 'worktree',
15546
17383
  };
15547
17384
  }
15548
17385
  case 'navigateOpenComposeForFile': {
@@ -15755,7 +17592,7 @@ function formatCapturedAiOutput(output) {
15755
17592
  }
15756
17593
  async function runChangelogAction(argv) {
15757
17594
  try {
15758
- const output = await captureStdout(() => handler$6(argv, new Logger({
17595
+ const output = await captureStdout(() => handler$7(argv, new Logger({
15759
17596
  verbose: true,
15760
17597
  silent: false,
15761
17598
  })));
@@ -17196,7 +19033,7 @@ function truncate$2(value, width) {
17196
19033
  }
17197
19034
  return `${value.slice(0, width - 3)}...`;
17198
19035
  }
17199
- function formatChangedFile$1(file) {
19036
+ function formatChangedFile(file) {
17200
19037
  if (file.oldPath) {
17201
19038
  return `${file.status} ${file.oldPath} -> ${file.path}`;
17202
19039
  }
@@ -17224,7 +19061,7 @@ function renderCommitList(state, maxRows, width) {
17224
19061
  return truncate$2(row, width);
17225
19062
  });
17226
19063
  }
17227
- function formatDivergence$1(branch) {
19064
+ function formatDivergence(branch) {
17228
19065
  if (!branch.upstream) {
17229
19066
  return 'no upstream';
17230
19067
  }
@@ -17252,7 +19089,7 @@ function renderBranchOverview(overview, ui, width) {
17252
19089
  .map((branch) => {
17253
19090
  const marker = branch.current ? '*' : ' ';
17254
19091
  const selected = isBranchFocused && selectedBranches[ui.branchIndex || 0] === branch ? '>' : ' ';
17255
- return `${selected}${marker} ${branch.shortName} ${formatDivergence$1(branch)}`;
19092
+ return `${selected}${marker} ${branch.shortName} ${formatDivergence(branch)}`;
17256
19093
  });
17257
19094
  const remoteBranches = overview.remoteBranches.slice(0, 6).map((branch) => {
17258
19095
  const selected = isBranchFocused && selectedBranches[ui.branchIndex || 0] === branch ? '>' : ' ';
@@ -17266,7 +19103,7 @@ function renderBranchOverview(overview, ui, width) {
17266
19103
  : [];
17267
19104
  return [
17268
19105
  `Branches: ${overview.currentBranch || '<detached>'} | ${dirty}`,
17269
- current ? `Upstream: ${formatDivergence$1(current)}` : 'Upstream: none',
19106
+ current ? `Upstream: ${formatDivergence(current)}` : 'Upstream: none',
17270
19107
  ui.pendingDeleteBranch
17271
19108
  ? `Pending delete: press D to delete ${ui.pendingDeleteBranch}`
17272
19109
  : 'Branch actions: tab focus | enter checkout/track | f fetch | p push | P pull | d delete',
@@ -17519,7 +19356,7 @@ function renderDetail(detail, width) {
17519
19356
  const refs = detail.refs.length ? ` (${detail.refs.join(', ')})` : '';
17520
19357
  const body = detail.body ? ['', ...detail.body.split('\n').map((line) => ` ${line}`)] : [];
17521
19358
  const files = detail.files.length
17522
- ? detail.files.slice(0, 12).map((file) => ` ${formatChangedFile$1(file)}`)
19359
+ ? detail.files.slice(0, 12).map((file) => ` ${formatChangedFile(file)}`)
17523
19360
  : [' No changed files found.'];
17524
19361
  const hiddenFiles = detail.files.length > 12
17525
19362
  ? [` ... ${detail.files.length - 12} more file(s)`]
@@ -18873,18 +20710,6 @@ const truncate$1 = truncateCells;
18873
20710
  function compactHash(hash) {
18874
20711
  return hash ? hash.slice(0, 7) : '<none>';
18875
20712
  }
18876
- function formatChangedFile(file) {
18877
- const stats = file.binary
18878
- ? 'bin'
18879
- : file.additions !== undefined || file.deletions !== undefined
18880
- ? `+${file.additions || 0}/-${file.deletions || 0}`
18881
- : '';
18882
- const suffix = stats ? ` ${stats}` : '';
18883
- if (file.oldPath) {
18884
- return `${file.status} ${file.oldPath} -> ${file.path}${suffix}`;
18885
- }
18886
- return `${file.status} ${file.path}${suffix}`;
18887
- }
18888
20713
  async function safe(promise) {
18889
20714
  try {
18890
20715
  return await promise;
@@ -18970,6 +20795,70 @@ function focusBorderColor(theme, focused) {
18970
20795
  function panelTitle(title, focused) {
18971
20796
  return focused ? `${title} *` : title;
18972
20797
  }
20798
+ /**
20799
+ * Map a unified-diff line to the props passed to an Ink `<Text>` so the
20800
+ * standard +/-/@@ prefixes render in their conventional colors. File
20801
+ * headers (`+++`, `---`, `diff --git`, `index`) get a softer treatment so
20802
+ * they don't compete with the actual hunk content.
20803
+ *
20804
+ * `theme.noColor` collapses everything to dim/normal so we stay readable
20805
+ * under `NO_COLOR` and the `monochrome` preset.
20806
+ */
20807
+ function diffLineProps(line, theme) {
20808
+ if (theme.noColor) {
20809
+ return { dimColor: line.startsWith(' ') || line.startsWith('diff ') || line.startsWith('index ') };
20810
+ }
20811
+ if (line.startsWith('diff ') || line.startsWith('index ') || line.startsWith('+++') || line.startsWith('---')) {
20812
+ return { dimColor: true };
20813
+ }
20814
+ if (line.startsWith('@@')) {
20815
+ return { color: theme.colors.accent };
20816
+ }
20817
+ if (line.startsWith('+')) {
20818
+ return { color: theme.colors.gitAdded };
20819
+ }
20820
+ if (line.startsWith('-')) {
20821
+ return { color: theme.colors.gitDeleted };
20822
+ }
20823
+ return {};
20824
+ }
20825
+ /**
20826
+ * Pick a theme color for a single name-status code (`A`, `M`, `D`,
20827
+ * `R100`, etc.) so the inspector and commit-diff file list render with
20828
+ * familiar git colors at a glance. Letters stay in the line so the
20829
+ * meaning survives `NO_COLOR`.
20830
+ */
20831
+ function statusCodeColor(status, theme) {
20832
+ if (theme.noColor) {
20833
+ return undefined;
20834
+ }
20835
+ const head = status.charAt(0);
20836
+ switch (head) {
20837
+ case 'A':
20838
+ return theme.colors.gitAdded;
20839
+ case 'D':
20840
+ return theme.colors.gitDeleted;
20841
+ case 'U':
20842
+ return theme.colors.danger;
20843
+ case 'M':
20844
+ case 'T':
20845
+ return theme.colors.gitModified;
20846
+ case 'R':
20847
+ case 'C':
20848
+ return theme.colors.accent;
20849
+ default:
20850
+ return undefined;
20851
+ }
20852
+ }
20853
+ function formatChangedFileStats(file) {
20854
+ if (file.binary) {
20855
+ return 'bin';
20856
+ }
20857
+ if (file.additions === undefined && file.deletions === undefined) {
20858
+ return '';
20859
+ }
20860
+ return `+${file.additions || 0}/-${file.deletions || 0}`;
20861
+ }
18973
20862
  function sidebarTabLabel(tab) {
18974
20863
  switch (tab) {
18975
20864
  case 'status':
@@ -18986,16 +20875,7 @@ function sidebarTabLabel(tab) {
18986
20875
  return tab;
18987
20876
  }
18988
20877
  }
18989
- function formatDivergence(branch) {
18990
- if (!branch.upstream) {
18991
- return 'no upstream';
18992
- }
18993
- if (branch.ahead === 0 && branch.behind === 0) {
18994
- return `even with ${branch.upstream}`;
18995
- }
18996
- return `+${branch.ahead}/-${branch.behind} ${branch.upstream}`;
18997
- }
18998
- function sidebarLines(context, contextStatus, tab, width) {
20878
+ function sidebarLines(context, contextStatus, tab, width, state, theme) {
18999
20879
  if (tab === 'status') {
19000
20880
  const worktree = context.worktree;
19001
20881
  if (isLogInkContextKeyLoading(contextStatus, 'worktree')) {
@@ -19020,20 +20900,22 @@ function sidebarLines(context, contextStatus, tab, width) {
19020
20900
  if (!branches) {
19021
20901
  return ['Branches unavailable'];
19022
20902
  }
20903
+ const sortedBranches = sortBranches(branches.localBranches, state.branchSort);
19023
20904
  return [
19024
20905
  `Current: ${branches.currentBranch || '<detached>'}`,
19025
20906
  branches.dirty ? 'Worktree: dirty' : 'Worktree: clean',
19026
20907
  '',
19027
- ...branches.localBranches.slice(0, 8).map((branch) => `${branch.current ? '*' : ' '} ${truncate$1(branch.shortName, width - 4)}`),
19028
- ...branches.localBranches.slice(0, 4).map((branch) => ` ${truncate$1(formatDivergence(branch), width - 2)}`),
20908
+ ...sortedBranches.slice(0, 8).map((branch) => `${branchRowMarker(branch, { ascii: theme.ascii })} ${truncate$1(branch.shortName, width - 4)}`),
20909
+ ...sortedBranches.slice(0, 4).map((branch) => ` ${truncate$1(formatBranchDivergence(branch, { ascii: theme.ascii }), width - 2)}`),
19029
20910
  ];
19030
20911
  }
19031
20912
  if (tab === 'tags') {
19032
20913
  if (isLogInkContextKeyLoading(contextStatus, 'tags')) {
19033
20914
  return ['Loading tags...'];
19034
20915
  }
19035
- return context.tags?.tags.length
19036
- ? context.tags.tags.slice(0, 12).map((tag) => `${truncate$1(tag.name, 16)} ${truncate$1(tag.subject, Math.max(8, width - 18))}`)
20916
+ const sortedTags = sortTags(context.tags?.tags || [], state.tagSort);
20917
+ return sortedTags.length
20918
+ ? sortedTags.slice(0, 12).map((tag) => `${truncate$1(tag.name, 16)} ${truncate$1(tag.subject, Math.max(8, width - 18))}`)
19037
20919
  : ['No tags found'];
19038
20920
  }
19039
20921
  if (tab === 'stashes') {
@@ -19055,35 +20937,127 @@ function sidebarLines(context, contextStatus, tab, width) {
19055
20937
  })
19056
20938
  : ['No linked worktrees'];
19057
20939
  }
19058
- async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
19059
- const input = streams.input || process.stdin;
19060
- const output = streams.output || process.stdout;
19061
- const error = streams.error || process.stderr;
19062
- if (!canStartLogInkTui(input, output)) {
19063
- await startInteractiveLog(git, rows, {
19064
- appLabel: options.appLabel,
19065
- input,
19066
- output,
19067
- });
19068
- return;
20940
+ async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
20941
+ const input = streams.input || process.stdin;
20942
+ const output = streams.output || process.stdout;
20943
+ const error = streams.error || process.stderr;
20944
+ if (!canStartLogInkTui(input, output)) {
20945
+ await startInteractiveLog(git, rows, {
20946
+ appLabel: options.appLabel,
20947
+ input,
20948
+ output,
20949
+ });
20950
+ return;
20951
+ }
20952
+ const runtime = await loadInkRuntime();
20953
+ const { ink, React } = runtime;
20954
+ // Forward declared so the lifecycle handler can call back into the React
20955
+ // tree on SIGCONT to force a repaint after the user `fg`s.
20956
+ const resumeRef = { current: null };
20957
+ const app = React.createElement(LogInkApp, {
20958
+ appLabel: options.appLabel || 'coco log',
20959
+ git,
20960
+ idleTipsEnabled: Boolean(options.idleTips),
20961
+ ink,
20962
+ initialView: options.initialView || 'history',
20963
+ logArgv: options.logArgv,
20964
+ React,
20965
+ rows,
20966
+ theme: createLogInkTheme(options.theme),
20967
+ resumeRef,
20968
+ });
20969
+ const instance = ink.render(app, getLogInkRenderOptions({ input, output, error }));
20970
+ const lifecycle = installTerminalLifecycle({
20971
+ input,
20972
+ output,
20973
+ instance,
20974
+ onResume: () => resumeRef.current?.(),
20975
+ });
20976
+ try {
20977
+ await instance.waitUntilExit();
20978
+ }
20979
+ finally {
20980
+ lifecycle.dispose();
20981
+ }
20982
+ }
20983
+ /**
20984
+ * Predict the filter value that a filter-mutating action would land on, so
20985
+ * the runtime can compute the post-filter selection snapshot before the
20986
+ * reducer ever runs (P4.5). Returns undefined when the action isn't a
20987
+ * filter action.
20988
+ */
20989
+ function predictNextFilter(action, currentFilter) {
20990
+ switch (action.type) {
20991
+ case 'appendFilter':
20992
+ return `${currentFilter}${action.value}`;
20993
+ case 'backspaceFilter':
20994
+ return currentFilter.slice(0, -1);
20995
+ case 'clearFilter':
20996
+ case 'clearFilterText':
20997
+ return '';
20998
+ case 'setFilter':
20999
+ return action.value;
21000
+ default:
21001
+ return undefined;
21002
+ }
21003
+ }
21004
+ /**
21005
+ * Build the post-filter selection snapshot for branches / tags / stash so
21006
+ * the reducer can preserve the cursor when the previously-selected item is
21007
+ * still in the filtered result. Identifies items by a single key per view
21008
+ * (branch shortName, tag name, stash ref) — the same matchesPromotedFilter
21009
+ * the surfaces use covers the multi-field haystacks.
21010
+ */
21011
+ function computePromotedSelectionsSnapshot(state, context, nextFilter) {
21012
+ const allBranches = context.branches?.localBranches || [];
21013
+ const filteredBranches = nextFilter
21014
+ ? allBranches.filter((branch) => matchesPromotedFilter([branch.shortName, branch.upstream || ''], nextFilter))
21015
+ : allBranches;
21016
+ const currentBranches = state.filter
21017
+ ? allBranches.filter((branch) => matchesPromotedFilter([branch.shortName, branch.upstream || ''], state.filter))
21018
+ : allBranches;
21019
+ const previousBranchKey = currentBranches[state.selectedBranchIndex]?.shortName;
21020
+ const branchIndex = rectifyPromotedSelectionIndex(filteredBranches.map((branch) => branch.shortName), previousBranchKey);
21021
+ const allTags = context.tags?.tags || [];
21022
+ const filteredTags = nextFilter
21023
+ ? allTags.filter((tag) => matchesPromotedFilter([tag.name, tag.subject], nextFilter))
21024
+ : allTags;
21025
+ const currentTags = state.filter
21026
+ ? allTags.filter((tag) => matchesPromotedFilter([tag.name, tag.subject], state.filter))
21027
+ : allTags;
21028
+ const previousTagKey = currentTags[state.selectedTagIndex]?.name;
21029
+ const tagIndex = rectifyPromotedSelectionIndex(filteredTags.map((tag) => tag.name), previousTagKey);
21030
+ const allStashes = context.stashes?.stashes || [];
21031
+ const filteredStashes = nextFilter
21032
+ ? allStashes.filter((stash) => matchesPromotedFilter([stash.ref, stash.message], nextFilter))
21033
+ : allStashes;
21034
+ const currentStashes = state.filter
21035
+ ? allStashes.filter((stash) => matchesPromotedFilter([stash.ref, stash.message], state.filter))
21036
+ : allStashes;
21037
+ const previousStashKey = currentStashes[state.selectedStashIndex]?.ref;
21038
+ const stashIndex = rectifyPromotedSelectionIndex(filteredStashes.map((stash) => stash.ref), previousStashKey);
21039
+ return { branchIndex, tagIndex, stashIndex };
21040
+ }
21041
+ function enrichFilterActionWithRectification(action, state, context) {
21042
+ const nextFilter = predictNextFilter(action, state.filter);
21043
+ if (nextFilter === undefined) {
21044
+ return action;
21045
+ }
21046
+ const promotedSelections = computePromotedSelectionsSnapshot(state, context, nextFilter);
21047
+ switch (action.type) {
21048
+ case 'appendFilter':
21049
+ case 'setFilter':
21050
+ return { ...action, promotedSelections };
21051
+ case 'backspaceFilter':
21052
+ case 'clearFilter':
21053
+ case 'clearFilterText':
21054
+ return { ...action, promotedSelections };
21055
+ default:
21056
+ return action;
19069
21057
  }
19070
- const runtime = await loadInkRuntime();
19071
- const { ink, React } = runtime;
19072
- const app = React.createElement(LogInkApp, {
19073
- appLabel: options.appLabel || 'coco log',
19074
- git,
19075
- ink,
19076
- initialView: options.initialView || 'history',
19077
- logArgv: options.logArgv,
19078
- React,
19079
- rows,
19080
- theme: createLogInkTheme(options.theme),
19081
- });
19082
- const instance = ink.render(app, getLogInkRenderOptions({ input, output, error }));
19083
- await instance.waitUntilExit();
19084
21058
  }
19085
21059
  function LogInkApp(deps) {
19086
- const { appLabel, git, ink, initialView, logArgv, React, rows, theme } = deps;
21060
+ const { appLabel, git, idleTipsEnabled, ink, initialView, logArgv, React, resumeRef, rows, theme } = deps;
19087
21061
  const { Box, Text, useApp, useInput, useWindowSize } = ink;
19088
21062
  const h = React.createElement;
19089
21063
  const { exit } = useApp();
@@ -19092,6 +21066,22 @@ function LogInkApp(deps) {
19092
21066
  columns: windowSize.columns || process.stdout.columns || LOG_INK_DEFAULT_COLUMNS,
19093
21067
  rows: windowSize.rows || process.stdout.rows || LOG_INK_DEFAULT_ROWS,
19094
21068
  });
21069
+ // Bumping this on SIGCONT forces the existing tree to repaint so users
21070
+ // land on a drawn screen after `fg` instead of an empty alt buffer.
21071
+ const [, setResumeTick] = React.useState(0);
21072
+ React.useEffect(() => {
21073
+ if (!resumeRef) {
21074
+ return;
21075
+ }
21076
+ resumeRef.current = () => setResumeTick((tick) => tick + 1);
21077
+ return () => {
21078
+ resumeRef.current = null;
21079
+ };
21080
+ }, [resumeRef]);
21081
+ // First-launch onboarding (P1.3). Persisted via a marker file in the
21082
+ // user's cache dir so the tip never reappears once dismissed. Lazy
21083
+ // initializer so the fs check only runs on mount, not every render.
21084
+ const [showOnboarding, setShowOnboarding] = React.useState(() => !hasSeenOnboarding());
19095
21085
  const [state, setState] = React.useState(() => createLogInkState(rows, { activeView: initialView }));
19096
21086
  const [context, setContext] = React.useState({});
19097
21087
  const [contextStatus, setContextStatus] = React.useState(() => createLogInkContextStatus('loading'));
@@ -19109,21 +21099,66 @@ function LogInkApp(deps) {
19109
21099
  const loadingMoreCommitsRef = React.useRef(false);
19110
21100
  const loadMoreRequestRef = React.useRef(0);
19111
21101
  const mountedRef = React.useRef(true);
21102
+ // P4.3 — idle tip rotation. tickIndex 0 ⇒ no tip; the hook bumps it after
21103
+ // a grace window of empty statusMessage and then on a steady cadence, so
21104
+ // the footer surfaces a different hint every interval until the user does
21105
+ // anything that sets statusMessage.
21106
+ const [idleTipIndex, setIdleTipIndex] = React.useState(0);
21107
+ React.useEffect(() => {
21108
+ if (!idleTipsEnabled)
21109
+ return;
21110
+ if (state.statusMessage) {
21111
+ // Any explicit message resets the cycle; next idle stretch starts
21112
+ // from the grace window again.
21113
+ setIdleTipIndex(0);
21114
+ return;
21115
+ }
21116
+ let interval;
21117
+ // Both timer callbacks are function literals (never strings) and the
21118
+ // delays are our own `IDLE_TIPS_*_MS` constants — no caller-supplied
21119
+ // data flows in, so the eval-injection vector that drives
21120
+ // DevSkim DS172411 doesn't apply here.
21121
+ // DevSkim: ignore DS172411
21122
+ const grace = setTimeout(() => {
21123
+ setIdleTipIndex(1);
21124
+ // DevSkim: ignore DS172411
21125
+ interval = setInterval(() => setIdleTipIndex((tick) => tick + 1), IDLE_TIPS_INTERVAL_MS);
21126
+ }, IDLE_TIPS_GRACE_MS);
21127
+ return () => {
21128
+ clearTimeout(grace);
21129
+ if (interval)
21130
+ clearInterval(interval);
21131
+ };
21132
+ }, [idleTipsEnabled, state.statusMessage]);
21133
+ const idleTip = idleTipsEnabled && !state.statusMessage ? pickIdleTip(idleTipIndex) : undefined;
19112
21134
  const selected = getSelectedInkCommit(state);
19113
21135
  const selectedDetailFile = detail?.files[state.selectedFileIndex];
19114
21136
  const selectedWorktreeFile = context.worktree?.files[state.selectedWorktreeFileIndex];
19115
21137
  const dispatch = React.useCallback((action) => {
19116
21138
  setState((current) => applyLogInkAction(current, action));
19117
21139
  }, []);
19118
- const refreshContext = React.useCallback(async () => {
19119
- dispatch({ type: 'setStatus', value: 'refreshing repository context' });
19120
- setContextStatus(createLogInkContextStatus('loading'));
19121
- setContext(await loadLogInkContext(git));
21140
+ const refreshContext = React.useCallback(async (options = {}) => {
21141
+ // Loud refresh (manual `r`): flip everything to 'loading' so the user
21142
+ // sees the surfaces clear, then settle to 'ready' on completion.
21143
+ // Silent refresh (fs.watch trigger): keep the existing data on screen
21144
+ // (stale-while-revalidate) and quietly swap it in once the new fetch
21145
+ // resolves — avoids the every-second flicker the watcher would
21146
+ // otherwise produce on busy repos.
21147
+ if (!options.silent) {
21148
+ dispatch({ type: 'setStatus', value: 'refreshing repository context' });
21149
+ setContextStatus(createLogInkContextStatus('loading'));
21150
+ }
21151
+ const next = await loadLogInkContext(git);
21152
+ setContext(next);
19122
21153
  setContextStatus(createLogInkContextStatus('ready'));
19123
- dispatch({ type: 'setStatus', value: 'repository context refreshed' });
21154
+ if (!options.silent) {
21155
+ dispatch({ type: 'setStatus', value: 'repository context refreshed' });
21156
+ }
19124
21157
  }, [dispatch, git]);
19125
- const refreshWorktreeContext = React.useCallback(async () => {
19126
- setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'loading'));
21158
+ const refreshWorktreeContext = React.useCallback(async (options = {}) => {
21159
+ if (!options.silent) {
21160
+ setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'loading'));
21161
+ }
19127
21162
  const worktree = await safe(getWorktreeOverview(git));
19128
21163
  setContext((current) => ({
19129
21164
  ...current,
@@ -19131,6 +21166,56 @@ function LogInkApp(deps) {
19131
21166
  }));
19132
21167
  setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'ready'));
19133
21168
  }, [git]);
21169
+ // Live refresh: watch .git metadata + the working tree root and reload
21170
+ // context when something changes outside the TUI (editor save, external
21171
+ // git commands, branch switch in another terminal). Best-effort — the
21172
+ // watcher quietly skips paths that don't exist or platforms where
21173
+ // fs.watch fails. Subdirectory unstaged edits don't fire; users can
21174
+ // press `r` for those.
21175
+ React.useEffect(() => {
21176
+ let cancelled = false;
21177
+ let watcher = null;
21178
+ void (async () => {
21179
+ try {
21180
+ const [repoRoot, gitDir] = await Promise.all([
21181
+ git.revparse(['--show-toplevel']),
21182
+ git.revparse(['--absolute-git-dir']),
21183
+ ]);
21184
+ if (cancelled) {
21185
+ return;
21186
+ }
21187
+ watcher = createRefreshWatcher({
21188
+ repoRoot: repoRoot.trim(),
21189
+ gitDir: gitDir.trim(),
21190
+ // Editor saves and git background processes can produce a steady
21191
+ // drip of fs events on busy repos. The default 250ms debounce
21192
+ // was tight enough that the watcher fired ~once per second; 750
21193
+ // batches the steady-state better without delaying the user's
21194
+ // perception of an actual change.
21195
+ debounceMs: 750,
21196
+ onChange: (kind) => {
21197
+ if (!mountedRef.current) {
21198
+ return;
21199
+ }
21200
+ if (kind === 'full') {
21201
+ void refreshContext({ silent: true });
21202
+ }
21203
+ else {
21204
+ void refreshWorktreeContext({ silent: true });
21205
+ }
21206
+ },
21207
+ });
21208
+ }
21209
+ catch {
21210
+ // Not in a git worktree, or revparse failed. Skip — manual `r`
21211
+ // refresh still works.
21212
+ }
21213
+ })();
21214
+ return () => {
21215
+ cancelled = true;
21216
+ watcher?.close();
21217
+ };
21218
+ }, [git, refreshContext, refreshWorktreeContext]);
19134
21219
  React.useEffect(() => {
19135
21220
  let active = true;
19136
21221
  async function loadWorktreeHunks() {
@@ -19341,6 +21426,72 @@ function LogInkApp(deps) {
19341
21426
  });
19342
21427
  dispatch({ type: 'setStatus', value: result.message });
19343
21428
  }, [dispatch]);
21429
+ // Resolve the destructive-action target from the live filtered+sorted
21430
+ // list the user is looking at, run the action against it, surface the
21431
+ // result on the status line, and silently refresh so the deleted item
21432
+ // disappears. Called from the y-confirm path for delete-branch / delete-
21433
+ // tag / drop-stash / remove-worktree / abort-operation.
21434
+ const runWorkflowAction = React.useCallback(async (id) => {
21435
+ const handlers = {
21436
+ 'delete-branch': async () => {
21437
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
21438
+ const visible = state.filter
21439
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
21440
+ : all;
21441
+ const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
21442
+ if (!branch)
21443
+ return { ok: false, message: 'No branch selected' };
21444
+ return deleteBranch(git, branch);
21445
+ },
21446
+ 'delete-tag': async () => {
21447
+ const all = sortTags(context.tags?.tags || [], state.tagSort);
21448
+ const visible = state.filter
21449
+ ? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
21450
+ : all;
21451
+ const tag = visible[Math.min(state.selectedTagIndex, visible.length - 1)];
21452
+ if (!tag)
21453
+ return { ok: false, message: 'No tag selected' };
21454
+ return deleteLocalTag(git, tag.name);
21455
+ },
21456
+ 'drop-stash': async () => {
21457
+ const all = context.stashes?.stashes || [];
21458
+ const visible = state.filter
21459
+ ? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
21460
+ : all;
21461
+ const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
21462
+ if (!stash)
21463
+ return { ok: false, message: 'No stash selected' };
21464
+ return dropStash(git, stash);
21465
+ },
21466
+ 'remove-worktree': async () => {
21467
+ const all = context.worktreeList?.worktrees || [];
21468
+ // No dedicated cursor for the worktrees tab yet — operate on the
21469
+ // first non-current worktree as a safe default.
21470
+ const target = all.find((w) => !w.current);
21471
+ if (!target)
21472
+ return { ok: false, message: 'No removable worktree' };
21473
+ return removeWorktree(git, target);
21474
+ },
21475
+ 'abort-operation': async () => {
21476
+ const operation = context.operation?.operation;
21477
+ if (!operation) {
21478
+ return { ok: false, message: 'No git operation in progress' };
21479
+ }
21480
+ return abortOperation(git, operation);
21481
+ },
21482
+ };
21483
+ const handler = handlers[id];
21484
+ if (!handler) {
21485
+ dispatch({ type: 'setStatus', value: `Workflow action ${id} not yet wired` });
21486
+ return;
21487
+ }
21488
+ const result = await handler();
21489
+ dispatch({ type: 'setStatus', value: result?.message || 'Workflow action complete' });
21490
+ // Silent refresh so the deleted item disappears from the list without
21491
+ // flickering the surfaces through a 'loading' phase.
21492
+ await refreshContext({ silent: true });
21493
+ }, [context, dispatch, git, refreshContext, state.branchSort, state.filter, state.selectedBranchIndex,
21494
+ state.selectedStashIndex, state.selectedTagIndex, state.tagSort]);
19344
21495
  React.useEffect(() => {
19345
21496
  let active = true;
19346
21497
  async function loadPreview() {
@@ -19418,16 +21569,50 @@ function LogInkApp(deps) {
19418
21569
  state.filteredCommits.length,
19419
21570
  state.selectedIndex,
19420
21571
  ]);
21572
+ const commitDiffHunkOffsets = React.useMemo(() => (filePreview?.hunks
21573
+ .map((line, index) => (line.startsWith('@@') ? index : -1))
21574
+ .filter((index) => index >= 0)), [filePreview]);
21575
+ const worktreeDirty = Boolean(context.worktree &&
21576
+ (context.worktree.stagedCount + context.worktree.unstagedCount + context.worktree.untrackedCount) > 0);
19421
21577
  useInput((inputValue, key) => {
21578
+ // First-launch onboarding (P1.3): any keystroke dismisses the overlay
21579
+ // and writes the seen-marker. Swallow the keystroke so the same key
21580
+ // doesn't also trigger normal input dispatch.
21581
+ if (showOnboarding) {
21582
+ setShowOnboarding(false);
21583
+ markOnboardingSeen();
21584
+ return;
21585
+ }
21586
+ // P4.5: navigation in branches/tags/stash uses the FILTERED list
21587
+ // length when a filter is active so j/k stay live instead of getting
21588
+ // stuck against a full-list count that no longer matches what's on
21589
+ // screen.
21590
+ const branchVisibleCount = state.filter
21591
+ ? (context.branches?.localBranches || [])
21592
+ .filter((branch) => matchesPromotedFilter([branch.shortName, branch.upstream || ''], state.filter))
21593
+ .length
21594
+ : context.branches?.localBranches.length;
21595
+ const tagVisibleCount = state.filter
21596
+ ? (context.tags?.tags || [])
21597
+ .filter((tag) => matchesPromotedFilter([tag.name, tag.subject], state.filter))
21598
+ .length
21599
+ : context.tags?.tags.length;
21600
+ const stashVisibleCount = state.filter
21601
+ ? (context.stashes?.stashes || [])
21602
+ .filter((stash) => matchesPromotedFilter([stash.ref, stash.message], state.filter))
21603
+ .length
21604
+ : context.stashes?.stashes.length;
19422
21605
  getLogInkInputEvents(state, inputValue, key, {
19423
21606
  detailFileCount: detail?.files.length,
19424
21607
  previewLineCount: filePreview?.hunks.length,
19425
21608
  worktreeDiffLineCount: worktreeDiff?.lines.length,
19426
21609
  worktreeFileCount: context.worktree?.files.length,
19427
21610
  worktreeHunkOffsets: worktreeDiff?.hunkOffsets,
19428
- branchCount: context.branches?.localBranches.length,
19429
- tagCount: context.tags?.tags.length,
19430
- stashCount: context.stashes?.stashes.length,
21611
+ commitDiffHunkOffsets,
21612
+ branchCount: branchVisibleCount,
21613
+ tagCount: tagVisibleCount,
21614
+ stashCount: stashVisibleCount,
21615
+ worktreeDirty,
19431
21616
  }).forEach((event) => {
19432
21617
  if (event.type === 'exit') {
19433
21618
  exit();
@@ -19453,8 +21638,18 @@ function LogInkApp(deps) {
19453
21638
  else if (event.type === 'runAiCommitDraft') {
19454
21639
  void runAiCommitDraft();
19455
21640
  }
21641
+ else if (event.type === 'runWorkflowAction') {
21642
+ void runWorkflowAction(event.id);
21643
+ }
19456
21644
  else {
19457
- dispatch(event.action);
21645
+ // P4.5: enrich filter-mutating actions with a precomputed
21646
+ // selection snapshot so the reducer can preserve the cursor on
21647
+ // the same item when it's still in the filtered result, only
21648
+ // snapping to result[0] when the previously selected item drops
21649
+ // out. The snapshot lives in the action so the reducer never
21650
+ // needs context items.
21651
+ const enriched = enrichFilterActionWithRectification(event.action, state, context);
21652
+ dispatch(enriched);
19458
21653
  }
19459
21654
  });
19460
21655
  });
@@ -19466,7 +21661,12 @@ function LogInkApp(deps) {
19466
21661
  paddingY: 1,
19467
21662
  }, h(Text, { bold: true }, appLabel), h(Text, undefined, `Terminal too small: ${layout.columns}x${layout.rows}`), h(Text, { dimColor: true }, `Minimum size is ${LOG_INK_MIN_COLUMNS}x${LOG_INK_MIN_ROWS}.`), h(Text, { dimColor: true }, 'Resize the terminal or run plain coco log.'));
19468
21663
  }
19469
- return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, theme), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, layout.bodyRows, theme, hasMoreCommits, loadingMoreCommits), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, theme)), renderFooter(h, { Box, Text }, state, theme));
21664
+ // First-launch onboarding overlay (P1.3) replaces the entire UI for
21665
+ // one render — any keystroke dismisses it and persists the seen-marker.
21666
+ if (showOnboarding) {
21667
+ return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
21668
+ }
21669
+ return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, theme), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, theme)), renderFooter(h, { Box, Text }, state, theme, idleTip));
19470
21670
  }
19471
21671
  function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
19472
21672
  const { Box, Text } = components;
@@ -19475,27 +21675,54 @@ function renderHeader(h, components, state, context, contextStatus, columns, the
19475
21675
  const repo = context.provider?.repository.owner && context.provider.repository.name
19476
21676
  ? `${context.provider.repository.owner}/${context.provider.repository.name}`
19477
21677
  : 'local repository';
19478
- const pr = context.provider?.currentPullRequest
19479
- ? `PR #${context.provider.currentPullRequest.number} ${context.provider.currentPullRequest.state}`
19480
- : context.pullRequest?.currentPullRequest
19481
- ? `PR #${context.pullRequest.currentPullRequest.number} ${context.pullRequest.currentPullRequest.state}`
19482
- : 'no PR';
21678
+ const prInfo = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
21679
+ const prGlyph = prInfo ? getPullRequestStateGlyph(prInfo, theme) : null;
21680
+ const prLabel = prInfo
21681
+ ? `PR #${prInfo.number} ${prInfo.isDraft ? 'DRAFT' : prInfo.state}`
21682
+ : 'no PR';
19483
21683
  const search = state.filterMode ? `search: ${state.filter}_` : state.filter ? `filter: ${state.filter}` : '';
19484
21684
  const loading = isLogInkContextLoading(contextStatus) ? ' loading context' : '';
19485
21685
  const breadcrumb = formatLogInkBreadcrumb(state.viewStack);
19486
21686
  const view = breadcrumb ? ` ${breadcrumb}` : '';
19487
- const title = truncate$1(`${appLabel} ${repo} ${branch} ${dirty} ${pr}${view}${loading}`, columns - 2);
21687
+ // Mode indicator (P2.2) surfaces the current input mode so users
21688
+ // never wonder why `q` doesn't quit while they're editing or filtering.
21689
+ const mode = state.commitCompose.editing
21690
+ ? '[EDIT]'
21691
+ : state.filterMode
21692
+ ? '[FILTER]'
21693
+ : '[NORMAL]';
21694
+ const titlePrefix = `${appLabel} ${repo} ${branch} ${dirty} `;
21695
+ const glyphPart = prGlyph?.glyph ? `${prGlyph.glyph} ` : '';
21696
+ const titleSuffix = `${view}${loading}`;
21697
+ const fullTitle = `${titlePrefix}${glyphPart}${prLabel}${titleSuffix}`;
21698
+ const titleBudget = columns - mode.length - 4;
21699
+ const truncatedTitle = truncate$1(fullTitle, titleBudget);
21700
+ // Only split into colored fragments when the prefix + glyph + label all
21701
+ // fit unmodified — otherwise the truncate ellipsis can land mid-fragment
21702
+ // and we'd render half a glyph in the wrong color.
21703
+ const splitFragments = truncatedTitle === fullTitle && glyphPart.length > 0;
21704
+ const modeColor = theme.noColor
21705
+ ? undefined
21706
+ : state.filterMode || state.commitCompose.editing
21707
+ ? theme.colors.warning
21708
+ : theme.colors.accent;
19488
21709
  return h(Box, {
19489
21710
  borderColor: theme.colors.border,
19490
21711
  borderStyle: theme.borderStyle,
19491
21712
  height: 3,
19492
21713
  paddingX: 1,
19493
- }, h(Text, { bold: true, color: theme.colors.accent }, title), search ? h(Text, { dimColor: true }, ` ${truncate$1(search, 36)}`) : undefined);
21714
+ }, splitFragments
21715
+ ? h(Text, { bold: true, color: theme.colors.accent }, titlePrefix)
21716
+ : h(Text, { bold: true, color: theme.colors.accent }, truncatedTitle), splitFragments
21717
+ ? h(Text, { bold: true, color: prGlyph?.color, dimColor: prGlyph?.dim }, glyphPart)
21718
+ : undefined, splitFragments
21719
+ ? h(Text, { bold: true, color: theme.colors.accent }, `${prLabel}${titleSuffix}`)
21720
+ : undefined, h(Text, { bold: true, color: modeColor }, ` ${mode}`), search ? h(Text, { dimColor: true }, ` ${truncate$1(search, 36)}`) : undefined);
19494
21721
  }
19495
21722
  function renderSidebar(h, components, state, context, contextStatus, width, theme) {
19496
21723
  const { Box, Text } = components;
19497
21724
  const focused = state.focus === 'sidebar';
19498
- const lines = sidebarLines(context, contextStatus, state.sidebarTab, width - 4);
21725
+ const lines = sidebarLines(context, contextStatus, state.sidebarTab, width - 4, state, theme);
19499
21726
  const tabs = getLogInkSidebarTabs();
19500
21727
  return h(Box, {
19501
21728
  borderColor: focusBorderColor(theme, focused),
@@ -19503,33 +21730,48 @@ function renderSidebar(h, components, state, context, contextStatus, width, them
19503
21730
  flexDirection: 'column',
19504
21731
  width,
19505
21732
  paddingX: 1,
19506
- }, h(Text, { bold: true }, panelTitle('Repository', focused)), h(Text, { dimColor: true }, tabs.map((tab) => tab === state.sidebarTab ? `[${sidebarTabLabel(tab)}]` : sidebarTabLabel(tab)).join(' ')), h(Text, undefined, ''), ...lines.map((line, index) => h(Text, { key: `sidebar-${index}` }, truncate$1(line, width - 4))));
19507
- }
19508
- function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, bodyRows, theme, hasMoreCommits, loadingMoreCommits) {
21733
+ }, h(Text, { bold: true }, panelTitle('Repository', focused)), h(Text, { dimColor: true }, tabs.map((tab) => {
21734
+ const count = sidebarTabCount(tab, context);
21735
+ const labelWithCount = count !== undefined
21736
+ ? `${sidebarTabLabel(tab)} (${count})`
21737
+ : sidebarTabLabel(tab);
21738
+ return tab === state.sidebarTab ? `[${labelWithCount}]` : labelWithCount;
21739
+ }).join(' ')), h(Text, undefined, ''), ...lines.map((line, index) => h(Text, { key: `sidebar-${index}` }, truncate$1(line, width - 4))));
21740
+ }
21741
+ function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
19509
21742
  if (state.activeView === 'status') {
19510
- return renderStatusSurface(h, components, state, context, contextStatus, bodyRows, theme);
21743
+ return renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
19511
21744
  }
19512
21745
  if (state.activeView === 'diff') {
19513
- return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, bodyRows, theme);
21746
+ return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, bodyRows, width, theme);
19514
21747
  }
19515
21748
  if (state.activeView === 'compose') {
19516
- return renderComposeSurface(h, components, state, context, contextStatus, bodyRows, theme);
21749
+ return renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
19517
21750
  }
19518
21751
  if (state.activeView === 'branches') {
19519
- return renderBranchesSurface(h, components, state, context, contextStatus, bodyRows, theme);
21752
+ return renderBranchesSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
19520
21753
  }
19521
21754
  if (state.activeView === 'tags') {
19522
- return renderTagsSurface(h, components, state, context, contextStatus, bodyRows, theme);
21755
+ return renderTagsSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
19523
21756
  }
19524
21757
  if (state.activeView === 'stash') {
19525
- return renderStashSurface(h, components, state, context, contextStatus, bodyRows, theme);
21758
+ return renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
19526
21759
  }
19527
- return renderHistoryPanel(h, components, state, bodyRows, theme, hasMoreCommits, loadingMoreCommits);
21760
+ return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits);
19528
21761
  }
19529
- function renderHistoryPanel(h, components, state, bodyRows, theme, hasMoreCommits, loadingMoreCommits) {
21762
+ function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
19530
21763
  const { Box, Text } = components;
19531
21764
  const focused = state.focus === 'commits';
19532
- const listRows = Math.max(3, bodyRows - 4);
21765
+ const worktree = context.worktree;
21766
+ const worktreeDirty = Boolean(worktree && (worktree.stagedCount + worktree.unstagedCount + worktree.untrackedCount) > 0);
21767
+ // The synthetic "(+) new commit" row only appears when the worktree is
21768
+ // dirty AND the visible window is anchored at the top of the list — i.e.
21769
+ // the first real commit (selectedIndex 0) is in view. Scroll past that
21770
+ // and the row slides off naturally; the user can `gg` to bring it back.
21771
+ const showPendingRow = worktreeDirty &&
21772
+ !state.filter &&
21773
+ state.selectedIndex === 0;
21774
+ const listRows = Math.max(3, bodyRows - (showPendingRow ? 5 : 4));
19533
21775
  const visible = getVisibleLogInkHistory(state, listRows);
19534
21776
  const loadState = loadingMoreCommits
19535
21777
  ? 'loading older commits'
@@ -19538,13 +21780,21 @@ function renderHistoryPanel(h, components, state, bodyRows, theme, hasMoreCommit
19538
21780
  : 'loaded';
19539
21781
  const title = `${state.filteredCommits.length}/${state.commits.length} commits`;
19540
21782
  const graphMode = state.fullGraph ? 'full graph' : 'compact graph';
21783
+ const pendingRowSelected = showPendingRow && Boolean(state.pendingCommitFocused) && focused;
21784
+ // Real-commit selection is suppressed while the cursor is on the pending
21785
+ // row so the visible cursor only renders in one place at a time.
21786
+ const realSelectionSuppressed = state.pendingCommitFocused;
21787
+ const pendingNode = showPendingRow
21788
+ ? renderPendingCommitRow(h, Text, worktree, pendingRowSelected, theme)
21789
+ : null;
19541
21790
  return h(Box, {
19542
21791
  borderColor: focusBorderColor(theme, focused),
19543
21792
  borderStyle: theme.borderStyle,
19544
21793
  flexDirection: 'column',
19545
- flexGrow: 1,
21794
+ flexShrink: 0,
19546
21795
  paddingX: 1,
19547
- }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Commits', focused)), h(Text, { dimColor: true }, `${title} | ${graphMode} | ${loadState}`)), visible.items.length === 0
21796
+ width,
21797
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Commits', focused)), h(Text, { dimColor: true }, `${title} | ${graphMode} | ${loadState}`)), ...(pendingNode ? [pendingNode] : []), visible.items.length === 0
19548
21798
  ? h(Text, { dimColor: true }, formatLogInkHistoryEmpty({
19549
21799
  filter: state.filter,
19550
21800
  totalCommits: state.commits.length,
@@ -19553,36 +21803,97 @@ function renderHistoryPanel(h, components, state, bodyRows, theme, hasMoreCommit
19553
21803
  if (item.type === 'graph') {
19554
21804
  return h(Text, {
19555
21805
  key: `graph-${index}-${item.graph}`,
19556
- dimColor: true,
19557
- }, truncate$1(item.graph.padEnd(visible.graphWidth), 140));
21806
+ color: theme.noColor ? undefined : theme.colors.muted,
21807
+ dimColor: theme.noColor,
21808
+ }, truncate$1(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }), 140));
19558
21809
  }
19559
- const { commit, selected } = item;
19560
- const graph = item.graph.padEnd(visible.graphWidth);
19561
- const row = `${graph} ${commit.shortHash} ${commit.date} ${commit.message}${formatInkRefLabels(commit.refs)}`;
19562
- return h(Text, {
19563
- key: `${commit.hash}-${index}`,
19564
- backgroundColor: selected && !theme.noColor ? theme.colors.selection : undefined,
19565
- inverse: selected,
19566
- }, truncate$1(row, 140));
21810
+ return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index);
19567
21811
  }));
19568
21812
  }
19569
- function renderStatusSurface(h, components, state, context, contextStatus, bodyRows, theme) {
21813
+ /**
21814
+ * Render a single commit row with each segment in its own colored span.
21815
+ * Graph chars render in `theme.colors.muted` so the topology visually
21816
+ * recedes; shortHash takes the accent so the eye lands on the commit
21817
+ * identifier first; date is dimmed; message is normal; ref labels
21818
+ * (`[HEAD -> main]`) trail in accent. Selection styling is applied at
21819
+ * the outer span via `backgroundColor` / `inverse` so the highlight
21820
+ * fills the whole row regardless of inner-span coloring.
21821
+ *
21822
+ * Truncation is per-segment so the variable-length message field gets
21823
+ * the leftover budget after fixed segments are accounted for.
21824
+ */
21825
+ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index) {
21826
+ const renderedGraph = substituteGraphChars(graph.padEnd(graphWidth), { ascii: theme.ascii });
21827
+ const refs = formatInkRefLabels(commit.refs);
21828
+ const totalWidth = 140;
21829
+ const fixedWidth = graphWidth + 1 + commit.shortHash.length + 1 + commit.date.length + 1;
21830
+ const messageRoom = Math.max(8, totalWidth - fixedWidth - cellWidth(refs));
21831
+ const message = truncate$1(commit.message, messageRoom);
21832
+ const selectedBg = selected && !theme.noColor ? theme.colors.selection : undefined;
21833
+ const accent = theme.noColor ? undefined : theme.colors.accent;
21834
+ const muted = theme.noColor ? undefined : theme.colors.muted;
21835
+ return h(Text, {
21836
+ key: `${commit.hash}-${index}`,
21837
+ backgroundColor: selectedBg,
21838
+ inverse: selected,
21839
+ }, h(Text, { color: muted, dimColor: theme.noColor }, renderedGraph), ' ', h(Text, { color: accent, bold: selected }, commit.shortHash), ' ', h(Text, { dimColor: true }, commit.date), ' ', h(Text, undefined, message), refs ? h(Text, { color: accent }, refs) : null);
21840
+ }
21841
+ /**
21842
+ * Render the synthetic "(+) new commit" affordance shown above the real
21843
+ * commit list when the worktree is dirty. Pressing up at `selectedIndex 0`
21844
+ * focuses this row; pressing Enter pushes the status view so the user can
21845
+ * stage / commit.
21846
+ */
21847
+ function renderPendingCommitRow(h, Text, worktree, selected, theme) {
21848
+ const parts = [];
21849
+ if (worktree.stagedCount) {
21850
+ parts.push(`${worktree.stagedCount} staged`);
21851
+ }
21852
+ if (worktree.unstagedCount) {
21853
+ parts.push(`${worktree.unstagedCount} unstaged`);
21854
+ }
21855
+ if (worktree.untrackedCount) {
21856
+ parts.push(`${worktree.untrackedCount} untracked`);
21857
+ }
21858
+ const summary = parts.length ? parts.join(' · ') : 'pending changes';
21859
+ const label = `${theme.ascii ? '[+]' : '(+)'} New commit · ${summary}`;
21860
+ return h(Text, {
21861
+ key: 'pending-commit-row',
21862
+ bold: true,
21863
+ color: theme.noColor ? undefined : theme.colors.accent,
21864
+ inverse: selected,
21865
+ backgroundColor: selected && !theme.noColor ? theme.colors.selection : undefined,
21866
+ }, truncate$1(label, 140));
21867
+ }
21868
+ function renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
19570
21869
  const { Box, Text } = components;
19571
21870
  const focused = state.focus === 'commits';
19572
21871
  const worktree = context.worktree;
19573
21872
  const listRows = Math.max(4, bodyRows - 5);
19574
21873
  const selectedIndex = state.selectedWorktreeFileIndex;
19575
21874
  const cleanHint = formatLogInkStatusEmpty({ hasChanges: Boolean(worktree?.files.length) });
19576
- const lines = isLogInkContextKeyLoading(contextStatus, 'worktree')
21875
+ const startIndex = Math.max(0, selectedIndex - Math.floor(listRows / 2));
21876
+ const isLoading = isLogInkContextKeyLoading(contextStatus, 'worktree');
21877
+ const fileRows = isLoading || !worktree?.files.length
21878
+ ? []
21879
+ : worktree.files.slice(startIndex).slice(0, listRows).map((file, offset) => {
21880
+ const index = startIndex + offset;
21881
+ const isSelected = index === selectedIndex;
21882
+ const cursorPart = `${isSelected ? '>' : ' '} `;
21883
+ const dotColor = getStageStatusDotColor(file.state, theme);
21884
+ const useDot = dotColor !== undefined;
21885
+ const dotCells = useDot ? cellWidth(STAGE_STATUS_DOT) + 1 : 0;
21886
+ const tail = `${file.indexStatus}${file.worktreeStatus} ${file.state.padEnd(9)} ${file.path}`;
21887
+ const tailTrunc = truncate$1(tail, Math.max(0, 140 - cellWidth(cursorPart) - dotCells));
21888
+ return h(Text, {
21889
+ key: `status-row-${index}`,
21890
+ dimColor: offset > 0,
21891
+ }, cursorPart, ...(useDot ? [h(Text, { color: dotColor }, STAGE_STATUS_DOT), ' '] : []), tailTrunc);
21892
+ });
21893
+ const fallbackLines = isLoading
19577
21894
  ? [formatLogInkLoading({ resource: 'worktree status' })]
19578
21895
  : worktree?.files.length
19579
- ? worktree.files
19580
- .slice(Math.max(0, selectedIndex - Math.floor(listRows / 2)))
19581
- .slice(0, listRows)
19582
- .map((file, offset) => {
19583
- const index = Math.max(0, selectedIndex - Math.floor(listRows / 2)) + offset;
19584
- return `${index === selectedIndex ? '>' : ' '} ${file.indexStatus}${file.worktreeStatus} ${file.state.padEnd(9)} ${file.path}`;
19585
- })
21896
+ ? []
19586
21897
  : cleanHint
19587
21898
  ? [cleanHint]
19588
21899
  : ['Worktree clean'];
@@ -19590,16 +21901,17 @@ function renderStatusSurface(h, components, state, context, contextStatus, bodyR
19590
21901
  borderColor: focusBorderColor(theme, focused),
19591
21902
  borderStyle: theme.borderStyle,
19592
21903
  flexDirection: 'column',
19593
- flexGrow: 1,
21904
+ flexShrink: 0,
19594
21905
  paddingX: 1,
21906
+ width,
19595
21907
  }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Worktree', focused)), h(Text, { dimColor: true }, worktree
19596
21908
  ? `${worktree.stagedCount} staged | ${worktree.unstagedCount} unstaged | ${worktree.untrackedCount} untracked`
19597
- : 'status loading')), ...lines.map((line, index) => h(Text, {
19598
- key: `status-surface-${index}`,
21909
+ : 'status loading')), ...fileRows, ...fallbackLines.map((line, index) => h(Text, {
21910
+ key: `status-surface-fallback-${index}`,
19599
21911
  dimColor: index > 0,
19600
21912
  }, truncate$1(line, 140))));
19601
21913
  }
19602
- function renderComposeSurface(h, components, state, context, contextStatus, bodyRows, theme) {
21914
+ function renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
19603
21915
  const { Box, Text } = components;
19604
21916
  const compose = state.commitCompose;
19605
21917
  const focused = state.focus === 'commits';
@@ -19612,52 +21924,65 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
19612
21924
  const summaryCursor = compose.editing && compose.field === 'summary' ? '_' : '';
19613
21925
  const bodyCursor = compose.editing && compose.field === 'body' ? '_' : '';
19614
21926
  const bodyRowsAvailable = Math.max(4, bodyRows - 10);
19615
- const bodyLines = compose.body
19616
- ? compose.body.split('\n').slice(0, bodyRowsAvailable)
21927
+ // Wrap each source line of the body to the panel width so long messages
21928
+ // line-wrap inside the compose surface instead of getting trimmed by an
21929
+ // outer truncate(line, 140). The 2-space indent eats 2 cells; chrome
21930
+ // (border + paddingX) eats 4 — same budget as renderCommitPanel.
21931
+ const bodyTextWidth = Math.max(8, width - 6);
21932
+ const bodyVisualLines = compose.body
21933
+ ? compose.body.split('\n').flatMap((line) => wrapCells(line, bodyTextWidth)).slice(0, bodyRowsAvailable)
19617
21934
  : ['<empty>'];
19618
- const stateLine = compose.loading
19619
- ? 'Working...'
19620
- : compose.editing
19621
- ? 'Editing — Enter switches summary↔body, Esc exits edit mode.'
19622
- : 'Press e to edit, c to commit, I for AI draft, esc to leave.';
19623
- const stagedFileLines = (worktree?.files || [])
19624
- .filter((file) => file.indexStatus !== ' ' && file.indexStatus !== '?')
19625
- .slice(0, 5)
19626
- .map((file) => ` ${file.indexStatus} ${file.path}`);
21935
+ const summaryVisualLines = wrapCells(`${compose.summary || '<empty>'}${summaryCursor}`, Math.max(8, width - 11) // "Summary " (9) + 2 chrome = 11
21936
+ );
21937
+ const stateLine = compose.editing
21938
+ ? 'Editing — Enter switches summary↔body, Esc exits edit mode.'
21939
+ : 'Press e to edit, c to commit, I for AI draft, esc to leave.';
21940
+ const hasStagedFiles = (worktree?.files || [])
21941
+ .some((file) => file.indexStatus !== ' ' && file.indexStatus !== '?');
21942
+ // Staged file list is rendered in the right Worktree panel
21943
+ // (renderComposeContextPanel); duplicating it here was confusing.
21944
+ // Keep only the actionable "stage something first" hint when nothing is
21945
+ // staged yet.
19627
21946
  const noStagedHint = !isLogInkContextKeyLoading(contextStatus, 'worktree')
19628
- ? formatLogInkComposeEmpty({ hasStaged: stagedFileLines.length > 0 })
21947
+ ? formatLogInkComposeEmpty({ hasStaged: hasStagedFiles })
19629
21948
  : undefined;
19630
21949
  return h(Box, {
19631
21950
  borderColor: focusBorderColor(theme, focused),
19632
21951
  borderStyle: theme.borderStyle,
19633
21952
  flexDirection: 'column',
19634
- flexGrow: 1,
21953
+ flexShrink: 0,
19635
21954
  paddingX: 1,
21955
+ width,
19636
21956
  }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Compose commit', focused)), h(Text, { dimColor: true }, statusLine)), h(Text, undefined, ''), h(Text, {
19637
21957
  bold: compose.field === 'summary' && compose.editing,
19638
- }, truncate$1(`Summary ${compose.summary || '<empty>'}${summaryCursor}`, 140)), h(Text, undefined, ''), h(Text, {
21958
+ }, `Summary ${summaryVisualLines[0] || ''}`), ...summaryVisualLines.slice(1).map((line, index) => h(Text, {
21959
+ key: `compose-summary-${index}`,
21960
+ bold: compose.field === 'summary' && compose.editing,
21961
+ }, ` ${line}`)), h(Text, undefined, ''), h(Text, {
19639
21962
  bold: compose.field === 'body' && compose.editing,
19640
- }, 'Body'), ...bodyLines.map((line, index) => h(Text, {
19641
- key: `compose-body-${index}`,
19642
- dimColor: line === '<empty>',
19643
- }, truncate$1(` ${line}${bodyCursor && index === bodyLines.length - 1 ? bodyCursor : ''}`, 140))), h(Text, undefined, ''), h(Text, { dimColor: true }, stateLine), ...(compose.message ? [h(Text, { key: 'compose-msg' }, truncate$1(compose.message, 140))] : []), ...(compose.details || []).map((line, index) => h(Text, {
21963
+ }, 'Body'), ...bodyVisualLines.map((line, index) => {
21964
+ const isLast = index === bodyVisualLines.length - 1;
21965
+ return h(Text, {
21966
+ key: `compose-body-${index}`,
21967
+ dimColor: line === '<empty>',
21968
+ }, ` ${line}${bodyCursor && isLast ? bodyCursor : ''}`);
21969
+ }), h(Text, undefined, ''), ...(compose.loading
21970
+ ? [h(Text, {
21971
+ key: 'compose-loading',
21972
+ bold: true,
21973
+ color: theme.noColor ? undefined : theme.colors.accent,
21974
+ }, theme.ascii
21975
+ ? '[...] Generating AI commit draft (this can take a moment)'
21976
+ : '⏳ Generating AI commit draft… (this can take a moment)')]
21977
+ : [h(Text, { dimColor: true }, stateLine)]), ...(compose.message ? [h(Text, { key: 'compose-msg' }, truncate$1(compose.message, 140))] : []), ...(compose.details || []).map((line, index) => h(Text, {
19644
21978
  key: `compose-detail-${index}`,
19645
21979
  dimColor: true,
19646
- }, truncate$1(` ${line}`, 140))), ...(stagedFileLines.length > 0
21980
+ }, truncate$1(` ${line}`, 140))), ...(!hasStagedFiles && noStagedHint
19647
21981
  ? [
19648
- h(Text, { key: 'compose-staged-spacer' }, ''),
19649
- h(Text, { key: 'compose-staged-title', bold: true }, 'Staged'),
19650
- ...stagedFileLines.map((line, index) => h(Text, {
19651
- key: `compose-staged-${index}`,
19652
- dimColor: true,
19653
- }, truncate$1(line, 140))),
21982
+ h(Text, { key: 'compose-no-staged-spacer' }, ''),
21983
+ h(Text, { key: 'compose-no-staged', dimColor: true }, truncate$1(noStagedHint, 140)),
19654
21984
  ]
19655
- : noStagedHint
19656
- ? [
19657
- h(Text, { key: 'compose-no-staged-spacer' }, ''),
19658
- h(Text, { key: 'compose-no-staged', dimColor: true }, truncate$1(noStagedHint, 140)),
19659
- ]
19660
- : []));
21985
+ : []));
19661
21986
  }
19662
21987
  function matchesPromotedFilter(haystacks, filter) {
19663
21988
  if (!filter.trim()) {
@@ -19666,23 +21991,24 @@ function matchesPromotedFilter(haystacks, filter) {
19666
21991
  const needle = filter.toLowerCase();
19667
21992
  return haystacks.some((value) => value.toLowerCase().includes(needle));
19668
21993
  }
19669
- function renderBranchesSurface(h, components, state, context, contextStatus, bodyRows, theme) {
21994
+ function renderBranchesSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
19670
21995
  const { Box, Text } = components;
19671
21996
  const focused = state.focus === 'commits';
19672
21997
  const branches = context.branches;
19673
21998
  const loading = isLogInkContextKeyLoading(contextStatus, 'branches');
19674
- const allLocalBranches = branches?.localBranches || [];
21999
+ const sortedAll = sortBranches(branches?.localBranches || [], state.branchSort);
19675
22000
  const localBranches = state.filter
19676
- ? allLocalBranches.filter((branch) => matchesPromotedFilter([branch.shortName, branch.upstream || ''], state.filter))
19677
- : allLocalBranches;
22001
+ ? sortedAll.filter((branch) => matchesPromotedFilter([branch.shortName, branch.upstream || ''], state.filter))
22002
+ : sortedAll;
19678
22003
  const selected = Math.max(0, Math.min(state.selectedBranchIndex, Math.max(0, localBranches.length - 1)));
19679
22004
  const listRows = Math.max(4, bodyRows - 4);
19680
22005
  const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
19681
22006
  const visible = localBranches.slice(startIndex, startIndex + listRows);
19682
22007
  const filterLabel = state.filter ? ` | filter: ${state.filter}` : '';
22008
+ const sortLabel = ` | ${formatSortIndicator(state.branchSort, { ascii: theme.ascii })}`;
19683
22009
  const headerRight = loading
19684
22010
  ? 'loading branches'
19685
- : `${localBranches.length}/${allLocalBranches.length} local | current: ${branches?.currentBranch || '<detached>'}${filterLabel}`;
22011
+ : `${localBranches.length}/${sortedAll.length} local | current: ${branches?.currentBranch || '<detached>'}${filterLabel}${sortLabel}`;
19686
22012
  const emptyLabel = formatLogInkBranchesEmpty({ filter: state.filter });
19687
22013
  const loadingLabel = formatLogInkLoading({ resource: 'branches' });
19688
22014
  const lines = loading
@@ -19693,8 +22019,8 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
19693
22019
  const index = startIndex + offset;
19694
22020
  const isSelected = index === selected;
19695
22021
  const cursor = isSelected ? '>' : ' ';
19696
- const marker = branch.current ? '*' : ' ';
19697
- const divergence = formatDivergence(branch);
22022
+ const marker = branchRowMarker(branch, { ascii: theme.ascii });
22023
+ const divergence = formatBranchDivergence(branch, { ascii: theme.ascii });
19698
22024
  return h(Text, {
19699
22025
  key: `branch-${index}`,
19700
22026
  bold: isSelected,
@@ -19705,26 +22031,28 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
19705
22031
  borderColor: focusBorderColor(theme, focused),
19706
22032
  borderStyle: theme.borderStyle,
19707
22033
  flexDirection: 'column',
19708
- flexGrow: 1,
22034
+ flexShrink: 0,
19709
22035
  paddingX: 1,
19710
- }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Branches', focused)), h(Text, { dimColor: true }, headerRight)), ...lines);
22036
+ width,
22037
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Branches', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
19711
22038
  }
19712
- function renderTagsSurface(h, components, state, context, contextStatus, bodyRows, theme) {
22039
+ function renderTagsSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
19713
22040
  const { Box, Text } = components;
19714
22041
  const focused = state.focus === 'commits';
19715
22042
  const loading = isLogInkContextKeyLoading(contextStatus, 'tags');
19716
- const allTags = context.tags?.tags || [];
22043
+ const sortedAll = sortTags(context.tags?.tags || [], state.tagSort);
19717
22044
  const tags = state.filter
19718
- ? allTags.filter((tag) => matchesPromotedFilter([tag.name, tag.subject], state.filter))
19719
- : allTags;
22045
+ ? sortedAll.filter((tag) => matchesPromotedFilter([tag.name, tag.subject], state.filter))
22046
+ : sortedAll;
19720
22047
  const selected = Math.max(0, Math.min(state.selectedTagIndex, Math.max(0, tags.length - 1)));
19721
22048
  const listRows = Math.max(4, bodyRows - 4);
19722
22049
  const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
19723
22050
  const visible = tags.slice(startIndex, startIndex + listRows);
19724
22051
  const filterLabel = state.filter ? ` | filter: ${state.filter}` : '';
22052
+ const sortLabel = ` | ${formatSortIndicator(state.tagSort, { ascii: theme.ascii })}`;
19725
22053
  const headerRight = loading
19726
22054
  ? 'loading tags'
19727
- : `${tags.length}/${allTags.length} tags${filterLabel}`;
22055
+ : `${tags.length}/${sortedAll.length} tags${filterLabel}${sortLabel}`;
19728
22056
  const emptyLabel = formatLogInkTagsEmpty({ filter: state.filter });
19729
22057
  const loadingLabel = formatLogInkLoading({ resource: 'tags' });
19730
22058
  const lines = loading
@@ -19735,21 +22063,39 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
19735
22063
  const index = startIndex + offset;
19736
22064
  const isSelected = index === selected;
19737
22065
  const cursor = isSelected ? '>' : ' ';
22066
+ // P5.1 — link the tag name to its GitHub tree page when we know
22067
+ // the remote. Truncation runs on the visible (pre-OSC) text;
22068
+ // formatHyperlink wraps just the tag name, leaving width math
22069
+ // intact.
22070
+ const url = buildRefUrl(context.provider?.repository, tag.name);
22071
+ const namePadded = tag.name.padEnd(20);
22072
+ const lineText = truncate$1(`${cursor} ${namePadded} ${tag.subject}`, 140);
22073
+ if (!url || lineText.indexOf(namePadded) < 0) {
22074
+ return h(Text, {
22075
+ key: `tag-${index}`,
22076
+ bold: isSelected,
22077
+ dimColor: !isSelected,
22078
+ }, lineText);
22079
+ }
22080
+ const linkStart = lineText.indexOf(namePadded);
22081
+ const before = lineText.slice(0, linkStart);
22082
+ const after = lineText.slice(linkStart + namePadded.length);
19738
22083
  return h(Text, {
19739
22084
  key: `tag-${index}`,
19740
22085
  bold: isSelected,
19741
22086
  dimColor: !isSelected,
19742
- }, truncate$1(`${cursor} ${tag.name.padEnd(20)} ${tag.subject}`, 140));
22087
+ }, before, formatHyperlink(namePadded, url), after);
19743
22088
  });
19744
22089
  return h(Box, {
19745
22090
  borderColor: focusBorderColor(theme, focused),
19746
22091
  borderStyle: theme.borderStyle,
19747
22092
  flexDirection: 'column',
19748
- flexGrow: 1,
22093
+ flexShrink: 0,
19749
22094
  paddingX: 1,
19750
- }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Tags', focused)), h(Text, { dimColor: true }, headerRight)), ...lines);
22095
+ width,
22096
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Tags', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
19751
22097
  }
19752
- function renderStashSurface(h, components, state, context, contextStatus, bodyRows, theme) {
22098
+ function renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
19753
22099
  const { Box, Text } = components;
19754
22100
  const focused = state.focus === 'commits';
19755
22101
  const loading = isLogInkContextKeyLoading(contextStatus, 'stashes');
@@ -19785,26 +22131,91 @@ function renderStashSurface(h, components, state, context, contextStatus, bodyRo
19785
22131
  borderColor: focusBorderColor(theme, focused),
19786
22132
  borderStyle: theme.borderStyle,
19787
22133
  flexDirection: 'column',
19788
- flexGrow: 1,
22134
+ flexShrink: 0,
19789
22135
  paddingX: 1,
19790
- }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Stash', focused)), h(Text, { dimColor: true }, headerRight)), ...lines);
22136
+ width,
22137
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Stash', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
22138
+ }
22139
+ /**
22140
+ * Filter input cursor for the promoted views (branches/tags/stash).
22141
+ * History already shows the same `filter: foo_` affordance in its header
22142
+ * — this mirrors that into the other surfaces so the user can see what
22143
+ * they're typing instead of watching the list silently shrink (P2.1).
22144
+ *
22145
+ * Returns an empty array when the surface isn't in filter mode so call
22146
+ * sites can spread it unconditionally.
22147
+ */
22148
+ function renderPromotedFilterAffordance(h, Text, state, theme) {
22149
+ if (!state.filterMode) {
22150
+ return [];
22151
+ }
22152
+ const accent = theme.noColor ? undefined : theme.colors.accent;
22153
+ return [
22154
+ h(Text, { key: 'promoted-filter-input', color: accent }, `filter: ${state.filter}_`),
22155
+ ];
19791
22156
  }
19792
- function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, bodyRows, theme) {
22157
+ function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, bodyRows, width, theme) {
19793
22158
  const { Box, Text } = components;
19794
22159
  const focused = state.focus === 'commits';
19795
22160
  const worktree = context.worktree;
19796
- const selectedFile = worktree?.files[state.selectedWorktreeFileIndex];
22161
+ const worktreeFile = worktree?.files[state.selectedWorktreeFileIndex];
19797
22162
  const visibleRows = Math.max(4, bodyRows - 4);
22163
+ // diffSource disambiguates: 'commit' was set when the user opened the
22164
+ // diff via history → Enter (read-only commit-diff explore), 'worktree'
22165
+ // was set when they came from status → Enter (stage / hunk / revert).
22166
+ // Falls back to the previous heuristic when no source is recorded so
22167
+ // older entry paths still render something sensible.
22168
+ const useCommitDiff = state.diffSource === 'commit' ||
22169
+ (state.diffSource === undefined && !worktreeFile && Boolean(selectedDetailFile));
22170
+ if (useCommitDiff) {
22171
+ const previewHunks = filePreview?.hunks || [];
22172
+ const visiblePreviewHunks = previewHunks.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
22173
+ const hunkCount = commitDiffHunkOffsets?.length || 0;
22174
+ const currentHunkIndex = hunkCount > 0
22175
+ ? Math.max(0, [...(commitDiffHunkOffsets || [])]
22176
+ .reverse()
22177
+ .findIndex((offset) => offset <= state.diffPreviewOffset))
22178
+ : 0;
22179
+ const currentHunkLabel = hunkCount > 0
22180
+ ? `Hunk ${Math.min(hunkCount - currentHunkIndex, hunkCount)}/${hunkCount}`
22181
+ : 'No hunks for this file.';
22182
+ const headerLines = filePreviewLoading
22183
+ ? [`Loading diff for ${selectedDetailFile?.path || 'selected file'}...`]
22184
+ : previewHunks.length
22185
+ ? [
22186
+ `Selected file: ${selectedDetailFile?.path || ''}`,
22187
+ currentHunkLabel,
22188
+ `Lines ${Math.min(state.diffPreviewOffset + 1, previewHunks.length || 1)}-${Math.min(state.diffPreviewOffset + visiblePreviewHunks.length, previewHunks.length)}/${previewHunks.length}`,
22189
+ '',
22190
+ ]
22191
+ : ['No diff preview available for this file.'];
22192
+ return h(Box, {
22193
+ borderColor: focusBorderColor(theme, focused),
22194
+ borderStyle: theme.borderStyle,
22195
+ flexDirection: 'column',
22196
+ flexShrink: 0,
22197
+ paddingX: 1,
22198
+ width,
22199
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Diff', focused)), h(Text, { dimColor: true }, selectedDetailFile?.path || 'no file')), ...headerLines.map((line, index) => h(Text, {
22200
+ key: `diff-surface-header-${index}`,
22201
+ dimColor: index > 0,
22202
+ }, truncate$1(line, 140))), ...(filePreviewLoading || !previewHunks.length
22203
+ ? []
22204
+ : visiblePreviewHunks.map((line, index) => h(Text, {
22205
+ key: `diff-surface-line-${state.diffPreviewOffset + index}`,
22206
+ ...diffLineProps(line, theme),
22207
+ }, truncate$1(line, 140)))));
22208
+ }
19798
22209
  const diffLines = worktreeDiff?.lines || [];
19799
22210
  const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
19800
22211
  const visibleDiffLines = diffLines.slice(state.worktreeDiffOffset, state.worktreeDiffOffset + visibleRows);
19801
- const lines = isLogInkContextKeyLoading(contextStatus, 'worktree')
22212
+ const headerLines = isLogInkContextKeyLoading(contextStatus, 'worktree')
19802
22213
  ? ['Loading file context...']
19803
22214
  : worktreeDiffLoading
19804
- ? [`Loading diff for ${selectedFile?.path || 'selected file'}...`]
19805
- : selectedFile
22215
+ ? [`Loading diff for ${worktreeFile?.path || 'selected file'}...`]
22216
+ : worktreeFile
19806
22217
  ? [
19807
- `Selected file: ${selectedFile.path}`,
22218
+ `Selected file: ${worktreeFile.path}`,
19808
22219
  worktreeHunksLoading
19809
22220
  ? 'Hunks loading...'
19810
22221
  : worktreeHunks?.hunks.length
@@ -19812,22 +22223,29 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
19812
22223
  : 'No stageable hunks for this file.',
19813
22224
  `Lines ${Math.min(state.worktreeDiffOffset + 1, diffLines.length || 1)}-${Math.min(state.worktreeDiffOffset + visibleDiffLines.length, diffLines.length)}/${diffLines.length}`,
19814
22225
  '',
19815
- ...visibleDiffLines,
19816
22226
  ]
19817
22227
  : ['No changed file selected.'];
22228
+ const showDiffLines = Boolean(worktreeFile) &&
22229
+ !worktreeDiffLoading &&
22230
+ !isLogInkContextKeyLoading(contextStatus, 'worktree');
19818
22231
  return h(Box, {
19819
22232
  borderColor: focusBorderColor(theme, focused),
19820
22233
  borderStyle: theme.borderStyle,
19821
22234
  flexDirection: 'column',
19822
- flexGrow: 1,
22235
+ flexShrink: 0,
19823
22236
  paddingX: 1,
19824
- }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Diff', focused)), h(Text, { dimColor: true }, selectedFile ? selectedFile.path : 'no file')), ...lines.map((line, index) => h(Text, {
19825
- key: `diff-surface-${index}`,
19826
- dimColor: index > 1,
19827
- }, truncate$1(line, 140))));
22237
+ width,
22238
+ }, 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, {
22239
+ key: `diff-surface-header-${index}`,
22240
+ dimColor: index > 0,
22241
+ }, truncate$1(line, 140))), ...(showDiffLines
22242
+ ? visibleDiffLines.map((line, index) => h(Text, {
22243
+ key: `diff-surface-line-${state.worktreeDiffOffset + index}`,
22244
+ ...diffLineProps(line, theme),
22245
+ }, truncate$1(line, 140)))
22246
+ : []));
19828
22247
  }
19829
22248
  function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, theme) {
19830
- const { Box, Text } = components;
19831
22249
  const focused = state.focus === 'detail';
19832
22250
  if (state.showHelp) {
19833
22251
  return renderHelpPanel(h, components, state, width, theme, focused);
@@ -19838,69 +22256,351 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
19838
22256
  if (state.pendingConfirmationId || state.pendingMutationConfirmation) {
19839
22257
  return renderConfirmationPanel(h, components, state, width, theme, focused);
19840
22258
  }
19841
- if (state.activeView === 'status' || state.activeView === 'diff') {
22259
+ // which-key style overlay shows the available chord continuations
22260
+ // when the user has pressed the prefix and we're waiting for the
22261
+ // second key. Mirrors helix / which-key.nvim / doom-emacs.
22262
+ if (state.pendingKey) {
22263
+ return renderChordOverlay(h, components, state, width, theme, focused);
22264
+ }
22265
+ // The synthetic "(+) new commit" row routes the inspector through the
22266
+ // worktree summary so the user sees what's staged / unstaged at a glance
22267
+ // — same surface as the compose view's right panel.
22268
+ if (state.activeView === 'history' && state.pendingCommitFocused) {
22269
+ return renderComposeContextPanel(h, components, state, context, contextStatus, width, theme, focused);
22270
+ }
22271
+ // Status + worktree-sourced diff keep the staging compose panel — it's
22272
+ // the action surface for stage / hunk / commit. Commit-sourced diff (from
22273
+ // history → Enter) gets a dedicated explore panel: subject, body, and a
22274
+ // navigable file list whose selection swaps the center diff.
22275
+ if (state.activeView === 'status') {
22276
+ return renderCommitPanel(h, components, state, context, contextStatus, width, theme, focused);
22277
+ }
22278
+ if (state.activeView === 'diff') {
22279
+ if (state.diffSource === 'commit') {
22280
+ return renderCommitDiffDetail(h, components, state, detail, loading, width, theme, focused);
22281
+ }
19842
22282
  return renderCommitPanel(h, components, state, context, contextStatus, width, theme, focused);
19843
22283
  }
22284
+ // Compose view: the right panel had been falling through to the inspector
22285
+ // and showing the last selected commit's data, which is wrong context for
22286
+ // an in-progress commit. Show the worktree summary instead.
22287
+ if (state.activeView === 'compose') {
22288
+ return renderComposeContextPanel(h, components, state, context, contextStatus, width, theme, focused);
22289
+ }
22290
+ // Preview pane (P4.1) — fzf / yazi / lazygit style: branches, tags, and
22291
+ // stash views each get a tailored summary of the selected entry instead
22292
+ // of falling through to the (stale) history inspector.
22293
+ if (state.activeView === 'branches') {
22294
+ return renderBranchPreviewPanel(h, components, state, context, contextStatus, width, theme, focused);
22295
+ }
22296
+ if (state.activeView === 'tags') {
22297
+ return renderTagPreviewPanel(h, components, state, context, contextStatus, width, theme, focused);
22298
+ }
22299
+ if (state.activeView === 'stash') {
22300
+ return renderStashPreviewPanel(h, components, state, context, contextStatus, width, theme, focused);
22301
+ }
22302
+ return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, theme, focused);
22303
+ }
22304
+ function renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, _filePreview, _filePreviewLoading, width, theme, focused) {
22305
+ const { Box, Text } = components;
19844
22306
  const selected = getSelectedInkCommit(state);
19845
- const selectedFile = detail?.files[state.selectedFileIndex];
19846
- const previewWindow = filePreview?.hunks.slice(state.diffPreviewOffset, state.diffPreviewOffset + 8);
19847
- const statLine = detail
19848
- ? `${detail.stats.filesChanged} files +${detail.stats.insertions}/-${detail.stats.deletions}`
19849
- : '';
19850
22307
  const workflowSections = getLogInkWorkflowSections({
19851
22308
  ...context,
19852
22309
  contextLoading: isLogInkContextLoading(contextStatus),
19853
22310
  selectedCommit: selected,
19854
22311
  });
19855
- const lines = detail
19856
- ? [
19857
- detail.message,
19858
- '',
19859
- `Commit: ${compactHash(detail.hash)}`,
19860
- `Author: ${detail.author}`,
19861
- `Date: ${detail.date}`,
19862
- detail.refs.length ? `Refs: ${detail.refs.join(', ')}` : 'Refs: none',
19863
- statLine,
19864
- '',
19865
- ...(detail.body ? detail.body.split('\n').slice(0, 6) : ['No commit body.']),
19866
- '',
19867
- 'Changed files:',
19868
- ...(detail.files.length
19869
- ? detail.files.slice(0, 8).map((file, index) => `${index === state.selectedFileIndex ? '>' : ' '} ${formatChangedFile(file)}`)
19870
- : ['No changed files found.']),
19871
- '',
19872
- selectedFile
19873
- ? `Preview: ${formatChangedFile(selectedFile)}`
19874
- : 'Preview: no file selected',
19875
- filePreviewLoading
19876
- ? 'Loading diff preview...'
19877
- : previewWindow?.length
19878
- ? `Lines ${state.diffPreviewOffset + 1}-${state.diffPreviewOffset + previewWindow.length}/${filePreview?.hunks.length || 0}`
19879
- : 'No hunk preview available.',
19880
- ...(previewWindow || []).map((line) => ` ${line}`),
19881
- '',
19882
- 'Workflows:',
19883
- ...workflowSections.flatMap((section) => [
19884
- section.title,
19885
- ...section.lines.slice(0, 3).map((line) => ` ${line}`),
19886
- ]).slice(0, 14),
19887
- ]
19888
- : [
22312
+ if (!detail) {
22313
+ const fallbackLines = [
19889
22314
  selected?.message || 'No commit selected.',
19890
22315
  '',
19891
22316
  loading ? 'Loading commit details...' : 'Commit details unavailable.',
19892
22317
  ];
22318
+ return h(Box, {
22319
+ borderColor: focusBorderColor(theme, focused),
22320
+ borderStyle: theme.borderStyle,
22321
+ flexDirection: 'column',
22322
+ width,
22323
+ paddingX: 1,
22324
+ }, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...fallbackLines.map((line, index) => h(Text, {
22325
+ key: `detail-${index}`,
22326
+ dimColor: index > 1,
22327
+ }, truncate$1(line, width - 4))));
22328
+ }
22329
+ const statLine = `${detail.stats.filesChanged} files +${detail.stats.insertions}/-${detail.stats.deletions}`;
22330
+ // P5.1 — link the commit hash and each ref out to GitHub when we know
22331
+ // the remote. OSC 8 escapes embed inline; supportsHyperlinks() decides
22332
+ // whether to wrap or fall through to plain text.
22333
+ const repository = context.provider?.repository;
22334
+ const commitLink = formatHyperlink(compactHash(detail.hash), buildCommitUrl(repository, detail.hash));
22335
+ const refNodes = detail.refs.length
22336
+ ? renderInspectorRefs(h, Text, detail.refs, repository)
22337
+ : null;
22338
+ const headerNodes = [
22339
+ h(Text, { key: 'detail-msg' }, truncate$1(detail.message, width - 4)),
22340
+ h(Text, { key: 'detail-spacer-1' }, ''),
22341
+ h(Text, { key: 'detail-commit', dimColor: true }, 'Commit: ', commitLink),
22342
+ h(Text, { key: 'detail-author', dimColor: true }, truncate$1(`Author: ${detail.author}`, width - 4)),
22343
+ h(Text, { key: 'detail-date', dimColor: true }, truncate$1(`Date: ${detail.date}`, width - 4)),
22344
+ refNodes
22345
+ ? h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: ', ...refNodes)
22346
+ : h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: none'),
22347
+ h(Text, { key: 'detail-stat', dimColor: true }, truncate$1(statLine, width - 4)),
22348
+ h(Text, { key: 'detail-spacer-2' }, ''),
22349
+ ...(detail.body ? detail.body.split('\n').slice(0, 6) : ['No commit body.']).map((line, index) => h(Text, {
22350
+ key: `detail-body-${index}`,
22351
+ dimColor: true,
22352
+ }, truncate$1(line, width - 4))),
22353
+ h(Text, { key: 'detail-spacer-3' }, ''),
22354
+ h(Text, { key: 'detail-files-title' }, 'Changed files:'),
22355
+ ];
22356
+ const fileListMaxRows = Math.max(4, Math.min(detail.files.length, 10));
22357
+ const fileListNodes = renderCommitFileList(h, Text, detail.files, state.selectedFileIndex, focused, fileListMaxRows, width, theme);
22358
+ const trailerLines = [
22359
+ '',
22360
+ 'Workflows:',
22361
+ ...workflowSections.flatMap((section) => [
22362
+ section.title,
22363
+ ...section.lines.slice(0, 3).map((line) => ` ${line}`),
22364
+ ]).slice(0, 12),
22365
+ ];
19893
22366
  return h(Box, {
19894
22367
  borderColor: focusBorderColor(theme, focused),
19895
22368
  borderStyle: theme.borderStyle,
19896
22369
  flexDirection: 'column',
19897
22370
  width,
19898
22371
  paddingX: 1,
19899
- }, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...lines.map((line, index) => h(Text, {
19900
- key: `detail-${index}`,
19901
- dimColor: index > 1 && line !== 'Changed files:',
22372
+ }, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...trailerLines.map((line, index) => h(Text, {
22373
+ key: `detail-trailer-${index}`,
22374
+ dimColor: index > 0,
19902
22375
  }, truncate$1(line, width - 4))));
19903
22376
  }
22377
+ /**
22378
+ * Build a commit URL for the repo when GitHub provider info is available.
22379
+ * Returns undefined for unsupported remotes — formatHyperlink falls through
22380
+ * to plain text in that case.
22381
+ */
22382
+ function buildCommitUrl(repository, hash) {
22383
+ if (!repository)
22384
+ return undefined;
22385
+ return buildProviderUrl(repository, { type: 'commit', commit: hash });
22386
+ }
22387
+ /**
22388
+ * Build a branch URL for a ref name. Strips the `HEAD -> ` and `tag: `
22389
+ * prefixes git decoration uses. For everything else we treat the ref as a
22390
+ * branch — GitHub's `/tree/<ref>` resolves both branches and tags.
22391
+ */
22392
+ function buildRefUrl(repository, ref) {
22393
+ if (!repository)
22394
+ return undefined;
22395
+ const stripped = ref.replace(/^HEAD -> /, '').replace(/^tag: /, '').trim();
22396
+ if (!stripped)
22397
+ return undefined;
22398
+ return buildProviderUrl(repository, { type: 'branch', branch: stripped });
22399
+ }
22400
+ /**
22401
+ * Render `refs` as a comma-separated sequence of <Text> fragments, each
22402
+ * wrapped in OSC 8 (no-op when the terminal can't render hyperlinks).
22403
+ */
22404
+ function renderInspectorRefs(h, Text, refs, repository) {
22405
+ const out = [];
22406
+ refs.forEach((ref, index) => {
22407
+ if (index > 0) {
22408
+ out.push(h(Text, { key: `ref-sep-${index}` }, ', '));
22409
+ }
22410
+ out.push(h(Text, { key: `ref-${index}` }, formatHyperlink(ref, buildRefUrl(repository, ref))));
22411
+ });
22412
+ return out;
22413
+ }
22414
+ function renderCommitDiffDetail(h, components, state, detail, loading, width, theme, focused) {
22415
+ const { Box, Text } = components;
22416
+ const selected = getSelectedInkCommit(state);
22417
+ if (!detail) {
22418
+ const fallbackLines = [
22419
+ selected?.message || 'No commit selected.',
22420
+ '',
22421
+ loading ? 'Loading commit details...' : 'Commit details unavailable.',
22422
+ ];
22423
+ return h(Box, {
22424
+ borderColor: focusBorderColor(theme, focused),
22425
+ borderStyle: theme.borderStyle,
22426
+ flexDirection: 'column',
22427
+ width,
22428
+ paddingX: 1,
22429
+ }, h(Text, { bold: true }, panelTitle('Commit', focused)), ...fallbackLines.map((line, index) => h(Text, {
22430
+ key: `commit-diff-${index}`,
22431
+ dimColor: index > 1,
22432
+ }, truncate$1(line, width - 4))));
22433
+ }
22434
+ const statLine = `${detail.stats.filesChanged} files +${detail.stats.insertions}/-${detail.stats.deletions}`;
22435
+ const headerLines = [
22436
+ detail.message,
22437
+ '',
22438
+ `${compactHash(detail.hash)} ${detail.date} ${detail.author}`,
22439
+ detail.refs.length ? `Refs: ${detail.refs.join(', ')}` : 'Refs: none',
22440
+ statLine,
22441
+ '',
22442
+ ];
22443
+ const bodyLines = detail.body ? detail.body.split('\n').slice(0, 5) : [];
22444
+ const filesHeader = ['Files:'];
22445
+ const fileListMaxRows = Math.max(4, Math.min(detail.files.length, 12));
22446
+ const fileListNodes = renderCommitFileList(h, Text, detail.files, state.selectedFileIndex, focused, fileListMaxRows, width, theme);
22447
+ const hint = focused
22448
+ ? 'j/k pick file · enter swaps the center diff'
22449
+ : 'tab focuses the file list';
22450
+ return h(Box, {
22451
+ borderColor: focusBorderColor(theme, focused),
22452
+ borderStyle: theme.borderStyle,
22453
+ flexDirection: 'column',
22454
+ width,
22455
+ paddingX: 1,
22456
+ }, h(Text, { bold: true }, panelTitle('Commit', focused)), ...headerLines.map((line, index) => h(Text, {
22457
+ key: `commit-diff-header-${index}`,
22458
+ bold: index === 0,
22459
+ dimColor: index > 0 && index < headerLines.length - 1,
22460
+ }, truncate$1(line, width - 4))), ...bodyLines.map((line, index) => h(Text, {
22461
+ key: `commit-diff-body-${index}`,
22462
+ dimColor: true,
22463
+ }, truncate$1(line, width - 4))), ...(bodyLines.length ? [h(Text, { key: 'commit-diff-body-spacer' }, '')] : []), ...filesHeader.map((line, index) => h(Text, {
22464
+ key: `commit-diff-files-${index}`,
22465
+ bold: true,
22466
+ }, truncate$1(line, width - 4))), ...fileListNodes, h(Text, undefined, ''), h(Text, { dimColor: true }, truncate$1(hint, width - 4)));
22467
+ }
22468
+ function renderComposeContextPanel(h, components, state, context, contextStatus, width, theme, focused) {
22469
+ const { Box, Text } = components;
22470
+ const worktree = context.worktree;
22471
+ const compose = state.commitCompose;
22472
+ const loadingWorktree = isLogInkContextKeyLoading(contextStatus, 'worktree');
22473
+ const summary = loadingWorktree
22474
+ ? 'Worktree status loading'
22475
+ : worktree
22476
+ ? `${worktree.stagedCount} staged · ${worktree.unstagedCount} unstaged · ${worktree.untrackedCount} untracked`
22477
+ : 'No worktree information yet';
22478
+ const stagedFiles = (worktree?.files || [])
22479
+ .filter((file) => file.indexStatus !== ' ' && file.indexStatus !== '?')
22480
+ .slice(0, 12);
22481
+ const unstagedFiles = (worktree?.files || [])
22482
+ .filter((file) => file.worktreeStatus !== ' ' && file.indexStatus !== '?')
22483
+ .slice(0, 6);
22484
+ return h(Box, {
22485
+ borderColor: focusBorderColor(theme, focused),
22486
+ borderStyle: theme.borderStyle,
22487
+ flexDirection: 'column',
22488
+ width,
22489
+ paddingX: 1,
22490
+ }, h(Text, { bold: true }, panelTitle('Worktree', focused)), h(Text, { dimColor: true }, truncate$1(summary, width - 4)), h(Text, undefined, ''), ...(compose.loading
22491
+ ? [h(Text, {
22492
+ key: 'compose-context-loading',
22493
+ bold: true,
22494
+ color: theme.noColor ? undefined : theme.colors.accent,
22495
+ }, truncate$1(theme.ascii ? '[...] AI draft in progress' : '⏳ AI draft in progress', width - 4))]
22496
+ : []), ...(stagedFiles.length
22497
+ ? [
22498
+ h(Text, { key: 'compose-context-staged-title', bold: true }, 'Staged'),
22499
+ ...stagedFiles.map((file, index) => h(Text, {
22500
+ key: `compose-context-staged-${index}`,
22501
+ color: theme.noColor ? undefined : theme.colors.gitAdded,
22502
+ }, truncate$1(` ${file.indexStatus} ${file.path}`, width - 4))),
22503
+ h(Text, { key: 'compose-context-staged-spacer' }, ''),
22504
+ ]
22505
+ : []), ...(unstagedFiles.length
22506
+ ? [
22507
+ h(Text, { key: 'compose-context-unstaged-title', bold: true }, 'Unstaged'),
22508
+ ...unstagedFiles.map((file, index) => h(Text, {
22509
+ key: `compose-context-unstaged-${index}`,
22510
+ color: theme.noColor ? undefined : theme.colors.gitModified,
22511
+ }, truncate$1(` ${file.worktreeStatus} ${file.path}`, width - 4))),
22512
+ ]
22513
+ : !stagedFiles.length && !loadingWorktree
22514
+ ? [h(Text, { dimColor: true }, 'No worktree changes detected.')]
22515
+ : []));
22516
+ }
22517
+ /**
22518
+ * Render a list of changed files with status-code colors and stats. Used
22519
+ * by both the history inspector and the commit-diff detail panel so the
22520
+ * two surfaces stay visually consistent.
22521
+ *
22522
+ * `focused` only controls whether the cursor row is inverse-highlighted —
22523
+ * keys j/k and Enter dispatch via the input handler regardless.
22524
+ */
22525
+ function renderCommitFileList(h, Text, files, selectedIndex, focused, maxRows, width, theme) {
22526
+ if (!files.length) {
22527
+ return [h(Text, { key: 'commit-file-list-empty', dimColor: true }, 'No changed files found.')];
22528
+ }
22529
+ const clamped = Math.max(0, Math.min(selectedIndex, files.length - 1));
22530
+ const startIndex = Math.max(0, clamped - Math.floor(maxRows / 2));
22531
+ const visible = files.slice(startIndex, startIndex + maxRows);
22532
+ return visible.map((file, offset) => {
22533
+ const index = startIndex + offset;
22534
+ const isSelected = index === clamped;
22535
+ const cursor = isSelected ? '>' : ' ';
22536
+ const stats = formatChangedFileStats(file);
22537
+ const renamed = file.oldPath ? ` (was ${file.oldPath})` : '';
22538
+ const statusCode = file.status.padEnd(3);
22539
+ const label = `${cursor} ${statusCode} ${file.path}${renamed}${stats ? ` ${stats}` : ''}`;
22540
+ return h(Text, {
22541
+ key: `commit-file-${index}`,
22542
+ color: statusCodeColor(file.status, theme),
22543
+ inverse: isSelected && focused && !theme.noColor,
22544
+ bold: isSelected,
22545
+ }, truncate$1(label, width - 4));
22546
+ });
22547
+ }
22548
+ function renderPreviewPanel(h, components, title, lines, width, theme, focused) {
22549
+ const { Box, Text } = components;
22550
+ return h(Box, {
22551
+ borderColor: focusBorderColor(theme, focused),
22552
+ borderStyle: theme.borderStyle,
22553
+ flexDirection: 'column',
22554
+ width,
22555
+ paddingX: 1,
22556
+ }, h(Text, { bold: true }, panelTitle(title, focused)), ...lines.map((line, index) => {
22557
+ const isHeading = line.emphasis === 'heading' && index > 0;
22558
+ return h(Text, {
22559
+ key: `preview-${index}`,
22560
+ bold: isHeading,
22561
+ dimColor: line.emphasis === 'dim',
22562
+ }, truncate$1(line.text, width - 4));
22563
+ }));
22564
+ }
22565
+ function renderBranchPreviewPanel(h, components, state, context, contextStatus, width, theme, focused) {
22566
+ const { Box, Text } = components;
22567
+ if (isLogInkContextKeyLoading(contextStatus, 'branches')) {
22568
+ return renderPreviewPanel(h, { Box, Text }, 'Branch preview', [{ text: formatLogInkLoading({ resource: 'branches' }), emphasis: 'dim' }], width, theme, focused);
22569
+ }
22570
+ const all = context.branches?.localBranches || [];
22571
+ const visible = state.filter
22572
+ ? all.filter((branch) => matchesPromotedFilter([branch.shortName, branch.upstream || ''], state.filter))
22573
+ : all;
22574
+ const index = Math.max(0, Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1)));
22575
+ const branch = visible[index];
22576
+ return renderPreviewPanel(h, { Box, Text }, 'Branch preview', formatBranchPreview(branch), width, theme, focused);
22577
+ }
22578
+ function renderTagPreviewPanel(h, components, state, context, contextStatus, width, theme, focused) {
22579
+ const { Box, Text } = components;
22580
+ if (isLogInkContextKeyLoading(contextStatus, 'tags')) {
22581
+ return renderPreviewPanel(h, { Box, Text }, 'Tag preview', [{ text: formatLogInkLoading({ resource: 'tags' }), emphasis: 'dim' }], width, theme, focused);
22582
+ }
22583
+ const all = context.tags?.tags || [];
22584
+ const visible = state.filter
22585
+ ? all.filter((tag) => matchesPromotedFilter([tag.name, tag.subject], state.filter))
22586
+ : all;
22587
+ const index = Math.max(0, Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1)));
22588
+ const tag = visible[index];
22589
+ return renderPreviewPanel(h, { Box, Text }, 'Tag preview', formatTagPreview(tag), width, theme, focused);
22590
+ }
22591
+ function renderStashPreviewPanel(h, components, state, context, contextStatus, width, theme, focused) {
22592
+ const { Box, Text } = components;
22593
+ if (isLogInkContextKeyLoading(contextStatus, 'stashes')) {
22594
+ return renderPreviewPanel(h, { Box, Text }, 'Stash preview', [{ text: formatLogInkLoading({ resource: 'stashes' }), emphasis: 'dim' }], width, theme, focused);
22595
+ }
22596
+ const all = context.stashes?.stashes || [];
22597
+ const visible = state.filter
22598
+ ? all.filter((stash) => matchesPromotedFilter([stash.ref, stash.message], state.filter))
22599
+ : all;
22600
+ const index = Math.max(0, Math.min(state.selectedStashIndex, Math.max(0, visible.length - 1)));
22601
+ const stash = visible[index];
22602
+ return renderPreviewPanel(h, { Box, Text }, 'Stash preview', formatStashPreview(stash), width, theme, focused);
22603
+ }
19904
22604
  function renderCommitPanel(h, components, state, context, contextStatus, width, theme, focused) {
19905
22605
  const { Box, Text } = components;
19906
22606
  const compose = state.commitCompose;
@@ -19912,31 +22612,55 @@ function renderCommitPanel(h, components, state, context, contextStatus, width,
19912
22612
  : `${stagedCount} staged | ${unstagedCount} unstaged`;
19913
22613
  const summaryCursor = compose.editing && compose.field === 'summary' ? '_' : '';
19914
22614
  const bodyCursor = compose.editing && compose.field === 'body' ? '_' : '';
19915
- const bodyLines = compose.body ? compose.body.split('\n').slice(0, 4) : ['<empty>'];
19916
- const lines = [
22615
+ const bodyTextWidth = Math.max(8, width - 6); // 4 for chrome + 2 for indent
22616
+ // Wrap each source line of the body so long messages don't get cut off
22617
+ // by the previous truncate(line, width - 4). The 12-line cap is generous
22618
+ // — most commit bodies fit, and the panel's column layout absorbs the
22619
+ // height naturally.
22620
+ const bodyHasContent = Boolean(compose.body);
22621
+ const bodyVisualLines = bodyHasContent
22622
+ ? compose.body.split('\n').flatMap((line) => wrapCells(line, bodyTextWidth)).slice(0, 12)
22623
+ : ['<empty>'];
22624
+ const summaryWrapped = wrapCells(`${compose.summary || '<empty>'}${summaryCursor}`, bodyTextWidth);
22625
+ const summaryFirst = `${compose.field === 'summary' && compose.editing ? '>' : ' '} Summary: ${summaryWrapped[0] || ''}`;
22626
+ const summaryRest = summaryWrapped.slice(1).map((line) => ` ${line}`);
22627
+ const headerLines = [
19917
22628
  statusLine,
19918
22629
  '',
19919
- `${compose.field === 'summary' && compose.editing ? '>' : ' '} Summary: ${compose.summary || '<empty>'}${summaryCursor}`,
22630
+ summaryFirst,
22631
+ ...summaryRest,
19920
22632
  `${compose.field === 'body' && compose.editing ? '>' : ' '} Body:`,
19921
- ...bodyLines.map((line) => ` ${line}${bodyCursor && line === bodyLines[bodyLines.length - 1] ? bodyCursor : ''}`),
22633
+ ...bodyVisualLines.map((line, index) => {
22634
+ const isLast = index === bodyVisualLines.length - 1;
22635
+ return ` ${line}${bodyCursor && isLast ? bodyCursor : ''}`;
22636
+ }),
19922
22637
  '',
19923
- loading
19924
- ? 'Working...'
19925
- : compose.editing
19926
- ? 'Enter/tab edits fields, Esc exits edit mode.'
19927
- : 'e edit | c commit | I AI draft',
22638
+ ];
22639
+ const trailerLines = [
19928
22640
  ...(compose.message ? ['', compose.message] : []),
19929
22641
  ...(compose.details || []).map((line) => ` ${line}`),
19930
22642
  ];
22643
+ const stateLine = compose.editing
22644
+ ? 'Enter/tab edits fields, Esc exits edit mode.'
22645
+ : 'e edit | c commit | I AI draft';
19931
22646
  return h(Box, {
19932
22647
  borderColor: focusBorderColor(theme, focused),
19933
22648
  borderStyle: theme.borderStyle,
19934
22649
  flexDirection: 'column',
19935
22650
  width,
19936
22651
  paddingX: 1,
19937
- }, h(Text, { bold: true }, panelTitle('Commit', focused)), ...lines.map((line, index) => h(Text, {
19938
- key: `commit-${index}`,
22652
+ }, h(Text, { bold: true }, panelTitle('Commit', focused)), ...headerLines.map((line, index) => h(Text, {
22653
+ key: `commit-header-${index}`,
19939
22654
  dimColor: index < 2 || line.startsWith(' ') || line === '<empty>',
22655
+ }, truncate$1(line, width - 4))), loading
22656
+ ? h(Text, {
22657
+ key: 'commit-loading',
22658
+ bold: true,
22659
+ color: theme.noColor ? undefined : theme.colors.accent,
22660
+ }, truncate$1(theme.ascii ? '[...] Generating AI draft' : '⏳ Generating AI draft…', width - 4))
22661
+ : h(Text, { key: 'commit-state', dimColor: true }, truncate$1(stateLine, width - 4)), ...trailerLines.map((line, index) => h(Text, {
22662
+ key: `commit-trailer-${index}`,
22663
+ dimColor: line.startsWith(' '),
19940
22664
  }, truncate$1(line, width - 4))));
19941
22665
  }
19942
22666
  function renderConfirmationPanel(h, components, state, width, theme, focused) {
@@ -19946,13 +22670,17 @@ function renderConfirmationPanel(h, components, state, width, theme, focused) {
19946
22670
  ? 'Revert selected hunk'
19947
22671
  : state.pendingMutationConfirmation === 'revert-file'
19948
22672
  ? 'Revert selected file'
19949
- : undefined;
22673
+ : state.pendingMutationConfirmation === 'discard-draft'
22674
+ ? 'Quit and discard the in-progress commit draft'
22675
+ : undefined;
19950
22676
  const label = action?.label || mutationLabel || 'Workflow action';
19951
- const warning = state.pendingMutationConfirmation
19952
- ? 'This discards local changes and cannot be undone by Coco.'
19953
- : action?.kind === 'ai'
19954
- ? `AI action requires confirmation. Estimated ${action.estimatedTokens || '<unknown>'} tokens.`
19955
- : 'Destructive Git action requires confirmation.';
22677
+ const warning = state.pendingMutationConfirmation === 'discard-draft'
22678
+ ? 'You have an unsaved commit draft. Press y to discard it and quit.'
22679
+ : state.pendingMutationConfirmation
22680
+ ? 'This discards local changes and cannot be undone by Coco.'
22681
+ : action?.kind === 'ai'
22682
+ ? `AI action requires confirmation. Estimated ${action.estimatedTokens || '<unknown>'} tokens.`
22683
+ : 'Destructive Git action requires confirmation.';
19956
22684
  return h(Box, {
19957
22685
  borderColor: focusBorderColor(theme, focused),
19958
22686
  borderStyle: theme.borderStyle,
@@ -19961,6 +22689,78 @@ function renderConfirmationPanel(h, components, state, width, theme, focused) {
19961
22689
  paddingX: 1,
19962
22690
  }, h(Text, { bold: true }, panelTitle('Confirm', focused)), h(Text, undefined, truncate$1(label, width - 4)), h(Text, { dimColor: true }, truncate$1(warning, width - 4)), h(Text, undefined, ''), h(Text, undefined, 'Press y to confirm or n/Esc to cancel.'));
19963
22691
  }
22692
+ /**
22693
+ * First-launch onboarding overlay (P1.3). Shown once per machine, gated
22694
+ * by an XDG-style cache marker so subsequent launches go straight to the
22695
+ * normal UI. Auto-dismisses on the next keystroke.
22696
+ *
22697
+ * Replaces the whole layout for the first render rather than overlaying
22698
+ * a transient banner — Ink doesn't support floating elements, and a full
22699
+ * takeover keeps the message readable on small terminals while still
22700
+ * being instantly dismissible.
22701
+ */
22702
+ function renderOnboardingOverlay(h, components, rows, columns, theme, appLabel) {
22703
+ const { Box, Text } = components;
22704
+ const accent = theme.noColor ? undefined : theme.colors.accent;
22705
+ const tips = [
22706
+ { keys: '?', text: 'open the help panel' },
22707
+ { keys: ':', text: 'open the command palette' },
22708
+ { keys: 'g h', text: 'jump to history (g s status, g d diff, g c compose, g b branches, g t tags, g z stash)' },
22709
+ { keys: '< esc', text: 'pop the navigation stack / go back' },
22710
+ { keys: '/', text: 'filter the active list' },
22711
+ { keys: 'q ctrl+c', text: 'quit' },
22712
+ ];
22713
+ const maxKeys = tips.reduce((max, tip) => Math.max(max, tip.keys.length), 0);
22714
+ const lineWidth = Math.max(40, columns - 4);
22715
+ return h(Box, {
22716
+ flexDirection: 'column',
22717
+ height: rows,
22718
+ paddingX: 2,
22719
+ paddingY: 1,
22720
+ }, h(Text, { bold: true, color: accent }, `Welcome to ${appLabel}`), h(Text, { dimColor: true }, 'A quick keyboard tour — press any key to dismiss.'), h(Text, undefined, ''), ...tips.map((tip, index) => h(Text, { key: `onboarding-tip-${index}` }, h(Text, { color: accent, bold: true }, ` ${tip.keys.padEnd(maxKeys)} `), h(Text, undefined, truncate$1(tip.text, lineWidth - maxKeys - 4)))), h(Text, undefined, ''), h(Text, { dimColor: true }, 'This tip is shown once per machine. Press any key to continue.'));
22721
+ }
22722
+ /**
22723
+ * Which-key style chord overlay (P1.1). When the user presses a chord
22724
+ * prefix (currently just `g`), the dispatcher sets `state.pendingKey`
22725
+ * and waits for the second key. This panel surfaces the available
22726
+ * continuations so newcomers don't have to memorize the chord set.
22727
+ *
22728
+ * Renders in the detail panel slot; auto-dismisses when the chord
22729
+ * completes or `pendingKey` is otherwise cleared.
22730
+ */
22731
+ function renderChordOverlay(h, components, state, width, theme, focused) {
22732
+ const { Box, Text } = components;
22733
+ const prefix = state.pendingKey || '';
22734
+ const continuations = getLogInkChordContinuations(prefix);
22735
+ const accent = theme.noColor ? undefined : theme.colors.accent;
22736
+ const lines = [
22737
+ h(Text, { key: 'chord-title', bold: true }, panelTitle(`${prefix} … jump`, focused)),
22738
+ h(Text, { key: 'chord-spacer' }, ''),
22739
+ ];
22740
+ if (continuations.length === 0) {
22741
+ lines.push(h(Text, {
22742
+ key: 'chord-empty',
22743
+ dimColor: true,
22744
+ }, truncate$1(`No bindings registered for the ${prefix} prefix.`, width - 4)));
22745
+ }
22746
+ else {
22747
+ for (const entry of continuations) {
22748
+ lines.push(h(Text, { key: `chord-${entry.key}` }, h(Text, { color: accent, bold: true }, ` ${entry.key} `), h(Text, undefined, truncate$1(`${entry.label.padEnd(10)} ${entry.description}`, width - 9))));
22749
+ }
22750
+ }
22751
+ lines.push(h(Text, { key: 'chord-foot-spacer' }, ''));
22752
+ lines.push(h(Text, {
22753
+ key: 'chord-hint',
22754
+ dimColor: true,
22755
+ }, truncate$1('press the second key to jump · esc cancels', width - 4)));
22756
+ return h(Box, {
22757
+ borderColor: focusBorderColor(theme, focused),
22758
+ borderStyle: theme.borderStyle,
22759
+ flexDirection: 'column',
22760
+ width,
22761
+ paddingX: 1,
22762
+ }, ...lines);
22763
+ }
19964
22764
  function renderHelpPanel(h, components, state, width, theme, focused) {
19965
22765
  const { Box, Text } = components;
19966
22766
  const children = [
@@ -20035,16 +22835,20 @@ function renderCommandPalette(h, components, state, width, theme, focused) {
20035
22835
  ? [h(Text, { key: 'palette-recent-hint', dimColor: true }, '· marks recently-used')]
20036
22836
  : []), ...itemLines);
20037
22837
  }
20038
- function renderFooter(h, components, state, theme) {
22838
+ function renderFooter(h, components, state, theme, idleTip) {
20039
22839
  const { Box, Text } = components;
20040
22840
  const hints = getLogInkFooterHints({
20041
22841
  activeView: state.activeView,
20042
22842
  filterMode: state.filterMode,
20043
22843
  focus: state.focus,
22844
+ pendingKey: state.pendingKey,
20044
22845
  showCommandPalette: state.showCommandPalette,
20045
22846
  showHelp: state.showHelp,
20046
22847
  });
20047
- const status = state.statusMessage ? ` ${state.statusMessage}` : '';
22848
+ // Real status messages always win; idle tips only fill the slot when it
22849
+ // would otherwise be empty.
22850
+ const trailing = state.statusMessage || idleTip || '';
22851
+ const status = trailing ? ` ${trailing}` : '';
20048
22852
  const contextualText = `${hints.contextual.join(' ')}${status}`;
20049
22853
  const globalText = hints.global.join(' · ');
20050
22854
  return h(Box, {
@@ -20085,6 +22889,7 @@ async function startCocoUiFromLogArgv(logArgv, options = {}) {
20085
22889
  const rows = options.rows || (await getLogRows(git, logArgv));
20086
22890
  await startInkInteractiveLog(git, rows, {}, {
20087
22891
  appLabel: 'coco ui',
22892
+ idleTips: config.logTui?.idleTips,
20088
22893
  initialView: 'history',
20089
22894
  logArgv,
20090
22895
  theme: config.logTui?.theme,
@@ -20097,6 +22902,7 @@ async function startCocoUi(argv) {
20097
22902
  const rows = await getLogRows(git, logArgv);
20098
22903
  await startInkInteractiveLog(git, rows, {}, {
20099
22904
  appLabel: 'coco ui',
22905
+ idleTips: config.logTui?.idleTips,
20100
22906
  initialView: argv.view || 'history',
20101
22907
  logArgv,
20102
22908
  theme: createUiTheme(config, argv),
@@ -21145,6 +23951,7 @@ y.command(changelog.command, changelog.desc, changelog.builder, changelog.handle
21145
23951
  y.command(recap.command, recap.desc, recap.builder, recap.handler);
21146
23952
  y.command(review.command, review.desc, review.builder, review.handler);
21147
23953
  y.command(init.command, init.desc, init.builder, init.handler);
23954
+ y.command(doctor.command, doctor.desc, doctor.builder, doctor.handler);
21148
23955
  y.command(log.command, log.desc, log.builder, log.handler);
21149
23956
  y.command(ui.command, ui.desc, ui.builder, ui.handler);
21150
23957
  y.help().parse(process.argv.slice(2));
@@ -21598,4 +24405,4 @@ var commitValidationHandler = /*#__PURE__*/Object.freeze({
21598
24405
  handleValidationErrors: handleValidationErrors
21599
24406
  });
21600
24407
 
21601
- export { changelog, commit, init, log, recap, types, ui };
24408
+ export { changelog, commit, doctor, init, log, recap, types, ui };