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.
package/dist/index.js CHANGED
@@ -36,6 +36,9 @@ require('@langchain/core/utils/env');
36
36
  require('@langchain/core/utils/async_caller');
37
37
  var tiktoken = require('tiktoken');
38
38
  var child_process = require('child_process');
39
+ var fs$1 = require('node:fs');
40
+ var os$1 = require('node:os');
41
+ var path$1 = require('node:path');
39
42
  var readline = require('readline');
40
43
  var util$1 = require('util');
41
44
  var url = require('url');
@@ -61,6 +64,9 @@ var fs__namespace = /*#__PURE__*/_interopNamespaceDefault(fs);
61
64
  var ini__namespace = /*#__PURE__*/_interopNamespaceDefault(ini);
62
65
  var os__namespace = /*#__PURE__*/_interopNamespaceDefault(os);
63
66
  var path__namespace = /*#__PURE__*/_interopNamespaceDefault(path);
67
+ var fs__namespace$1 = /*#__PURE__*/_interopNamespaceDefault(fs$1);
68
+ var os__namespace$1 = /*#__PURE__*/_interopNamespaceDefault(os$1);
69
+ var path__namespace$1 = /*#__PURE__*/_interopNamespaceDefault(path$1);
64
70
  var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
65
71
 
66
72
  // This file is auto-generated - DO NOT EDIT
@@ -68,7 +74,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
68
74
  /**
69
75
  * Current build version from package.json
70
76
  */
71
- const BUILD_VERSION = "0.34.0";
77
+ const BUILD_VERSION = "0.35.0";
72
78
 
73
79
  const isInteractive = (config) => {
74
80
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -549,14 +555,9 @@ const CONFIG_KEYS = Object.keys({
549
555
  prompt: '',
550
556
  });
551
557
 
552
- /**
553
- * Load environment variables
554
- *
555
- * @param {Config} config
556
- * @returns {Config} Updated config
557
- **/
558
- function loadEnvConfig(config) {
558
+ function loadEnvConfig(config, opts) {
559
559
  const envConfig = {};
560
+ let foundAny = false;
560
561
  const envKeys = [
561
562
  ...CONFIG_KEYS,
562
563
  'COCO_SERVICE_PROVIDER',
@@ -591,15 +592,20 @@ function loadEnvConfig(config) {
591
592
  // @ts-ignore
592
593
  envConfig.service = envConfig.service || {};
593
594
  handleServiceEnvVar(envConfig.service, key, envValue);
595
+ foundAny = true;
594
596
  }
595
597
  else {
596
598
  if (key === 'service' || !envValue) {
597
599
  return;
598
600
  }
599
601
  envConfig[key] = envValue;
602
+ foundAny = true;
600
603
  }
601
604
  });
602
- return { ...config, ...removeUndefined(envConfig) };
605
+ const merged = { ...config, ...removeUndefined(envConfig) };
606
+ {
607
+ return { config: merged, active: foundAny };
608
+ }
603
609
  }
604
610
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
605
611
  function handleServiceEnvVar(service, key, value) {
@@ -726,19 +732,15 @@ const appendToEnvFile = async (filePath, config) => {
726
732
  });
727
733
  };
728
734
 
