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.d.ts +20 -3
- package/dist/index.esm.mjs +3207 -400
- package/dist/index.js +3210 -399
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
1983
|
-
|
|
1984
|
-
|
|
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$
|
|
5971
|
+
const command$7 = 'changelog';
|
|
5935
5972
|
/**
|
|
5936
5973
|
* Command line options via yargs
|
|
5937
5974
|
*/
|
|
5938
|
-
const options$
|
|
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$
|
|
5986
|
-
return yargs.options(options$
|
|
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$
|
|
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$
|
|
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$
|
|
11081
|
-
handler: commandExecutor(handler$
|
|
11082
|
-
options: options$
|
|
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$
|
|
11136
|
+
const command$6 = 'commit';
|
|
11100
11137
|
/**
|
|
11101
11138
|
* Command line options via yargs
|
|
11102
11139
|
*/
|
|
11103
|
-
const options$
|
|
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$
|
|
11176
|
-
return yargs.options(options$
|
|
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$
|
|
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:
|
|
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$
|
|
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.
|
|
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
|
-
|
|
13493
|
-
|
|
13494
|
-
|
|
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
|
-
|
|
13563
|
-
|
|
13564
|
-
|
|
13565
|
-
|
|
13566
|
-
|
|
13567
|
-
|
|
13568
|
-
|
|
13569
|
-
|
|
13570
|
-
|
|
13571
|
-
|
|
13572
|
-
|
|
13573
|
-
|
|
13574
|
-
|
|
13575
|
-
|
|
13576
|
-
|
|
13577
|
-
|
|
13578
|
-
|
|
13579
|
-
|
|
13580
|
-
|
|
13581
|
-
|
|
13582
|
-
|
|
13583
|
-
|
|
13584
|
-
|
|
13585
|
-
|
|
13586
|
-
}
|
|
13587
|
-
|
|
13588
|
-
const
|
|
13589
|
-
|
|
13590
|
-
|
|
13591
|
-
|
|
13592
|
-
|
|
13593
|
-
|
|
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
|
-
|
|
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$
|
|
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
|
|
13674
|
-
const draft =
|
|
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:
|
|
13677
|
-
message:
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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'
|
|
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
|
|
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:
|
|
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: '
|
|
15774
|
+
type: 'pageWorktreeDiff',
|
|
14819
15775
|
delta: -1,
|
|
14820
|
-
|
|
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: '
|
|
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
|
-
|
|
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
|
|
16294
|
+
detailWidth,
|
|
16295
|
+
mainPanelWidth: Math.max(20, columns - sidebarWidth - detailWidth),
|
|
14981
16296
|
rows,
|
|
14982
|
-
sidebarWidth
|
|
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
|
|
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
|
-
|
|
15347
|
-
|
|
17129
|
+
const nextOffset = hunkOffsets.find((offset) => offset > currentOffset);
|
|
17130
|
+
return nextOffset === undefined ? currentOffset : nextOffset;
|
|
15348
17131
|
}
|
|
15349
|
-
|
|
15350
|
-
|
|
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$
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
...
|
|
19049
|
-
...
|
|
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
|
-
|
|
19057
|
-
|
|
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
|
-
|
|
19141
|
-
|
|
19142
|
-
|
|
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
|
-
|
|
21178
|
+
if (!options.silent) {
|
|
21179
|
+
dispatch({ type: 'setStatus', value: 'repository context refreshed' });
|
|
21180
|
+
}
|
|
19145
21181
|
}, [dispatch, git]);
|
|
19146
|
-
const refreshWorktreeContext = React.useCallback(async () => {
|
|
19147
|
-
|
|
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
|
-
|
|
19450
|
-
|
|
19451
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
19500
|
-
|
|
19501
|
-
|
|
19502
|
-
|
|
19503
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
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) =>
|
|
19528
|
-
|
|
19529
|
-
|
|
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
|
|
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
|
-
|
|
21818
|
+
flexShrink: 0,
|
|
19567
21819
|
paddingX: 1,
|
|
19568
|
-
|
|
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
|
-
|
|
19578
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
?
|
|
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
|
-
|
|
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')), ...
|
|
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
|
-
|
|
19637
|
-
|
|
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
|
|
19640
|
-
|
|
19641
|
-
|
|
19642
|
-
|
|
19643
|
-
|
|
19644
|
-
const
|
|
19645
|
-
.
|
|
19646
|
-
|
|
19647
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
},
|
|
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'), ...
|
|
19662
|
-
|
|
19663
|
-
|
|
19664
|
-
|
|
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))), ...(
|
|
22004
|
+
}, truncate$1(` ${line}`, 140))), ...(!hasStagedFiles && noStagedHint
|
|
19668
22005
|
? [
|
|
19669
|
-
h(Text, { key: 'compose-staged-spacer' }, ''),
|
|
19670
|
-
h(Text, { key: 'compose-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
|
-
:
|
|
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
|
|
22023
|
+
const sortedAll = sortBranches(branches?.localBranches || [], state.branchSort);
|
|
19696
22024
|
const localBranches = state.filter
|
|
19697
|
-
?
|
|
19698
|
-
:
|
|
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}/${
|
|
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
|
|
19718
|
-
const divergence =
|
|
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
|
-
|
|
22058
|
+
flexShrink: 0,
|
|
19730
22059
|
paddingX: 1,
|
|
19731
|
-
|
|
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
|
|
22067
|
+
const sortedAll = sortTags(context.tags?.tags || [], state.tagSort);
|
|
19738
22068
|
const tags = state.filter
|
|
19739
|
-
?
|
|
19740
|
-
:
|
|
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}/${
|
|
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
|
-
},
|
|
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
|
-
|
|
22117
|
+
flexShrink: 0,
|
|
19770
22118
|
paddingX: 1,
|
|
19771
|
-
|
|
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
|
-
|
|
22158
|
+
flexShrink: 0,
|
|
19810
22159
|
paddingX: 1,
|
|
19811
|
-
|
|
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
|
|
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
|
|
22236
|
+
const headerLines = isLogInkContextKeyLoading(contextStatus, 'worktree')
|
|
19823
22237
|
? ['Loading file context...']
|
|
19824
22238
|
: worktreeDiffLoading
|
|
19825
|
-
? [`Loading diff for ${
|
|
19826
|
-
:
|
|
22239
|
+
? [`Loading diff for ${worktreeFile?.path || 'selected file'}...`]
|
|
22240
|
+
: worktreeFile
|
|
19827
22241
|
? [
|
|
19828
|
-
`Selected file: ${
|
|
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
|
-
|
|
22259
|
+
flexShrink: 0,
|
|
19844
22260
|
paddingX: 1,
|
|
19845
|
-
|
|
19846
|
-
|
|
19847
|
-
|
|
19848
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)), ...
|
|
19921
|
-
key: `detail-${index}`,
|
|
19922
|
-
dimColor: index >
|
|
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
|
|
19937
|
-
|
|
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
|
-
|
|
22654
|
+
summaryFirst,
|
|
22655
|
+
...summaryRest,
|
|
19941
22656
|
`${compose.field === 'body' && compose.editing ? '>' : ' '} Body:`,
|
|
19942
|
-
...
|
|
22657
|
+
...bodyVisualLines.map((line, index) => {
|
|
22658
|
+
const isLast = index === bodyVisualLines.length - 1;
|
|
22659
|
+
return ` ${line}${bodyCursor && isLast ? bodyCursor : ''}`;
|
|
22660
|
+
}),
|
|
19943
22661
|
'',
|
|
19944
|
-
|
|
19945
|
-
|
|
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)), ...
|
|
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
|
-
:
|
|
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
|
-
? '
|
|
19974
|
-
:
|
|
19975
|
-
?
|
|
19976
|
-
:
|
|
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
|
-
|
|
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;
|