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