729
- /**
730
- * Load git profile config (from ~/.gitconfig)
731
- *
732
- * @param {Config} config
733
- * @returns {Config} Updated config
734
- **/
735
- function loadGitConfig(config) {
735
+ function loadGitConfig(config, opts) {
736
736
  const gitConfigPath = path__namespace.join(os__namespace.homedir(), '.gitconfig');
737
+ let foundPath;
737
738
  if (fs__namespace.existsSync(gitConfigPath)) {
738
739
  const gitConfigRaw = fs__namespace.readFileSync(gitConfigPath, 'utf-8');
739
740
  const gitConfigParsed = ini__namespace.parse(gitConfigRaw);
740
741
  let service = config.service;
741
742
  if (gitConfigParsed.coco) {
743
+ foundPath = gitConfigPath;
742
744
  service = {
743
745
  provider: gitConfigParsed.coco?.serviceProvider,
744
746
  model: gitConfigParsed.coco?.serviceModel,
@@ -792,7 +794,10 @@ function loadGitConfig(config) {
792
794
  includeBranchName: gitConfigParsed.coco?.includeBranchName || config.includeBranchName,
793
795
  };
794
796
  }
795
- return removeUndefined(config);
797
+ const cleaned = removeUndefined(config);
798
+ {
799
+ return { config: cleaned, path: foundPath };
800
+ }
796
801
  }
797
802
  /**
798
803
  * Appends the provided configuration to a git config file.
@@ -1032,6 +1037,10 @@ const schema$1 = {
1032
1037
  "theme": {
1033
1038
  "$ref": "#/definitions/LogInkThemeConfig",
1034
1039
  "description": "Theme settings for `coco log -i`."
1040
+ },
1041
+ "idleTips": {
1042
+ "type": "boolean",
1043
+ "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."
1035
1044
  }
1036
1045
  },
1037
1046
  "additionalProperties": false,
@@ -1865,24 +1874,29 @@ const ajv = new Ajv({
1865
1874
  });
1866
1875
 
1867
1876
  const validate = ajv.compile(schema$1);
1868
- /**
1869
- * Load project config
1870
- *
1871
- * @param {Config} config
1872
- * @returns {Config} Updated config
1873
- **/
1874
- function loadProjectJsonConfig(config) {
1875
- if (fs__namespace.existsSync('.coco.config.json')) {
1877
+ function loadProjectJsonConfig(config, opts) {
1878
+ // Prefer .coco.json, fall back to .coco.config.json
1879
+ const candidates = ['.coco.json', '.coco.config.json'];
1880
+ let resolvedPath;
1881
+ for (const candidate of candidates) {
1882
+ if (fs__namespace.existsSync(candidate)) {
1883
+ resolvedPath = candidate;
1884
+ break;
1885
+ }
1886
+ }
1887
+ if (resolvedPath) {
1876
1888
  // Removing $schema from the project config to avoid validation errors.
1877
1889
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
1878
- const { $schema, ...projectConfig } = JSON.parse(fs__namespace.readFileSync('.coco.config.json', 'utf-8'));
1890
+ const { $schema, ...projectConfig } = JSON.parse(fs__namespace.readFileSync(resolvedPath, 'utf-8'));
1879
1891
  config = { ...config, ...projectConfig };
1880
1892
  const isProjectConfigValid = validate(config);
1881
1893
  if (!isProjectConfigValid) {
1882
1894
  throw new Error('Invalid project config', { cause: ajv.errorsText(validate.errors) });
1883
1895
  }
1884
1896
  }
1885
- return config;
1897
+ {
1898
+ return { config: config, path: resolvedPath };
1899
+ }
1886
1900
  }
1887
1901
  const appendToProjectJsonConfig = (filePath, config) => {
1888
1902
  if (!fs__namespace.existsSync(filePath)) {
@@ -1894,16 +1908,12 @@ const appendToProjectJsonConfig = (filePath, config) => {
1894
1908
  }, null, 2));
1895
1909
  };
1896
1910
 
1897
- /**
1898
- * Load XDG config
1899
- *
1900
- * @param {Config} config
1901
- * @returns {Config} Updated config
1902
- */
1903
- function loadXDGConfig(config) {
1911
+ function loadXDGConfig(config, opts) {
1904
1912
  const xdgConfigHome = process.env.XDG_CONFIG_HOME || path__namespace.join(os__namespace.homedir(), '.config');
1905
1913
  const xdgConfigPath = path__namespace.join(xdgConfigHome, 'coco', 'config.json');
1914
+ let foundPath;
1906
1915
  if (fs__namespace.existsSync(xdgConfigPath)) {
1916
+ foundPath = xdgConfigPath;
1907
1917
  const xdgConfig = JSON.parse(fs__namespace.readFileSync(xdgConfigPath, 'utf-8'));
1908
1918
  const service = parseServiceConfig(xdgConfig.service || config.service);
1909
1919
  config = {
@@ -1912,7 +1922,9 @@ function loadXDGConfig(config) {
1912
1922
  service: service
1913
1923
  };
1914
1924
  }
1915
- return config;
1925
+ {
1926
+ return { config: config, path: foundPath };
1927
+ }
1916
1928
  }
1917
1929
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1918
1930
  function parseServiceConfig(service) {
@@ -1956,6 +1968,17 @@ function parseServiceConfig(service) {
1956
1968
  }
1957
1969
  }
1958
1970
 
1971
+ /**
1972
+ * Tracked config sources populated during the last loadConfig call.
1973
+ * Useful for diagnostics (e.g. `coco doctor`).
1974
+ */
1975
+ let _lastConfigSources = [];
1976
+ /**
1977
+ * Returns the config sources detected during the most recent loadConfig call.
1978
+ */
1979
+ function getConfigSources() {
1980
+ return _lastConfigSources;
1981
+ }
1959
1982
  /**
1960
1983
  * Load application config
1961
1984
  *
@@ -1974,14 +1997,28 @@ function parseServiceConfig(service) {
1974
1997
  * @returns {Config} application config
1975
1998
  **/
1976
1999
  function loadConfig(argv = {}) {
2000
+ const sources = [{ source: 'default' }];
1977
2001
  // Default config
1978
2002
  let config = DEFAULT_CONFIG;
1979
2003
  config = loadGitignore(config);
1980
2004
  config = loadIgnore(config);
1981
- config = loadXDGConfig(config);
1982
- config = loadGitConfig(config);
1983
- config = loadProjectJsonConfig(config);
1984
- config = loadEnvConfig(config);
2005
+ const { config: xdgConfig, path: xdgPath } = loadXDGConfig(config);
2006
+ config = xdgConfig;
2007
+ if (xdgPath)
2008
+ sources.push({ source: 'xdg', path: xdgPath });
2009
+ const { config: gitConfig, path: gitPath } = loadGitConfig(config);
2010
+ config = gitConfig;
2011
+ if (gitPath)
2012
+ sources.push({ source: 'git', path: gitPath });
2013
+ const { config: projectConfig, path: projectPath } = loadProjectJsonConfig(config);
2014
+ config = projectConfig;
2015
+ if (projectPath)
2016
+ sources.push({ source: 'project', path: projectPath });
2017
+ const { config: envConfig, active: envActive } = loadEnvConfig(config);
2018
+ config = envConfig;
2019
+ if (envActive)
2020
+ sources.push({ source: 'env' });
2021
+ _lastConfigSources = sources;
1985
2022
  return { ...config, ...argv };
1986
2023
  }
1987
2024
 
@@ -5931,11 +5968,11 @@ const ChangelogResponseSchema = objectType({
5931
5968
  title: stringType(),
5932
5969
  content: stringType(),
5933
5970
  });
5934
- const command$6 = 'changelog';
5971
+ const command$7 = 'changelog';
5935
5972
  /**
5936
5973
  * Command line options via yargs
5937
5974
  */
5938
- const options$6 = {
5975
+ const options$7 = {
5939
5976
  range: {
5940
5977
  type: 'string',
5941
5978
  alias: 'r',
@@ -5982,8 +6019,8 @@ const options$6 = {
5982
6019
  description: 'Toggle interactive mode',
5983
6020
  },
5984
6021
  };
5985
- const builder$6 = (yargs) => {
5986
- return yargs.options(options$6).usage(getCommandUsageHeader(command$6));
6022
+ const builder$7 = (yargs) => {
6023
+ return yargs.options(options$7).usage(getCommandUsageHeader(command$7));
5987
6024
  };
5988
6025
 
5989
6026
  /**
@@ -10850,7 +10887,7 @@ async function processInWaves(items, processor, maxConcurrent = 6) {
10850
10887
  }
10851
10888
  return results;
10852
10889
  }
10853
- const handler$6 = async (argv, logger) => {
10890
+ const handler$7 = async (argv, logger) => {
10854
10891
  const config = loadConfig(argv);
10855
10892
  const git = getRepo();
10856
10893
  const key = getApiKeyForModel(config);
@@ -11075,11 +11112,11 @@ const handler$6 = async (argv, logger) => {
11075
11112
  };
11076
11113
 
11077
11114
  var changelog = {
11078
- command: command$6,
11115
+ command: command$7,
11079
11116
  desc: 'Generate a changelog from current or target branch, provided commit range, or since the last tag.',
11080
- builder: builder$6,
11081
- handler: commandExecutor(handler$6),
11082
- options: options$6,
11117
+ builder: builder$7,
11118
+ handler: commandExecutor(handler$7),
11119
+ options: options$7,
11083
11120
  };
11084
11121
 
11085
11122
  const conventionalTypeRegex = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?!?:/;
@@ -11096,11 +11133,11 @@ const ConventionalCommitMessageResponseSchema = objectType({
11096
11133
  body: stringType().describe("Body of the commit message")
11097
11134
  // .max(280, "Body must be 280 characters or less"),
11098
11135
  }).describe("Object with Conventional Commit message 'title' and 'body' adhering to Conventional Commits specification");
11099
- const command$5 = 'commit';
11136
+ const command$6 = 'commit';
11100
11137
  /**
11101
11138
  * Command line options via yargs
11102
11139
  */
11103
- const options$5 = {
11140
+ const options$6 = {
11104
11141
  i: {
11105
11142
  alias: 'interactive',
11106
11143
  description: 'Toggle interactive mode',
@@ -11172,8 +11209,8 @@ const options$5 = {
11172
11209
  default: false,
11173
11210
  },
11174
11211
  };
11175
- const builder$5 = (yargs) => {
11176
- return yargs.options(options$5).usage(getCommandUsageHeader(command$5));
11212
+ const builder$6 = (yargs) => {
11213
+ return yargs.options(options$6).usage(getCommandUsageHeader(command$6));
11177
11214
  };
11178
11215
 
11179
11216
  /**
@@ -12008,7 +12045,7 @@ async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, })
12008
12045
  return formatCommitSplitPlan(plan);
12009
12046
  }
12010
12047
 
12011
- const handler$5 = async (argv, logger) => {
12048
+ const handler$6 = async (argv, logger) => {
12012
12049
  const git = getRepo();
12013
12050
  const config = loadConfig(argv);
12014
12051
  const key = getApiKeyForModel(config);
@@ -12049,9 +12086,14 @@ const handler$5 = async (argv, logger) => {
12049
12086
  tokenizer,
12050
12087
  llm,
12051
12088
  });
12089
+ const splitMode = INTERACTIVE ? 'interactive' : (config.mode || 'stdout');
12052
12090
  await handleResult({
12053
12091
  result: splitResult,
12054
- mode: config.mode || 'stdout',
12092
+ mode: splitMode,
12093
+ interactiveModeCallback: async (result) => {
12094
+ logger.log(result);
12095
+ logSuccess();
12096
+ },
12055
12097
  });
12056
12098
  logLlmTelemetrySummary(logger, 'commit');
12057
12099
  return;
@@ -12468,11 +12510,407 @@ IMPORTANT RULES:
12468
12510
  };
12469
12511
 
12470
12512
  var commit = {
12471
- command: command$5,
12513
+ command: command$6,
12472
12514
  desc: 'Summarize the staged changes in a commit message.',
12515
+ builder: builder$6,
12516
+ handler: commandExecutor(handler$6),
12517
+ options: options$6,
12518
+ };
12519
+
12520
+ const command$5 = 'doctor';
12521
+ const options$5 = {
12522
+ fix: {
12523
+ description: 'Attempt to auto-fix detected issues and write the updated config',
12524
+ type: 'boolean',
12525
+ default: false,
12526
+ },
12527
+ };
12528
+ const builder$5 = (yargs) => {
12529
+ return yargs.options(options$5).usage(getCommandUsageHeader(command$5));
12530
+ };
12531
+
12532
+ /**
12533
+ * Deprecated or renamed model identifiers that should be updated.
12534
+ */
12535
+ const MODEL_UPGRADES = {
12536
+ 'gpt-4-turbo-preview': 'gpt-4o',
12537
+ 'gpt-4-0125-preview': 'gpt-4o',
12538
+ 'gpt-4-1106-preview': 'gpt-4o',
12539
+ 'gpt-3.5-turbo-0125': 'gpt-4o-mini',
12540
+ 'gpt-3.5-turbo-1106': 'gpt-4o-mini',
12541
+ 'gpt-3.5-turbo-16k': 'gpt-4o-mini',
12542
+ 'claude-3-opus-20240229': 'claude-sonnet-4-0',
12543
+ 'claude-3-sonnet-20240229': 'claude-3-5-sonnet-latest',
12544
+ 'claude-3-haiku-20240307': 'claude-3-5-haiku-latest',
12545
+ };
12546
+ function runDiagnostics(config) {
12547
+ const diagnostics = [];
12548
+ checkServiceBlock(config, diagnostics);
12549
+ checkAuthentication(config, diagnostics);
12550
+ checkModelCurrency(config, diagnostics);
12551
+ checkModeConfig(config, diagnostics);
12552
+ checkDynamicRouting(config, diagnostics);
12553
+ checkTokenLimits(config, diagnostics);
12554
+ checkIgnoredFiles(config, diagnostics);
12555
+ checkProjectConfigFile(diagnostics);
12556
+ return diagnostics;
12557
+ }
12558
+ function checkServiceBlock(config, diagnostics) {
12559
+ if (!config.service) {
12560
+ diagnostics.push({
12561
+ severity: 'error',
12562
+ message: 'No service configuration found. Coco needs an AI provider to generate results.',
12563
+ fix: 'Run `coco init` to set up a provider, or add a "service" block to .coco.config.json.',
12564
+ });
12565
+ return;
12566
+ }
12567
+ if (!config.service.provider) {
12568
+ diagnostics.push({
12569
+ severity: 'error',
12570
+ message: 'No provider set in service config.',
12571
+ fix: 'Set service.provider to "openai", "anthropic", or "ollama".',
12572
+ });
12573
+ }
12574
+ if (!config.service.model) {
12575
+ diagnostics.push({
12576
+ severity: 'error',
12577
+ message: 'No model set in service config.',
12578
+ fix: 'Set service.model to a valid model name (e.g. "gpt-4o") or "dynamic" for task-based routing.',
12579
+ });
12580
+ }
12581
+ }
12582
+ function checkAuthentication(config, diagnostics) {
12583
+ if (!config.service)
12584
+ return;
12585
+ const { provider, authentication } = config.service;
12586
+ if (provider === 'ollama') {
12587
+ if (authentication && authentication.type !== 'None') {
12588
+ diagnostics.push({
12589
+ severity: 'warn',
12590
+ message: 'Ollama does not require authentication. Set authentication.type to "None".',
12591
+ fix: 'Change service.authentication to { "type": "None" }.',
12592
+ autoFix: (raw) => {
12593
+ const svc = raw.service;
12594
+ if (svc) {
12595
+ svc.authentication = { type: 'None' };
12596
+ }
12597
+ },
12598
+ });
12599
+ }
12600
+ const ollamaService = config.service;
12601
+ if (!ollamaService.endpoint) {
12602
+ diagnostics.push({
12603
+ severity: 'warn',
12604
+ message: 'No Ollama endpoint configured. Defaulting to http://localhost:11434.',
12605
+ fix: 'Add service.endpoint: "http://localhost:11434" to your config.',
12606
+ autoFix: (raw) => {
12607
+ const svc = raw.service;
12608
+ if (svc) {
12609
+ svc.endpoint = 'http://localhost:11434';
12610
+ }
12611
+ },
12612
+ });
12613
+ }
12614
+ return;
12615
+ }
12616
+ if (!authentication || authentication.type === 'None') {
12617
+ diagnostics.push({
12618
+ severity: 'error',
12619
+ message: `Provider "${provider}" requires authentication but none is configured.`,
12620
+ fix: `Set service.authentication to { "type": "APIKey", "credentials": { "apiKey": "..." } } or use the OPENAI_API_KEY / ANTHROPIC_API_KEY environment variable.`,
12621
+ });
12622
+ return;
12623
+ }
12624
+ if (authentication.type === 'APIKey') {
12625
+ const key = authentication.credentials?.apiKey;
12626
+ if (!key || key === '•••••••••••••••' || key.trim() === '') {
12627
+ diagnostics.push({
12628
+ severity: 'warn',
12629
+ message: 'API key appears to be a placeholder or empty. Coco may fall back to environment variables.',
12630
+ fix: `Set the API key in your config or via environment variable (OPENAI_API_KEY or ANTHROPIC_API_KEY).`,
12631
+ });
12632
+ }
12633
+ }
12634
+ }
12635
+ function checkModelCurrency(config, diagnostics) {
12636
+ if (!config.service?.model || config.service.model === 'dynamic')
12637
+ return;
12638
+ const model = String(config.service.model);
12639
+ const upgrade = MODEL_UPGRADES[model];
12640
+ if (upgrade) {
12641
+ diagnostics.push({
12642
+ severity: 'warn',
12643
+ message: `Model "${model}" has a newer replacement available: "${upgrade}".`,
12644
+ fix: `Update service.model to "${upgrade}" for better performance and pricing.`,
12645
+ autoFix: (raw) => {
12646
+ const svc = raw.service;
12647
+ if (svc) {
12648
+ svc.model = upgrade;
12649
+ }
12650
+ },
12651
+ });
12652
+ }
12653
+ }
12654
+ function checkModeConfig(config, diagnostics) {
12655
+ if (!config.mode || config.mode === 'stdout') {
12656
+ diagnostics.push({
12657
+ severity: 'info',
12658
+ message: 'Output mode is "stdout". Interactive features like commit split require -i or mode: "interactive".',
12659
+ fix: 'Set "mode": "interactive" in your config, or pass -i when using interactive commands.',
12660
+ });
12661
+ }
12662
+ }
12663
+ function checkDynamicRouting(config, diagnostics) {
12664
+ if (!config.service)
12665
+ return;
12666
+ if (config.service.model === 'dynamic') {
12667
+ if (!config.service.dynamicModelPreference) {
12668
+ diagnostics.push({
12669
+ severity: 'info',
12670
+ message: 'Dynamic model routing is enabled but no preference is set. Defaulting to "balanced".',
12671
+ fix: 'Optionally set service.dynamicModelPreference to "cost", "balanced", or "quality".',
12672
+ });
12673
+ }
12674
+ if (config.service.dynamicModels) {
12675
+ const validTasks = ['summarize', 'commit', 'changelog', 'review', 'recap', 'repair', 'largeDiff'];
12676
+ for (const task of Object.keys(config.service.dynamicModels)) {
12677
+ if (!validTasks.includes(task)) {
12678
+ diagnostics.push({
12679
+ severity: 'warn',
12680
+ message: `Unknown dynamic model task "${task}". Valid tasks: ${validTasks.join(', ')}.`,
12681
+ fix: `Remove or rename the "${task}" key in service.dynamicModels.`,
12682
+ });
12683
+ }
12684
+ }
12685
+ }
12686
+ diagnostics.push({
12687
+ severity: 'info',
12688
+ message: 'Dynamic model routing is active. Coco will select models per task based on your preference.',
12689
+ });
12690
+ }
12691
+ else {
12692
+ diagnostics.push({
12693
+ severity: 'info',
12694
+ message: `Using fixed model "${config.service.model}" for all tasks. Set service.model to "dynamic" to enable per-task model selection.`,
12695
+ });
12696
+ }
12697
+ }
12698
+ function checkTokenLimits(config, diagnostics) {
12699
+ if (!config.service)
12700
+ return;
12701
+ const tokenLimit = config.service.tokenLimit || 2048;
12702
+ if (tokenLimit < 512) {
12703
+ diagnostics.push({
12704
+ severity: 'warn',
12705
+ message: `Token limit (${tokenLimit}) is very low. This may cause truncated or poor-quality results.`,
12706
+ fix: 'Increase service.tokenLimit to at least 1024, ideally 2048 or higher.',
12707
+ autoFix: (raw) => {
12708
+ const svc = raw.service;
12709
+ if (svc) {
12710
+ svc.tokenLimit = 2048;
12711
+ }
12712
+ },
12713
+ });
12714
+ }
12715
+ if (config.service.maxConcurrent && config.service.provider === 'ollama' && config.service.maxConcurrent > 1) {
12716
+ diagnostics.push({
12717
+ severity: 'warn',
12718
+ message: `maxConcurrent is ${config.service.maxConcurrent} but Ollama typically handles one request at a time.`,
12719
+ fix: 'Set service.maxConcurrent to 1 for Ollama.',
12720
+ autoFix: (raw) => {
12721
+ const svc = raw.service;
12722
+ if (svc) {
12723
+ svc.maxConcurrent = 1;
12724
+ }
12725
+ },
12726
+ });
12727
+ }
12728
+ }
12729
+ function checkIgnoredFiles(config, diagnostics) {
12730
+ if (!config.ignoredFiles || config.ignoredFiles.length === 0) {
12731
+ diagnostics.push({
12732
+ severity: 'info',
12733
+ message: 'No custom ignored files configured. Coco uses defaults (package-lock.json, .gitignore contents, etc.).',
12734
+ });
12735
+ }
12736
+ }
12737
+ function checkProjectConfigFile(diagnostics) {
12738
+ const hasNewName = fs__namespace.existsSync('.coco.json');
12739
+ const hasLegacyName = fs__namespace.existsSync('.coco.config.json');
12740
+ if (!hasNewName && !hasLegacyName) {
12741
+ diagnostics.push({
12742
+ severity: 'info',
12743
+ message: 'No project config file found in the current directory. Using git config, env vars, or defaults.',
12744
+ fix: 'Run `coco init --scope project` to create a project config.',
12745
+ });
12746
+ return;
12747
+ }
12748
+ if (hasLegacyName && !hasNewName) {
12749
+ diagnostics.push({
12750
+ severity: 'info',
12751
+ message: 'Using legacy config filename .coco.config.json. Consider renaming to .coco.json.',
12752
+ fix: 'Rename .coco.config.json to .coco.json. Both filenames are supported, but .coco.json is preferred.',
12753
+ });
12754
+ }
12755
+ const configPath = hasNewName ? '.coco.json' : '.coco.config.json';
12756
+ try {
12757
+ const raw = JSON.parse(fs__namespace.readFileSync(configPath, 'utf-8'));
12758
+ if (!raw.$schema) {
12759
+ diagnostics.push({
12760
+ severity: 'info',
12761
+ message: `Project config (${configPath}) is missing the $schema field. Adding it enables editor autocompletion.`,
12762
+ fix: `Add "$schema": "https://coco.griffen.codes/schema.json" to ${configPath}.`,
12763
+ autoFix: (rawConfig) => {
12764
+ rawConfig.$schema = 'https://coco.griffen.codes/schema.json';
12765
+ },
12766
+ });
12767
+ }
12768
+ }
12769
+ catch {
12770
+ diagnostics.push({
12771
+ severity: 'error',
12772
+ message: `${configPath} contains invalid JSON.`,
12773
+ fix: `Fix the JSON syntax in ${configPath} or regenerate it with \`coco init --scope project\`.`,
12774
+ });
12775
+ }
12776
+ }
12777
+
12778
+ const SEVERITY_ICON = {
12779
+ error: chalk.red('✖'),
12780
+ warn: chalk.yellow('⚠'),
12781
+ info: chalk.blue('ℹ'),
12782
+ };
12783
+ const SEVERITY_LABEL = {
12784
+ error: chalk.red('error'),
12785
+ warn: chalk.yellow('warn'),
12786
+ info: chalk.blue('info'),
12787
+ };
12788
+ const SOURCE_LABELS = {
12789
+ default: 'Built-in defaults',
12790
+ xdg: 'XDG config',
12791
+ git: 'Git config (~/.gitconfig)',
12792
+ project: 'Project config',
12793
+ env: 'Environment variables',
12794
+ };
12795
+ function formatSourceInfo(sources) {
12796
+ const lines = [];
12797
+ for (const source of sources) {
12798
+ const label = SOURCE_LABELS[source.source] || source.source;
12799
+ if (source.path) {
12800
+ lines.push(` ${chalk.green('✓')} ${label} ${chalk.dim(`(${source.path})`)}`);
12801
+ }
12802
+ else {
12803
+ lines.push(` ${chalk.green('✓')} ${label}`);
12804
+ }
12805
+ }
12806
+ return lines;
12807
+ }
12808
+ const handler$5 = async (argv, logger) => {
12809
+ const config = loadConfig(argv);
12810
+ const sources = getConfigSources();
12811
+ logger.log(LOGO);
12812
+ logger.log('');
12813
+ logger.log(chalk.bold('coco doctor') + ' — checking your configuration\n');
12814
+ // Show active config sources
12815
+ logger.log(chalk.bold('Config sources') + chalk.dim(' (lowest → highest precedence):\n'));
12816
+ const sourceLines = formatSourceInfo(sources);
12817
+ for (const line of sourceLines) {
12818
+ logger.log(line);
12819
+ }
12820
+ // Show inactive sources
12821
+ const activeSources = new Set(sources.map((s) => s.source));
12822
+ const allSources = ['xdg', 'git', 'project', 'env'];
12823
+ const inactive = allSources.filter((s) => !activeSources.has(s));
12824
+ if (inactive.length > 0) {
12825
+ logger.log('');
12826
+ for (const source of inactive) {
12827
+ const label = SOURCE_LABELS[source] || source;
12828
+ logger.log(` ${chalk.dim('–')} ${chalk.dim(label)} ${chalk.dim('(not found)')}`);
12829
+ }
12830
+ }
12831
+ logger.log('');
12832
+ // Run diagnostics
12833
+ const diagnostics = runDiagnostics(config);
12834
+ if (diagnostics.length === 0) {
12835
+ logger.log(chalk.green('✓ No issues found. Your configuration looks good!'));
12836
+ return;
12837
+ }
12838
+ const errors = diagnostics.filter((d) => d.severity === 'error');
12839
+ const warnings = diagnostics.filter((d) => d.severity === 'warn');
12840
+ const infos = diagnostics.filter((d) => d.severity === 'info');
12841
+ logger.log(chalk.bold('Diagnostics:\n'));
12842
+ for (const diagnostic of diagnostics) {
12843
+ const icon = SEVERITY_ICON[diagnostic.severity];
12844
+ const label = SEVERITY_LABEL[diagnostic.severity];
12845
+ logger.log(`${icon} ${label}: ${diagnostic.message}`);
12846
+ if (diagnostic.fix) {
12847
+ logger.log(chalk.dim(` → ${diagnostic.fix}`));
12848
+ }
12849
+ logger.log('');
12850
+ }
12851
+ // Summary
12852
+ const parts = [];
12853
+ if (errors.length > 0)
12854
+ parts.push(chalk.red(`${errors.length} error(s)`));
12855
+ if (warnings.length > 0)
12856
+ parts.push(chalk.yellow(`${warnings.length} warning(s)`));
12857
+ if (infos.length > 0)
12858
+ parts.push(chalk.blue(`${infos.length} info(s)`));
12859
+ logger.log(`Found ${parts.join(', ')}.\n`);
12860
+ // Auto-fix
12861
+ if (argv.fix) {
12862
+ const fixable = diagnostics.filter((d) => d.autoFix);
12863
+ if (fixable.length === 0) {
12864
+ logger.log(chalk.dim('No auto-fixable issues found.'));
12865
+ return;
12866
+ }
12867
+ // Find the project config file to write to
12868
+ const projectSource = sources.find((s) => s.source === 'project');
12869
+ const configPath = projectSource?.path;
12870
+ if (!configPath) {
12871
+ logger.log(chalk.yellow('No project config file found. Run `coco init --scope project` first, then re-run `coco doctor --fix`.'));
12872
+ logger.log(chalk.dim(' Auto-fix writes to the project config file (.coco.json or .coco.config.json).'));
12873
+ // If config is coming from git or env, explain
12874
+ const gitSource = sources.find((s) => s.source === 'git');
12875
+ const envSource = sources.find((s) => s.source === 'env');
12876
+ if (gitSource) {
12877
+ logger.log(chalk.dim(` Your config is loaded from ${gitSource.path} — edit that file manually, or create a project config.`));
12878
+ }
12879
+ if (envSource) {
12880
+ logger.log(chalk.dim(' Some config comes from environment variables — update those in your shell profile.'));
12881
+ }
12882
+ return;
12883
+ }
12884
+ try {
12885
+ const raw = JSON.parse(fs__namespace.readFileSync(configPath, 'utf-8'));
12886
+ for (const diagnostic of fixable) {
12887
+ diagnostic.autoFix(raw);
12888
+ logger.log(chalk.green(` ✓ Fixed: ${diagnostic.message}`));
12889
+ }
12890
+ // Ensure $schema is present
12891
+ if (!raw.$schema) {
12892
+ raw.$schema = SCHEMA_PUBLIC_URL;
12893
+ }
12894
+ fs__namespace.writeFileSync(configPath, JSON.stringify(raw, null, 2) + '\n');
12895
+ logger.log(chalk.green(`\nWrote ${fixable.length} fix(es) to ${configPath}`));
12896
+ }
12897
+ catch (e) {
12898
+ logger.log(chalk.red(`Failed to apply fixes: ${e.message}`));
12899
+ }
12900
+ }
12901
+ else {
12902
+ const fixable = diagnostics.filter((d) => d.autoFix);
12903
+ if (fixable.length > 0) {
12904
+ logger.log(chalk.dim(`${fixable.length} issue(s) can be auto-fixed. Run \`coco doctor --fix\` to apply.`));
12905
+ }
12906
+ }
12907
+ };
12908
+
12909
+ var doctor = {
12910
+ command: command$5,
12911
+ desc: 'Check your coco configuration for common issues and suggest fixes',
12473
12912
  builder: builder$5,
12474
12913
  handler: commandExecutor(handler$5),
12475
- options: options$5,
12476
12914
  };
12477
12915
 
12478
12916
  const command$4 = 'init';
@@ -12828,7 +13266,11 @@ const questions = {
12828
13266
  message: 'where would you like to store the project config?',
12829
13267
  choices: [
12830
13268
  {
12831
- name: '.coco.config.json',
13269
+ name: '.coco.json (recommended)',
13270
+ value: '.coco.json',
13271
+ },
13272
+ {
13273
+ name: '.coco.config.json (legacy)',
12832
13274
  value: '.coco.config.json',
12833
13275
  },
12834
13276
  {
@@ -13489,10 +13931,11 @@ function applyCommitComposeAction(state, action) {
13489
13931
  details: action.details,
13490
13932
  };
13491
13933
  case 'reset':
13492
- return createCommitComposeState({
13493
- message: state.message,
13494
- details: state.details,
13495
- });
13934
+ // Drop message/details too — the post-commit "Created commit ..."
13935
+ // notification is already on the runtime status line (footer); a
13936
+ // duplicate copy lingering in the Compose panel reads as stale
13937
+ // state once the user starts a fresh draft.
13938
+ return createCommitComposeState();
13496
13939
  default:
13497
13940
  return state;
13498
13941
  }
@@ -13559,56 +14002,291 @@ async function createManualCommit({ git, summary, body, noVerify = false, }) {
13559
14002
  }
13560
14003
  }
13561
14004
 
13562
- function createCommitWorkflowArgv(action) {
13563
- const split = action === 'split-plan' || action === 'split-apply';
13564
- const apply = action === 'split-apply';
13565
- const plan = action === 'split-plan';
13566
- return {
13567
- $0: 'coco',
13568
- _: split ? ['commit', 'split'] : ['commit'],
13569
- interactive: false,
13570
- verbose: false,
13571
- version: false,
13572
- help: false,
13573
- mode: 'stdout',
13574
- openInEditor: false,
13575
- ignoredFiles: [],
13576
- ignoredExtensions: [],
13577
- withPreviousCommits: 0,
13578
- conventional: false,
13579
- includeBranchName: true,
13580
- noVerify: false,
13581
- noDiff: false,
13582
- split,
13583
- plan,
13584
- apply,
13585
- };
13586
- }
13587
- function formatCommitWorkflowMessage(action, output) {
13588
- const normalized = output.trim();
13589
- if (normalized) {
13590
- return normalized.split('\n')[0];
13591
- }
13592
- if (action === 'split-plan') {
13593
- return 'Generated commit split plan.';
13594
- }
13595
- if (action === 'split-apply') {
13596
- return 'Applied commit split plan.';
13597
- }
13598
- return 'Generated commit message.';
13599
- }
13600
- function compactOutputLines$3(output) {
13601
- return output
13602
- .split('\n')
13603
- .map((line) => line.trim())
13604
- .filter(Boolean);
13605
- }
13606
- function formatCommitFailure(error) {
13607
- if (error instanceof PreCommitHookError) {
13608
- const details = compactOutputLines$3(error.hookOutput);
14005
+ const FORMAT_INSTRUCTIONS_TEMPLATE = (schemaDescription) => (`CRITICAL: You must return ONLY a valid JSON object with no additional text, explanations, or markdown formatting.
14006
+
14007
+ REQUIRED JSON FORMAT:
14008
+ ${schemaDescription}
14009
+
14010
+ EXAMPLE (follow this EXACT format - compact JSON on a single line or minimal whitespace):
14011
+ {"title": "feat(auth): add user authentication system", "body": "Implement JWT-based authentication with login and logout functionality. Includes password hashing and session management."}
14012
+
14013
+ IMPORTANT RULES:
14014
+ - Return ONLY the JSON object - NO markdown code blocks, NO backticks, NO extra text
14015
+ - ALL string values MUST be enclosed in double quotes
14016
+ - Use compact JSON format (minimal whitespace) for best compatibility
14017
+ - NO trailing commas
14018
+ - NO comments or additional text outside the JSON
14019
+ - The "title" and "body" values must be properly quoted strings`);
14020
+ /**
14021
+ * Generate a commit message draft with no UI side effects.
14022
+ *
14023
+ * Mirrors the LLM-chain logic from `commit/handler.ts`'s agent callback but
14024
+ * skips the review loop, ora spinners, Inquirer prompts, and stdout writes
14025
+ * that would corrupt the surrounding Ink TUI alt screen. Validation failures
14026
+ * are surfaced as `validationErrors`/`warnings` rather than driving an
14027
+ * interactive retry flow — the TUI can re-invoke or let the user edit.
14028
+ */
14029
+ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: true }), }) {
14030
+ const config = loadConfig(argv);
14031
+ const key = getApiKeyForModel(config);
14032
+ const { provider } = getModelAndProviderFromConfig(config);
14033
+ const commitService = resolveDynamicService(config, 'commit');
14034
+ const summaryService = resolveDynamicService(config, 'summarize');
14035
+ const model = commitService.model;
14036
+ if (config.service.authentication.type !== 'None' && !key) {
13609
14037
  return {
13610
14038
  ok: false,
13611
- message: `Commit blocked by hook: ${details[0] || 'hook failed'}`,
14039
+ draft: '',
14040
+ warnings: [],
14041
+ validationErrors: ['No API key configured for the commit service.'],
14042
+ };
14043
+ }
14044
+ const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
14045
+ const llm = getLlm(provider, model, { ...config, service: commitService });
14046
+ const summaryLlm = getLlm(provider, summaryService.model, {
14047
+ ...config,
14048
+ service: summaryService,
14049
+ });
14050
+ const useConventional = Boolean(config.conventionalCommits || argv.conventional);
14051
+ const changes = await (async () => {
14052
+ if (config.noDiff) {
14053
+ const status = await git.status();
14054
+ return status.files.map((file) => ({
14055
+ filePath: file.path,
14056
+ status: (file.index === 'A' || file.index === '?'
14057
+ ? 'added'
14058
+ : 'modified'),
14059
+ summary: file.path,
14060
+ }));
14061
+ }
14062
+ const result = await getChanges({
14063
+ git,
14064
+ options: {
14065
+ ignoredFiles: config.ignoredFiles || undefined,
14066
+ ignoredExtensions: config.ignoredExtensions || undefined,
14067
+ },
14068
+ });
14069
+ return result.staged;
14070
+ })();
14071
+ if (!changes || changes.length === 0) {
14072
+ return {
14073
+ ok: false,
14074
+ draft: '',
14075
+ warnings: ['No staged changes detected.'],
14076
+ validationErrors: [],
14077
+ };
14078
+ }
14079
+ const summary = config.noDiff
14080
+ ? `Staged files:\n${changes.map((c) => `${c.status}: ${c.filePath}`).join('\n')}`
14081
+ : await fileChangeParser({
14082
+ changes,
14083
+ commit: '--staged',
14084
+ options: createFileChangeParserOptions({
14085
+ command: 'commit',
14086
+ tokenizer,
14087
+ git,
14088
+ llm: summaryLlm,
14089
+ logger,
14090
+ provider,
14091
+ model: String(summaryService.model),
14092
+ service: config.service,
14093
+ }),
14094
+ });
14095
+ if (!summary || !summary.length) {
14096
+ return {
14097
+ ok: false,
14098
+ draft: '',
14099
+ warnings: ['Diff summary was empty after parsing staged changes.'],
14100
+ validationErrors: [],
14101
+ };
14102
+ }
14103
+ const schema = useConventional
14104
+ ? ConventionalCommitMessageResponseSchema
14105
+ : CommitMessageResponseSchema;
14106
+ const promptTemplate = useConventional ? CONVENTIONAL_COMMIT_PROMPT : COMMIT_PROMPT;
14107
+ const prompt = getPrompt({
14108
+ template: config.prompt || promptTemplate.template,
14109
+ variables: promptTemplate.inputVariables,
14110
+ fallback: promptTemplate,
14111
+ });
14112
+ const formatInstructions = FORMAT_INSTRUCTIONS_TEMPLATE(schema.description || '');
14113
+ const additionalContext = argv.additional ? `## Additional Context\n${argv.additional}` : '';
14114
+ let commitHistory = '';
14115
+ const previousCommitsCount = Number(argv.withPreviousCommits || 0);
14116
+ if (previousCommitsCount > 0) {
14117
+ const commitHistoryData = await getPreviousCommits({ git, count: previousCommitsCount });
14118
+ if (commitHistoryData) {
14119
+ commitHistory = `## Commit History\n${commitHistoryData}`;
14120
+ }
14121
+ }
14122
+ const branchName = await getCurrentBranchName({ git });
14123
+ const includeBranchName = argv.includeBranchName !== undefined
14124
+ ? argv.includeBranchName
14125
+ : config.includeBranchName !== false;
14126
+ const branchNameContext = includeBranchName ? `Current git branch name: ${branchName}` : '';
14127
+ const warnings = [];
14128
+ const hasCommitLintConfig = await hasCommitlintConfig();
14129
+ let commitlintRulesContext = '';
14130
+ let validationEnabled = useConventional || hasCommitLintConfig;
14131
+ if (validationEnabled) {
14132
+ const { getCommitlintRulesContext, checkCommitlintAvailability } = await Promise.resolve().then(function () { return commitlintValidator; });
14133
+ const availability = checkCommitlintAvailability();
14134
+ if (!availability.available) {
14135
+ warnings.push(`Skipping commitlint validation: missing packages (${availability.missingPackages.join(', ')}).`);
14136
+ validationEnabled = false;
14137
+ }
14138
+ else {
14139
+ commitlintRulesContext = await getCommitlintRulesContext();
14140
+ }
14141
+ }
14142
+ const baseVariables = {
14143
+ summary,
14144
+ format_instructions: formatInstructions,
14145
+ additional_context: additionalContext,
14146
+ commit_history: commitHistory,
14147
+ branch_name_context: branchNameContext,
14148
+ commitlint_rules_context: commitlintRulesContext,
14149
+ };
14150
+ const maxParsingAttempts = config.service.provider === 'ollama' && 'maxParsingAttempts' in config.service
14151
+ ? config.service.maxParsingAttempts || 3
14152
+ : 3;
14153
+ let lastValidationErrors = [];
14154
+ let validationFeedback = '';
14155
+ let lastDraft = '';
14156
+ // Two attempts max — one initial generation plus one retry that incorporates
14157
+ // commitlint feedback. Beyond that we surface warnings and let the TUI user
14158
+ // edit the draft manually rather than driving an interactive prompt loop.
14159
+ for (let attempt = 1; attempt <= 2; attempt++) {
14160
+ const variables = {
14161
+ ...baseVariables,
14162
+ additional_context: validationFeedback
14163
+ ? `${baseVariables.additional_context}\n\n## Validation Errors from Previous Attempt\nPlease fix the following issues:\n${validationFeedback}`
14164
+ : baseVariables.additional_context,
14165
+ };
14166
+ const budgetedPrompt = await enforcePromptBudget({
14167
+ prompt,
14168
+ variables,
14169
+ tokenizer,
14170
+ maxTokens: config.service.tokenLimit || 2048,
14171
+ });
14172
+ const commitMsg = await executeChainWithSchema(schema, llm, prompt, budgetedPrompt.variables, {
14173
+ logger,
14174
+ tokenizer,
14175
+ metadata: {
14176
+ task: useConventional ? 'commit-message-conventional' : 'commit-message',
14177
+ command: 'commit-draft',
14178
+ provider,
14179
+ model: String(model),
14180
+ },
14181
+ retryOptions: {
14182
+ maxAttempts: maxParsingAttempts,
14183
+ },
14184
+ fallbackParser: (text) => {
14185
+ try {
14186
+ let cleanText = text.trim();
14187
+ const codeBlockMatch = cleanText.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
14188
+ if (codeBlockMatch && codeBlockMatch[1]) {
14189
+ cleanText = codeBlockMatch[1].trim();
14190
+ }
14191
+ const parsed = JSON.parse(cleanText);
14192
+ if (parsed && typeof parsed === 'object' &&
14193
+ typeof parsed.title === 'string' &&
14194
+ typeof parsed.body === 'string' &&
14195
+ parsed.title.length > 0) {
14196
+ return parsed;
14197
+ }
14198
+ }
14199
+ catch {
14200
+ // fall through
14201
+ }
14202
+ return {
14203
+ title: text.split('\n')[0] || 'Auto-generated commit',
14204
+ body: text.split('\n').slice(1).join('\n') || 'Generated commit message',
14205
+ };
14206
+ },
14207
+ });
14208
+ const ticketId = extractTicketIdFromBranchName(branchName);
14209
+ const fullMessage = formatCommitMessage(commitMsg, {
14210
+ append: argv.append,
14211
+ ticketId: ticketId || undefined,
14212
+ appendTicket: argv.appendTicket,
14213
+ });
14214
+ lastDraft = fullMessage;
14215
+ if (!validationEnabled) {
14216
+ return { ok: true, draft: fullMessage, warnings, validationErrors: [] };
14217
+ }
14218
+ const { validateCommitMessage } = await Promise.resolve().then(function () { return commitlintValidator; });
14219
+ const validationResult = await validateCommitMessage(fullMessage);
14220
+ if (validationResult.missingDependencies && validationResult.missingDependencies.length > 0) {
14221
+ warnings.push(`Skipping commitlint validation: missing packages (${validationResult.missingDependencies.join(', ')}).`);
14222
+ return { ok: true, draft: fullMessage, warnings, validationErrors: [] };
14223
+ }
14224
+ if (validationResult.valid) {
14225
+ return { ok: true, draft: fullMessage, warnings, validationErrors: [] };
14226
+ }
14227
+ lastValidationErrors = validationResult.errors;
14228
+ validationFeedback = validationResult.errors.map((error) => `- ${error}`).join('\n');
14229
+ }
14230
+ // Both attempts failed validation — return the latest draft so the user can
14231
+ // hand-edit in the compose surface, plus the validator output for context.
14232
+ return {
14233
+ ok: false,
14234
+ draft: lastDraft,
14235
+ warnings,
14236
+ validationErrors: lastValidationErrors,
14237
+ };
14238
+ }
14239
+
14240
+ function createCommitWorkflowArgv(action) {
14241
+ const split = action === 'split-plan' || action === 'split-apply';
14242
+ const apply = action === 'split-apply';
14243
+ const plan = action === 'split-plan';
14244
+ return {
14245
+ $0: 'coco',
14246
+ _: split ? ['commit', 'split'] : ['commit'],
14247
+ interactive: false,
14248
+ verbose: false,
14249
+ version: false,
14250
+ help: false,
14251
+ mode: 'stdout',
14252
+ openInEditor: false,
14253
+ ignoredFiles: [],
14254
+ ignoredExtensions: [],
14255
+ withPreviousCommits: 0,
14256
+ conventional: false,
14257
+ includeBranchName: true,
14258
+ noVerify: false,
14259
+ noDiff: false,
14260
+ split,
14261
+ plan,
14262
+ apply,
14263
+ };
14264
+ }
14265
+ function formatCommitWorkflowMessage(action, output) {
14266
+ const normalized = output.trim();
14267
+ if (normalized) {
14268
+ return normalized.split('\n')[0];
14269
+ }
14270
+ if (action === 'split-plan') {
14271
+ return 'Generated commit split plan.';
14272
+ }
14273
+ if (action === 'split-apply') {
14274
+ return 'Applied commit split plan.';
14275
+ }
14276
+ return 'Generated commit message.';
14277
+ }
14278
+ function compactOutputLines$3(output) {
14279
+ return output
14280
+ .split('\n')
14281
+ .map((line) => line.trim())
14282
+ .filter(Boolean);
14283
+ }
14284
+ function formatCommitFailure(error) {
14285
+ if (error instanceof PreCommitHookError) {
14286
+ const details = compactOutputLines$3(error.hookOutput);
14287
+ return {
14288
+ ok: false,
14289
+ message: `Commit blocked by hook: ${details[0] || 'hook failed'}`,
13612
14290
  details: details.slice(1, 6),
13613
14291
  };
13614
14292
  }
@@ -13633,7 +14311,7 @@ async function runCommitWorkflow({ action, git = getRepo(), noVerify = false, })
13633
14311
  return true;
13634
14312
  });
13635
14313
  try {
13636
- await handler$5(argv, logger);
14314
+ await handler$6(argv, logger);
13637
14315
  const message = output.trim();
13638
14316
  if (action === 'commit' && message) {
13639
14317
  await createCommit(message, git, undefined, { noVerify: config.noVerify || false });
@@ -13658,41 +14336,45 @@ async function runCommitWorkflow({ action, git = getRepo(), noVerify = false, })
13658
14336
  process.stdout.write = originalWrite;
13659
14337
  }
13660
14338
  }
13661
- async function runCommitDraftWorkflow() {
14339
+ async function runCommitDraftWorkflow(input = {}) {
14340
+ const git = input.git || getRepo();
13662
14341
  const argv = createCommitWorkflowArgv('commit');
13663
14342
  const logger = new Logger({ silent: true });
13664
- const originalWrite = process.stdout.write.bind(process.stdout);
13665
- let output = '';
13666
- process.stdout.write = ((chunk, ...args) => {
13667
- output += typeof chunk === 'string' ? chunk : chunk.toString();
13668
- const callback = args.find((arg) => typeof arg === 'function');
13669
- callback?.();
13670
- return true;
13671
- });
13672
14343
  try {
13673
- await handler$5(argv, logger);
13674
- const draft = output.trim();
14344
+ const result = await generateCommitDraft({ git, argv, logger });
14345
+ const draft = result.draft.trim();
14346
+ if (result.ok && draft) {
14347
+ return {
14348
+ ok: true,
14349
+ message: formatCommitWorkflowMessage('commit', draft),
14350
+ details: result.warnings,
14351
+ draft,
14352
+ };
14353
+ }
14354
+ const failureLines = [
14355
+ ...(result.validationErrors || []),
14356
+ ...(result.warnings || []),
14357
+ ];
13675
14358
  return {
13676
- ok: Boolean(draft),
13677
- message: draft ? formatCommitWorkflowMessage('commit', draft) : 'AI draft was empty.',
14359
+ ok: false,
14360
+ message: failureLines[0] ||
14361
+ (draft ? 'AI draft did not pass commitlint.' : 'AI draft was empty.'),
14362
+ details: failureLines.slice(1, 6),
13678
14363
  draft,
13679
14364
  };
13680
14365
  }
13681
14366
  catch (error) {
13682
14367
  if (isCommandExitError(error)) {
13683
- const lines = compactOutputLines$3(output || error.message);
14368
+ const lines = compactOutputLines$3(error.message);
13684
14369
  return {
13685
14370
  ok: error.code === 0,
13686
14371
  message: lines[0] || error.message,
13687
14372
  details: lines.slice(1, 6),
13688
- draft: output.trim(),
14373
+ draft: '',
13689
14374
  };
13690
14375
  }
13691
14376
  return formatCommitFailure(error);
13692
14377
  }
13693
- finally {
13694
- process.stdout.write = originalWrite;
13695
- }
13696
14378
  }
13697
14379
 
13698
14380
  const LOG_INK_CONTEXT_KEYS = [
@@ -14009,16 +14691,30 @@ const LOG_INK_KEY_BINDINGS = [
14009
14691
  id: 'previousSidebarTab',
14010
14692
  keys: ['['],
14011
14693
  label: 'previous tab',
14012
- description: 'Move to the previous repository sidebar tab.',
14694
+ description: 'Move to the previous repository sidebar tab (outside diff view).',
14013
14695
  contexts: ['sidebar'],
14014
14696
  },
14015
14697
  {
14016
14698
  id: 'nextSidebarTab',
14017
14699
  keys: [']'],
14018
14700
  label: 'next tab',
14019
- description: 'Move to the next repository sidebar tab.',
14701
+ description: 'Move to the next repository sidebar tab (outside diff view).',
14020
14702
  contexts: ['sidebar'],
14021
14703
  },
14704
+ {
14705
+ id: 'previousHunk',
14706
+ keys: ['['],
14707
+ label: 'previous hunk',
14708
+ description: 'Jump to the previous diff hunk in the current diff view.',
14709
+ contexts: ['commits'],
14710
+ },
14711
+ {
14712
+ id: 'nextHunk',
14713
+ keys: [']'],
14714
+ label: 'next hunk',
14715
+ description: 'Jump to the next diff hunk in the current diff view.',
14716
+ contexts: ['commits'],
14717
+ },
14022
14718
  {
14023
14719
  id: 'focusNext',
14024
14720
  keys: ['tab'],
@@ -14145,6 +14841,13 @@ const LOG_INK_KEY_BINDINGS = [
14145
14841
  description: 'Create a commit from staged changes with the current draft.',
14146
14842
  contexts: ['commits'],
14147
14843
  },
14844
+ {
14845
+ id: 'cycleSort',
14846
+ keys: ['s'],
14847
+ label: 'sort',
14848
+ description: 'Cycle the sort mode in branches (name/recent/ahead) or tags (recent/name).',
14849
+ contexts: ['commits'],
14850
+ },
14148
14851
  {
14149
14852
  id: 'help',
14150
14853
  keys: ['?'],
@@ -14174,6 +14877,28 @@ const LOG_INK_KEY_BINDINGS = [
14174
14877
  contexts: ['normal', 'search'],
14175
14878
  },
14176
14879
  ];
14880
+ /**
14881
+ * Surface the second-key continuations for a chord prefix (e.g. `g`)
14882
+ * as a flat list, sourced from the canonical keymap so the help, footer
14883
+ * hint, and which-key overlay all stay in sync. Continuations are sorted
14884
+ * by key for stable, scannable output.
14885
+ */
14886
+ function getLogInkChordContinuations(prefix) {
14887
+ const continuations = [];
14888
+ for (const binding of LOG_INK_KEY_BINDINGS) {
14889
+ for (const keys of binding.keys) {
14890
+ if (keys.length === 2 && keys.startsWith(prefix)) {
14891
+ continuations.push({
14892
+ key: keys.charAt(1),
14893
+ label: binding.label,
14894
+ description: binding.description,
14895
+ });
14896
+ break;
14897
+ }
14898
+ }
14899
+ }
14900
+ return continuations.sort((a, b) => a.key.localeCompare(b.key));
14901
+ }
14177
14902
  /**
14178
14903
  * Bindings considered "global" — always available regardless of which view
14179
14904
  * or pane has focus. Surfaced as a separate group in the help overlay and
@@ -14218,9 +14943,23 @@ function formatLogInkBreadcrumb(viewStack) {
14218
14943
  if (viewStack.length === 1 && viewStack[0] === 'history') {
14219
14944
  return '';
14220
14945
  }
14221
- return viewStack.join(' ');
14946
+ // Trailing back-hint (P2.5) reminds the user how to walk back when
14947
+ // they're nested deeper than the root view.
14948
+ return `${viewStack.join(' › ')} ← <`;
14222
14949
  }
14223
14950
  function getLogInkFooterHints(options) {
14951
+ if (options.pendingKey) {
14952
+ const continuations = getLogInkChordContinuations(options.pendingKey);
14953
+ if (continuations.length > 0) {
14954
+ return {
14955
+ contextual: [
14956
+ `${options.pendingKey} …`,
14957
+ ...continuations.map((entry) => `${entry.key} ${entry.label}`),
14958
+ ],
14959
+ global: ['esc cancel'],
14960
+ };
14961
+ }
14962
+ }
14224
14963
  if (options.filterMode) {
14225
14964
  return {
14226
14965
  contextual: ['enter apply', 'esc cancel', 'ctrl+u clear'],
@@ -14271,13 +15010,13 @@ function getLogInkFooterHints(options) {
14271
15010
  }
14272
15011
  if (options.activeView === 'branches') {
14273
15012
  return {
14274
- contextual: ['↑/↓ branches', 'D delete', 'X checkout', 'enter diff', 'esc back'],
15013
+ contextual: ['↑/↓ branches', 's sort', 'D delete', 'X checkout', 'enter diff'],
14275
15014
  global: NORMAL_GLOBAL_HINTS,
14276
15015
  };
14277
15016
  }
14278
15017
  if (options.activeView === 'tags') {
14279
15018
  return {
14280
- contextual: ['↑/↓ tags', 'T create', 'X push', 'esc back'],
15019
+ contextual: ['↑/↓ tags', 's sort', 'T create', 'X push', 'esc back'],
14281
15020
  global: NORMAL_GLOBAL_HINTS,
14282
15021
  };
14283
15022
  }
@@ -14431,6 +15170,110 @@ function filterLogInkPaletteCommands(commands, filter, recent) {
14431
15170
  .map((entry) => entry.command);
14432
15171
  }
14433
15172
 
15173
+ /**
15174
+ * The chars `git log --graph` emits for branch topology — `*`, `|`, `\`,
15175
+ * `/`, `_`, ` `. ASCII-only output is bulletproof for legacy terminals
15176
+ * but the angles read poorly when many branches overlap.
15177
+ *
15178
+ * `substituteGraphChars` swaps them for box-drawing / geometric Unicode
15179
+ * equivalents when the terminal can render them; falls back to ASCII
15180
+ * under `theme.ascii` (TERM=dumb / vt100) and `theme.noColor` is
15181
+ * orthogonal — the Unicode chars are still rendered, just without color.
15182
+ *
15183
+ * Kept ASCII-only intentionally:
15184
+ * - alphanumerics (commit refs / annotations git sometimes injects)
15185
+ * - parens / brackets (HEAD decoration markers, not part of the graph)
15186
+ * - hyphens / colons (likewise)
15187
+ */
15188
+ const ASCII_TO_UNICODE = {
15189
+ '*': '●',
15190
+ '|': '│',
15191
+ '/': '╱',
15192
+ '\\': '╲',
15193
+ '_': '─',
15194
+ };
15195
+ function substituteGraphChars(graph, options) {
15196
+ if (options.ascii) {
15197
+ return graph;
15198
+ }
15199
+ let output = '';
15200
+ for (const character of graph) {
15201
+ output += ASCII_TO_UNICODE[character] ?? character;
15202
+ }
15203
+ return output;
15204
+ }
15205
+
15206
+ /**
15207
+ * OSC 8 hyperlink helpers for the Ink TUI (P5.1).
15208
+ *
15209
+ * Modern terminals (iTerm2, kitty, WezTerm, Ghostty, recent VS Code, Windows
15210
+ * Terminal, Alacritty, foot) recognise the OSC 8 escape sequence and turn
15211
+ * the wrapped text into a clickable / Cmd-clickable link. Older or
15212
+ * minimal terminals ignore the sequence — but a few of them render the
15213
+ * escape codes as raw garbage instead, so we feature-detect and fall back
15214
+ * to plain text rather than always emitting.
15215
+ *
15216
+ * Sequence: ESC ] 8 ; ; <url> ESC \ <text> ESC ] 8 ; ; ESC \
15217
+ */
15218
+ const ESC = '\u001b';
15219
+ const OSC_PREFIX = `${ESC}]8;;`;
15220
+ const ST = `${ESC}\\`;
15221
+ /**
15222
+ * Detect whether the host terminal will likely render OSC 8 hyperlinks.
15223
+ *
15224
+ * Conservative: only returns true for terminals that have publicly
15225
+ * confirmed support. Unknown terminals fall back to plain text — making
15226
+ * the wrap a no-op rather than risking garbage output. Set
15227
+ * `FORCE_HYPERLINK=1` to override during testing.
15228
+ */
15229
+ function supportsHyperlinks(env = process.env) {
15230
+ if (env.FORCE_HYPERLINK) {
15231
+ return env.FORCE_HYPERLINK !== '0';
15232
+ }
15233
+ // Honor NO_COLOR for parity with our color handling — users who opt out
15234
+ // of color formatting generally want a clean plain-text output too.
15235
+ if (env.NO_COLOR) {
15236
+ return false;
15237
+ }
15238
+ // kitty publishes either of these markers.
15239
+ if (env.KITTY_WINDOW_ID || env.TERM === 'xterm-kitty') {
15240
+ return true;
15241
+ }
15242
+ // Windows Terminal sets WT_SESSION.
15243
+ if (env.WT_SESSION) {
15244
+ return true;
15245
+ }
15246
+ // Ghostty exposes its resources dir.
15247
+ if (env.GHOSTTY_RESOURCES_DIR) {
15248
+ return true;
15249
+ }
15250
+ switch (env.TERM_PROGRAM) {
15251
+ case 'iTerm.app':
15252
+ case 'WezTerm':
15253
+ case 'vscode':
15254
+ case 'ghostty':
15255
+ case 'mintty':
15256
+ case 'Hyper':
15257
+ return true;
15258
+ default:
15259
+ return false;
15260
+ }
15261
+ }
15262
+ /**
15263
+ * Wrap `text` in an OSC 8 hyperlink when the host terminal supports it,
15264
+ * otherwise return the plain text unchanged. Empty / missing url falls
15265
+ * back to the plain text.
15266
+ */
15267
+ function formatHyperlink(text, url, env = process.env) {
15268
+ if (!url) {
15269
+ return text;
15270
+ }
15271
+ if (!supportsHyperlinks(env)) {
15272
+ return text;
15273
+ }
15274
+ return `${OSC_PREFIX}${url}${ST}${text}${OSC_PREFIX}${ST}`;
15275
+ }
15276
+
14434
15277
  function action(actionValue) {
14435
15278
  return {
14436
15279
  type: 'action',
@@ -14481,6 +15324,15 @@ function getLogInkPaletteExecuteEvents(command, state) {
14481
15324
  return [action({ type: 'previousSidebarTab' })];
14482
15325
  case 'nextSidebarTab':
14483
15326
  return [action({ type: 'nextSidebarTab' })];
15327
+ case 'previousHunk':
15328
+ case 'nextHunk':
15329
+ // Palette execution can't reach the live worktree/commit hunk offsets
15330
+ // (those live in runtime state, not the reducer). Surface a hint and
15331
+ // let the user press the keystroke directly in diff view.
15332
+ return [action({
15333
+ type: 'setStatus',
15334
+ value: 'open the diff view and press [ or ] to jump hunks',
15335
+ })];
14484
15336
  case 'focusNext':
14485
15337
  return [action({ type: 'focusNext' })];
14486
15338
  case 'focusPrevious':
@@ -14553,9 +15405,23 @@ function getLogInkPaletteExecuteEvents(command, state) {
14553
15405
  // Aggregate entry; individual workflows are surfaced separately.
14554
15406
  return [];
14555
15407
  case 'quit':
15408
+ if (hasUnsavedComposeDraft(state)) {
15409
+ return [action({ type: 'setPendingMutationConfirmation', value: 'discard-draft' })];
15410
+ }
14556
15411
  return [{ type: 'exit' }];
14557
15412
  case 'clearSearch':
14558
15413
  return [action({ type: 'clearFilter' })];
15414
+ case 'cycleSort':
15415
+ if (state.activeView === 'branches') {
15416
+ return [action({ type: 'cycleBranchSort' })];
15417
+ }
15418
+ if (state.activeView === 'tags') {
15419
+ return [action({ type: 'cycleTagSort' })];
15420
+ }
15421
+ return [action({
15422
+ type: 'setStatus',
15423
+ value: 'Sort cycle is available in the branches and tags views',
15424
+ })];
14559
15425
  default:
14560
15426
  return [];
14561
15427
  }
@@ -14567,8 +15433,24 @@ const SIDEBAR_TAB_BY_NUMBER = {
14567
15433
  '4': 'stashes',
14568
15434
  '5': 'worktrees',
14569
15435
  };
15436
+ /**
15437
+ * Returns true when the compose surface holds an unsaved commit message
15438
+ * (any text in summary or body and no in-flight AI draft). Used by the
15439
+ * quit confirmation flow (P2.3) so users can't lose drafts via a stray
15440
+ * `q` / Ctrl+C.
15441
+ */
15442
+ function hasUnsavedComposeDraft(state) {
15443
+ const compose = state.commitCompose;
15444
+ if (compose.loading) {
15445
+ return false;
15446
+ }
15447
+ return Boolean(compose.summary.trim() || compose.body.trim());
15448
+ }
14570
15449
  function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14571
15450
  if (key.ctrl && inputValue === 'c') {
15451
+ if (hasUnsavedComposeDraft(state) && !state.pendingMutationConfirmation) {
15452
+ return [action({ type: 'setPendingMutationConfirmation', value: 'discard-draft' })];
15453
+ }
14572
15454
  return [{ type: 'exit' }];
14573
15455
  }
14574
15456
  if (state.commitCompose.editing) {
@@ -14600,7 +15482,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14600
15482
  return [];
14601
15483
  }
14602
15484
  if (state.filterMode) {
14603
- if (key.return || key.escape) {
15485
+ if (key.return) {
15486
+ return [action({ type: 'toggleFilterMode' })];
15487
+ }
15488
+ // Two-stage Esc (P2.4 / P4.4): first Esc with a non-empty filter
15489
+ // clears the input but keeps filterMode active so the user can keep
15490
+ // typing; second Esc exits filterMode entirely. Matches vim and
15491
+ // most modal TUIs.
15492
+ if (key.escape) {
15493
+ if (state.filter.length > 0) {
15494
+ return [action({ type: 'clearFilterText' })];
15495
+ }
14604
15496
  return [action({ type: 'toggleFilterMode' })];
14605
15497
  }
14606
15498
  if (key.backspace || key.delete) {
@@ -14623,14 +15515,19 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14623
15515
  action({ type: 'setPendingConfirmation', value: undefined }),
14624
15516
  ];
14625
15517
  }
15518
+ // Destructive + provider workflow actions (delete-branch, delete-tag,
15519
+ // drop-stash, remove-worktree, abort-operation, create-pr, …) defer
15520
+ // to the runtime — it has the live context needed to identify the
15521
+ // selected item and run the right action function.
15522
+ if (workflowAction) {
15523
+ return [
15524
+ { type: 'runWorkflowAction', id: workflowAction.id },
15525
+ action({ type: 'setPendingConfirmation', value: undefined }),
15526
+ ];
15527
+ }
14626
15528
  return [
14627
15529
  action({ type: 'setPendingConfirmation', value: undefined }),
14628
- action({
14629
- type: 'setStatus',
14630
- value: workflowAction
14631
- ? `${workflowAction.label} queued for workflow execution`
14632
- : 'workflow action queued',
14633
- }),
15530
+ action({ type: 'setStatus', value: 'workflow action queued' }),
14634
15531
  ];
14635
15532
  }
14636
15533
  if (inputValue === 'n' || key.escape) {
@@ -14643,6 +15540,12 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14643
15540
  }
14644
15541
  if (state.pendingMutationConfirmation) {
14645
15542
  if (inputValue === 'y') {
15543
+ if (state.pendingMutationConfirmation === 'discard-draft') {
15544
+ return [
15545
+ action({ type: 'setPendingMutationConfirmation', value: undefined }),
15546
+ { type: 'exit' },
15547
+ ];
15548
+ }
14646
15549
  return [
14647
15550
  state.pendingMutationConfirmation === 'revert-hunk'
14648
15551
  ? { type: 'revertSelectedHunk' }
@@ -14651,9 +15554,12 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14651
15554
  ];
14652
15555
  }
14653
15556
  if (inputValue === 'n' || key.escape) {
15557
+ const cancelMessage = state.pendingMutationConfirmation === 'discard-draft'
15558
+ ? 'kept draft — press q again to quit without saving'
15559
+ : 'revert cancelled';
14654
15560
  return [
14655
15561
  action({ type: 'setPendingMutationConfirmation', value: undefined }),
14656
- action({ type: 'setStatus', value: 'revert cancelled' }),
15562
+ action({ type: 'setStatus', value: cancelMessage }),
14657
15563
  ];
14658
15564
  }
14659
15565
  return [];
@@ -14661,6 +15567,11 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14661
15567
  if (state.showCommandPalette) {
14662
15568
  const filtered = filterLogInkPaletteCommands(getLogInkPaletteCommands(), state.paletteFilter, state.paletteRecent);
14663
15569
  if (key.escape) {
15570
+ // Two-stage Esc inside the palette: first Esc with non-empty
15571
+ // input clears the filter; second Esc closes the palette. P2.4.
15572
+ if (state.paletteFilter.length > 0) {
15573
+ return [action({ type: 'clearPaletteFilter' })];
15574
+ }
14664
15575
  return [action({ type: 'toggleCommandPalette' })];
14665
15576
  }
14666
15577
  if (key.return) {
@@ -14707,6 +15618,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14707
15618
  return [action({ type: 'popView' })];
14708
15619
  }
14709
15620
  if (inputValue === 'q') {
15621
+ if (hasUnsavedComposeDraft(state)) {
15622
+ return [action({ type: 'setPendingMutationConfirmation', value: 'discard-draft' })];
15623
+ }
14710
15624
  return [{ type: 'exit' }];
14711
15625
  }
14712
15626
  if (inputValue === '?') {
@@ -14787,13 +15701,51 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14787
15701
  if (inputValue === 'r') {
14788
15702
  return [{ type: 'refreshContext' }];
14789
15703
  }
15704
+ if (inputValue === 's') {
15705
+ if (state.activeView === 'branches') {
15706
+ return [action({ type: 'cycleBranchSort' })];
15707
+ }
15708
+ if (state.activeView === 'tags') {
15709
+ return [action({ type: 'cycleTagSort' })];
15710
+ }
15711
+ // Falls through so other views (history/status/diff/compose/stash) still
15712
+ // see the literal `s` for whatever per-view bindings they may grow.
15713
+ }
14790
15714
  if (inputValue === ':') {
14791
15715
  return [action({ type: 'toggleCommandPalette' })];
14792
15716
  }
14793
15717
  if (inputValue === '[') {
15718
+ if (state.activeView === 'diff' && context.worktreeHunkOffsets?.length) {
15719
+ return [action({
15720
+ type: 'jumpWorktreeHunk',
15721
+ delta: -1,
15722
+ hunkOffsets: context.worktreeHunkOffsets,
15723
+ })];
15724
+ }
15725
+ if (state.activeView === 'diff' && context.commitDiffHunkOffsets?.length) {
15726
+ return [action({
15727
+ type: 'jumpCommitDiffHunk',
15728
+ delta: -1,
15729
+ hunkOffsets: context.commitDiffHunkOffsets,
15730
+ })];
15731
+ }
14794
15732
  return [action({ type: 'previousSidebarTab' })];
14795
15733
  }
14796
15734
  if (inputValue === ']') {
15735
+ if (state.activeView === 'diff' && context.worktreeHunkOffsets?.length) {
15736
+ return [action({
15737
+ type: 'jumpWorktreeHunk',
15738
+ delta: 1,
15739
+ hunkOffsets: context.worktreeHunkOffsets,
15740
+ })];
15741
+ }
15742
+ if (state.activeView === 'diff' && context.commitDiffHunkOffsets?.length) {
15743
+ return [action({
15744
+ type: 'jumpCommitDiffHunk',
15745
+ delta: 1,
15746
+ hunkOffsets: context.commitDiffHunkOffsets,
15747
+ })];
15748
+ }
14797
15749
  return [action({ type: 'nextSidebarTab' })];
14798
15750
  }
14799
15751
  if (SIDEBAR_TAB_BY_NUMBER[inputValue]) {
@@ -14813,11 +15765,22 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14813
15765
  fileCount: context.worktreeFileCount,
14814
15766
  })];
14815
15767
  }
15768
+ // Diff view: j/k scrolls the visible diff one line. Hunk navigation
15769
+ // moved to ]/[ so single-hunk files (longer than the preview pane)
15770
+ // can scroll bidirectionally instead of getting pinned to a hunk
15771
+ // anchor.
14816
15772
  if (state.activeView === 'diff' && context.worktreeDiffLineCount) {
14817
15773
  return [action({
14818
- type: 'jumpWorktreeHunk',
15774
+ type: 'pageWorktreeDiff',
14819
15775
  delta: -1,
14820
- hunkOffsets: context.worktreeHunkOffsets || [],
15776
+ lineCount: context.worktreeDiffLineCount,
15777
+ })];
15778
+ }
15779
+ if (state.activeView === 'diff' && context.previewLineCount) {
15780
+ return [action({
15781
+ type: 'pageDetailPreview',
15782
+ delta: -1,
15783
+ previewLineCount: context.previewLineCount,
14821
15784
  })];
14822
15785
  }
14823
15786
  if (state.activeView === 'branches' && context.branchCount) {
@@ -14829,6 +15792,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14829
15792
  if (state.activeView === 'stash' && context.stashCount) {
14830
15793
  return [action({ type: 'moveStash', delta: -1, count: context.stashCount })];
14831
15794
  }
15795
+ if (state.activeView === 'history' &&
15796
+ state.focus === 'commits' &&
15797
+ state.selectedIndex === 0 &&
15798
+ !state.pendingCommitFocused &&
15799
+ context.worktreeDirty) {
15800
+ return [action({ type: 'focusPendingCommit' })];
15801
+ }
14832
15802
  return [
14833
15803
  action(state.focus === 'sidebar'
14834
15804
  ? { type: 'previousSidebarTab' }
@@ -14836,6 +15806,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14836
15806
  ];
14837
15807
  }
14838
15808
  if (key.downArrow || inputValue === 'j') {
15809
+ if (state.activeView === 'history' && state.pendingCommitFocused) {
15810
+ return [action({ type: 'unfocusPendingCommit' })];
15811
+ }
14839
15812
  if (state.focus === 'detail' && context.detailFileCount) {
14840
15813
  return [action({ type: 'moveDetailFile', delta: 1, fileCount: context.detailFileCount })];
14841
15814
  }
@@ -14848,9 +15821,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14848
15821
  }
14849
15822
  if (state.activeView === 'diff' && context.worktreeDiffLineCount) {
14850
15823
  return [action({
14851
- type: 'jumpWorktreeHunk',
15824
+ type: 'pageWorktreeDiff',
15825
+ delta: 1,
15826
+ lineCount: context.worktreeDiffLineCount,
15827
+ })];
15828
+ }
15829
+ if (state.activeView === 'diff' && context.previewLineCount) {
15830
+ return [action({
15831
+ type: 'pageDetailPreview',
14852
15832
  delta: 1,
14853
- hunkOffsets: context.worktreeHunkOffsets || [],
15833
+ previewLineCount: context.previewLineCount,
14854
15834
  })];
14855
15835
  }
14856
15836
  if (state.activeView === 'branches' && context.branchCount) {
@@ -14876,6 +15856,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14876
15856
  lineCount: context.worktreeDiffLineCount,
14877
15857
  })];
14878
15858
  }
15859
+ if (state.activeView === 'diff' && context.previewLineCount) {
15860
+ return [action({
15861
+ type: 'pageDetailPreview',
15862
+ delta: -8,
15863
+ previewLineCount: context.previewLineCount,
15864
+ })];
15865
+ }
14879
15866
  if (state.focus === 'detail' && context.previewLineCount) {
14880
15867
  return [action({
14881
15868
  type: 'pageDetailPreview',
@@ -14893,6 +15880,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14893
15880
  lineCount: context.worktreeDiffLineCount,
14894
15881
  })];
14895
15882
  }
15883
+ if (state.activeView === 'diff' && context.previewLineCount) {
15884
+ return [action({
15885
+ type: 'pageDetailPreview',
15886
+ delta: 8,
15887
+ previewLineCount: context.previewLineCount,
15888
+ })];
15889
+ }
14896
15890
  if (state.focus === 'detail' && context.previewLineCount) {
14897
15891
  return [action({
14898
15892
  type: 'pageDetailPreview',
@@ -14902,6 +15896,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14902
15896
  }
14903
15897
  return [action({ type: 'page', delta: 10 })];
14904
15898
  }
15899
+ // Enter on the synthetic "(+) new commit" row pushes the status view so
15900
+ // the user can stage/commit. The pending flag is cleared on view push so
15901
+ // popping back lands on the real commit at index 0.
15902
+ if (key.return &&
15903
+ state.activeView === 'history' &&
15904
+ state.pendingCommitFocused) {
15905
+ return [
15906
+ action({ type: 'pushView', value: 'status' }),
15907
+ action({ type: 'setStatus', value: 'staging worktree changes' }),
15908
+ ];
15909
+ }
14905
15910
  if (key.return &&
14906
15911
  state.activeView === 'history' &&
14907
15912
  state.focus === 'commits' &&
@@ -14918,6 +15923,25 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14918
15923
  ];
14919
15924
  }
14920
15925
  }
15926
+ // From the inspector / commit-diff detail panel, Enter opens (or refocuses)
15927
+ // the diff view scoped to the currently-selected commit and file. Lets the
15928
+ // user drive the explore flow entirely from the right panel: j/k picks a
15929
+ // file, Enter opens the diff for it.
15930
+ if (key.return &&
15931
+ state.focus === 'detail' &&
15932
+ (state.activeView === 'history' || state.activeView === 'diff') &&
15933
+ context.detailFileCount &&
15934
+ state.filteredCommits.length > 0) {
15935
+ const selected = state.filteredCommits[state.selectedIndex];
15936
+ if (selected) {
15937
+ return [action({
15938
+ type: 'navigateOpenDiffForCommit',
15939
+ sha: selected.hash,
15940
+ commitIndex: state.selectedIndex,
15941
+ fileIndex: state.selectedFileIndex,
15942
+ })];
15943
+ }
15944
+ }
14921
15945
  if (key.return && state.activeView === 'status' && context.worktreeFileCount) {
14922
15946
  return [action({
14923
15947
  type: 'navigateOpenDiffForWorktreeFile',
@@ -14967,6 +15991,294 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
14967
15991
  return [];
14968
15992
  }
14969
15993
 
15994
+ /**
15995
+ * Track whether the user has seen the first-launch onboarding overlay
15996
+ * (P1.3 from #756) via an XDG-friendly marker file. We persist this
15997
+ * outside of `.coco.config.json` so a fresh repo doesn't re-show the
15998
+ * tip when the user already dismissed it elsewhere.
15999
+ *
16000
+ * The marker is touched empty — its existence is the signal. Writes
16001
+ * are best-effort: filesystem failures (read-only $HOME, permissions)
16002
+ * fall back to "already seen" so we never block startup.
16003
+ */
16004
+ const MARKER_BASENAME = 'onboarding.seen';
16005
+ function resolveCacheDir() {
16006
+ const xdg = process.env.XDG_CACHE_HOME;
16007
+ if (xdg && xdg.trim().length > 0) {
16008
+ return path__namespace$1.join(xdg, 'coco');
16009
+ }
16010
+ return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco');
16011
+ }
16012
+ function getOnboardingMarkerPath() {
16013
+ return path__namespace$1.join(resolveCacheDir(), MARKER_BASENAME);
16014
+ }
16015
+ function hasSeenOnboarding() {
16016
+ try {
16017
+ return fs__namespace$1.existsSync(getOnboardingMarkerPath());
16018
+ }
16019
+ catch {
16020
+ // If we can't even stat the path (sandboxed env, etc.), treat the
16021
+ // user as "seen" so we don't keep showing a panel they can never
16022
+ // dismiss persistently.
16023
+ return true;
16024
+ }
16025
+ }
16026
+ function markOnboardingSeen() {
16027
+ const markerPath = getOnboardingMarkerPath();
16028
+ try {
16029
+ fs__namespace$1.mkdirSync(path__namespace$1.dirname(markerPath), { recursive: true });
16030
+ fs__namespace$1.writeFileSync(markerPath, '');
16031
+ }
16032
+ catch {
16033
+ // Best-effort persistence; swallow.
16034
+ }
16035
+ }
16036
+
16037
+ /**
16038
+ * Promoted-view selection rectification on filter changes (P4.5).
16039
+ *
16040
+ * Without this, the reducer used to snap selectedBranchIndex/Tag/Stash to 0
16041
+ * on every filter keystroke — which kept the cursor in range but lost the
16042
+ * user's place even when the previously-selected item was still in the
16043
+ * filtered result.
16044
+ *
16045
+ * The proper behavior (called out in #756 P4.5):
16046
+ * - If the previously-selected item is still in the filtered list, the
16047
+ * cursor follows it (its new index in the filtered list).
16048
+ * - If it dropped out of the filtered list, snap to result[0].
16049
+ * - If no filter change happened, leave the cursor alone.
16050
+ *
16051
+ * Implementation: the runtime computes a `PromotedSelectionsSnapshot` from
16052
+ * existing context items + the predicted next filter, attaches it to the
16053
+ * filter-mutating action, and the reducer applies the precomputed indexes.
16054
+ *
16055
+ * The rectification is a pure function over (lookup, filteredKeys); see
16056
+ * inkSelectionRectify.test.ts for the cases it covers.
16057
+ */
16058
+ /**
16059
+ * Map (filtered keys, previously-selected key) → new selected index.
16060
+ * Falls back to 0 when the key dropped out or no key was provided —
16061
+ * matching the spec's "snap to result[0]" requirement.
16062
+ *
16063
+ * The runtime is responsible for producing `filteredKeys` using the same
16064
+ * match function the renderer uses (multi-field haystacks per item).
16065
+ */
16066
+ function rectifyPromotedSelectionIndex(filteredKeys, selectedKey) {
16067
+ if (filteredKeys.length === 0) {
16068
+ return 0;
16069
+ }
16070
+ if (!selectedKey) {
16071
+ return 0;
16072
+ }
16073
+ const next = filteredKeys.indexOf(selectedKey);
16074
+ return next < 0 ? 0 : next;
16075
+ }
16076
+
16077
+ const DEFAULT_DEBOUNCE_MS = 250;
16078
+ const DEFAULT_SCHEDULER = {
16079
+ // `callback` is typed `() => void` (a function reference, never a string),
16080
+ // and `ms` is a number, so the eval-injection vector behind DevSkim
16081
+ // DS172411 doesn't apply here.
16082
+ // DevSkim: ignore DS172411
16083
+ setTimeout: (callback, ms) => setTimeout(callback, ms),
16084
+ clearTimeout: (handle) => clearTimeout(handle),
16085
+ };
16086
+ /**
16087
+ * Pure debouncer that coalesces a burst of `trigger` calls into one
16088
+ * `onSettle` invocation. Tracks the highest-severity kind across the
16089
+ * window so a fast sequence of worktree-then-HEAD changes still produces
16090
+ * a single `full` refresh.
16091
+ *
16092
+ * Extracted from the watcher so it's testable without touching `fs.watch`.
16093
+ */
16094
+ function createRefreshDebouncer(options) {
16095
+ const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
16096
+ const scheduler = options.scheduler ?? DEFAULT_SCHEDULER;
16097
+ let timer = null;
16098
+ let pendingKind = null;
16099
+ const onTimerFire = () => {
16100
+ timer = null;
16101
+ const kindToEmit = pendingKind || 'worktree';
16102
+ pendingKind = null;
16103
+ options.onSettle(kindToEmit);
16104
+ };
16105
+ const trigger = (kind) => {
16106
+ pendingKind = pendingKind === 'full' ? 'full' : kind;
16107
+ if (timer !== null) {
16108
+ scheduler.clearTimeout(timer);
16109
+ }
16110
+ // The first arg is a function value defined above (`onTimerFire`),
16111
+ // never a string, so the eval-injection vector that drives
16112
+ // DevSkim DS172411 doesn't apply here. The second arg is also our
16113
+ // own `debounceMs` constant — no caller-supplied data flows in.
16114
+ // DevSkim: ignore DS172411
16115
+ timer = scheduler.setTimeout(onTimerFire, debounceMs);
16116
+ };
16117
+ const close = () => {
16118
+ if (timer !== null) {
16119
+ scheduler.clearTimeout(timer);
16120
+ timer = null;
16121
+ }
16122
+ pendingKind = null;
16123
+ };
16124
+ return { trigger, close };
16125
+ }
16126
+ /**
16127
+ * Watch the repo's `.git` metadata + the working tree root for changes
16128
+ * that should refresh the TUI's repository context. Best-effort: missing
16129
+ * paths or platforms without `fs.watch` support degrade gracefully — the
16130
+ * user can still manually refresh with `r`.
16131
+ *
16132
+ * The watch surface is deliberately narrow:
16133
+ *
16134
+ * - `.git/index` (worktree refresh) — fires on `git add` / `rm` / `commit`
16135
+ * - `.git/HEAD` (full refresh) — fires on branch switches
16136
+ * - `.git/refs/heads` recursively (full refresh) — fires on commits to a
16137
+ * branch tip, branch creation/deletion
16138
+ * - repo root non-recursively (worktree refresh) — picks up top-level
16139
+ * create/delete/rename. Subdirectory unstaged edits do NOT trigger an
16140
+ * auto-refresh; the user can press `r` for those, which keeps watch
16141
+ * overhead negligible on large repos.
16142
+ */
16143
+ function createRefreshWatcher(options) {
16144
+ const debouncer = createRefreshDebouncer({
16145
+ debounceMs: options.debounceMs,
16146
+ onSettle: options.onChange,
16147
+ });
16148
+ const watchers = [];
16149
+ const safeWatch = (pathname, kind, watchOptions = {}) => {
16150
+ try {
16151
+ const watcher = fs__namespace$1.watch(pathname, watchOptions, () => debouncer.trigger(kind));
16152
+ // fs.watch errors at runtime (e.g. file removed) shouldn't crash the
16153
+ // TUI — the watcher is best-effort.
16154
+ watcher.on('error', () => { });
16155
+ watchers.push(watcher);
16156
+ }
16157
+ catch {
16158
+ // Path may not exist (fresh repo with no commits yet) or the platform
16159
+ // may not support fs.watch on this entry. Skip silently.
16160
+ }
16161
+ };
16162
+ safeWatch(path__namespace$1.join(options.gitDir, 'index'), 'worktree');
16163
+ safeWatch(path__namespace$1.join(options.gitDir, 'HEAD'), 'full');
16164
+ safeWatch(path__namespace$1.join(options.gitDir, 'refs', 'heads'), 'full', { recursive: true });
16165
+ safeWatch(options.repoRoot, 'worktree');
16166
+ return {
16167
+ close: () => {
16168
+ debouncer.close();
16169
+ for (const watcher of watchers) {
16170
+ try {
16171
+ watcher.close();
16172
+ }
16173
+ catch {
16174
+ // already closed; ignore
16175
+ }
16176
+ }
16177
+ watchers.length = 0;
16178
+ },
16179
+ };
16180
+ }
16181
+
16182
+ /**
16183
+ * Install panic + suspend handlers around an Ink instance so the terminal
16184
+ * never gets left in alt-screen / raw-mode / hidden-cursor state when:
16185
+ *
16186
+ * - an uncaught exception throws past Ink's render loop (P1.4)
16187
+ * - the user hits Ctrl+Z and the kernel raises SIGTSTP (P1.5)
16188
+ *
16189
+ * `dispose()` removes every registered listener so a clean exit doesn't
16190
+ * leak global handlers on subsequent runs.
16191
+ *
16192
+ * The escape sequences here are the standard ANSI/xterm trio:
16193
+ * - `\x1b[?25h` — show the cursor
16194
+ * - `\x1b[?25l` — hide the cursor
16195
+ * - `\x1b[?1049l` — exit alt screen
16196
+ * - `\x1b[?1049h` — enter alt screen
16197
+ */
16198
+ const SHOW_CURSOR = '\x1b[?25h';
16199
+ const HIDE_CURSOR = '\x1b[?25l';
16200
+ const ENTER_ALT_SCREEN = '\x1b[?1049h';
16201
+ const EXIT_ALT_SCREEN = '\x1b[?1049l';
16202
+ const tryWrite = (output, sequence) => {
16203
+ try {
16204
+ output.write(sequence);
16205
+ }
16206
+ catch {
16207
+ // stream may already be closed during shutdown; ignore
16208
+ }
16209
+ };
16210
+ const trySetRawMode = (input, value) => {
16211
+ try {
16212
+ input.setRawMode?.(value);
16213
+ }
16214
+ catch {
16215
+ // stdin may not be a TTY; ignore
16216
+ }
16217
+ };
16218
+ const tryUnmount = (instance) => {
16219
+ try {
16220
+ instance.unmount();
16221
+ }
16222
+ catch {
16223
+ // Ink may have already cleaned up; ignore
16224
+ }
16225
+ };
16226
+ function installTerminalLifecycle(options) {
16227
+ const { input, instance, output } = options;
16228
+ const restoreTerminal = () => {
16229
+ // Belt-and-suspenders: tell Ink to unmount AND write the escape
16230
+ // sequences directly. Ink's unmount handles most cases but we've
16231
+ // seen it leave artifacts when a render is in flight at panic time.
16232
+ tryUnmount(instance);
16233
+ trySetRawMode(input, false);
16234
+ tryWrite(output, `${SHOW_CURSOR}${EXIT_ALT_SCREEN}`);
16235
+ };
16236
+ const handlePanic = (error) => {
16237
+ restoreTerminal();
16238
+ if (options.onPanic) {
16239
+ options.onPanic(error);
16240
+ }
16241
+ else if (error instanceof Error) {
16242
+ process.stderr.write(`\n${error.stack || error.message}\n`);
16243
+ }
16244
+ else {
16245
+ process.stderr.write(`\n${String(error)}\n`);
16246
+ }
16247
+ // Exit with non-zero so callers (CI, scripts) see the failure.
16248
+ process.exit(1);
16249
+ };
16250
+ const onUncaughtException = (error) => handlePanic(error);
16251
+ const onUnhandledRejection = (reason) => handlePanic(reason);
16252
+ // Ctrl+Z: leave the alt screen + restore the cursor + drop raw mode
16253
+ // BEFORE the kernel actually suspends us. We don't unmount Ink — the
16254
+ // tree stays alive so SIGCONT can repaint without re-mounting.
16255
+ const onSigtstp = () => {
16256
+ trySetRawMode(input, false);
16257
+ tryWrite(output, `${SHOW_CURSOR}${EXIT_ALT_SCREEN}`);
16258
+ process.kill(process.pid, 'SIGSTOP');
16259
+ };
16260
+ // Resume: re-enter alt screen + hide cursor + raw mode back on, then
16261
+ // ask the runtime to nudge React so the user lands on a painted screen
16262
+ // instead of an empty alt buffer.
16263
+ const onSigcont = () => {
16264
+ tryWrite(output, `${ENTER_ALT_SCREEN}${HIDE_CURSOR}`);
16265
+ trySetRawMode(input, true);
16266
+ options.onResume?.();
16267
+ };
16268
+ process.on('uncaughtException', onUncaughtException);
16269
+ process.on('unhandledRejection', onUnhandledRejection);
16270
+ process.on('SIGTSTP', onSigtstp);
16271
+ process.on('SIGCONT', onSigcont);
16272
+ return {
16273
+ dispose: () => {
16274
+ process.off('uncaughtException', onUncaughtException);
16275
+ process.off('unhandledRejection', onUnhandledRejection);
16276
+ process.off('SIGTSTP', onSigtstp);
16277
+ process.off('SIGCONT', onSigcont);
16278
+ },
16279
+ };
16280
+ }
16281
+
14970
16282
  const LOG_INK_MIN_COLUMNS = 80;
14971
16283
  const LOG_INK_MIN_ROWS = 24;
14972
16284
  const LOG_INK_DEFAULT_COLUMNS = 120;
@@ -14974,16 +16286,83 @@ const LOG_INK_DEFAULT_ROWS = 40;
14974
16286
  function getLogInkLayout(input) {
14975
16287
  const columns = input.columns || LOG_INK_DEFAULT_COLUMNS;
14976
16288
  const rows = input.rows || LOG_INK_DEFAULT_ROWS;
16289
+ const detailWidth = Math.max(30, Math.min(56, Math.floor(columns * 0.34)));
16290
+ const sidebarWidth = Math.max(22, Math.min(34, Math.floor(columns * 0.24)));
14977
16291
  return {
14978
16292
  bodyRows: Math.max(8, rows - 5),
14979
16293
  columns,
14980
- detailWidth: Math.max(30, Math.min(56, Math.floor(columns * 0.34))),
16294
+ detailWidth,
16295
+ mainPanelWidth: Math.max(20, columns - sidebarWidth - detailWidth),
14981
16296
  rows,
14982
- sidebarWidth: Math.max(22, Math.min(34, Math.floor(columns * 0.24))),
16297
+ sidebarWidth,
14983
16298
  tooSmall: columns < LOG_INK_MIN_COLUMNS || rows < LOG_INK_MIN_ROWS,
14984
16299
  };
14985
16300
  }
14986
16301
 
16302
+ /**
16303
+ * Explicit color-level detection for the Ink TUI (P5.2).
16304
+ *
16305
+ * Chalk already approximates hex colors when the terminal can't render
16306
+ * truecolor — but we want an explicit signal so the catppuccin / gruvbox
16307
+ * presets (which use hex) can fall back to the ANSI-named `default` preset
16308
+ * cleanly on minimal SSH sessions, instead of relying on chalk's
16309
+ * heuristics. Users who set `NO_COLOR` or pick the `monochrome` preset
16310
+ * still get the manual override.
16311
+ *
16312
+ * Levels (matching the chalk taxonomy):
16313
+ * - 'mono' → no ANSI escapes at all (NO_COLOR / TERM=dumb)
16314
+ * - '16' → standard 16-color ANSI palette
16315
+ * - '256' → xterm-256color
16316
+ * - 'truecolor' → 24-bit RGB (COLORTERM=truecolor or known terminals)
16317
+ */
16318
+ function getColorLevel(env = process.env) {
16319
+ if (env.NO_COLOR)
16320
+ return 'mono';
16321
+ switch (env.FORCE_COLOR) {
16322
+ case '0':
16323
+ return 'mono';
16324
+ case '1':
16325
+ return '16';
16326
+ case '2':
16327
+ return '256';
16328
+ case '3':
16329
+ return 'truecolor';
16330
+ }
16331
+ const colorterm = env.COLORTERM?.toLowerCase();
16332
+ if (colorterm === 'truecolor' || colorterm === '24bit') {
16333
+ return 'truecolor';
16334
+ }
16335
+ // Modern terminal emulators that publicly advertise truecolor support.
16336
+ if (env.KITTY_WINDOW_ID || env.TERM === 'xterm-kitty') {
16337
+ return 'truecolor';
16338
+ }
16339
+ if (env.WT_SESSION) {
16340
+ return 'truecolor';
16341
+ }
16342
+ switch (env.TERM_PROGRAM) {
16343
+ case 'iTerm.app':
16344
+ case 'WezTerm':
16345
+ case 'vscode':
16346
+ case 'ghostty':
16347
+ case 'Hyper':
16348
+ return 'truecolor';
16349
+ }
16350
+ if (env.TERM === 'dumb')
16351
+ return 'mono';
16352
+ if (env.TERM?.includes('256color'))
16353
+ return '256';
16354
+ return '16';
16355
+ }
16356
+ const TRUECOLOR_PRESETS = new Set(['catppuccin', 'gruvbox']);
16357
+ /**
16358
+ * `true` when the named preset relies on hex colors that look best under
16359
+ * 24-bit RGB. Used by `createLogInkTheme` to decide whether to downgrade
16360
+ * to the ANSI-named `default` palette on lower-capability terminals.
16361
+ */
16362
+ function presetUsesTrueColor(preset) {
16363
+ return preset !== undefined && TRUECOLOR_PRESETS.has(preset);
16364
+ }
16365
+
14987
16366
  const THEME_PRESET_COLORS = {
14988
16367
  default: {
14989
16368
  accent: 'cyan',
@@ -15038,7 +16417,15 @@ function createLogInkTheme(options = {}) {
15038
16417
  const noColor = (options.noColor ?? Boolean(process.env.NO_COLOR)) ||
15039
16418
  options.preset === 'monochrome';
15040
16419
  const ascii = options.ascii ?? shouldUseAscii(options.term ?? process.env.TERM);
15041
- const preset = options.preset && options.preset !== 'monochrome' ? options.preset : 'default';
16420
+ const requestedPreset = options.preset && options.preset !== 'monochrome' ? options.preset : 'default';
16421
+ // P5.2 — gracefully downgrade hex presets (catppuccin / gruvbox) when
16422
+ // the host terminal can't render truecolor. Chalk approximates hex in
16423
+ // those modes anyway, but the default preset's ANSI-named palette
16424
+ // renders far more faithfully on 16-color terminals.
16425
+ const colorLevel = getColorLevel(options.env ?? process.env);
16426
+ const preset = !noColor && presetUsesTrueColor(requestedPreset) && colorLevel !== 'truecolor'
16427
+ ? 'default'
16428
+ : requestedPreset;
15042
16429
  const colors = noColor
15043
16430
  ? {}
15044
16431
  : {
@@ -15053,6 +16440,313 @@ function createLogInkTheme(options = {}) {
15053
16440
  };
15054
16441
  }
15055
16442
 
16443
+ /**
16444
+ * Iconography helpers for the Ink TUI surfaces.
16445
+ *
16446
+ * Letters always carry the meaning; symbols enhance. Glyphs come from the
16447
+ * Geometric Shapes / Arrows blocks (high-compat Unicode, no emoji), and all
16448
+ * helpers degrade cleanly under `theme.ascii` and `theme.noColor`.
16449
+ */
16450
+ /**
16451
+ * Format a branch's relationship to its upstream.
16452
+ * - no upstream → "no upstream"
16453
+ * - even → "even with <upstream>"
16454
+ * - divergent → "↑<ahead> ↓<behind> <upstream>" (only the non-zero side
16455
+ * is rendered so the line stays tight). ASCII mode falls back to the
16456
+ * legacy `+N/-N` form.
16457
+ */
16458
+ function formatBranchDivergence(branch, options = {}) {
16459
+ if (!branch.upstream) {
16460
+ return 'no upstream';
16461
+ }
16462
+ if (branch.ahead === 0 && branch.behind === 0) {
16463
+ return `even with ${branch.upstream}`;
16464
+ }
16465
+ if (options.ascii) {
16466
+ return `+${branch.ahead}/-${branch.behind} ${branch.upstream}`;
16467
+ }
16468
+ const parts = [];
16469
+ if (branch.ahead > 0)
16470
+ parts.push(`↑${branch.ahead}`);
16471
+ if (branch.behind > 0)
16472
+ parts.push(`↓${branch.behind}`);
16473
+ return `${parts.join(' ')} ${branch.upstream}`;
16474
+ }
16475
+ /**
16476
+ * Single-cell marker shown to the left of a branch name in lists.
16477
+ * `*` = current, `◌` = no upstream (detached from a remote), space otherwise.
16478
+ */
16479
+ function branchRowMarker(branch, options = {}) {
16480
+ if (branch.current)
16481
+ return '*';
16482
+ if (!branch.upstream)
16483
+ return options.ascii ? '?' : '◌';
16484
+ return ' ';
16485
+ }
16486
+ /**
16487
+ * Pick the glyph + color for a PR state badge.
16488
+ * Returns an empty glyph under ASCII mode so the textual state (OPEN /
16489
+ * MERGED / DRAFT / CLOSED) carries the meaning alone.
16490
+ */
16491
+ function getPullRequestStateGlyph(pr, theme) {
16492
+ if (theme.ascii) {
16493
+ return { glyph: '', color: undefined, dim: false };
16494
+ }
16495
+ if (pr.isDraft) {
16496
+ return { glyph: '◇', color: undefined, dim: true };
16497
+ }
16498
+ switch (pr.state.toUpperCase()) {
16499
+ case 'OPEN':
16500
+ return { glyph: '◉', color: theme.colors.success, dim: false };
16501
+ case 'MERGED':
16502
+ return { glyph: '●', color: theme.noColor ? undefined : 'magenta', dim: false };
16503
+ case 'CLOSED':
16504
+ return { glyph: '×', color: theme.colors.danger, dim: false };
16505
+ default:
16506
+ return { glyph: '·', color: undefined, dim: true };
16507
+ }
16508
+ }
16509
+ /**
16510
+ * Color for the leading dot in a status row. `undefined` means "skip the
16511
+ * dot" — under noColor or ascii mode the dot carries no information so the
16512
+ * raw porcelain codes (M / ?? / etc.) and the textual state carry meaning
16513
+ * alone.
16514
+ */
16515
+ function getStageStatusDotColor(state, theme) {
16516
+ if (theme.noColor || theme.ascii)
16517
+ return undefined;
16518
+ switch (state) {
16519
+ case 'unstaged':
16520
+ return theme.colors.danger;
16521
+ case 'staged':
16522
+ return theme.colors.warning;
16523
+ case 'untracked':
16524
+ return theme.colors.muted;
16525
+ default:
16526
+ return undefined;
16527
+ }
16528
+ }
16529
+ const STAGE_STATUS_DOT = '●';
16530
+ /**
16531
+ * Count to show next to a sidebar tab name, or `undefined` when the
16532
+ * underlying data has not loaded yet (so the label renders without a `(N)`
16533
+ * rather than a misleading `(0)`).
16534
+ */
16535
+ function sidebarTabCount(tab, context) {
16536
+ switch (tab) {
16537
+ case 'status':
16538
+ return context.worktree?.files.length;
16539
+ case 'branches':
16540
+ return context.branches?.localBranches.length;
16541
+ case 'tags':
16542
+ return context.tags?.tags.length;
16543
+ case 'stashes':
16544
+ return context.stashes?.stashes.length;
16545
+ case 'worktrees':
16546
+ return context.worktreeList?.worktrees.length;
16547
+ default:
16548
+ return undefined;
16549
+ }
16550
+ }
16551
+
16552
+ /**
16553
+ * Idle status-line tip rotation (P4.3).
16554
+ *
16555
+ * Off by default; opt-in via `logTui.idleTips: true`. The runtime drives a
16556
+ * tick counter that this module turns into a tip — pure mapping so the
16557
+ * cadence + content can be tested without spinning React or timers.
16558
+ *
16559
+ * Convention:
16560
+ * - tickIndex 0 → no tip (initial grace, before the first idle window).
16561
+ * - tickIndex N>0 → IDLE_TIPS[(N - 1) % IDLE_TIPS.length].
16562
+ *
16563
+ * The runtime keeps tickIndex at 0 whenever the user is active or
16564
+ * `state.statusMessage` is non-empty, so the tip only appears during true
16565
+ * idle stretches.
16566
+ */
16567
+ const IDLE_TIPS = [
16568
+ 'press : to search every command',
16569
+ 'g h returns home from anywhere',
16570
+ '/ filters the active view',
16571
+ 'press ? to see the full keymap',
16572
+ 's cycles sort modes in branches and tags',
16573
+ 'gz opens the stash view',
16574
+ '< or esc walks the navigation stack back',
16575
+ ];
16576
+ /**
16577
+ * Threshold (in ms) of idle time before the first tip appears. Picked at 10s
16578
+ * to match the spec in #756 — long enough that an active user never sees
16579
+ * one, short enough to be useful when the user genuinely paused.
16580
+ */
16581
+ const IDLE_TIPS_GRACE_MS = 10000;
16582
+ /** Cadence between subsequent tips in ms. */
16583
+ const IDLE_TIPS_INTERVAL_MS = 8000;
16584
+ function pickIdleTip(tickIndex) {
16585
+ if (tickIndex <= 0)
16586
+ return undefined;
16587
+ if (IDLE_TIPS.length === 0)
16588
+ return undefined;
16589
+ return IDLE_TIPS[(tickIndex - 1) % IDLE_TIPS.length];
16590
+ }
16591
+
16592
+ /**
16593
+ * Preview-pane content formatters for the promoted views (P4.1).
16594
+ *
16595
+ * Each formatter turns an existing context entry into a list of lines the
16596
+ * detail panel renders on the right. Pure — no git calls, no React — so the
16597
+ * shape is easy to assert in unit tests and the renderer stays a simple map
16598
+ * over the result.
16599
+ *
16600
+ * Designed to mirror what `lazygit` / `yazi` show in their preview pane:
16601
+ * the answer to "what am I about to act on" without forcing a checkout / show.
16602
+ */
16603
+ const heading = (text) => ({ text, emphasis: 'heading' });
16604
+ const dim = (text) => ({ text, emphasis: 'dim' });
16605
+ const line = (text) => ({ text });
16606
+ const blank = () => ({ text: '' });
16607
+ function shortHash(hash) {
16608
+ return hash ? hash.slice(0, 7) : '<none>';
16609
+ }
16610
+ /* ------------------------------- branch -------------------------------- */
16611
+ function describeBranchDivergence(branch) {
16612
+ if (branch.ahead === 0 && branch.behind === 0) {
16613
+ return 'in sync';
16614
+ }
16615
+ return `${branch.ahead} ahead, ${branch.behind} behind`;
16616
+ }
16617
+ function formatBranchPreview(branch) {
16618
+ if (!branch) {
16619
+ return [dim('Select a branch to preview.')];
16620
+ }
16621
+ const out = [
16622
+ heading(branch.shortName),
16623
+ blank(),
16624
+ line(`Tip: ${shortHash(branch.hash)}`),
16625
+ line(`Date: ${branch.date || '<unknown>'}`),
16626
+ line(`Subject: ${branch.subject || '<no subject>'}`),
16627
+ blank(),
16628
+ ];
16629
+ if (branch.upstream) {
16630
+ out.push(line(`Upstream: ${branch.upstream}`));
16631
+ out.push(line(`Status: ${describeBranchDivergence(branch)}`));
16632
+ }
16633
+ else {
16634
+ out.push(dim('No upstream tracking.'));
16635
+ }
16636
+ if (branch.current) {
16637
+ out.push(blank());
16638
+ out.push(dim('* current branch'));
16639
+ }
16640
+ return out;
16641
+ }
16642
+ /* --------------------------------- tag --------------------------------- */
16643
+ function formatTagPreview(tag) {
16644
+ if (!tag) {
16645
+ return [dim('Select a tag to preview.')];
16646
+ }
16647
+ return [
16648
+ heading(tag.name),
16649
+ blank(),
16650
+ line(`Commit: ${shortHash(tag.hash)}`),
16651
+ line(`Date: ${tag.date || '<unknown>'}`),
16652
+ blank(),
16653
+ line('Subject:'),
16654
+ line(` ${tag.subject || '<no subject>'}`),
16655
+ ];
16656
+ }
16657
+ function formatStashPreview(stash, options = {}) {
16658
+ if (!stash) {
16659
+ return [dim('Select a stash to preview.')];
16660
+ }
16661
+ const cap = options.fileCap ?? 10;
16662
+ const out = [
16663
+ heading(stash.ref),
16664
+ blank(),
16665
+ line(`On: ${stash.branch || '<unknown>'}`),
16666
+ line(`Commit: ${shortHash(stash.hash)}`),
16667
+ line(`Date: ${stash.date || '<unknown>'}`),
16668
+ blank(),
16669
+ line('Message:'),
16670
+ line(` ${stash.message || '<no message>'}`),
16671
+ ];
16672
+ const files = stash.files || [];
16673
+ if (files.length > 0) {
16674
+ out.push(blank());
16675
+ out.push(line(`Files (${files.length}):`));
16676
+ files.slice(0, cap).forEach((path) => out.push(line(` ${path}`)));
16677
+ if (files.length > cap) {
16678
+ out.push(dim(` … ${files.length - cap} more`));
16679
+ }
16680
+ }
16681
+ else {
16682
+ out.push(blank());
16683
+ out.push(dim('No files in stash.'));
16684
+ }
16685
+ return out;
16686
+ }
16687
+
16688
+ /**
16689
+ * Sort modes for the promoted views (P4.2).
16690
+ *
16691
+ * Pure: takes existing context entries + the active mode, returns a sorted
16692
+ * copy. Tested in isolation; the runtime just calls these helpers.
16693
+ *
16694
+ * Display label uses `▼` (U+25BC) under truecolor / UTF-8 and falls back to
16695
+ * `v` under ASCII. Letters (`recent` / `name` / `ahead`) carry meaning;
16696
+ * shape enhances.
16697
+ */
16698
+ const BRANCH_SORT_MODES = ['name', 'recent', 'ahead'];
16699
+ const DEFAULT_BRANCH_SORT_MODE = 'name';
16700
+ function cycleBranchSort(mode) {
16701
+ const index = BRANCH_SORT_MODES.indexOf(mode);
16702
+ if (index < 0)
16703
+ return BRANCH_SORT_MODES[0];
16704
+ return BRANCH_SORT_MODES[(index + 1) % BRANCH_SORT_MODES.length];
16705
+ }
16706
+ function sortBranches(branches, mode) {
16707
+ const copy = branches.slice();
16708
+ switch (mode) {
16709
+ case 'name':
16710
+ return copy.sort((a, b) => a.shortName.localeCompare(b.shortName));
16711
+ case 'recent':
16712
+ // ISO-shaped dates compare byte-for-byte; descending so the freshest
16713
+ // branch sits at the top.
16714
+ return copy.sort((a, b) => (b.date || '').localeCompare(a.date || '') ||
16715
+ a.shortName.localeCompare(b.shortName));
16716
+ case 'ahead':
16717
+ // ahead-first; ties broken by behind, then by name. Keeps "this branch
16718
+ // has unmerged work" in the user's first scroll.
16719
+ return copy.sort((a, b) => b.ahead - a.ahead || b.behind - a.behind ||
16720
+ a.shortName.localeCompare(b.shortName));
16721
+ default:
16722
+ return copy;
16723
+ }
16724
+ }
16725
+ const TAG_SORT_MODES = ['recent', 'name'];
16726
+ const DEFAULT_TAG_SORT_MODE = 'recent';
16727
+ function cycleTagSort(mode) {
16728
+ const index = TAG_SORT_MODES.indexOf(mode);
16729
+ if (index < 0)
16730
+ return TAG_SORT_MODES[0];
16731
+ return TAG_SORT_MODES[(index + 1) % TAG_SORT_MODES.length];
16732
+ }
16733
+ function sortTags(tags, mode) {
16734
+ const copy = tags.slice();
16735
+ switch (mode) {
16736
+ case 'name':
16737
+ return copy.sort((a, b) => a.name.localeCompare(b.name));
16738
+ case 'recent':
16739
+ return copy.sort((a, b) => (b.date || '').localeCompare(a.date || '') ||
16740
+ a.name.localeCompare(b.name));
16741
+ default:
16742
+ return copy;
16743
+ }
16744
+ }
16745
+ /* ---------------------------- header indicator -------------------------- */
16746
+ function formatSortIndicator(mode, options = {}) {
16747
+ return `${options.ascii ? 'v' : '▼'} ${mode}`;
16748
+ }
16749
+
15056
16750
  /**
15057
16751
  * Empty- and loading-state messages for each TUI surface.
15058
16752
  *
@@ -15147,6 +16841,73 @@ function characterWidth(character) {
15147
16841
  function cellWidth(value) {
15148
16842
  return Array.from(value).reduce((width, character) => width + characterWidth(character), 0);
15149
16843
  }
16844
+ /**
16845
+ * Word-wrap `value` into lines that each fit within `width` cells. Breaks
16846
+ * on whitespace where possible; falls back to mid-word splits when a single
16847
+ * word is wider than the budget. Preserves blank input as a single empty
16848
+ * line so `value.split('\n').flatMap(wrapCells)` round-trips cleanly.
16849
+ */
16850
+ function wrapCells(value, width) {
16851
+ if (width < 1) {
16852
+ return [value];
16853
+ }
16854
+ if (cellWidth(value) <= width) {
16855
+ return [value];
16856
+ }
16857
+ const lines = [];
16858
+ let current = '';
16859
+ let currentWidth = 0;
16860
+ const flush = () => {
16861
+ if (current.length > 0) {
16862
+ lines.push(current);
16863
+ current = '';
16864
+ currentWidth = 0;
16865
+ }
16866
+ };
16867
+ // Tokenize into runs of whitespace + non-whitespace so we can keep word
16868
+ // boundaries when possible.
16869
+ const tokens = value.match(/\s+|\S+/g) || [];
16870
+ for (const token of tokens) {
16871
+ const tokenWidth = cellWidth(token);
16872
+ if (currentWidth + tokenWidth <= width) {
16873
+ current += token;
16874
+ currentWidth += tokenWidth;
16875
+ continue;
16876
+ }
16877
+ if (/^\s+$/.test(token)) {
16878
+ // Drop boundary whitespace at line breaks.
16879
+ flush();
16880
+ continue;
16881
+ }
16882
+ flush();
16883
+ if (tokenWidth <= width) {
16884
+ current = token;
16885
+ currentWidth = tokenWidth;
16886
+ continue;
16887
+ }
16888
+ // Word longer than budget — hard-split into chunks.
16889
+ let remaining = token;
16890
+ while (cellWidth(remaining) > width) {
16891
+ let chunk = '';
16892
+ let chunkWidth = 0;
16893
+ for (const character of Array.from(remaining)) {
16894
+ const charW = characterWidth(character);
16895
+ if (chunkWidth + charW > width)
16896
+ break;
16897
+ chunk += character;
16898
+ chunkWidth += charW;
16899
+ }
16900
+ lines.push(chunk);
16901
+ remaining = remaining.slice(chunk.length);
16902
+ }
16903
+ if (remaining.length > 0) {
16904
+ current = remaining;
16905
+ currentWidth = cellWidth(remaining);
16906
+ }
16907
+ }
16908
+ flush();
16909
+ return lines.length > 0 ? lines : [value];
16910
+ }
15150
16911
  function truncateCells(value, width) {
15151
16912
  if (width < 1) {
15152
16913
  return '';
@@ -15268,6 +17029,8 @@ function withPushedView(state, value) {
15268
17029
  viewStack,
15269
17030
  worktreeDiffOffset: value === 'diff' ? state.worktreeDiffOffset : 0,
15270
17031
  selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
17032
+ diffSource: value === 'diff' ? state.diffSource : undefined,
17033
+ pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
15271
17034
  pendingKey: undefined,
15272
17035
  };
15273
17036
  }
@@ -15283,6 +17046,8 @@ function withPoppedView(state) {
15283
17046
  viewStack,
15284
17047
  worktreeDiffOffset: next === 'diff' ? state.worktreeDiffOffset : 0,
15285
17048
  selectedWorktreeHunkIndex: next === 'diff' ? state.selectedWorktreeHunkIndex : 0,
17049
+ diffSource: next === 'diff' ? state.diffSource : undefined,
17050
+ pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
15286
17051
  pendingKey: undefined,
15287
17052
  };
15288
17053
  }
@@ -15297,17 +17062,35 @@ function withReplacedView(state, value) {
15297
17062
  viewStack,
15298
17063
  worktreeDiffOffset: value === 'diff' ? state.worktreeDiffOffset : 0,
15299
17064
  selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
17065
+ diffSource: value === 'diff' ? state.diffSource : undefined,
17066
+ pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
15300
17067
  pendingKey: undefined,
15301
17068
  };
15302
17069
  }
15303
- function withFilter$1(state, filter) {
17070
+ function withFilter$1(state, filter, promotedSelections) {
15304
17071
  const filteredCommits = filterCommits$1(state.commits, filter);
17072
+ // P4.5: rectify promoted-view selections when the filter changes. Prefer
17073
+ // the runtime-supplied snapshot — which preserves the cursor on the same
17074
+ // item when it's still in the filtered list and only snaps to result[0]
17075
+ // when the previously-selected item dropped out. Falls back to the older
17076
+ // "snap to 0" behavior when no snapshot was provided (test paths,
17077
+ // dispatchers without context).
17078
+ const filterChanged = state.filter !== filter;
17079
+ const branchIndex = promotedSelections?.branchIndex ??
17080
+ (filterChanged ? 0 : state.selectedBranchIndex);
17081
+ const tagIndex = promotedSelections?.tagIndex ??
17082
+ (filterChanged ? 0 : state.selectedTagIndex);
17083
+ const stashIndex = promotedSelections?.stashIndex ??
17084
+ (filterChanged ? 0 : state.selectedStashIndex);
15305
17085
  return {
15306
17086
  ...state,
15307
17087
  filter,
15308
17088
  filteredCommits,
15309
17089
  selectedIndex: clampIndex$1(state.selectedIndex, filteredCommits.length),
15310
17090
  selectedFileIndex: 0,
17091
+ selectedBranchIndex: branchIndex,
17092
+ selectedTagIndex: tagIndex,
17093
+ selectedStashIndex: stashIndex,
15311
17094
  diffPreviewOffset: 0,
15312
17095
  pendingKey: undefined,
15313
17096
  };
@@ -15343,11 +17126,11 @@ function nextHunkOffset(currentOffset, hunkOffsets, delta) {
15343
17126
  return currentOffset;
15344
17127
  }
15345
17128
  if (delta > 0) {
15346
- return hunkOffsets.find((offset) => offset > currentOffset) ||
15347
- hunkOffsets[hunkOffsets.length - 1];
17129
+ const nextOffset = hunkOffsets.find((offset) => offset > currentOffset);
17130
+ return nextOffset === undefined ? currentOffset : nextOffset;
15348
17131
  }
15349
- return [...hunkOffsets].reverse().find((offset) => offset < currentOffset) ||
15350
- hunkOffsets[0];
17132
+ const previousOffset = [...hunkOffsets].reverse().find((offset) => offset < currentOffset);
17133
+ return previousOffset === undefined ? currentOffset : previousOffset;
15351
17134
  }
15352
17135
  function nextHunkIndex(currentOffset, hunkOffsets, delta) {
15353
17136
  const offset = nextHunkOffset(currentOffset, hunkOffsets, delta);
@@ -15372,6 +17155,8 @@ function createLogInkState(rows, options = {}) {
15372
17155
  selectedBranchIndex: 0,
15373
17156
  selectedTagIndex: 0,
15374
17157
  selectedStashIndex: 0,
17158
+ branchSort: DEFAULT_BRANCH_SORT_MODE,
17159
+ tagSort: DEFAULT_TAG_SORT_MODE,
15375
17160
  paletteFilter: '',
15376
17161
  paletteSelectedIndex: 0,
15377
17162
  paletteRecent: [],
@@ -15392,6 +17177,12 @@ function createLogInkState(rows, options = {}) {
15392
17177
  };
15393
17178
  }
15394
17179
  function getSelectedInkCommit(state) {
17180
+ if (state.pendingCommitFocused) {
17181
+ // The cursor is on the synthetic "(+) new commit" row, not a real
17182
+ // commit; callers (detail loaders, diff intents) should treat this as
17183
+ // "no commit selected" and route to the worktree summary instead.
17184
+ return undefined;
17185
+ }
15395
17186
  return state.filteredCommits[state.selectedIndex];
15396
17187
  }
15397
17188
  function applyLogInkAction(state, action) {
@@ -15399,14 +17190,18 @@ function applyLogInkAction(state, action) {
15399
17190
  case 'appendRows':
15400
17191
  return appendRows(state, action.rows);
15401
17192
  case 'appendFilter':
15402
- return withFilter$1(state, `${state.filter}${action.value}`);
17193
+ return withFilter$1(state, `${state.filter}${action.value}`, action.promotedSelections);
15403
17194
  case 'backspaceFilter':
15404
- return withFilter$1(state, state.filter.slice(0, -1));
17195
+ return withFilter$1(state, state.filter.slice(0, -1), action.promotedSelections);
15405
17196
  case 'clearFilter':
15406
17197
  return withFilter$1({
15407
17198
  ...state,
15408
17199
  filterMode: false,
15409
- }, '');
17200
+ }, '', action.promotedSelections);
17201
+ case 'clearFilterText':
17202
+ // Clears the filter input but stays in filterMode so the user can
17203
+ // keep typing. P2.4 / P4.4: pairs with the two-stage Esc semantics.
17204
+ return withFilter$1(state, '', action.promotedSelections);
15410
17205
  case 'commitCompose':
15411
17206
  return {
15412
17207
  ...state,
@@ -15431,6 +17226,24 @@ function applyLogInkAction(state, action) {
15431
17226
  selectedIndex: clampIndex$1(state.selectedIndex + action.delta, state.filteredCommits.length),
15432
17227
  selectedFileIndex: 0,
15433
17228
  diffPreviewOffset: 0,
17229
+ pendingCommitFocused: false,
17230
+ pendingKey: undefined,
17231
+ };
17232
+ case 'focusPendingCommit':
17233
+ return {
17234
+ ...state,
17235
+ pendingCommitFocused: true,
17236
+ selectedFileIndex: 0,
17237
+ diffPreviewOffset: 0,
17238
+ pendingKey: undefined,
17239
+ };
17240
+ case 'unfocusPendingCommit':
17241
+ return {
17242
+ ...state,
17243
+ pendingCommitFocused: false,
17244
+ selectedIndex: 0,
17245
+ selectedFileIndex: 0,
17246
+ diffPreviewOffset: 0,
15434
17247
  pendingKey: undefined,
15435
17248
  };
15436
17249
  case 'moveDetailFile':
@@ -15467,12 +17280,29 @@ function applyLogInkAction(state, action) {
15467
17280
  selectedStashIndex: clampIndex$1(state.selectedStashIndex + action.delta, action.count),
15468
17281
  pendingKey: undefined,
15469
17282
  };
17283
+ case 'cycleBranchSort':
17284
+ return {
17285
+ ...state,
17286
+ branchSort: cycleBranchSort(state.branchSort),
17287
+ // Snap to the top of the (newly ordered) list so the user always
17288
+ // sees what's now most relevant under the new mode.
17289
+ selectedBranchIndex: 0,
17290
+ pendingKey: undefined,
17291
+ };
17292
+ case 'cycleTagSort':
17293
+ return {
17294
+ ...state,
17295
+ tagSort: cycleTagSort(state.tagSort),
17296
+ selectedTagIndex: 0,
17297
+ pendingKey: undefined,
17298
+ };
15470
17299
  case 'moveToBottom':
15471
17300
  return {
15472
17301
  ...state,
15473
17302
  selectedIndex: clampIndex$1(state.filteredCommits.length - 1, state.filteredCommits.length),
15474
17303
  selectedFileIndex: 0,
15475
17304
  diffPreviewOffset: 0,
17305
+ pendingCommitFocused: false,
15476
17306
  pendingKey: undefined,
15477
17307
  };
15478
17308
  case 'moveToTop':
@@ -15481,6 +17311,7 @@ function applyLogInkAction(state, action) {
15481
17311
  selectedIndex: 0,
15482
17312
  selectedFileIndex: 0,
15483
17313
  diffPreviewOffset: 0,
17314
+ pendingCommitFocused: false,
15484
17315
  pendingKey: undefined,
15485
17316
  };
15486
17317
  case 'nextSidebarTab':
@@ -15516,6 +17347,12 @@ function applyLogInkAction(state, action) {
15516
17347
  selectedWorktreeHunkIndex: nextHunkIndex(state.worktreeDiffOffset, action.hunkOffsets, action.delta),
15517
17348
  pendingKey: undefined,
15518
17349
  };
17350
+ case 'jumpCommitDiffHunk':
17351
+ return {
17352
+ ...state,
17353
+ diffPreviewOffset: nextHunkOffset(state.diffPreviewOffset, action.hunkOffsets, action.delta),
17354
+ pendingKey: undefined,
17355
+ };
15519
17356
  case 'previousSidebarTab':
15520
17357
  return {
15521
17358
  ...state,
@@ -15523,7 +17360,7 @@ function applyLogInkAction(state, action) {
15523
17360
  pendingKey: undefined,
15524
17361
  };
15525
17362
  case 'setFilter':
15526
- return withFilter$1(state, action.value);
17363
+ return withFilter$1(state, action.value, action.promotedSelections);
15527
17364
  case 'setActiveView':
15528
17365
  return withReplacedView(state, action.value);
15529
17366
  case 'pushView':
@@ -15542,6 +17379,7 @@ function applyLogInkAction(state, action) {
15542
17379
  viewStack: [HOME_VIEW],
15543
17380
  worktreeDiffOffset: 0,
15544
17381
  selectedWorktreeHunkIndex: 0,
17382
+ pendingCommitFocused: false,
15545
17383
  pendingKey: undefined,
15546
17384
  };
15547
17385
  }
@@ -15553,8 +17391,9 @@ function applyLogInkAction(state, action) {
15553
17391
  return {
15554
17392
  ...next,
15555
17393
  selectedIndex: clampIndex$1(selectedIndex, filteredCommits.length),
15556
- selectedFileIndex: 0,
17394
+ selectedFileIndex: Math.max(0, action.fileIndex ?? 0),
15557
17395
  diffPreviewOffset: 0,
17396
+ diffSource: 'commit',
15558
17397
  };
15559
17398
  }
15560
17399
  case 'navigateOpenDiffForWorktreeFile': {
@@ -15564,6 +17403,7 @@ function applyLogInkAction(state, action) {
15564
17403
  selectedWorktreeFileIndex: Math.max(0, action.fileIndex),
15565
17404
  selectedWorktreeHunkIndex: 0,
15566
17405
  worktreeDiffOffset: 0,
17406
+ diffSource: 'worktree',
15567
17407
  };
15568
17408
  }
15569
17409
  case 'navigateOpenComposeForFile': {
@@ -15776,7 +17616,7 @@ function formatCapturedAiOutput(output) {
15776
17616
  }
15777
17617
  async function runChangelogAction(argv) {
15778
17618
  try {
15779
- const output = await captureStdout(() => handler$6(argv, new Logger({
17619
+ const output = await captureStdout(() => handler$7(argv, new Logger({
15780
17620
  verbose: true,
15781
17621
  silent: false,
15782
17622
  })));
@@ -17217,7 +19057,7 @@ function truncate$2(value, width) {
17217
19057
  }
17218
19058
  return `${value.slice(0, width - 3)}...`;
17219
19059
  }
17220
- function formatChangedFile$1(file) {
19060
+ function formatChangedFile(file) {
17221
19061
  if (file.oldPath) {
17222
19062
  return `${file.status} ${file.oldPath} -> ${file.path}`;
17223
19063
  }
@@ -17245,7 +19085,7 @@ function renderCommitList(state, maxRows, width) {
17245
19085
  return truncate$2(row, width);
17246
19086
  });
17247
19087
  }
17248
- function formatDivergence$1(branch) {
19088
+ function formatDivergence(branch) {
17249
19089
  if (!branch.upstream) {
17250
19090
  return 'no upstream';
17251
19091
  }
@@ -17273,7 +19113,7 @@ function renderBranchOverview(overview, ui, width) {
17273
19113
  .map((branch) => {
17274
19114
  const marker = branch.current ? '*' : ' ';
17275
19115
  const selected = isBranchFocused && selectedBranches[ui.branchIndex || 0] === branch ? '>' : ' ';
17276
- return `${selected}${marker} ${branch.shortName} ${formatDivergence$1(branch)}`;
19116
+ return `${selected}${marker} ${branch.shortName} ${formatDivergence(branch)}`;
17277
19117
  });
17278
19118
  const remoteBranches = overview.remoteBranches.slice(0, 6).map((branch) => {
17279
19119
  const selected = isBranchFocused && selectedBranches[ui.branchIndex || 0] === branch ? '>' : ' ';
@@ -17287,7 +19127,7 @@ function renderBranchOverview(overview, ui, width) {
17287
19127
  : [];
17288
19128
  return [
17289
19129
  `Branches: ${overview.currentBranch || '<detached>'} | ${dirty}`,
17290
- current ? `Upstream: ${formatDivergence$1(current)}` : 'Upstream: none',
19130
+ current ? `Upstream: ${formatDivergence(current)}` : 'Upstream: none',
17291
19131
  ui.pendingDeleteBranch
17292
19132
  ? `Pending delete: press D to delete ${ui.pendingDeleteBranch}`
17293
19133
  : 'Branch actions: tab focus | enter checkout/track | f fetch | p push | P pull | d delete',
@@ -17540,7 +19380,7 @@ function renderDetail(detail, width) {
17540
19380
  const refs = detail.refs.length ? ` (${detail.refs.join(', ')})` : '';
17541
19381
  const body = detail.body ? ['', ...detail.body.split('\n').map((line) => ` ${line}`)] : [];
17542
19382
  const files = detail.files.length
17543
- ? detail.files.slice(0, 12).map((file) => ` ${formatChangedFile$1(file)}`)
19383
+ ? detail.files.slice(0, 12).map((file) => ` ${formatChangedFile(file)}`)
17544
19384
  : [' No changed files found.'];
17545
19385
  const hiddenFiles = detail.files.length > 12
17546
19386
  ? [` ... ${detail.files.length - 12} more file(s)`]
@@ -18894,18 +20734,6 @@ const truncate$1 = truncateCells;
18894
20734
  function compactHash(hash) {
18895
20735
  return hash ? hash.slice(0, 7) : '<none>';
18896
20736
  }
18897
- function formatChangedFile(file) {
18898
- const stats = file.binary
18899
- ? 'bin'
18900
- : file.additions !== undefined || file.deletions !== undefined
18901
- ? `+${file.additions || 0}/-${file.deletions || 0}`
18902
- : '';
18903
- const suffix = stats ? ` ${stats}` : '';
18904
- if (file.oldPath) {
18905
- return `${file.status} ${file.oldPath} -> ${file.path}${suffix}`;
18906
- }
18907
- return `${file.status} ${file.path}${suffix}`;
18908
- }
18909
20737
  async function safe(promise) {
18910
20738
  try {
18911
20739
  return await promise;
@@ -18991,6 +20819,70 @@ function focusBorderColor(theme, focused) {
18991
20819
  function panelTitle(title, focused) {
18992
20820
  return focused ? `${title} *` : title;
18993
20821
  }
20822
+ /**
20823
+ * Map a unified-diff line to the props passed to an Ink `<Text>` so the
20824
+ * standard +/-/@@ prefixes render in their conventional colors. File
20825
+ * headers (`+++`, `---`, `diff --git`, `index`) get a softer treatment so
20826
+ * they don't compete with the actual hunk content.
20827
+ *
20828
+ * `theme.noColor` collapses everything to dim/normal so we stay readable
20829
+ * under `NO_COLOR` and the `monochrome` preset.
20830
+ */
20831
+ function diffLineProps(line, theme) {
20832
+ if (theme.noColor) {
20833
+ return { dimColor: line.startsWith(' ') || line.startsWith('diff ') || line.startsWith('index ') };
20834
+ }
20835
+ if (line.startsWith('diff ') || line.startsWith('index ') || line.startsWith('+++') || line.startsWith('---')) {
20836
+ return { dimColor: true };
20837
+ }
20838
+ if (line.startsWith('@@')) {
20839
+ return { color: theme.colors.accent };
20840
+ }
20841
+ if (line.startsWith('+')) {
20842
+ return { color: theme.colors.gitAdded };
20843
+ }
20844
+ if (line.startsWith('-')) {
20845
+ return { color: theme.colors.gitDeleted };
20846
+ }
20847
+ return {};
20848
+ }
20849
+ /**
20850
+ * Pick a theme color for a single name-status code (`A`, `M`, `D`,
20851
+ * `R100`, etc.) so the inspector and commit-diff file list render with
20852
+ * familiar git colors at a glance. Letters stay in the line so the
20853
+ * meaning survives `NO_COLOR`.
20854
+ */
20855
+ function statusCodeColor(status, theme) {
20856
+ if (theme.noColor) {
20857
+ return undefined;
20858
+ }
20859
+ const head = status.charAt(0);
20860
+ switch (head) {
20861
+ case 'A':
20862
+ return theme.colors.gitAdded;
20863
+ case 'D':
20864
+ return theme.colors.gitDeleted;
20865
+ case 'U':
20866
+ return theme.colors.danger;
20867
+ case 'M':
20868
+ case 'T':
20869
+ return theme.colors.gitModified;
20870
+ case 'R':
20871
+ case 'C':
20872
+ return theme.colors.accent;
20873
+ default:
20874
+ return undefined;
20875
+ }
20876
+ }
20877
+ function formatChangedFileStats(file) {
20878
+ if (file.binary) {
20879
+ return 'bin';
20880
+ }
20881
+ if (file.additions === undefined && file.deletions === undefined) {
20882
+ return '';
20883
+ }
20884
+ return `+${file.additions || 0}/-${file.deletions || 0}`;
20885
+ }
18994
20886
  function sidebarTabLabel(tab) {
18995
20887
  switch (tab) {
18996
20888
  case 'status':
@@ -19007,16 +20899,7 @@ function sidebarTabLabel(tab) {
19007
20899
  return tab;
19008
20900
  }
19009
20901
  }
19010
- function formatDivergence(branch) {
19011
- if (!branch.upstream) {
19012
- return 'no upstream';
19013
- }
19014
- if (branch.ahead === 0 && branch.behind === 0) {
19015
- return `even with ${branch.upstream}`;
19016
- }
19017
- return `+${branch.ahead}/-${branch.behind} ${branch.upstream}`;
19018
- }
19019
- function sidebarLines(context, contextStatus, tab, width) {
20902
+ function sidebarLines(context, contextStatus, tab, width, state, theme) {
19020
20903
  if (tab === 'status') {
19021
20904
  const worktree = context.worktree;
19022
20905
  if (isLogInkContextKeyLoading(contextStatus, 'worktree')) {
@@ -19041,20 +20924,22 @@ function sidebarLines(context, contextStatus, tab, width) {
19041
20924
  if (!branches) {
19042
20925
  return ['Branches unavailable'];
19043
20926
  }
20927
+ const sortedBranches = sortBranches(branches.localBranches, state.branchSort);
19044
20928
  return [
19045
20929
  `Current: ${branches.currentBranch || '<detached>'}`,
19046
20930
  branches.dirty ? 'Worktree: dirty' : 'Worktree: clean',
19047
20931
  '',
19048
- ...branches.localBranches.slice(0, 8).map((branch) => `${branch.current ? '*' : ' '} ${truncate$1(branch.shortName, width - 4)}`),
19049
- ...branches.localBranches.slice(0, 4).map((branch) => ` ${truncate$1(formatDivergence(branch), width - 2)}`),
20932
+ ...sortedBranches.slice(0, 8).map((branch) => `${branchRowMarker(branch, { ascii: theme.ascii })} ${truncate$1(branch.shortName, width - 4)}`),
20933
+ ...sortedBranches.slice(0, 4).map((branch) => ` ${truncate$1(formatBranchDivergence(branch, { ascii: theme.ascii }), width - 2)}`),
19050
20934
  ];
19051
20935
  }
19052
20936
  if (tab === 'tags') {
19053
20937
  if (isLogInkContextKeyLoading(contextStatus, 'tags')) {
19054
20938
  return ['Loading tags...'];
19055
20939
  }
19056
- return context.tags?.tags.length
19057
- ? context.tags.tags.slice(0, 12).map((tag) => `${truncate$1(tag.name, 16)} ${truncate$1(tag.subject, Math.max(8, width - 18))}`)
20940
+ const sortedTags = sortTags(context.tags?.tags || [], state.tagSort);
20941
+ return sortedTags.length
20942
+ ? sortedTags.slice(0, 12).map((tag) => `${truncate$1(tag.name, 16)} ${truncate$1(tag.subject, Math.max(8, width - 18))}`)
19058
20943
  : ['No tags found'];
19059
20944
  }
19060
20945
  if (tab === 'stashes') {
@@ -19076,35 +20961,127 @@ function sidebarLines(context, contextStatus, tab, width) {
19076
20961
  })
19077
20962
  : ['No linked worktrees'];
19078
20963
  }
19079
- async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
19080
- const input = streams.input || process.stdin;
19081
- const output = streams.output || process.stdout;
19082
- const error = streams.error || process.stderr;
19083
- if (!canStartLogInkTui(input, output)) {
19084
- await startInteractiveLog(git, rows, {
19085
- appLabel: options.appLabel,
19086
- input,
19087
- output,
19088
- });
19089
- return;
20964
+ async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
20965
+ const input = streams.input || process.stdin;
20966
+ const output = streams.output || process.stdout;
20967
+ const error = streams.error || process.stderr;
20968
+ if (!canStartLogInkTui(input, output)) {
20969
+ await startInteractiveLog(git, rows, {
20970
+ appLabel: options.appLabel,
20971
+ input,
20972
+ output,
20973
+ });
20974
+ return;
20975
+ }
20976
+ const runtime = await loadInkRuntime();
20977
+ const { ink, React } = runtime;
20978
+ // Forward declared so the lifecycle handler can call back into the React
20979
+ // tree on SIGCONT to force a repaint after the user `fg`s.
20980
+ const resumeRef = { current: null };
20981
+ const app = React.createElement(LogInkApp, {
20982
+ appLabel: options.appLabel || 'coco log',
20983
+ git,
20984
+ idleTipsEnabled: Boolean(options.idleTips),
20985
+ ink,
20986
+ initialView: options.initialView || 'history',
20987
+ logArgv: options.logArgv,
20988
+ React,
20989
+ rows,
20990
+ theme: createLogInkTheme(options.theme),
20991
+ resumeRef,
20992
+ });
20993
+ const instance = ink.render(app, getLogInkRenderOptions({ input, output, error }));
20994
+ const lifecycle = installTerminalLifecycle({
20995
+ input,
20996
+ output,
20997
+ instance,
20998
+ onResume: () => resumeRef.current?.(),
20999
+ });
21000
+ try {
21001
+ await instance.waitUntilExit();
21002
+ }
21003
+ finally {
21004
+ lifecycle.dispose();
21005
+ }
21006
+ }
21007
+ /**
21008
+ * Predict the filter value that a filter-mutating action would land on, so
21009
+ * the runtime can compute the post-filter selection snapshot before the
21010
+ * reducer ever runs (P4.5). Returns undefined when the action isn't a
21011
+ * filter action.
21012
+ */
21013
+ function predictNextFilter(action, currentFilter) {
21014
+ switch (action.type) {
21015
+ case 'appendFilter':
21016
+ return `${currentFilter}${action.value}`;
21017
+ case 'backspaceFilter':
21018
+ return currentFilter.slice(0, -1);
21019
+ case 'clearFilter':
21020
+ case 'clearFilterText':
21021
+ return '';
21022
+ case 'setFilter':
21023
+ return action.value;
21024
+ default:
21025
+ return undefined;
21026
+ }
21027
+ }
21028
+ /**
21029
+ * Build the post-filter selection snapshot for branches / tags / stash so
21030
+ * the reducer can preserve the cursor when the previously-selected item is
21031
+ * still in the filtered result. Identifies items by a single key per view
21032
+ * (branch shortName, tag name, stash ref) — the same matchesPromotedFilter
21033
+ * the surfaces use covers the multi-field haystacks.
21034
+ */
21035
+ function computePromotedSelectionsSnapshot(state, context, nextFilter) {
21036
+ const allBranches = context.branches?.localBranches || [];
21037
+ const filteredBranches = nextFilter
21038
+ ? allBranches.filter((branch) => matchesPromotedFilter([branch.shortName, branch.upstream || ''], nextFilter))
21039
+ : allBranches;
21040
+ const currentBranches = state.filter
21041
+ ? allBranches.filter((branch) => matchesPromotedFilter([branch.shortName, branch.upstream || ''], state.filter))
21042
+ : allBranches;
21043
+ const previousBranchKey = currentBranches[state.selectedBranchIndex]?.shortName;
21044
+ const branchIndex = rectifyPromotedSelectionIndex(filteredBranches.map((branch) => branch.shortName), previousBranchKey);
21045
+ const allTags = context.tags?.tags || [];
21046
+ const filteredTags = nextFilter
21047
+ ? allTags.filter((tag) => matchesPromotedFilter([tag.name, tag.subject], nextFilter))
21048
+ : allTags;
21049
+ const currentTags = state.filter
21050
+ ? allTags.filter((tag) => matchesPromotedFilter([tag.name, tag.subject], state.filter))
21051
+ : allTags;
21052
+ const previousTagKey = currentTags[state.selectedTagIndex]?.name;
21053
+ const tagIndex = rectifyPromotedSelectionIndex(filteredTags.map((tag) => tag.name), previousTagKey);
21054
+ const allStashes = context.stashes?.stashes || [];
21055
+ const filteredStashes = nextFilter
21056
+ ? allStashes.filter((stash) => matchesPromotedFilter([stash.ref, stash.message], nextFilter))
21057
+ : allStashes;
21058
+ const currentStashes = state.filter
21059
+ ? allStashes.filter((stash) => matchesPromotedFilter([stash.ref, stash.message], state.filter))
21060
+ : allStashes;
21061
+ const previousStashKey = currentStashes[state.selectedStashIndex]?.ref;
21062
+ const stashIndex = rectifyPromotedSelectionIndex(filteredStashes.map((stash) => stash.ref), previousStashKey);
21063
+ return { branchIndex, tagIndex, stashIndex };
21064
+ }
21065
+ function enrichFilterActionWithRectification(action, state, context) {
21066
+ const nextFilter = predictNextFilter(action, state.filter);
21067
+ if (nextFilter === undefined) {
21068
+ return action;
21069
+ }
21070
+ const promotedSelections = computePromotedSelectionsSnapshot(state, context, nextFilter);
21071
+ switch (action.type) {
21072
+ case 'appendFilter':
21073
+ case 'setFilter':
21074
+ return { ...action, promotedSelections };
21075
+ case 'backspaceFilter':
21076
+ case 'clearFilter':
21077
+ case 'clearFilterText':
21078
+ return { ...action, promotedSelections };
21079
+ default:
21080
+ return action;
19090
21081
  }
19091
- const runtime = await loadInkRuntime();
19092
- const { ink, React } = runtime;
19093
- const app = React.createElement(LogInkApp, {
19094
- appLabel: options.appLabel || 'coco log',
19095
- git,
19096
- ink,
19097
- initialView: options.initialView || 'history',
19098
- logArgv: options.logArgv,
19099
- React,
19100
- rows,
19101
- theme: createLogInkTheme(options.theme),
19102
- });
19103
- const instance = ink.render(app, getLogInkRenderOptions({ input, output, error }));
19104
- await instance.waitUntilExit();
19105
21082
  }
19106
21083
  function LogInkApp(deps) {
19107
- const { appLabel, git, ink, initialView, logArgv, React, rows, theme } = deps;
21084
+ const { appLabel, git, idleTipsEnabled, ink, initialView, logArgv, React, resumeRef, rows, theme } = deps;
19108
21085
  const { Box, Text, useApp, useInput, useWindowSize } = ink;
19109
21086
  const h = React.createElement;
19110
21087
  const { exit } = useApp();
@@ -19113,6 +21090,22 @@ function LogInkApp(deps) {
19113
21090
  columns: windowSize.columns || process.stdout.columns || LOG_INK_DEFAULT_COLUMNS,
19114
21091
  rows: windowSize.rows || process.stdout.rows || LOG_INK_DEFAULT_ROWS,
19115
21092
  });
21093
+ // Bumping this on SIGCONT forces the existing tree to repaint so users
21094
+ // land on a drawn screen after `fg` instead of an empty alt buffer.
21095
+ const [, setResumeTick] = React.useState(0);
21096
+ React.useEffect(() => {
21097
+ if (!resumeRef) {
21098
+ return;
21099
+ }
21100
+ resumeRef.current = () => setResumeTick((tick) => tick + 1);
21101
+ return () => {
21102
+ resumeRef.current = null;
21103
+ };
21104
+ }, [resumeRef]);
21105
+ // First-launch onboarding (P1.3). Persisted via a marker file in the
21106
+ // user's cache dir so the tip never reappears once dismissed. Lazy
21107
+ // initializer so the fs check only runs on mount, not every render.
21108
+ const [showOnboarding, setShowOnboarding] = React.useState(() => !hasSeenOnboarding());
19116
21109
  const [state, setState] = React.useState(() => createLogInkState(rows, { activeView: initialView }));
19117
21110
  const [context, setContext] = React.useState({});
19118
21111
  const [contextStatus, setContextStatus] = React.useState(() => createLogInkContextStatus('loading'));
@@ -19130,21 +21123,66 @@ function LogInkApp(deps) {
19130
21123
  const loadingMoreCommitsRef = React.useRef(false);
19131
21124
  const loadMoreRequestRef = React.useRef(0);
19132
21125
  const mountedRef = React.useRef(true);
21126
+ // P4.3 — idle tip rotation. tickIndex 0 ⇒ no tip; the hook bumps it after
21127
+ // a grace window of empty statusMessage and then on a steady cadence, so
21128
+ // the footer surfaces a different hint every interval until the user does
21129
+ // anything that sets statusMessage.
21130
+ const [idleTipIndex, setIdleTipIndex] = React.useState(0);
21131
+ React.useEffect(() => {
21132
+ if (!idleTipsEnabled)
21133
+ return;
21134
+ if (state.statusMessage) {
21135
+ // Any explicit message resets the cycle; next idle stretch starts
21136
+ // from the grace window again.
21137
+ setIdleTipIndex(0);
21138
+ return;
21139
+ }
21140
+ let interval;
21141
+ // Both timer callbacks are function literals (never strings) and the
21142
+ // delays are our own `IDLE_TIPS_*_MS` constants — no caller-supplied
21143
+ // data flows in, so the eval-injection vector that drives
21144
+ // DevSkim DS172411 doesn't apply here.
21145
+ // DevSkim: ignore DS172411
21146
+ const grace = setTimeout(() => {
21147
+ setIdleTipIndex(1);
21148
+ // DevSkim: ignore DS172411
21149
+ interval = setInterval(() => setIdleTipIndex((tick) => tick + 1), IDLE_TIPS_INTERVAL_MS);
21150
+ }, IDLE_TIPS_GRACE_MS);
21151
+ return () => {
21152
+ clearTimeout(grace);
21153
+ if (interval)
21154
+ clearInterval(interval);
21155
+ };
21156
+ }, [idleTipsEnabled, state.statusMessage]);
21157
+ const idleTip = idleTipsEnabled && !state.statusMessage ? pickIdleTip(idleTipIndex) : undefined;
19133
21158
  const selected = getSelectedInkCommit(state);
19134
21159
  const selectedDetailFile = detail?.files[state.selectedFileIndex];
19135
21160
  const selectedWorktreeFile = context.worktree?.files[state.selectedWorktreeFileIndex];
19136
21161
  const dispatch = React.useCallback((action) => {
19137
21162
  setState((current) => applyLogInkAction(current, action));
19138
21163
  }, []);
19139
- const refreshContext = React.useCallback(async () => {
19140
- dispatch({ type: 'setStatus', value: 'refreshing repository context' });
19141
- setContextStatus(createLogInkContextStatus('loading'));
19142
- setContext(await loadLogInkContext(git));
21164
+ const refreshContext = React.useCallback(async (options = {}) => {
21165
+ // Loud refresh (manual `r`): flip everything to 'loading' so the user
21166
+ // sees the surfaces clear, then settle to 'ready' on completion.
21167
+ // Silent refresh (fs.watch trigger): keep the existing data on screen
21168
+ // (stale-while-revalidate) and quietly swap it in once the new fetch
21169
+ // resolves — avoids the every-second flicker the watcher would
21170
+ // otherwise produce on busy repos.
21171
+ if (!options.silent) {
21172
+ dispatch({ type: 'setStatus', value: 'refreshing repository context' });
21173
+ setContextStatus(createLogInkContextStatus('loading'));
21174
+ }
21175
+ const next = await loadLogInkContext(git);
21176
+ setContext(next);
19143
21177
  setContextStatus(createLogInkContextStatus('ready'));
19144
- dispatch({ type: 'setStatus', value: 'repository context refreshed' });
21178
+ if (!options.silent) {
21179
+ dispatch({ type: 'setStatus', value: 'repository context refreshed' });
21180
+ }
19145
21181
  }, [dispatch, git]);
19146
- const refreshWorktreeContext = React.useCallback(async () => {
19147
- setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'loading'));
21182
+ const refreshWorktreeContext = React.useCallback(async (options = {}) => {
21183
+ if (!options.silent) {
21184
+ setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'loading'));
21185
+ }
19148
21186
  const worktree = await safe(getWorktreeOverview(git));
19149
21187
  setContext((current) => ({
19150
21188
  ...current,
@@ -19152,6 +21190,56 @@ function LogInkApp(deps) {
19152
21190
  }));
19153
21191
  setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'ready'));
19154
21192
  }, [git]);
21193
+ // Live refresh: watch .git metadata + the working tree root and reload
21194
+ // context when something changes outside the TUI (editor save, external
21195
+ // git commands, branch switch in another terminal). Best-effort — the
21196
+ // watcher quietly skips paths that don't exist or platforms where
21197
+ // fs.watch fails. Subdirectory unstaged edits don't fire; users can
21198
+ // press `r` for those.
21199
+ React.useEffect(() => {
21200
+ let cancelled = false;
21201
+ let watcher = null;
21202
+ void (async () => {
21203
+ try {
21204
+ const [repoRoot, gitDir] = await Promise.all([
21205
+ git.revparse(['--show-toplevel']),
21206
+ git.revparse(['--absolute-git-dir']),
21207
+ ]);
21208
+ if (cancelled) {
21209
+ return;
21210
+ }
21211
+ watcher = createRefreshWatcher({
21212
+ repoRoot: repoRoot.trim(),
21213
+ gitDir: gitDir.trim(),
21214
+ // Editor saves and git background processes can produce a steady
21215
+ // drip of fs events on busy repos. The default 250ms debounce
21216
+ // was tight enough that the watcher fired ~once per second; 750
21217
+ // batches the steady-state better without delaying the user's
21218
+ // perception of an actual change.
21219
+ debounceMs: 750,
21220
+ onChange: (kind) => {
21221
+ if (!mountedRef.current) {
21222
+ return;
21223
+ }
21224
+ if (kind === 'full') {
21225
+ void refreshContext({ silent: true });
21226
+ }
21227
+ else {
21228
+ void refreshWorktreeContext({ silent: true });
21229
+ }
21230
+ },
21231
+ });
21232
+ }
21233
+ catch {
21234
+ // Not in a git worktree, or revparse failed. Skip — manual `r`
21235
+ // refresh still works.
21236
+ }
21237
+ })();
21238
+ return () => {
21239
+ cancelled = true;
21240
+ watcher?.close();
21241
+ };
21242
+ }, [git, refreshContext, refreshWorktreeContext]);
19155
21243
  React.useEffect(() => {
19156
21244
  let active = true;
19157
21245
  async function loadWorktreeHunks() {
@@ -19362,6 +21450,72 @@ function LogInkApp(deps) {
19362
21450
  });
19363
21451
  dispatch({ type: 'setStatus', value: result.message });
19364
21452
  }, [dispatch]);
21453
+ // Resolve the destructive-action target from the live filtered+sorted
21454
+ // list the user is looking at, run the action against it, surface the
21455
+ // result on the status line, and silently refresh so the deleted item
21456
+ // disappears. Called from the y-confirm path for delete-branch / delete-
21457
+ // tag / drop-stash / remove-worktree / abort-operation.
21458
+ const runWorkflowAction = React.useCallback(async (id) => {
21459
+ const handlers = {
21460
+ 'delete-branch': async () => {
21461
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
21462
+ const visible = state.filter
21463
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
21464
+ : all;
21465
+ const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
21466
+ if (!branch)
21467
+ return { ok: false, message: 'No branch selected' };
21468
+ return deleteBranch(git, branch);
21469
+ },
21470
+ 'delete-tag': async () => {
21471
+ const all = sortTags(context.tags?.tags || [], state.tagSort);
21472
+ const visible = state.filter
21473
+ ? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
21474
+ : all;
21475
+ const tag = visible[Math.min(state.selectedTagIndex, visible.length - 1)];
21476
+ if (!tag)
21477
+ return { ok: false, message: 'No tag selected' };
21478
+ return deleteLocalTag(git, tag.name);
21479
+ },
21480
+ 'drop-stash': async () => {
21481
+ const all = context.stashes?.stashes || [];
21482
+ const visible = state.filter
21483
+ ? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
21484
+ : all;
21485
+ const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
21486
+ if (!stash)
21487
+ return { ok: false, message: 'No stash selected' };
21488
+ return dropStash(git, stash);
21489
+ },
21490
+ 'remove-worktree': async () => {
21491
+ const all = context.worktreeList?.worktrees || [];
21492
+ // No dedicated cursor for the worktrees tab yet — operate on the
21493
+ // first non-current worktree as a safe default.
21494
+ const target = all.find((w) => !w.current);
21495
+ if (!target)
21496
+ return { ok: false, message: 'No removable worktree' };
21497
+ return removeWorktree(git, target);
21498
+ },
21499
+ 'abort-operation': async () => {
21500
+ const operation = context.operation?.operation;
21501
+ if (!operation) {
21502
+ return { ok: false, message: 'No git operation in progress' };
21503
+ }
21504
+ return abortOperation(git, operation);
21505
+ },
21506
+ };
21507
+ const handler = handlers[id];
21508
+ if (!handler) {
21509
+ dispatch({ type: 'setStatus', value: `Workflow action ${id} not yet wired` });
21510
+ return;
21511
+ }
21512
+ const result = await handler();
21513
+ dispatch({ type: 'setStatus', value: result?.message || 'Workflow action complete' });
21514
+ // Silent refresh so the deleted item disappears from the list without
21515
+ // flickering the surfaces through a 'loading' phase.
21516
+ await refreshContext({ silent: true });
21517
+ }, [context, dispatch, git, refreshContext, state.branchSort, state.filter, state.selectedBranchIndex,
21518
+ state.selectedStashIndex, state.selectedTagIndex, state.tagSort]);
19365
21519
  React.useEffect(() => {
19366
21520
  let active = true;
19367
21521
  async function loadPreview() {
@@ -19439,16 +21593,50 @@ function LogInkApp(deps) {
19439
21593
  state.filteredCommits.length,
19440
21594
  state.selectedIndex,
19441
21595
  ]);
21596
+ const commitDiffHunkOffsets = React.useMemo(() => (filePreview?.hunks
21597
+ .map((line, index) => (line.startsWith('@@') ? index : -1))
21598
+ .filter((index) => index >= 0)), [filePreview]);
21599
+ const worktreeDirty = Boolean(context.worktree &&
21600
+ (context.worktree.stagedCount + context.worktree.unstagedCount + context.worktree.untrackedCount) > 0);
19442
21601
  useInput((inputValue, key) => {
21602
+ // First-launch onboarding (P1.3): any keystroke dismisses the overlay
21603
+ // and writes the seen-marker. Swallow the keystroke so the same key
21604
+ // doesn't also trigger normal input dispatch.
21605
+ if (showOnboarding) {
21606
+ setShowOnboarding(false);
21607
+ markOnboardingSeen();
21608
+ return;
21609
+ }
21610
+ // P4.5: navigation in branches/tags/stash uses the FILTERED list
21611
+ // length when a filter is active so j/k stay live instead of getting
21612
+ // stuck against a full-list count that no longer matches what's on
21613
+ // screen.
21614
+ const branchVisibleCount = state.filter
21615
+ ? (context.branches?.localBranches || [])
21616
+ .filter((branch) => matchesPromotedFilter([branch.shortName, branch.upstream || ''], state.filter))
21617
+ .length
21618
+ : context.branches?.localBranches.length;
21619
+ const tagVisibleCount = state.filter
21620
+ ? (context.tags?.tags || [])
21621
+ .filter((tag) => matchesPromotedFilter([tag.name, tag.subject], state.filter))
21622
+ .length
21623
+ : context.tags?.tags.length;
21624
+ const stashVisibleCount = state.filter
21625
+ ? (context.stashes?.stashes || [])
21626
+ .filter((stash) => matchesPromotedFilter([stash.ref, stash.message], state.filter))
21627
+ .length
21628
+ : context.stashes?.stashes.length;
19443
21629
  getLogInkInputEvents(state, inputValue, key, {
19444
21630
  detailFileCount: detail?.files.length,
19445
21631
  previewLineCount: filePreview?.hunks.length,
19446
21632
  worktreeDiffLineCount: worktreeDiff?.lines.length,
19447
21633
  worktreeFileCount: context.worktree?.files.length,
19448
21634
  worktreeHunkOffsets: worktreeDiff?.hunkOffsets,
19449
- branchCount: context.branches?.localBranches.length,
19450
- tagCount: context.tags?.tags.length,
19451
- stashCount: context.stashes?.stashes.length,
21635
+ commitDiffHunkOffsets,
21636
+ branchCount: branchVisibleCount,
21637
+ tagCount: tagVisibleCount,
21638
+ stashCount: stashVisibleCount,
21639
+ worktreeDirty,
19452
21640
  }).forEach((event) => {
19453
21641
  if (event.type === 'exit') {
19454
21642
  exit();
@@ -19474,8 +21662,18 @@ function LogInkApp(deps) {
19474
21662
  else if (event.type === 'runAiCommitDraft') {
19475
21663
  void runAiCommitDraft();
19476
21664
  }
21665
+ else if (event.type === 'runWorkflowAction') {
21666
+ void runWorkflowAction(event.id);
21667
+ }
19477
21668
  else {
19478
- dispatch(event.action);
21669
+ // P4.5: enrich filter-mutating actions with a precomputed
21670
+ // selection snapshot so the reducer can preserve the cursor on
21671
+ // the same item when it's still in the filtered result, only
21672
+ // snapping to result[0] when the previously selected item drops
21673
+ // out. The snapshot lives in the action so the reducer never
21674
+ // needs context items.
21675
+ const enriched = enrichFilterActionWithRectification(event.action, state, context);
21676
+ dispatch(enriched);
19479
21677
  }
19480
21678
  });
19481
21679
  });
@@ -19487,7 +21685,12 @@ function LogInkApp(deps) {
19487
21685
  paddingY: 1,
19488
21686
  }, 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.'));
19489
21687
  }
19490
- 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));
21688
+ // First-launch onboarding overlay (P1.3) replaces the entire UI for
21689
+ // one render — any keystroke dismisses it and persists the seen-marker.
21690
+ if (showOnboarding) {
21691
+ return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
21692
+ }
21693
+ 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));
19491
21694
  }
19492
21695
  function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
19493
21696
  const { Box, Text } = components;
@@ -19496,27 +21699,54 @@ function renderHeader(h, components, state, context, contextStatus, columns, the
19496
21699
  const repo = context.provider?.repository.owner && context.provider.repository.name
19497
21700
  ? `${context.provider.repository.owner}/${context.provider.repository.name}`
19498
21701
  : 'local repository';
19499
- const pr = context.provider?.currentPullRequest
19500
- ? `PR #${context.provider.currentPullRequest.number} ${context.provider.currentPullRequest.state}`
19501
- : context.pullRequest?.currentPullRequest
19502
- ? `PR #${context.pullRequest.currentPullRequest.number} ${context.pullRequest.currentPullRequest.state}`
19503
- : 'no PR';
21702
+ const prInfo = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
21703
+ const prGlyph = prInfo ? getPullRequestStateGlyph(prInfo, theme) : null;
21704
+ const prLabel = prInfo
21705
+ ? `PR #${prInfo.number} ${prInfo.isDraft ? 'DRAFT' : prInfo.state}`
21706
+ : 'no PR';
19504
21707
  const search = state.filterMode ? `search: ${state.filter}_` : state.filter ? `filter: ${state.filter}` : '';
19505
21708
  const loading = isLogInkContextLoading(contextStatus) ? ' loading context' : '';
19506
21709
  const breadcrumb = formatLogInkBreadcrumb(state.viewStack);
19507
21710
  const view = breadcrumb ? ` ${breadcrumb}` : '';
19508
- const title = truncate$1(`${appLabel} ${repo} ${branch} ${dirty} ${pr}${view}${loading}`, columns - 2);
21711
+ // Mode indicator (P2.2) surfaces the current input mode so users
21712
+ // never wonder why `q` doesn't quit while they're editing or filtering.
21713
+ const mode = state.commitCompose.editing
21714
+ ? '[EDIT]'
21715
+ : state.filterMode
21716
+ ? '[FILTER]'
21717
+ : '[NORMAL]';
21718
+ const titlePrefix = `${appLabel} ${repo} ${branch} ${dirty} `;
21719
+ const glyphPart = prGlyph?.glyph ? `${prGlyph.glyph} ` : '';
21720
+ const titleSuffix = `${view}${loading}`;
21721
+ const fullTitle = `${titlePrefix}${glyphPart}${prLabel}${titleSuffix}`;
21722
+ const titleBudget = columns - mode.length - 4;
21723
+ const truncatedTitle = truncate$1(fullTitle, titleBudget);
21724
+ // Only split into colored fragments when the prefix + glyph + label all
21725
+ // fit unmodified — otherwise the truncate ellipsis can land mid-fragment
21726
+ // and we'd render half a glyph in the wrong color.
21727
+ const splitFragments = truncatedTitle === fullTitle && glyphPart.length > 0;
21728
+ const modeColor = theme.noColor
21729
+ ? undefined
21730
+ : state.filterMode || state.commitCompose.editing
21731
+ ? theme.colors.warning
21732
+ : theme.colors.accent;
19509
21733
  return h(Box, {
19510
21734
  borderColor: theme.colors.border,
19511
21735
  borderStyle: theme.borderStyle,
19512
21736
  height: 3,
19513
21737
  paddingX: 1,
19514
- }, h(Text, { bold: true, color: theme.colors.accent }, title), search ? h(Text, { dimColor: true }, ` ${truncate$1(search, 36)}`) : undefined);
21738
+ }, splitFragments
21739
+ ? h(Text, { bold: true, color: theme.colors.accent }, titlePrefix)
21740
+ : h(Text, { bold: true, color: theme.colors.accent }, truncatedTitle), splitFragments
21741
+ ? h(Text, { bold: true, color: prGlyph?.color, dimColor: prGlyph?.dim }, glyphPart)
21742
+ : undefined, splitFragments
21743
+ ? h(Text, { bold: true, color: theme.colors.accent }, `${prLabel}${titleSuffix}`)
21744
+ : undefined, h(Text, { bold: true, color: modeColor }, ` ${mode}`), search ? h(Text, { dimColor: true }, ` ${truncate$1(search, 36)}`) : undefined);
19515
21745
  }
19516
21746
  function renderSidebar(h, components, state, context, contextStatus, width, theme) {
19517
21747
  const { Box, Text } = components;
19518
21748
  const focused = state.focus === 'sidebar';
19519
- const lines = sidebarLines(context, contextStatus, state.sidebarTab, width - 4);
21749
+ const lines = sidebarLines(context, contextStatus, state.sidebarTab, width - 4, state, theme);
19520
21750
  const tabs = getLogInkSidebarTabs();
19521
21751
  return h(Box, {
19522
21752
  borderColor: focusBorderColor(theme, focused),
@@ -19524,33 +21754,48 @@ function renderSidebar(h, components, state, context, contextStatus, width, them
19524
21754
  flexDirection: 'column',
19525
21755
  width,
19526
21756
  paddingX: 1,
19527
- }, 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))));
19528
- }
19529
- function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, bodyRows, theme, hasMoreCommits, loadingMoreCommits) {
21757
+ }, h(Text, { bold: true }, panelTitle('Repository', focused)), h(Text, { dimColor: true }, tabs.map((tab) => {
21758
+ const count = sidebarTabCount(tab, context);
21759
+ const labelWithCount = count !== undefined
21760
+ ? `${sidebarTabLabel(tab)} (${count})`
21761
+ : sidebarTabLabel(tab);
21762
+ return tab === state.sidebarTab ? `[${labelWithCount}]` : labelWithCount;
21763
+ }).join(' ')), h(Text, undefined, ''), ...lines.map((line, index) => h(Text, { key: `sidebar-${index}` }, truncate$1(line, width - 4))));
21764
+ }
21765
+ function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
19530
21766
  if (state.activeView === 'status') {
19531
- return renderStatusSurface(h, components, state, context, contextStatus, bodyRows, theme);
21767
+ return renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
19532
21768
  }
19533
21769
  if (state.activeView === 'diff') {
19534
- return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, bodyRows, theme);
21770
+ return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, bodyRows, width, theme);
19535
21771
  }
19536
21772
  if (state.activeView === 'compose') {
19537
- return renderComposeSurface(h, components, state, context, contextStatus, bodyRows, theme);
21773
+ return renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
19538
21774
  }
19539
21775
  if (state.activeView === 'branches') {
19540
- return renderBranchesSurface(h, components, state, context, contextStatus, bodyRows, theme);
21776
+ return renderBranchesSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
19541
21777
  }
19542
21778
  if (state.activeView === 'tags') {
19543
- return renderTagsSurface(h, components, state, context, contextStatus, bodyRows, theme);
21779
+ return renderTagsSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
19544
21780
  }
19545
21781
  if (state.activeView === 'stash') {
19546
- return renderStashSurface(h, components, state, context, contextStatus, bodyRows, theme);
21782
+ return renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
19547
21783
  }
19548
- return renderHistoryPanel(h, components, state, bodyRows, theme, hasMoreCommits, loadingMoreCommits);
21784
+ return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits);
19549
21785
  }
19550
- function renderHistoryPanel(h, components, state, bodyRows, theme, hasMoreCommits, loadingMoreCommits) {
21786
+ function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
19551
21787
  const { Box, Text } = components;
19552
21788
  const focused = state.focus === 'commits';
19553
- const listRows = Math.max(3, bodyRows - 4);
21789
+ const worktree = context.worktree;
21790
+ const worktreeDirty = Boolean(worktree && (worktree.stagedCount + worktree.unstagedCount + worktree.untrackedCount) > 0);
21791
+ // The synthetic "(+) new commit" row only appears when the worktree is
21792
+ // dirty AND the visible window is anchored at the top of the list — i.e.
21793
+ // the first real commit (selectedIndex 0) is in view. Scroll past that
21794
+ // and the row slides off naturally; the user can `gg` to bring it back.
21795
+ const showPendingRow = worktreeDirty &&
21796
+ !state.filter &&
21797
+ state.selectedIndex === 0;
21798
+ const listRows = Math.max(3, bodyRows - (showPendingRow ? 5 : 4));
19554
21799
  const visible = getVisibleLogInkHistory(state, listRows);
19555
21800
  const loadState = loadingMoreCommits
19556
21801
  ? 'loading older commits'
@@ -19559,13 +21804,21 @@ function renderHistoryPanel(h, components, state, bodyRows, theme, hasMoreCommit
19559
21804
  : 'loaded';
19560
21805
  const title = `${state.filteredCommits.length}/${state.commits.length} commits`;
19561
21806
  const graphMode = state.fullGraph ? 'full graph' : 'compact graph';
21807
+ const pendingRowSelected = showPendingRow && Boolean(state.pendingCommitFocused) && focused;
21808
+ // Real-commit selection is suppressed while the cursor is on the pending
21809
+ // row so the visible cursor only renders in one place at a time.
21810
+ const realSelectionSuppressed = state.pendingCommitFocused;
21811
+ const pendingNode = showPendingRow
21812
+ ? renderPendingCommitRow(h, Text, worktree, pendingRowSelected, theme)
21813
+ : null;
19562
21814
  return h(Box, {
19563
21815
  borderColor: focusBorderColor(theme, focused),
19564
21816
  borderStyle: theme.borderStyle,
19565
21817
  flexDirection: 'column',
19566
- flexGrow: 1,
21818
+ flexShrink: 0,
19567
21819
  paddingX: 1,
19568
- }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Commits', focused)), h(Text, { dimColor: true }, `${title} | ${graphMode} | ${loadState}`)), visible.items.length === 0
21820
+ width,
21821
+ }, 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
19569
21822
  ? h(Text, { dimColor: true }, formatLogInkHistoryEmpty({
19570
21823
  filter: state.filter,
19571
21824
  totalCommits: state.commits.length,
@@ -19574,36 +21827,97 @@ function renderHistoryPanel(h, components, state, bodyRows, theme, hasMoreCommit
19574
21827
  if (item.type === 'graph') {
19575
21828
  return h(Text, {
19576
21829
  key: `graph-${index}-${item.graph}`,
19577
- dimColor: true,
19578
- }, truncate$1(item.graph.padEnd(visible.graphWidth), 140));
21830
+ color: theme.noColor ? undefined : theme.colors.muted,
21831
+ dimColor: theme.noColor,
21832
+ }, truncate$1(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }), 140));
19579
21833
  }
19580
- const { commit, selected } = item;
19581
- const graph = item.graph.padEnd(visible.graphWidth);
19582
- const row = `${graph} ${commit.shortHash} ${commit.date} ${commit.message}${formatInkRefLabels(commit.refs)}`;
19583
- return h(Text, {
19584
- key: `${commit.hash}-${index}`,
19585
- backgroundColor: selected && !theme.noColor ? theme.colors.selection : undefined,
19586
- inverse: selected,
19587
- }, truncate$1(row, 140));
21834
+ return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index);
19588
21835
  }));
19589
21836
  }
19590
- function renderStatusSurface(h, components, state, context, contextStatus, bodyRows, theme) {
21837
+ /**
21838
+ * Render a single commit row with each segment in its own colored span.
21839
+ * Graph chars render in `theme.colors.muted` so the topology visually
21840
+ * recedes; shortHash takes the accent so the eye lands on the commit
21841
+ * identifier first; date is dimmed; message is normal; ref labels
21842
+ * (`[HEAD -> main]`) trail in accent. Selection styling is applied at
21843
+ * the outer span via `backgroundColor` / `inverse` so the highlight
21844
+ * fills the whole row regardless of inner-span coloring.
21845
+ *
21846
+ * Truncation is per-segment so the variable-length message field gets
21847
+ * the leftover budget after fixed segments are accounted for.
21848
+ */
21849
+ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index) {
21850
+ const renderedGraph = substituteGraphChars(graph.padEnd(graphWidth), { ascii: theme.ascii });
21851
+ const refs = formatInkRefLabels(commit.refs);
21852
+ const totalWidth = 140;
21853
+ const fixedWidth = graphWidth + 1 + commit.shortHash.length + 1 + commit.date.length + 1;
21854
+ const messageRoom = Math.max(8, totalWidth - fixedWidth - cellWidth(refs));
21855
+ const message = truncate$1(commit.message, messageRoom);
21856
+ const selectedBg = selected && !theme.noColor ? theme.colors.selection : undefined;
21857
+ const accent = theme.noColor ? undefined : theme.colors.accent;
21858
+ const muted = theme.noColor ? undefined : theme.colors.muted;
21859
+ return h(Text, {
21860
+ key: `${commit.hash}-${index}`,
21861
+ backgroundColor: selectedBg,
21862
+ inverse: selected,
21863
+ }, 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);
21864
+ }
21865
+ /**
21866
+ * Render the synthetic "(+) new commit" affordance shown above the real
21867
+ * commit list when the worktree is dirty. Pressing up at `selectedIndex 0`
21868
+ * focuses this row; pressing Enter pushes the status view so the user can
21869
+ * stage / commit.
21870
+ */
21871
+ function renderPendingCommitRow(h, Text, worktree, selected, theme) {
21872
+ const parts = [];
21873
+ if (worktree.stagedCount) {
21874
+ parts.push(`${worktree.stagedCount} staged`);
21875
+ }
21876
+ if (worktree.unstagedCount) {
21877
+ parts.push(`${worktree.unstagedCount} unstaged`);
21878
+ }
21879
+ if (worktree.untrackedCount) {
21880
+ parts.push(`${worktree.untrackedCount} untracked`);
21881
+ }
21882
+ const summary = parts.length ? parts.join(' · ') : 'pending changes';
21883
+ const label = `${theme.ascii ? '[+]' : '(+)'} New commit · ${summary}`;
21884
+ return h(Text, {
21885
+ key: 'pending-commit-row',
21886
+ bold: true,
21887
+ color: theme.noColor ? undefined : theme.colors.accent,
21888
+ inverse: selected,
21889
+ backgroundColor: selected && !theme.noColor ? theme.colors.selection : undefined,
21890
+ }, truncate$1(label, 140));
21891
+ }
21892
+ function renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
19591
21893
  const { Box, Text } = components;
19592
21894
  const focused = state.focus === 'commits';
19593
21895
  const worktree = context.worktree;
19594
21896
  const listRows = Math.max(4, bodyRows - 5);
19595
21897
  const selectedIndex = state.selectedWorktreeFileIndex;
19596
21898
  const cleanHint = formatLogInkStatusEmpty({ hasChanges: Boolean(worktree?.files.length) });
19597
- const lines = isLogInkContextKeyLoading(contextStatus, 'worktree')
21899
+ const startIndex = Math.max(0, selectedIndex - Math.floor(listRows / 2));
21900
+ const isLoading = isLogInkContextKeyLoading(contextStatus, 'worktree');
21901
+ const fileRows = isLoading || !worktree?.files.length
21902
+ ? []
21903
+ : worktree.files.slice(startIndex).slice(0, listRows).map((file, offset) => {
21904
+ const index = startIndex + offset;
21905
+ const isSelected = index === selectedIndex;
21906
+ const cursorPart = `${isSelected ? '>' : ' '} `;
21907
+ const dotColor = getStageStatusDotColor(file.state, theme);
21908
+ const useDot = dotColor !== undefined;
21909
+ const dotCells = useDot ? cellWidth(STAGE_STATUS_DOT) + 1 : 0;
21910
+ const tail = `${file.indexStatus}${file.worktreeStatus} ${file.state.padEnd(9)} ${file.path}`;
21911
+ const tailTrunc = truncate$1(tail, Math.max(0, 140 - cellWidth(cursorPart) - dotCells));
21912
+ return h(Text, {
21913
+ key: `status-row-${index}`,
21914
+ dimColor: offset > 0,
21915
+ }, cursorPart, ...(useDot ? [h(Text, { color: dotColor }, STAGE_STATUS_DOT), ' '] : []), tailTrunc);
21916
+ });
21917
+ const fallbackLines = isLoading
19598
21918
  ? [formatLogInkLoading({ resource: 'worktree status' })]
19599
21919
  : worktree?.files.length
19600
- ? worktree.files
19601
- .slice(Math.max(0, selectedIndex - Math.floor(listRows / 2)))
19602
- .slice(0, listRows)
19603
- .map((file, offset) => {
19604
- const index = Math.max(0, selectedIndex - Math.floor(listRows / 2)) + offset;
19605
- return `${index === selectedIndex ? '>' : ' '} ${file.indexStatus}${file.worktreeStatus} ${file.state.padEnd(9)} ${file.path}`;
19606
- })
21920
+ ? []
19607
21921
  : cleanHint
19608
21922
  ? [cleanHint]
19609
21923
  : ['Worktree clean'];
@@ -19611,16 +21925,17 @@ function renderStatusSurface(h, components, state, context, contextStatus, bodyR
19611
21925
  borderColor: focusBorderColor(theme, focused),
19612
21926
  borderStyle: theme.borderStyle,
19613
21927
  flexDirection: 'column',
19614
- flexGrow: 1,
21928
+ flexShrink: 0,
19615
21929
  paddingX: 1,
21930
+ width,
19616
21931
  }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Worktree', focused)), h(Text, { dimColor: true }, worktree
19617
21932
  ? `${worktree.stagedCount} staged | ${worktree.unstagedCount} unstaged | ${worktree.untrackedCount} untracked`
19618
- : 'status loading')), ...lines.map((line, index) => h(Text, {
19619
- key: `status-surface-${index}`,
21933
+ : 'status loading')), ...fileRows, ...fallbackLines.map((line, index) => h(Text, {
21934
+ key: `status-surface-fallback-${index}`,
19620
21935
  dimColor: index > 0,
19621
21936
  }, truncate$1(line, 140))));
19622
21937
  }
19623
- function renderComposeSurface(h, components, state, context, contextStatus, bodyRows, theme) {
21938
+ function renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
19624
21939
  const { Box, Text } = components;
19625
21940
  const compose = state.commitCompose;
19626
21941
  const focused = state.focus === 'commits';
@@ -19633,52 +21948,65 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
19633
21948
  const summaryCursor = compose.editing && compose.field === 'summary' ? '_' : '';
19634
21949
  const bodyCursor = compose.editing && compose.field === 'body' ? '_' : '';
19635
21950
  const bodyRowsAvailable = Math.max(4, bodyRows - 10);
19636
- const bodyLines = compose.body
19637
- ? compose.body.split('\n').slice(0, bodyRowsAvailable)
21951
+ // Wrap each source line of the body to the panel width so long messages
21952
+ // line-wrap inside the compose surface instead of getting trimmed by an
21953
+ // outer truncate(line, 140). The 2-space indent eats 2 cells; chrome
21954
+ // (border + paddingX) eats 4 — same budget as renderCommitPanel.
21955
+ const bodyTextWidth = Math.max(8, width - 6);
21956
+ const bodyVisualLines = compose.body
21957
+ ? compose.body.split('\n').flatMap((line) => wrapCells(line, bodyTextWidth)).slice(0, bodyRowsAvailable)
19638
21958
  : ['<empty>'];
19639
- const stateLine = compose.loading
19640
- ? 'Working...'
19641
- : compose.editing
19642
- ? 'Editing — Enter switches summary↔body, Esc exits edit mode.'
19643
- : 'Press e to edit, c to commit, I for AI draft, esc to leave.';
19644
- const stagedFileLines = (worktree?.files || [])
19645
- .filter((file) => file.indexStatus !== ' ' && file.indexStatus !== '?')
19646
- .slice(0, 5)
19647
- .map((file) => ` ${file.indexStatus} ${file.path}`);
21959
+ const summaryVisualLines = wrapCells(`${compose.summary || '<empty>'}${summaryCursor}`, Math.max(8, width - 11) // "Summary " (9) + 2 chrome = 11
21960
+ );
21961
+ const stateLine = compose.editing
21962
+ ? 'Editing — Enter switches summary↔body, Esc exits edit mode.'
21963
+ : 'Press e to edit, c to commit, I for AI draft, esc to leave.';
21964
+ const hasStagedFiles = (worktree?.files || [])
21965
+ .some((file) => file.indexStatus !== ' ' && file.indexStatus !== '?');
21966
+ // Staged file list is rendered in the right Worktree panel
21967
+ // (renderComposeContextPanel); duplicating it here was confusing.
21968
+ // Keep only the actionable "stage something first" hint when nothing is
21969
+ // staged yet.
19648
21970
  const noStagedHint = !isLogInkContextKeyLoading(contextStatus, 'worktree')
19649
- ? formatLogInkComposeEmpty({ hasStaged: stagedFileLines.length > 0 })
21971
+ ? formatLogInkComposeEmpty({ hasStaged: hasStagedFiles })
19650
21972
  : undefined;
19651
21973
  return h(Box, {
19652
21974
  borderColor: focusBorderColor(theme, focused),
19653
21975
  borderStyle: theme.borderStyle,
19654
21976
  flexDirection: 'column',
19655
- flexGrow: 1,
21977
+ flexShrink: 0,
19656
21978
  paddingX: 1,
21979
+ width,
19657
21980
  }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Compose commit', focused)), h(Text, { dimColor: true }, statusLine)), h(Text, undefined, ''), h(Text, {
19658
21981
  bold: compose.field === 'summary' && compose.editing,
19659
- }, truncate$1(`Summary ${compose.summary || '<empty>'}${summaryCursor}`, 140)), h(Text, undefined, ''), h(Text, {
21982
+ }, `Summary ${summaryVisualLines[0] || ''}`), ...summaryVisualLines.slice(1).map((line, index) => h(Text, {
21983
+ key: `compose-summary-${index}`,
21984
+ bold: compose.field === 'summary' && compose.editing,
21985
+ }, ` ${line}`)), h(Text, undefined, ''), h(Text, {
19660
21986
  bold: compose.field === 'body' && compose.editing,
19661
- }, 'Body'), ...bodyLines.map((line, index) => h(Text, {
19662
- key: `compose-body-${index}`,
19663
- dimColor: line === '<empty>',
19664
- }, 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, {
21987
+ }, 'Body'), ...bodyVisualLines.map((line, index) => {
21988
+ const isLast = index === bodyVisualLines.length - 1;
21989
+ return h(Text, {
21990
+ key: `compose-body-${index}`,
21991
+ dimColor: line === '<empty>',
21992
+ }, ` ${line}${bodyCursor && isLast ? bodyCursor : ''}`);
21993
+ }), h(Text, undefined, ''), ...(compose.loading
21994
+ ? [h(Text, {
21995
+ key: 'compose-loading',
21996
+ bold: true,
21997
+ color: theme.noColor ? undefined : theme.colors.accent,
21998
+ }, theme.ascii
21999
+ ? '[...] Generating AI commit draft (this can take a moment)'
22000
+ : '⏳ Generating AI commit draft… (this can take a moment)')]
22001
+ : [h(Text, { dimColor: true }, stateLine)]), ...(compose.message ? [h(Text, { key: 'compose-msg' }, truncate$1(compose.message, 140))] : []), ...(compose.details || []).map((line, index) => h(Text, {
19665
22002
  key: `compose-detail-${index}`,
19666
22003
  dimColor: true,
19667
- }, truncate$1(` ${line}`, 140))), ...(stagedFileLines.length > 0
22004
+ }, truncate$1(` ${line}`, 140))), ...(!hasStagedFiles && noStagedHint
19668
22005
  ? [
19669
- h(Text, { key: 'compose-staged-spacer' }, ''),
19670
- h(Text, { key: 'compose-staged-title', bold: true }, 'Staged'),
19671
- ...stagedFileLines.map((line, index) => h(Text, {
19672
- key: `compose-staged-${index}`,
19673
- dimColor: true,
19674
- }, truncate$1(line, 140))),
22006
+ h(Text, { key: 'compose-no-staged-spacer' }, ''),
22007
+ h(Text, { key: 'compose-no-staged', dimColor: true }, truncate$1(noStagedHint, 140)),
19675
22008
  ]
19676
- : noStagedHint
19677
- ? [
19678
- h(Text, { key: 'compose-no-staged-spacer' }, ''),
19679
- h(Text, { key: 'compose-no-staged', dimColor: true }, truncate$1(noStagedHint, 140)),
19680
- ]
19681
- : []));
22009
+ : []));
19682
22010
  }
19683
22011
  function matchesPromotedFilter(haystacks, filter) {
19684
22012
  if (!filter.trim()) {
@@ -19687,23 +22015,24 @@ function matchesPromotedFilter(haystacks, filter) {
19687
22015
  const needle = filter.toLowerCase();
19688
22016
  return haystacks.some((value) => value.toLowerCase().includes(needle));
19689
22017
  }
19690
- function renderBranchesSurface(h, components, state, context, contextStatus, bodyRows, theme) {
22018
+ function renderBranchesSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
19691
22019
  const { Box, Text } = components;
19692
22020
  const focused = state.focus === 'commits';
19693
22021
  const branches = context.branches;
19694
22022
  const loading = isLogInkContextKeyLoading(contextStatus, 'branches');
19695
- const allLocalBranches = branches?.localBranches || [];
22023
+ const sortedAll = sortBranches(branches?.localBranches || [], state.branchSort);
19696
22024
  const localBranches = state.filter
19697
- ? allLocalBranches.filter((branch) => matchesPromotedFilter([branch.shortName, branch.upstream || ''], state.filter))
19698
- : allLocalBranches;
22025
+ ? sortedAll.filter((branch) => matchesPromotedFilter([branch.shortName, branch.upstream || ''], state.filter))
22026
+ : sortedAll;
19699
22027
  const selected = Math.max(0, Math.min(state.selectedBranchIndex, Math.max(0, localBranches.length - 1)));
19700
22028
  const listRows = Math.max(4, bodyRows - 4);
19701
22029
  const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
19702
22030
  const visible = localBranches.slice(startIndex, startIndex + listRows);
19703
22031
  const filterLabel = state.filter ? ` | filter: ${state.filter}` : '';
22032
+ const sortLabel = ` | ${formatSortIndicator(state.branchSort, { ascii: theme.ascii })}`;
19704
22033
  const headerRight = loading
19705
22034
  ? 'loading branches'
19706
- : `${localBranches.length}/${allLocalBranches.length} local | current: ${branches?.currentBranch || '<detached>'}${filterLabel}`;
22035
+ : `${localBranches.length}/${sortedAll.length} local | current: ${branches?.currentBranch || '<detached>'}${filterLabel}${sortLabel}`;
19707
22036
  const emptyLabel = formatLogInkBranchesEmpty({ filter: state.filter });
19708
22037
  const loadingLabel = formatLogInkLoading({ resource: 'branches' });
19709
22038
  const lines = loading
@@ -19714,8 +22043,8 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
19714
22043
  const index = startIndex + offset;
19715
22044
  const isSelected = index === selected;
19716
22045
  const cursor = isSelected ? '>' : ' ';
19717
- const marker = branch.current ? '*' : ' ';
19718
- const divergence = formatDivergence(branch);
22046
+ const marker = branchRowMarker(branch, { ascii: theme.ascii });
22047
+ const divergence = formatBranchDivergence(branch, { ascii: theme.ascii });
19719
22048
  return h(Text, {
19720
22049
  key: `branch-${index}`,
19721
22050
  bold: isSelected,
@@ -19726,26 +22055,28 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
19726
22055
  borderColor: focusBorderColor(theme, focused),
19727
22056
  borderStyle: theme.borderStyle,
19728
22057
  flexDirection: 'column',
19729
- flexGrow: 1,
22058
+ flexShrink: 0,
19730
22059
  paddingX: 1,
19731
- }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Branches', focused)), h(Text, { dimColor: true }, headerRight)), ...lines);
22060
+ width,
22061
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Branches', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
19732
22062
  }
19733
- function renderTagsSurface(h, components, state, context, contextStatus, bodyRows, theme) {
22063
+ function renderTagsSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
19734
22064
  const { Box, Text } = components;
19735
22065
  const focused = state.focus === 'commits';
19736
22066
  const loading = isLogInkContextKeyLoading(contextStatus, 'tags');
19737
- const allTags = context.tags?.tags || [];
22067
+ const sortedAll = sortTags(context.tags?.tags || [], state.tagSort);
19738
22068
  const tags = state.filter
19739
- ? allTags.filter((tag) => matchesPromotedFilter([tag.name, tag.subject], state.filter))
19740
- : allTags;
22069
+ ? sortedAll.filter((tag) => matchesPromotedFilter([tag.name, tag.subject], state.filter))
22070
+ : sortedAll;
19741
22071
  const selected = Math.max(0, Math.min(state.selectedTagIndex, Math.max(0, tags.length - 1)));
19742
22072
  const listRows = Math.max(4, bodyRows - 4);
19743
22073
  const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
19744
22074
  const visible = tags.slice(startIndex, startIndex + listRows);
19745
22075
  const filterLabel = state.filter ? ` | filter: ${state.filter}` : '';
22076
+ const sortLabel = ` | ${formatSortIndicator(state.tagSort, { ascii: theme.ascii })}`;
19746
22077
  const headerRight = loading
19747
22078
  ? 'loading tags'
19748
- : `${tags.length}/${allTags.length} tags${filterLabel}`;
22079
+ : `${tags.length}/${sortedAll.length} tags${filterLabel}${sortLabel}`;
19749
22080
  const emptyLabel = formatLogInkTagsEmpty({ filter: state.filter });
19750
22081
  const loadingLabel = formatLogInkLoading({ resource: 'tags' });
19751
22082
  const lines = loading
@@ -19756,21 +22087,39 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
19756
22087
  const index = startIndex + offset;
19757
22088
  const isSelected = index === selected;
19758
22089
  const cursor = isSelected ? '>' : ' ';
22090
+ // P5.1 — link the tag name to its GitHub tree page when we know
22091
+ // the remote. Truncation runs on the visible (pre-OSC) text;
22092
+ // formatHyperlink wraps just the tag name, leaving width math
22093
+ // intact.
22094
+ const url = buildRefUrl(context.provider?.repository, tag.name);
22095
+ const namePadded = tag.name.padEnd(20);
22096
+ const lineText = truncate$1(`${cursor} ${namePadded} ${tag.subject}`, 140);
22097
+ if (!url || lineText.indexOf(namePadded) < 0) {
22098
+ return h(Text, {
22099
+ key: `tag-${index}`,
22100
+ bold: isSelected,
22101
+ dimColor: !isSelected,
22102
+ }, lineText);
22103
+ }
22104
+ const linkStart = lineText.indexOf(namePadded);
22105
+ const before = lineText.slice(0, linkStart);
22106
+ const after = lineText.slice(linkStart + namePadded.length);
19759
22107
  return h(Text, {
19760
22108
  key: `tag-${index}`,
19761
22109
  bold: isSelected,
19762
22110
  dimColor: !isSelected,
19763
- }, truncate$1(`${cursor} ${tag.name.padEnd(20)} ${tag.subject}`, 140));
22111
+ }, before, formatHyperlink(namePadded, url), after);
19764
22112
  });
19765
22113
  return h(Box, {
19766
22114
  borderColor: focusBorderColor(theme, focused),
19767
22115
  borderStyle: theme.borderStyle,
19768
22116
  flexDirection: 'column',
19769
- flexGrow: 1,
22117
+ flexShrink: 0,
19770
22118
  paddingX: 1,
19771
- }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Tags', focused)), h(Text, { dimColor: true }, headerRight)), ...lines);
22119
+ width,
22120
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Tags', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
19772
22121
  }
19773
- function renderStashSurface(h, components, state, context, contextStatus, bodyRows, theme) {
22122
+ function renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
19774
22123
  const { Box, Text } = components;
19775
22124
  const focused = state.focus === 'commits';
19776
22125
  const loading = isLogInkContextKeyLoading(contextStatus, 'stashes');
@@ -19806,26 +22155,91 @@ function renderStashSurface(h, components, state, context, contextStatus, bodyRo
19806
22155
  borderColor: focusBorderColor(theme, focused),
19807
22156
  borderStyle: theme.borderStyle,
19808
22157
  flexDirection: 'column',
19809
- flexGrow: 1,
22158
+ flexShrink: 0,
19810
22159
  paddingX: 1,
19811
- }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Stash', focused)), h(Text, { dimColor: true }, headerRight)), ...lines);
22160
+ width,
22161
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Stash', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
22162
+ }
22163
+ /**
22164
+ * Filter input cursor for the promoted views (branches/tags/stash).
22165
+ * History already shows the same `filter: foo_` affordance in its header
22166
+ * — this mirrors that into the other surfaces so the user can see what
22167
+ * they're typing instead of watching the list silently shrink (P2.1).
22168
+ *
22169
+ * Returns an empty array when the surface isn't in filter mode so call
22170
+ * sites can spread it unconditionally.
22171
+ */
22172
+ function renderPromotedFilterAffordance(h, Text, state, theme) {
22173
+ if (!state.filterMode) {
22174
+ return [];
22175
+ }
22176
+ const accent = theme.noColor ? undefined : theme.colors.accent;
22177
+ return [
22178
+ h(Text, { key: 'promoted-filter-input', color: accent }, `filter: ${state.filter}_`),
22179
+ ];
19812
22180
  }
19813
- function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, bodyRows, theme) {
22181
+ function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, bodyRows, width, theme) {
19814
22182
  const { Box, Text } = components;
19815
22183
  const focused = state.focus === 'commits';
19816
22184
  const worktree = context.worktree;
19817
- const selectedFile = worktree?.files[state.selectedWorktreeFileIndex];
22185
+ const worktreeFile = worktree?.files[state.selectedWorktreeFileIndex];
19818
22186
  const visibleRows = Math.max(4, bodyRows - 4);
22187
+ // diffSource disambiguates: 'commit' was set when the user opened the
22188
+ // diff via history → Enter (read-only commit-diff explore), 'worktree'
22189
+ // was set when they came from status → Enter (stage / hunk / revert).
22190
+ // Falls back to the previous heuristic when no source is recorded so
22191
+ // older entry paths still render something sensible.
22192
+ const useCommitDiff = state.diffSource === 'commit' ||
22193
+ (state.diffSource === undefined && !worktreeFile && Boolean(selectedDetailFile));
22194
+ if (useCommitDiff) {
22195
+ const previewHunks = filePreview?.hunks || [];
22196
+ const visiblePreviewHunks = previewHunks.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
22197
+ const hunkCount = commitDiffHunkOffsets?.length || 0;
22198
+ const currentHunkIndex = hunkCount > 0
22199
+ ? Math.max(0, [...(commitDiffHunkOffsets || [])]
22200
+ .reverse()
22201
+ .findIndex((offset) => offset <= state.diffPreviewOffset))
22202
+ : 0;
22203
+ const currentHunkLabel = hunkCount > 0
22204
+ ? `Hunk ${Math.min(hunkCount - currentHunkIndex, hunkCount)}/${hunkCount}`
22205
+ : 'No hunks for this file.';
22206
+ const headerLines = filePreviewLoading
22207
+ ? [`Loading diff for ${selectedDetailFile?.path || 'selected file'}...`]
22208
+ : previewHunks.length
22209
+ ? [
22210
+ `Selected file: ${selectedDetailFile?.path || ''}`,
22211
+ currentHunkLabel,
22212
+ `Lines ${Math.min(state.diffPreviewOffset + 1, previewHunks.length || 1)}-${Math.min(state.diffPreviewOffset + visiblePreviewHunks.length, previewHunks.length)}/${previewHunks.length}`,
22213
+ '',
22214
+ ]
22215
+ : ['No diff preview available for this file.'];
22216
+ return h(Box, {
22217
+ borderColor: focusBorderColor(theme, focused),
22218
+ borderStyle: theme.borderStyle,
22219
+ flexDirection: 'column',
22220
+ flexShrink: 0,
22221
+ paddingX: 1,
22222
+ width,
22223
+ }, 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, {
22224
+ key: `diff-surface-header-${index}`,
22225
+ dimColor: index > 0,
22226
+ }, truncate$1(line, 140))), ...(filePreviewLoading || !previewHunks.length
22227
+ ? []
22228
+ : visiblePreviewHunks.map((line, index) => h(Text, {
22229
+ key: `diff-surface-line-${state.diffPreviewOffset + index}`,
22230
+ ...diffLineProps(line, theme),
22231
+ }, truncate$1(line, 140)))));
22232
+ }
19819
22233
  const diffLines = worktreeDiff?.lines || [];
19820
22234
  const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
19821
22235
  const visibleDiffLines = diffLines.slice(state.worktreeDiffOffset, state.worktreeDiffOffset + visibleRows);
19822
- const lines = isLogInkContextKeyLoading(contextStatus, 'worktree')
22236
+ const headerLines = isLogInkContextKeyLoading(contextStatus, 'worktree')
19823
22237
  ? ['Loading file context...']
19824
22238
  : worktreeDiffLoading
19825
- ? [`Loading diff for ${selectedFile?.path || 'selected file'}...`]
19826
- : selectedFile
22239
+ ? [`Loading diff for ${worktreeFile?.path || 'selected file'}...`]
22240
+ : worktreeFile
19827
22241
  ? [
19828
- `Selected file: ${selectedFile.path}`,
22242
+ `Selected file: ${worktreeFile.path}`,
19829
22243
  worktreeHunksLoading
19830
22244
  ? 'Hunks loading...'
19831
22245
  : worktreeHunks?.hunks.length
@@ -19833,22 +22247,29 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
19833
22247
  : 'No stageable hunks for this file.',
19834
22248
  `Lines ${Math.min(state.worktreeDiffOffset + 1, diffLines.length || 1)}-${Math.min(state.worktreeDiffOffset + visibleDiffLines.length, diffLines.length)}/${diffLines.length}`,
19835
22249
  '',
19836
- ...visibleDiffLines,
19837
22250
  ]
19838
22251
  : ['No changed file selected.'];
22252
+ const showDiffLines = Boolean(worktreeFile) &&
22253
+ !worktreeDiffLoading &&
22254
+ !isLogInkContextKeyLoading(contextStatus, 'worktree');
19839
22255
  return h(Box, {
19840
22256
  borderColor: focusBorderColor(theme, focused),
19841
22257
  borderStyle: theme.borderStyle,
19842
22258
  flexDirection: 'column',
19843
- flexGrow: 1,
22259
+ flexShrink: 0,
19844
22260
  paddingX: 1,
19845
- }, 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, {
19846
- key: `diff-surface-${index}`,
19847
- dimColor: index > 1,
19848
- }, truncate$1(line, 140))));
22261
+ width,
22262
+ }, 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, {
22263
+ key: `diff-surface-header-${index}`,
22264
+ dimColor: index > 0,
22265
+ }, truncate$1(line, 140))), ...(showDiffLines
22266
+ ? visibleDiffLines.map((line, index) => h(Text, {
22267
+ key: `diff-surface-line-${state.worktreeDiffOffset + index}`,
22268
+ ...diffLineProps(line, theme),
22269
+ }, truncate$1(line, 140)))
22270
+ : []));
19849
22271
  }
19850
22272
  function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, theme) {
19851
- const { Box, Text } = components;
19852
22273
  const focused = state.focus === 'detail';
19853
22274
  if (state.showHelp) {
19854
22275
  return renderHelpPanel(h, components, state, width, theme, focused);
@@ -19859,69 +22280,351 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
19859
22280
  if (state.pendingConfirmationId || state.pendingMutationConfirmation) {
19860
22281
  return renderConfirmationPanel(h, components, state, width, theme, focused);
19861
22282
  }
19862
- if (state.activeView === 'status' || state.activeView === 'diff') {
22283
+ // which-key style overlay shows the available chord continuations
22284
+ // when the user has pressed the prefix and we're waiting for the
22285
+ // second key. Mirrors helix / which-key.nvim / doom-emacs.
22286
+ if (state.pendingKey) {
22287
+ return renderChordOverlay(h, components, state, width, theme, focused);
22288
+ }
22289
+ // The synthetic "(+) new commit" row routes the inspector through the
22290
+ // worktree summary so the user sees what's staged / unstaged at a glance
22291
+ // — same surface as the compose view's right panel.
22292
+ if (state.activeView === 'history' && state.pendingCommitFocused) {
22293
+ return renderComposeContextPanel(h, components, state, context, contextStatus, width, theme, focused);
22294
+ }
22295
+ // Status + worktree-sourced diff keep the staging compose panel — it's
22296
+ // the action surface for stage / hunk / commit. Commit-sourced diff (from
22297
+ // history → Enter) gets a dedicated explore panel: subject, body, and a
22298
+ // navigable file list whose selection swaps the center diff.
22299
+ if (state.activeView === 'status') {
22300
+ return renderCommitPanel(h, components, state, context, contextStatus, width, theme, focused);
22301
+ }
22302
+ if (state.activeView === 'diff') {
22303
+ if (state.diffSource === 'commit') {
22304
+ return renderCommitDiffDetail(h, components, state, detail, loading, width, theme, focused);
22305
+ }
19863
22306
  return renderCommitPanel(h, components, state, context, contextStatus, width, theme, focused);
19864
22307
  }
22308
+ // Compose view: the right panel had been falling through to the inspector
22309
+ // and showing the last selected commit's data, which is wrong context for
22310
+ // an in-progress commit. Show the worktree summary instead.
22311
+ if (state.activeView === 'compose') {
22312
+ return renderComposeContextPanel(h, components, state, context, contextStatus, width, theme, focused);
22313
+ }
22314
+ // Preview pane (P4.1) — fzf / yazi / lazygit style: branches, tags, and
22315
+ // stash views each get a tailored summary of the selected entry instead
22316
+ // of falling through to the (stale) history inspector.
22317
+ if (state.activeView === 'branches') {
22318
+ return renderBranchPreviewPanel(h, components, state, context, contextStatus, width, theme, focused);
22319
+ }
22320
+ if (state.activeView === 'tags') {
22321
+ return renderTagPreviewPanel(h, components, state, context, contextStatus, width, theme, focused);
22322
+ }
22323
+ if (state.activeView === 'stash') {
22324
+ return renderStashPreviewPanel(h, components, state, context, contextStatus, width, theme, focused);
22325
+ }
22326
+ return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, theme, focused);
22327
+ }
22328
+ function renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, _filePreview, _filePreviewLoading, width, theme, focused) {
22329
+ const { Box, Text } = components;
19865
22330
  const selected = getSelectedInkCommit(state);
19866
- const selectedFile = detail?.files[state.selectedFileIndex];
19867
- const previewWindow = filePreview?.hunks.slice(state.diffPreviewOffset, state.diffPreviewOffset + 8);
19868
- const statLine = detail
19869
- ? `${detail.stats.filesChanged} files +${detail.stats.insertions}/-${detail.stats.deletions}`
19870
- : '';
19871
22331
  const workflowSections = getLogInkWorkflowSections({
19872
22332
  ...context,
19873
22333
  contextLoading: isLogInkContextLoading(contextStatus),
19874
22334
  selectedCommit: selected,
19875
22335
  });
19876
- const lines = detail
19877
- ? [
19878
- detail.message,
19879
- '',
19880
- `Commit: ${compactHash(detail.hash)}`,
19881
- `Author: ${detail.author}`,
19882
- `Date: ${detail.date}`,
19883
- detail.refs.length ? `Refs: ${detail.refs.join(', ')}` : 'Refs: none',
19884
- statLine,
19885
- '',
19886
- ...(detail.body ? detail.body.split('\n').slice(0, 6) : ['No commit body.']),
19887
- '',
19888
- 'Changed files:',
19889
- ...(detail.files.length
19890
- ? detail.files.slice(0, 8).map((file, index) => `${index === state.selectedFileIndex ? '>' : ' '} ${formatChangedFile(file)}`)
19891
- : ['No changed files found.']),
19892
- '',
19893
- selectedFile
19894
- ? `Preview: ${formatChangedFile(selectedFile)}`
19895
- : 'Preview: no file selected',
19896
- filePreviewLoading
19897
- ? 'Loading diff preview...'
19898
- : previewWindow?.length
19899
- ? `Lines ${state.diffPreviewOffset + 1}-${state.diffPreviewOffset + previewWindow.length}/${filePreview?.hunks.length || 0}`
19900
- : 'No hunk preview available.',
19901
- ...(previewWindow || []).map((line) => ` ${line}`),
19902
- '',
19903
- 'Workflows:',
19904
- ...workflowSections.flatMap((section) => [
19905
- section.title,
19906
- ...section.lines.slice(0, 3).map((line) => ` ${line}`),
19907
- ]).slice(0, 14),
19908
- ]
19909
- : [
22336
+ if (!detail) {
22337
+ const fallbackLines = [
19910
22338
  selected?.message || 'No commit selected.',
19911
22339
  '',
19912
22340
  loading ? 'Loading commit details...' : 'Commit details unavailable.',
19913
22341
  ];
22342
+ return h(Box, {
22343
+ borderColor: focusBorderColor(theme, focused),
22344
+ borderStyle: theme.borderStyle,
22345
+ flexDirection: 'column',
22346
+ width,
22347
+ paddingX: 1,
22348
+ }, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...fallbackLines.map((line, index) => h(Text, {
22349
+ key: `detail-${index}`,
22350
+ dimColor: index > 1,
22351
+ }, truncate$1(line, width - 4))));
22352
+ }
22353
+ const statLine = `${detail.stats.filesChanged} files +${detail.stats.insertions}/-${detail.stats.deletions}`;
22354
+ // P5.1 — link the commit hash and each ref out to GitHub when we know
22355
+ // the remote. OSC 8 escapes embed inline; supportsHyperlinks() decides
22356
+ // whether to wrap or fall through to plain text.
22357
+ const repository = context.provider?.repository;
22358
+ const commitLink = formatHyperlink(compactHash(detail.hash), buildCommitUrl(repository, detail.hash));
22359
+ const refNodes = detail.refs.length
22360
+ ? renderInspectorRefs(h, Text, detail.refs, repository)
22361
+ : null;
22362
+ const headerNodes = [
22363
+ h(Text, { key: 'detail-msg' }, truncate$1(detail.message, width - 4)),
22364
+ h(Text, { key: 'detail-spacer-1' }, ''),
22365
+ h(Text, { key: 'detail-commit', dimColor: true }, 'Commit: ', commitLink),
22366
+ h(Text, { key: 'detail-author', dimColor: true }, truncate$1(`Author: ${detail.author}`, width - 4)),
22367
+ h(Text, { key: 'detail-date', dimColor: true }, truncate$1(`Date: ${detail.date}`, width - 4)),
22368
+ refNodes
22369
+ ? h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: ', ...refNodes)
22370
+ : h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: none'),
22371
+ h(Text, { key: 'detail-stat', dimColor: true }, truncate$1(statLine, width - 4)),
22372
+ h(Text, { key: 'detail-spacer-2' }, ''),
22373
+ ...(detail.body ? detail.body.split('\n').slice(0, 6) : ['No commit body.']).map((line, index) => h(Text, {
22374
+ key: `detail-body-${index}`,
22375
+ dimColor: true,
22376
+ }, truncate$1(line, width - 4))),
22377
+ h(Text, { key: 'detail-spacer-3' }, ''),
22378
+ h(Text, { key: 'detail-files-title' }, 'Changed files:'),
22379
+ ];
22380
+ const fileListMaxRows = Math.max(4, Math.min(detail.files.length, 10));
22381
+ const fileListNodes = renderCommitFileList(h, Text, detail.files, state.selectedFileIndex, focused, fileListMaxRows, width, theme);
22382
+ const trailerLines = [
22383
+ '',
22384
+ 'Workflows:',
22385
+ ...workflowSections.flatMap((section) => [
22386
+ section.title,
22387
+ ...section.lines.slice(0, 3).map((line) => ` ${line}`),
22388
+ ]).slice(0, 12),
22389
+ ];
19914
22390
  return h(Box, {
19915
22391
  borderColor: focusBorderColor(theme, focused),
19916
22392
  borderStyle: theme.borderStyle,
19917
22393
  flexDirection: 'column',
19918
22394
  width,
19919
22395
  paddingX: 1,
19920
- }, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...lines.map((line, index) => h(Text, {
19921
- key: `detail-${index}`,
19922
- dimColor: index > 1 && line !== 'Changed files:',
22396
+ }, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...trailerLines.map((line, index) => h(Text, {
22397
+ key: `detail-trailer-${index}`,
22398
+ dimColor: index > 0,
19923
22399
  }, truncate$1(line, width - 4))));
19924
22400
  }
22401
+ /**
22402
+ * Build a commit URL for the repo when GitHub provider info is available.
22403
+ * Returns undefined for unsupported remotes — formatHyperlink falls through
22404
+ * to plain text in that case.
22405
+ */
22406
+ function buildCommitUrl(repository, hash) {
22407
+ if (!repository)
22408
+ return undefined;
22409
+ return buildProviderUrl(repository, { type: 'commit', commit: hash });
22410
+ }
22411
+ /**
22412
+ * Build a branch URL for a ref name. Strips the `HEAD -> ` and `tag: `
22413
+ * prefixes git decoration uses. For everything else we treat the ref as a
22414
+ * branch — GitHub's `/tree/<ref>` resolves both branches and tags.
22415
+ */
22416
+ function buildRefUrl(repository, ref) {
22417
+ if (!repository)
22418
+ return undefined;
22419
+ const stripped = ref.replace(/^HEAD -> /, '').replace(/^tag: /, '').trim();
22420
+ if (!stripped)
22421
+ return undefined;
22422
+ return buildProviderUrl(repository, { type: 'branch', branch: stripped });
22423
+ }
22424
+ /**
22425
+ * Render `refs` as a comma-separated sequence of <Text> fragments, each
22426
+ * wrapped in OSC 8 (no-op when the terminal can't render hyperlinks).
22427
+ */
22428
+ function renderInspectorRefs(h, Text, refs, repository) {
22429
+ const out = [];
22430
+ refs.forEach((ref, index) => {
22431
+ if (index > 0) {
22432
+ out.push(h(Text, { key: `ref-sep-${index}` }, ', '));
22433
+ }
22434
+ out.push(h(Text, { key: `ref-${index}` }, formatHyperlink(ref, buildRefUrl(repository, ref))));
22435
+ });
22436
+ return out;
22437
+ }
22438
+ function renderCommitDiffDetail(h, components, state, detail, loading, width, theme, focused) {
22439
+ const { Box, Text } = components;
22440
+ const selected = getSelectedInkCommit(state);
22441
+ if (!detail) {
22442
+ const fallbackLines = [
22443
+ selected?.message || 'No commit selected.',
22444
+ '',
22445
+ loading ? 'Loading commit details...' : 'Commit details unavailable.',
22446
+ ];
22447
+ return h(Box, {
22448
+ borderColor: focusBorderColor(theme, focused),
22449
+ borderStyle: theme.borderStyle,
22450
+ flexDirection: 'column',
22451
+ width,
22452
+ paddingX: 1,
22453
+ }, h(Text, { bold: true }, panelTitle('Commit', focused)), ...fallbackLines.map((line, index) => h(Text, {
22454
+ key: `commit-diff-${index}`,
22455
+ dimColor: index > 1,
22456
+ }, truncate$1(line, width - 4))));
22457
+ }
22458
+ const statLine = `${detail.stats.filesChanged} files +${detail.stats.insertions}/-${detail.stats.deletions}`;
22459
+ const headerLines = [
22460
+ detail.message,
22461
+ '',
22462
+ `${compactHash(detail.hash)} ${detail.date} ${detail.author}`,
22463
+ detail.refs.length ? `Refs: ${detail.refs.join(', ')}` : 'Refs: none',
22464
+ statLine,
22465
+ '',
22466
+ ];
22467
+ const bodyLines = detail.body ? detail.body.split('\n').slice(0, 5) : [];
22468
+ const filesHeader = ['Files:'];
22469
+ const fileListMaxRows = Math.max(4, Math.min(detail.files.length, 12));
22470
+ const fileListNodes = renderCommitFileList(h, Text, detail.files, state.selectedFileIndex, focused, fileListMaxRows, width, theme);
22471
+ const hint = focused
22472
+ ? 'j/k pick file · enter swaps the center diff'
22473
+ : 'tab focuses the file list';
22474
+ return h(Box, {
22475
+ borderColor: focusBorderColor(theme, focused),
22476
+ borderStyle: theme.borderStyle,
22477
+ flexDirection: 'column',
22478
+ width,
22479
+ paddingX: 1,
22480
+ }, h(Text, { bold: true }, panelTitle('Commit', focused)), ...headerLines.map((line, index) => h(Text, {
22481
+ key: `commit-diff-header-${index}`,
22482
+ bold: index === 0,
22483
+ dimColor: index > 0 && index < headerLines.length - 1,
22484
+ }, truncate$1(line, width - 4))), ...bodyLines.map((line, index) => h(Text, {
22485
+ key: `commit-diff-body-${index}`,
22486
+ dimColor: true,
22487
+ }, truncate$1(line, width - 4))), ...(bodyLines.length ? [h(Text, { key: 'commit-diff-body-spacer' }, '')] : []), ...filesHeader.map((line, index) => h(Text, {
22488
+ key: `commit-diff-files-${index}`,
22489
+ bold: true,
22490
+ }, truncate$1(line, width - 4))), ...fileListNodes, h(Text, undefined, ''), h(Text, { dimColor: true }, truncate$1(hint, width - 4)));
22491
+ }
22492
+ function renderComposeContextPanel(h, components, state, context, contextStatus, width, theme, focused) {
22493
+ const { Box, Text } = components;
22494
+ const worktree = context.worktree;
22495
+ const compose = state.commitCompose;
22496
+ const loadingWorktree = isLogInkContextKeyLoading(contextStatus, 'worktree');
22497
+ const summary = loadingWorktree
22498
+ ? 'Worktree status loading'
22499
+ : worktree
22500
+ ? `${worktree.stagedCount} staged · ${worktree.unstagedCount} unstaged · ${worktree.untrackedCount} untracked`
22501
+ : 'No worktree information yet';
22502
+ const stagedFiles = (worktree?.files || [])
22503
+ .filter((file) => file.indexStatus !== ' ' && file.indexStatus !== '?')
22504
+ .slice(0, 12);
22505
+ const unstagedFiles = (worktree?.files || [])
22506
+ .filter((file) => file.worktreeStatus !== ' ' && file.indexStatus !== '?')
22507
+ .slice(0, 6);
22508
+ return h(Box, {
22509
+ borderColor: focusBorderColor(theme, focused),
22510
+ borderStyle: theme.borderStyle,
22511
+ flexDirection: 'column',
22512
+ width,
22513
+ paddingX: 1,
22514
+ }, h(Text, { bold: true }, panelTitle('Worktree', focused)), h(Text, { dimColor: true }, truncate$1(summary, width - 4)), h(Text, undefined, ''), ...(compose.loading
22515
+ ? [h(Text, {
22516
+ key: 'compose-context-loading',
22517
+ bold: true,
22518
+ color: theme.noColor ? undefined : theme.colors.accent,
22519
+ }, truncate$1(theme.ascii ? '[...] AI draft in progress' : '⏳ AI draft in progress', width - 4))]
22520
+ : []), ...(stagedFiles.length
22521
+ ? [
22522
+ h(Text, { key: 'compose-context-staged-title', bold: true }, 'Staged'),
22523
+ ...stagedFiles.map((file, index) => h(Text, {
22524
+ key: `compose-context-staged-${index}`,
22525
+ color: theme.noColor ? undefined : theme.colors.gitAdded,
22526
+ }, truncate$1(` ${file.indexStatus} ${file.path}`, width - 4))),
22527
+ h(Text, { key: 'compose-context-staged-spacer' }, ''),
22528
+ ]
22529
+ : []), ...(unstagedFiles.length
22530
+ ? [
22531
+ h(Text, { key: 'compose-context-unstaged-title', bold: true }, 'Unstaged'),
22532
+ ...unstagedFiles.map((file, index) => h(Text, {
22533
+ key: `compose-context-unstaged-${index}`,
22534
+ color: theme.noColor ? undefined : theme.colors.gitModified,
22535
+ }, truncate$1(` ${file.worktreeStatus} ${file.path}`, width - 4))),
22536
+ ]
22537
+ : !stagedFiles.length && !loadingWorktree
22538
+ ? [h(Text, { dimColor: true }, 'No worktree changes detected.')]
22539
+ : []));
22540
+ }
22541
+ /**
22542
+ * Render a list of changed files with status-code colors and stats. Used
22543
+ * by both the history inspector and the commit-diff detail panel so the
22544
+ * two surfaces stay visually consistent.
22545
+ *
22546
+ * `focused` only controls whether the cursor row is inverse-highlighted —
22547
+ * keys j/k and Enter dispatch via the input handler regardless.
22548
+ */
22549
+ function renderCommitFileList(h, Text, files, selectedIndex, focused, maxRows, width, theme) {
22550
+ if (!files.length) {
22551
+ return [h(Text, { key: 'commit-file-list-empty', dimColor: true }, 'No changed files found.')];
22552
+ }
22553
+ const clamped = Math.max(0, Math.min(selectedIndex, files.length - 1));
22554
+ const startIndex = Math.max(0, clamped - Math.floor(maxRows / 2));
22555
+ const visible = files.slice(startIndex, startIndex + maxRows);
22556
+ return visible.map((file, offset) => {
22557
+ const index = startIndex + offset;
22558
+ const isSelected = index === clamped;
22559
+ const cursor = isSelected ? '>' : ' ';
22560
+ const stats = formatChangedFileStats(file);
22561
+ const renamed = file.oldPath ? ` (was ${file.oldPath})` : '';
22562
+ const statusCode = file.status.padEnd(3);
22563
+ const label = `${cursor} ${statusCode} ${file.path}${renamed}${stats ? ` ${stats}` : ''}`;
22564
+ return h(Text, {
22565
+ key: `commit-file-${index}`,
22566
+ color: statusCodeColor(file.status, theme),
22567
+ inverse: isSelected && focused && !theme.noColor,
22568
+ bold: isSelected,
22569
+ }, truncate$1(label, width - 4));
22570
+ });
22571
+ }
22572
+ function renderPreviewPanel(h, components, title, lines, width, theme, focused) {
22573
+ const { Box, Text } = components;
22574
+ return h(Box, {
22575
+ borderColor: focusBorderColor(theme, focused),
22576
+ borderStyle: theme.borderStyle,
22577
+ flexDirection: 'column',
22578
+ width,
22579
+ paddingX: 1,
22580
+ }, h(Text, { bold: true }, panelTitle(title, focused)), ...lines.map((line, index) => {
22581
+ const isHeading = line.emphasis === 'heading' && index > 0;
22582
+ return h(Text, {
22583
+ key: `preview-${index}`,
22584
+ bold: isHeading,
22585
+ dimColor: line.emphasis === 'dim',
22586
+ }, truncate$1(line.text, width - 4));
22587
+ }));
22588
+ }
22589
+ function renderBranchPreviewPanel(h, components, state, context, contextStatus, width, theme, focused) {
22590
+ const { Box, Text } = components;
22591
+ if (isLogInkContextKeyLoading(contextStatus, 'branches')) {
22592
+ return renderPreviewPanel(h, { Box, Text }, 'Branch preview', [{ text: formatLogInkLoading({ resource: 'branches' }), emphasis: 'dim' }], width, theme, focused);
22593
+ }
22594
+ const all = context.branches?.localBranches || [];
22595
+ const visible = state.filter
22596
+ ? all.filter((branch) => matchesPromotedFilter([branch.shortName, branch.upstream || ''], state.filter))
22597
+ : all;
22598
+ const index = Math.max(0, Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1)));
22599
+ const branch = visible[index];
22600
+ return renderPreviewPanel(h, { Box, Text }, 'Branch preview', formatBranchPreview(branch), width, theme, focused);
22601
+ }
22602
+ function renderTagPreviewPanel(h, components, state, context, contextStatus, width, theme, focused) {
22603
+ const { Box, Text } = components;
22604
+ if (isLogInkContextKeyLoading(contextStatus, 'tags')) {
22605
+ return renderPreviewPanel(h, { Box, Text }, 'Tag preview', [{ text: formatLogInkLoading({ resource: 'tags' }), emphasis: 'dim' }], width, theme, focused);
22606
+ }
22607
+ const all = context.tags?.tags || [];
22608
+ const visible = state.filter
22609
+ ? all.filter((tag) => matchesPromotedFilter([tag.name, tag.subject], state.filter))
22610
+ : all;
22611
+ const index = Math.max(0, Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1)));
22612
+ const tag = visible[index];
22613
+ return renderPreviewPanel(h, { Box, Text }, 'Tag preview', formatTagPreview(tag), width, theme, focused);
22614
+ }
22615
+ function renderStashPreviewPanel(h, components, state, context, contextStatus, width, theme, focused) {
22616
+ const { Box, Text } = components;
22617
+ if (isLogInkContextKeyLoading(contextStatus, 'stashes')) {
22618
+ return renderPreviewPanel(h, { Box, Text }, 'Stash preview', [{ text: formatLogInkLoading({ resource: 'stashes' }), emphasis: 'dim' }], width, theme, focused);
22619
+ }
22620
+ const all = context.stashes?.stashes || [];
22621
+ const visible = state.filter
22622
+ ? all.filter((stash) => matchesPromotedFilter([stash.ref, stash.message], state.filter))
22623
+ : all;
22624
+ const index = Math.max(0, Math.min(state.selectedStashIndex, Math.max(0, visible.length - 1)));
22625
+ const stash = visible[index];
22626
+ return renderPreviewPanel(h, { Box, Text }, 'Stash preview', formatStashPreview(stash), width, theme, focused);
22627
+ }
19925
22628
  function renderCommitPanel(h, components, state, context, contextStatus, width, theme, focused) {
19926
22629
  const { Box, Text } = components;
19927
22630
  const compose = state.commitCompose;
@@ -19933,31 +22636,55 @@ function renderCommitPanel(h, components, state, context, contextStatus, width,
19933
22636
  : `${stagedCount} staged | ${unstagedCount} unstaged`;
19934
22637
  const summaryCursor = compose.editing && compose.field === 'summary' ? '_' : '';
19935
22638
  const bodyCursor = compose.editing && compose.field === 'body' ? '_' : '';
19936
- const bodyLines = compose.body ? compose.body.split('\n').slice(0, 4) : ['<empty>'];
19937
- const lines = [
22639
+ const bodyTextWidth = Math.max(8, width - 6); // 4 for chrome + 2 for indent
22640
+ // Wrap each source line of the body so long messages don't get cut off
22641
+ // by the previous truncate(line, width - 4). The 12-line cap is generous
22642
+ // — most commit bodies fit, and the panel's column layout absorbs the
22643
+ // height naturally.
22644
+ const bodyHasContent = Boolean(compose.body);
22645
+ const bodyVisualLines = bodyHasContent
22646
+ ? compose.body.split('\n').flatMap((line) => wrapCells(line, bodyTextWidth)).slice(0, 12)
22647
+ : ['<empty>'];
22648
+ const summaryWrapped = wrapCells(`${compose.summary || '<empty>'}${summaryCursor}`, bodyTextWidth);
22649
+ const summaryFirst = `${compose.field === 'summary' && compose.editing ? '>' : ' '} Summary: ${summaryWrapped[0] || ''}`;
22650
+ const summaryRest = summaryWrapped.slice(1).map((line) => ` ${line}`);
22651
+ const headerLines = [
19938
22652
  statusLine,
19939
22653
  '',
19940
- `${compose.field === 'summary' && compose.editing ? '>' : ' '} Summary: ${compose.summary || '<empty>'}${summaryCursor}`,
22654
+ summaryFirst,
22655
+ ...summaryRest,
19941
22656
  `${compose.field === 'body' && compose.editing ? '>' : ' '} Body:`,
19942
- ...bodyLines.map((line) => ` ${line}${bodyCursor && line === bodyLines[bodyLines.length - 1] ? bodyCursor : ''}`),
22657
+ ...bodyVisualLines.map((line, index) => {
22658
+ const isLast = index === bodyVisualLines.length - 1;
22659
+ return ` ${line}${bodyCursor && isLast ? bodyCursor : ''}`;
22660
+ }),
19943
22661
  '',
19944
- loading
19945
- ? 'Working...'
19946
- : compose.editing
19947
- ? 'Enter/tab edits fields, Esc exits edit mode.'
19948
- : 'e edit | c commit | I AI draft',
22662
+ ];
22663
+ const trailerLines = [
19949
22664
  ...(compose.message ? ['', compose.message] : []),
19950
22665
  ...(compose.details || []).map((line) => ` ${line}`),
19951
22666
  ];
22667
+ const stateLine = compose.editing
22668
+ ? 'Enter/tab edits fields, Esc exits edit mode.'
22669
+ : 'e edit | c commit | I AI draft';
19952
22670
  return h(Box, {
19953
22671
  borderColor: focusBorderColor(theme, focused),
19954
22672
  borderStyle: theme.borderStyle,
19955
22673
  flexDirection: 'column',
19956
22674
  width,
19957
22675
  paddingX: 1,
19958
- }, h(Text, { bold: true }, panelTitle('Commit', focused)), ...lines.map((line, index) => h(Text, {
19959
- key: `commit-${index}`,
22676
+ }, h(Text, { bold: true }, panelTitle('Commit', focused)), ...headerLines.map((line, index) => h(Text, {
22677
+ key: `commit-header-${index}`,
19960
22678
  dimColor: index < 2 || line.startsWith(' ') || line === '<empty>',
22679
+ }, truncate$1(line, width - 4))), loading
22680
+ ? h(Text, {
22681
+ key: 'commit-loading',
22682
+ bold: true,
22683
+ color: theme.noColor ? undefined : theme.colors.accent,
22684
+ }, truncate$1(theme.ascii ? '[...] Generating AI draft' : '⏳ Generating AI draft…', width - 4))
22685
+ : h(Text, { key: 'commit-state', dimColor: true }, truncate$1(stateLine, width - 4)), ...trailerLines.map((line, index) => h(Text, {
22686
+ key: `commit-trailer-${index}`,
22687
+ dimColor: line.startsWith(' '),
19961
22688
  }, truncate$1(line, width - 4))));
19962
22689
  }
19963
22690
  function renderConfirmationPanel(h, components, state, width, theme, focused) {
@@ -19967,13 +22694,17 @@ function renderConfirmationPanel(h, components, state, width, theme, focused) {
19967
22694
  ? 'Revert selected hunk'
19968
22695
  : state.pendingMutationConfirmation === 'revert-file'
19969
22696
  ? 'Revert selected file'
19970
- : undefined;
22697
+ : state.pendingMutationConfirmation === 'discard-draft'
22698
+ ? 'Quit and discard the in-progress commit draft'
22699
+ : undefined;
19971
22700
  const label = action?.label || mutationLabel || 'Workflow action';
19972
- const warning = state.pendingMutationConfirmation
19973
- ? 'This discards local changes and cannot be undone by Coco.'
19974
- : action?.kind === 'ai'
19975
- ? `AI action requires confirmation. Estimated ${action.estimatedTokens || '<unknown>'} tokens.`
19976
- : 'Destructive Git action requires confirmation.';
22701
+ const warning = state.pendingMutationConfirmation === 'discard-draft'
22702
+ ? 'You have an unsaved commit draft. Press y to discard it and quit.'
22703
+ : state.pendingMutationConfirmation
22704
+ ? 'This discards local changes and cannot be undone by Coco.'
22705
+ : action?.kind === 'ai'
22706
+ ? `AI action requires confirmation. Estimated ${action.estimatedTokens || '<unknown>'} tokens.`
22707
+ : 'Destructive Git action requires confirmation.';
19977
22708
  return h(Box, {
19978
22709
  borderColor: focusBorderColor(theme, focused),
19979
22710
  borderStyle: theme.borderStyle,
@@ -19982,6 +22713,78 @@ function renderConfirmationPanel(h, components, state, width, theme, focused) {
19982
22713
  paddingX: 1,
19983
22714
  }, 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.'));
19984
22715
  }
22716
+ /**
22717
+ * First-launch onboarding overlay (P1.3). Shown once per machine, gated
22718
+ * by an XDG-style cache marker so subsequent launches go straight to the
22719
+ * normal UI. Auto-dismisses on the next keystroke.
22720
+ *
22721
+ * Replaces the whole layout for the first render rather than overlaying
22722
+ * a transient banner — Ink doesn't support floating elements, and a full
22723
+ * takeover keeps the message readable on small terminals while still
22724
+ * being instantly dismissible.
22725
+ */
22726
+ function renderOnboardingOverlay(h, components, rows, columns, theme, appLabel) {
22727
+ const { Box, Text } = components;
22728
+ const accent = theme.noColor ? undefined : theme.colors.accent;
22729
+ const tips = [
22730
+ { keys: '?', text: 'open the help panel' },
22731
+ { keys: ':', text: 'open the command palette' },
22732
+ { 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)' },
22733
+ { keys: '< esc', text: 'pop the navigation stack / go back' },
22734
+ { keys: '/', text: 'filter the active list' },
22735
+ { keys: 'q ctrl+c', text: 'quit' },
22736
+ ];
22737
+ const maxKeys = tips.reduce((max, tip) => Math.max(max, tip.keys.length), 0);
22738
+ const lineWidth = Math.max(40, columns - 4);
22739
+ return h(Box, {
22740
+ flexDirection: 'column',
22741
+ height: rows,
22742
+ paddingX: 2,
22743
+ paddingY: 1,
22744
+ }, 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.'));
22745
+ }
22746
+ /**
22747
+ * Which-key style chord overlay (P1.1). When the user presses a chord
22748
+ * prefix (currently just `g`), the dispatcher sets `state.pendingKey`
22749
+ * and waits for the second key. This panel surfaces the available
22750
+ * continuations so newcomers don't have to memorize the chord set.
22751
+ *
22752
+ * Renders in the detail panel slot; auto-dismisses when the chord
22753
+ * completes or `pendingKey` is otherwise cleared.
22754
+ */
22755
+ function renderChordOverlay(h, components, state, width, theme, focused) {
22756
+ const { Box, Text } = components;
22757
+ const prefix = state.pendingKey || '';
22758
+ const continuations = getLogInkChordContinuations(prefix);
22759
+ const accent = theme.noColor ? undefined : theme.colors.accent;
22760
+ const lines = [
22761
+ h(Text, { key: 'chord-title', bold: true }, panelTitle(`${prefix} … jump`, focused)),
22762
+ h(Text, { key: 'chord-spacer' }, ''),
22763
+ ];
22764
+ if (continuations.length === 0) {
22765
+ lines.push(h(Text, {
22766
+ key: 'chord-empty',
22767
+ dimColor: true,
22768
+ }, truncate$1(`No bindings registered for the ${prefix} prefix.`, width - 4)));
22769
+ }
22770
+ else {
22771
+ for (const entry of continuations) {
22772
+ 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))));
22773
+ }
22774
+ }
22775
+ lines.push(h(Text, { key: 'chord-foot-spacer' }, ''));
22776
+ lines.push(h(Text, {
22777
+ key: 'chord-hint',
22778
+ dimColor: true,
22779
+ }, truncate$1('press the second key to jump · esc cancels', width - 4)));
22780
+ return h(Box, {
22781
+ borderColor: focusBorderColor(theme, focused),
22782
+ borderStyle: theme.borderStyle,
22783
+ flexDirection: 'column',
22784
+ width,
22785
+ paddingX: 1,
22786
+ }, ...lines);
22787
+ }
19985
22788
  function renderHelpPanel(h, components, state, width, theme, focused) {
19986
22789
  const { Box, Text } = components;
19987
22790
  const children = [
@@ -20056,16 +22859,20 @@ function renderCommandPalette(h, components, state, width, theme, focused) {
20056
22859
  ? [h(Text, { key: 'palette-recent-hint', dimColor: true }, '· marks recently-used')]
20057
22860
  : []), ...itemLines);
20058
22861
  }
20059
- function renderFooter(h, components, state, theme) {
22862
+ function renderFooter(h, components, state, theme, idleTip) {
20060
22863
  const { Box, Text } = components;
20061
22864
  const hints = getLogInkFooterHints({
20062
22865
  activeView: state.activeView,
20063
22866
  filterMode: state.filterMode,
20064
22867
  focus: state.focus,
22868
+ pendingKey: state.pendingKey,
20065
22869
  showCommandPalette: state.showCommandPalette,
20066
22870
  showHelp: state.showHelp,
20067
22871
  });
20068
- const status = state.statusMessage ? ` ${state.statusMessage}` : '';
22872
+ // Real status messages always win; idle tips only fill the slot when it
22873
+ // would otherwise be empty.
22874
+ const trailing = state.statusMessage || idleTip || '';
22875
+ const status = trailing ? ` ${trailing}` : '';
20069
22876
  const contextualText = `${hints.contextual.join(' ')}${status}`;
20070
22877
  const globalText = hints.global.join(' · ');
20071
22878
  return h(Box, {
@@ -20106,6 +22913,7 @@ async function startCocoUiFromLogArgv(logArgv, options = {}) {
20106
22913
  const rows = options.rows || (await getLogRows(git, logArgv));
20107
22914
  await startInkInteractiveLog(git, rows, {}, {
20108
22915
  appLabel: 'coco ui',
22916
+ idleTips: config.logTui?.idleTips,
20109
22917
  initialView: 'history',
20110
22918
  logArgv,
20111
22919
  theme: config.logTui?.theme,
@@ -20118,6 +22926,7 @@ async function startCocoUi(argv) {
20118
22926
  const rows = await getLogRows(git, logArgv);
20119
22927
  await startInkInteractiveLog(git, rows, {}, {
20120
22928
  appLabel: 'coco ui',
22929
+ idleTips: config.logTui?.idleTips,
20121
22930
  initialView: argv.view || 'history',
20122
22931
  logArgv,
20123
22932
  theme: createUiTheme(config, argv),
@@ -21166,6 +23975,7 @@ y.command(changelog.command, changelog.desc, changelog.builder, changelog.handle
21166
23975
  y.command(recap.command, recap.desc, recap.builder, recap.handler);
21167
23976
  y.command(review.command, review.desc, review.builder, review.handler);
21168
23977
  y.command(init.command, init.desc, init.builder, init.handler);
23978
+ y.command(doctor.command, doctor.desc, doctor.builder, doctor.handler);
21169
23979
  y.command(log.command, log.desc, log.builder, log.handler);
21170
23980
  y.command(ui.command, ui.desc, ui.builder, ui.handler);
21171
23981
  y.help().parse(process.argv.slice(2));
@@ -21621,6 +24431,7 @@ var commitValidationHandler = /*#__PURE__*/Object.freeze({
21621
24431
 
21622
24432
  exports.changelog = changelog;
21623
24433
  exports.commit = commit;
24434
+ exports.doctor = doctor;
21624
24435
  exports.init = init;
21625
24436
  exports.log = log;
21626
24437
  exports.recap = recap;