Repository: JanDeDobbeleer/oh-my-posh
Pull request #5523 (2024-08-27 07:38:05)

joining prs
### Prerequisites

- [x] I have read and understood the [contributing guide][CONTRIBUTING.md].
- [x] The commit message follows the [conventional commits][cc] guidelines.
- [x] Tests for the changes have been added (for bug fixes / features).
- [x] Docs have been added/updated (for bug fixes / features).

### Description

- **feat(upgrade): remove no new version notice**
- **fix(shell): avoid unexpected expansions in Bash/Zsh**
- **fix(bash): respect `PROMPT_COMMAND` as an array**
- **fix(export): omit specific fields**
- **fix(cli): correct execution logic and improve messages**
- **fix(shell): use Windows PID as `POSH_PID` in a Cygwin environment**

closes #5474 #5486 #5491 

<!---

Tips:

If you're not comfortable with working with Git,
we're working a guide (https://ohmyposh.dev/docs/contributing/git) to help you out.
Oh My Posh advises GitKraken (https://www.gitkraken.com/invite/nQmDPR9D) as your preferred cross platform Git GUI power tool.

-->

[CONTRIBUTING.md]: https://github.com/JanDeDobbeleer/oh-my-posh/blob/main/CONTRIBUTING.md
[cc]: https://www.conventionalcommits.org/en/v1.0.0/#summary



Patch:
diff --git a/src/cli/cache.go b/src/cli/cache.go
index cd9023fb..1ea50543 100644
--- a/src/cli/cache.go
+++ b/src/cli/cache.go
@@ -3,7 +3,6 @@ package cli
 import (
 	"fmt"
 	"os"
-	"os/exec"
 	"path/filepath"
 	"strings"
 
@@ -50,7 +49,7 @@ You can do the following:
 			clear(env.CachePath())
 		case "edit":
 			cacheFilePath := filepath.Join(env.CachePath(), cache.FileName)
-			editFileWithEditor(cacheFilePath)
+			os.Exit(editFileWithEditor(cacheFilePath))
 		}
 	},
 }
@@ -59,25 +58,6 @@ func init() {
 	RootCmd.AddCommand(getCache)
 }
 
-func editFileWithEditor(file string) {
-	editor := os.Getenv("EDITOR")
-
-	var args []string
-	if strings.Contains(editor, " ") {
-		splitted := strings.Split(editor, " ")
-		editor = splitted[0]
-		args = splitted[1:]
-	}
-
-	args = append(args, file)
-	cmd := exec.Command(editor, args...)
-
-	err := cmd.Run()
-	if err != nil {
-		fmt.Println(err.Error())
-	}
-}
-
 func clear(cachePath string) {
 	// get all files in the cache directory that start with omp.cache and delete them
 	files, err := os.ReadDir(cachePath)
diff --git a/src/cli/config.go b/src/cli/config.go
index 7b330c4e..2bca2323 100644
--- a/src/cli/config.go
+++ b/src/cli/config.go
@@ -5,16 +5,18 @@ import (
 	"os"
 	"time"
 
+	"github.com/jandedobbeleer/oh-my-posh/src/runtime"
+
 	"github.com/spf13/cobra"
 )
 
 // configCmd represents the config command
 var configCmd = &cobra.Command{
-	Use:   "config [export|migrate|edit]",
+	Use:   "config edit",
 	Short: "Interact with the config",
 	Long: `Interact with the config.
 
-You can export, migrate or edit the config.`,
+You can export, migrate or edit the config (via the editor specified in the environment variable "EDITOR").`,
 	ValidArgs: []string{
 		"export",
 		"migrate",
@@ -29,7 +31,13 @@ You can export, migrate or edit the config.`,
 		}
 		switch args[0] {
 		case "edit":
-			editFileWithEditor(os.Getenv("POSH_THEME"))
+			env := &runtime.Terminal{
+				CmdFlags: &runtime.Flags{
+					Config: configFlag,
+				},
+			}
+			env.ResolveConfigPath()
+			os.Exit(editFileWithEditor(env.CmdFlags.Config))
 		case "get":
 			// only here for backwards compatibility
 			fmt.Print(time.Now().UnixNano() / 1000000)
diff --git a/src/cli/config_export.go b/src/cli/config_export.go
index 7f3dbb03..eaf534e9 100644
--- a/src/cli/config_export.go
+++ b/src/cli/config_export.go
@@ -4,7 +4,6 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
-	"slices"
 	"strings"
 
 	"github.com/jandedobbeleer/oh-my-posh/src/config"
@@ -13,9 +12,7 @@ import (
 	"github.com/spf13/cobra"
 )
 
-var (
-	output string
-)
+var output string
 
 // exportCmd represents the export command
 var exportCmd = &cobra.Command{
@@ -27,15 +24,21 @@ You can choose to print the output to stdout, or export your config in the forma
 
 Example usage:
 
-> oh-my-posh config export --config ~/myconfig.omp.json
+> oh-my-posh config export --config ~/myconfig.omp.json --format toml
 
-Exports the ~/myconfig.omp.json config file and prints the result to stdout.
+Exports the config file "~/myconfig.omp.json" in TOML format and prints the result to stdout.
 
-> oh-my-posh config export --config ~/myconfig.omp.json --format toml
+> oh-my-posh config export --output ~/new_config.omp.json
 
-Exports the ~/myconfig.omp.json config file to toml and prints the result to stdout.`,
+Exports the current config to "~/new_config.omp.json" (in JSON format).`,
 	Args: cobra.NoArgs,
 	Run: func(_ *cobra.Command, _ []string) {
+		if len(output) == 0 && len(format) == 0 {
+			// usage error
+			fmt.Println("neither output path nor export format is specified")
+			os.Exit(2)
+		}
+
 		env := &runtime.Terminal{
 			CmdFlags: &runtime.Flags{
 				Config: configFlag,
@@ -45,15 +48,25 @@ Exports the ~/myconfig.omp.json config file to toml and prints the result to std
 		defer env.Close()
 		cfg := config.Load(env)
 
-		if len(output) == 0 && len(format) == 0 {
-			// usage error
-			os.Exit(2)
+		validateExportFormat := func() {
+			format = strings.ToLower(format)
+			switch format {
+			case "json", "jsonc":
+				format = config.JSON
+			case "toml", "tml":
+				format = config.TOML
+			case "yaml", "yml":
+				format = config.YAML
+			default:
+				formats := []string{"json", "jsonc", "toml", "tml", "yaml", "yml"}
+				// usage error
+				fmt.Printf("export format must be one of these: %s\n", strings.Join(formats, ", "))
+				os.Exit(2)
+			}
 		}
 
-		formats := []string{"json", "jsonc", "toml", "tml", "yaml", "yml"}
-		if len(format) != 0 && !slices.Contains(formats, format) {
-			// usage error
-			os.Exit(2)
+		if len(format) != 0 {
+			validateExportFormat()
 		}
 
 		if len(output) == 0 {
@@ -65,18 +78,7 @@ Exports the ~/myconfig.omp.json config file to toml and prints the result to std
 
 		if len(format) == 0 {
 			format = strings.TrimPrefix(filepath.Ext(output), ".")
-		}
-
-		switch format {
-		case "json", "jsonc":
-			format = config.JSON
-		case "toml", "tml":
-			format = config.TOML
-		case "yaml", "yml":
-			format = config.YAML
-		default:
-			// data error
-			os.Exit(65)
+			validateExportFormat()
 		}
 
 		cfg.Write(format)
@@ -84,10 +86,7 @@ Exports the ~/myconfig.omp.json config file to toml and prints the result to std
 }
 
 func cleanOutputPath(path string, env runtime.Environment) string {
-	if strings.HasPrefix(path, "~") {
-		path = strings.TrimPrefix(path, "~")
-		path = filepath.Join(env.Home(), path)
-	}
+	path = runtime.ReplaceTildePrefixWithHomeDir(env, path)
 
 	if !filepath.IsAbs(path) {
 		if absPath, err := filepath.Abs(path); err == nil {
diff --git a/src/cli/edit.go b/src/cli/edit.go
new file mode 100644
index 00000000..f03227cc
--- /dev/null
+++ b/src/cli/edit.go
@@ -0,0 +1,34 @@
+package cli
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"strings"
+)
+
+func editFileWithEditor(file string) int {
+	editor := strings.TrimSpace(os.Getenv("EDITOR"))
+	if len(editor) == 0 {
+		fmt.Println(`no editor specified in the environment variable "EDITOR"`)
+		return 1
+	}
+
+	var args []string
+	if strings.Contains(editor, " ") {
+		strs := strings.Split(editor, " ")
+		editor = strs[0]
+		args = strs[1:]
+	}
+
+	args = append(args, file)
+	cmd := exec.Command(editor, args...)
+
+	err := cmd.Run()
+	if err != nil {
+		fmt.Println(err.Error())
+		return 1
+	}
+
+	return cmd.ProcessState.ExitCode()
+}
diff --git a/src/cli/print.go b/src/cli/print.go
index 4eb31c4a..c1e118e5 100644
--- a/src/cli/print.go
+++ b/src/cli/print.go
@@ -66,7 +66,6 @@ var printCmd = &cobra.Command{
 			Plain:         plain,
 			Primary:       args[0] == "primary",
 			Cleared:       cleared,
-			Cached:        cached,
 			NoExitCode:    noStatus,
 			Column:        column,
 			JobCount:      jobCount,
diff --git a/src/cli/upgrade.go b/src/cli/upgrade.go
index e15d4300..f646ba90 100644
--- a/src/cli/upgrade.go
+++ b/src/cli/upgrade.go
@@ -7,7 +7,6 @@ import (
 	"slices"
 
 	"github.com/jandedobbeleer/oh-my-posh/src/build"
-	"github.com/jandedobbeleer/oh-my-posh/src/config"
 	"github.com/jandedobbeleer/oh-my-posh/src/runtime"
 	"github.com/jandedobbeleer/oh-my-posh/src/terminal"
 	"github.com/jandedobbeleer/oh-my-posh/src/upgrade"
@@ -30,7 +29,6 @@ var upgradeCmd = &cobra.Command{
 		}
 
 		if !slices.Contains(supportedPlatforms, stdruntime.GOOS) {
-			fmt.Print("\n⚠️ upgrade is not supported on this platform\n\n")
 			return
 		}
 
@@ -53,8 +51,6 @@ var upgradeCmd = &cobra.Command{
 			return
 		}
 
-		cfg := config.Load(env)
-
 		version := fmt.Sprintf("v%s", build.Version)
 
 		if upgrade.IsMajorUpgrade(version, latest) {
@@ -69,13 +65,7 @@ var upgradeCmd = &cobra.Command{
 			return
 		}
 
-		message := terminal.StopProgress()
-
-		if !cfg.DisableNotice {
-			message += "\n✅ no new version available\n\n"
-		}
-
-		fmt.Print(message)
+		fmt.Print(terminal.StopProgress())
 	},
 }
 
diff --git a/src/config/segment.go b/src/config/segment.go
index c69fa2bb..6248d47a 100644
--- a/src/config/segment.go
+++ b/src/config/segment.go
@@ -53,13 +53,13 @@ type Segment struct {
 	MaxWidth               int            `json:"max_width,omitempty" toml:"max_width,omitempty"`
 	MinWidth               int            `json:"min_width,omitempty" toml:"min_width,omitempty"`
 	Filler                 string         `json:"filler,omitempty" toml:"filler,omitempty"`
-	Background             color.Ansi     `json:"background" toml:"background"`
-	Foreground             color.Ansi     `json:"foreground" toml:"foreground"`
+	Background             color.Ansi     `json:"background,omitempty" toml:"background,omitempty"`
+	Foreground             color.Ansi     `json:"foreground,omitempty" toml:"foreground,omitempty"`
 	Newline                bool           `json:"newline,omitempty" toml:"newline,omitempty"`
 
 	Enabled bool `json:"-" toml:"-"`
 
-	Text string
+	Text string `json:"-" toml:"-"`
 
 	env        runtime.Environment
 	writer     SegmentWriter
@@ -67,8 +67,8 @@ type Segment struct {
 	name       string
 
 	// debug info
-	Duration   time.Duration
-	NameLength int
+	Duration   time.Duration `json:"-" toml:"-"`
+	NameLength int           `json:"-" toml:"-"`
 }
 
 func (segment *Segment) Name() string {
diff --git a/src/prompt/extra.go b/src/prompt/extra.go
index a7622e47..3efd077f 100644
--- a/src/prompt/extra.go
+++ b/src/prompt/extra.go
@@ -2,7 +2,6 @@ package prompt
 
 import (
 	"fmt"
-	"strings"
 
 	"github.com/jandedobbeleer/oh-my-posh/src/color"
 	"github.com/jandedobbeleer/oh-my-posh/src/config"
@@ -101,17 +100,15 @@ func (e *Engine) ExtraPrompt(promptType ExtraPromptType) string {
 
 	switch e.Env.Shell() {
 	case shell.ZSH:
-		// escape double quotes contained in the prompt
 		if promptType == Transient {
-			prompt := fmt.Sprintf("PS1=\"%s\"", strings.ReplaceAll(str, "\"", "\"\""))
+			prompt := fmt.Sprintf("PS1=%s", shell.QuotePosixStr(str))
 			// empty RPROMPT
-			prompt += "\nRPROMPT=\"\""
+			prompt += "\nRPROMPT=''"
 			return prompt
 		}
 		return str
 	case shell.PWSH, shell.PWSH5:
 		if promptType == Transient {
-			// Return the string and empty our buffer
 			// clear the line afterwards to prevent text from being written on the same line
 			// see https://github.com/JanDeDobbeleer/oh-my-posh/issues/3628
 			return str + terminal.ClearAfter()
@@ -119,7 +116,6 @@ func (e *Engine) ExtraPrompt(promptType ExtraPromptType) string {
 
 		return str
 	case shell.CMD, shell.BASH, shell.FISH, shell.NU, shell.GENERIC:
-		// Return the string and empty our buffer
 		return str
 	}
 
diff --git a/src/prompt/primary.go b/src/prompt/primary.go
index 98498c2f..edefd305 100644
--- a/src/prompt/primary.go
+++ b/src/prompt/primary.go
@@ -23,14 +23,12 @@ func (e *Engine) Primary() string {
 		// Warp doesn't support RPROMPT so we need to write it manually
 		if e.isWarp() {
 			e.writePrimaryRightPrompt()
-			// escape double quotes contained in the prompt
-			prompt := fmt.Sprintf("PS1=\"%s\"", strings.ReplaceAll(e.string(), `"`, `\"`))
+			prompt := fmt.Sprintf("PS1=%s", shell.QuotePosixStr(e.string()))
 			return prompt
 		}
 
-		// escape double quotes contained in the prompt
-		prompt := fmt.Sprintf("PS1=\"%s\"", strings.ReplaceAll(e.string(), `"`, `\"`))
-		prompt += fmt.Sprintf("\nRPROMPT=\"%s\"", e.rprompt)
+		prompt := fmt.Sprintf("PS1=%s", shell.QuotePosixStr(e.string()))
+		prompt += fmt.Sprintf("\nRPROMPT=%s", shell.QuotePosixStr(e.rprompt))
 
 		return prompt
 	default:
diff --git a/src/runtime/environment.go b/src/runtime/environment.go
index 15e2bcd6..408382b1 100644
--- a/src/runtime/environment.go
+++ b/src/runtime/environment.go
@@ -98,7 +98,6 @@ type Flags struct {
 	HasTransient  bool
 	PromptCount   int
 	Cleared       bool
-	Cached        bool
 	NoExitCode    bool
 	Column        int
 	JobCount      int
diff --git a/src/runtime/terminal.go b/src/runtime/terminal.go
index 3a8313b8..2fd9e09d 100644
--- a/src/runtime/terminal.go
+++ b/src/runtime/terminal.go
@@ -74,7 +74,7 @@ func (term *Terminal) Init() {
 	term.deviceCache = initCache(cache.FileName)
 	term.sessionCache = initCache(cache.SessionFileName)
 
-	term.resolveConfigPath()
+	term.ResolveConfigPath()
 
 	term.cmdCache = &cache.Command{
 		Commands: maps.NewConcurrent(),
@@ -82,12 +82,10 @@ func (term *Terminal) Init() {
 
 	term.tmplCache = &cache.Template{}
 
-	if !term.CmdFlags.Cached {
-		term.SetPromptCount()
-	}
+	term.SetPromptCount()
 }
 
-func (term *Terminal) resolveConfigPath() {
+func (term *Terminal) ResolveConfigPath() {
 	defer term.Trace(time.Now())
 
 	// if the config flag is set, we'll use that over POSH_THEME
@@ -129,11 +127,7 @@ func (term *Terminal) resolveConfigPath() {
 		return
 	}
 
-	configFile := term.CmdFlags.Config
-	if strings.HasPrefix(configFile, "~") {
-		configFile = strings.TrimPrefix(configFile, "~")
-		configFile = filepath.Join(term.Home(), configFile)
-	}
+	configFile := ReplaceTildePrefixWithHomeDir(term, term.CmdFlags.Config)
 
 	abs, err := filepath.Abs(configFile)
 	if err != nil {
@@ -726,11 +720,14 @@ func dirMatchesOneOf(dir, home, goos string, regexes []string) bool {
 	}
 
 	for _, element := range regexes {
-		normalizedElement := strings.ReplaceAll(element, "\\\\", "/")
-		if strings.HasPrefix(normalizedElement, "~") {
-			normalizedElement = strings.Replace(normalizedElement, "~", home, 1)
+		normalized := strings.ReplaceAll(element, "\\\\", "/")
+		if strings.HasPrefix(normalized, "~") {
+			rem := normalized[1:]
+			if len(rem) == 0 || rem[0] == '/' {
+				normalized = home + rem
+			}
 		}
-		pattern := fmt.Sprintf("^%s$", normalizedElement)
+		pattern := fmt.Sprintf("^%s$", normalized)
 		if goos == WINDOWS || goos == DARWIN {
 			pattern = "(?i)" + pattern
 		}
@@ -843,9 +840,19 @@ func Base(env Environment, path string) string {
 	return path
 }
 
+func ReplaceTildePrefixWithHomeDir(env Environment, path string) string {
+	if !strings.HasPrefix(path, "~") {
+		return path
+	}
+	rem := path[1:]
+	if len(rem) == 0 || IsPathSeparator(env, rem[0]) {
+		return env.Home() + rem
+	}
+	return path
+}
+
 func ReplaceHomeDirPrefixWithTilde(env Environment, path string) string {
 	home := env.Home()
-	// match Home directory exactly
 	if !strings.HasPrefix(path, home) {
 		return path
 	}
diff --git a/src/shell/bash.go b/src/shell/bash.go
index 4f8fc202..48dc55cc 100644
--- a/src/shell/bash.go
+++ b/src/shell/bash.go
@@ -27,7 +27,7 @@ func (f Feature) Bash() Code {
 	}
 }
 
-func quotePosixStr(str string) string {
+func QuotePosixStr(str string) string {
 	if len(str) == 0 {
 		return "''"
 	}
diff --git a/src/shell/formats.go b/src/shell/formats.go
index 734c73fb..ced0e68c 100644
--- a/src/shell/formats.go
+++ b/src/shell/formats.go
@@ -16,7 +16,7 @@ type Formats struct {
 	Osc7  string
 	Osc51 string
 
-	EscapeSequences map[rune]rune
+	EscapeSequences map[rune]string
 
 	HyperlinkStart  string
 	HyperlinkCenter string
@@ -50,9 +50,8 @@ func GetFormats(shell string) *Formats {
 			ITermPromptMark:       "\\[$(iterm2_prompt_mark)\\]",
 			ITermCurrentDir:       "\\[\x1b]1337;CurrentDir=%s\x07\\]",
 			ITermRemoteHost:       "\\[\x1b]1337;RemoteHost=%s@%s\x07\\]",
-			EscapeSequences: map[rune]rune{
-				96: 92, // backtick
-				92: 92, // backslash
+			EscapeSequences: map[rune]string{
+				'\\': `\\`,
 			},
 		}
 	case ZSH, TCSH:
@@ -100,9 +99,8 @@ func GetFormats(shell string) *Formats {
 	}
 
 	if shell == ZSH {
-		formats.EscapeSequences = map[rune]rune{
-			96: 92, // backtick
-			37: 37, // %
+		formats.EscapeSequences = map[rune]string{
+			'%': "%%",
 		}
 	}
 
diff --git a/src/shell/init.go b/src/shell/init.go
index 1037c97f..79d342d6 100644
--- a/src/shell/init.go
+++ b/src/shell/init.go
@@ -93,12 +93,12 @@ func PrintInit(env runtime.Environment, features Features, startTime *time.Time)
 		configFile = quotePwshOrElvishStr(configFile)
 		script = pwshInit
 	case ZSH:
-		executable = quotePosixStr(executable)
-		configFile = quotePosixStr(configFile)
+		executable = QuotePosixStr(executable)
+		configFile = QuotePosixStr(configFile)
 		script = zshInit
 	case BASH:
-		executable = quotePosixStr(executable)
-		configFile = quotePosixStr(configFile)
+		executable = QuotePosixStr(executable)
+		configFile = QuotePosixStr(configFile)
 		script = bashInit
 	case FISH:
 		executable = quoteFishStr(executable)
@@ -113,8 +113,8 @@ func PrintInit(env runtime.Environment, features Features, startTime *time.Time)
 		configFile = quoteNuStr(configFile)
 		script = nuInit
 	case TCSH:
-		executable = quotePosixStr(executable)
-		configFile = quotePosixStr(configFile)
+		executable = QuotePosixStr(executable)
+		configFile = QuotePosixStr(configFile)
 		script = tcshInit
 	case ELVISH:
 		executable = quotePwshOrElvishStr(executable)
diff --git a/src/shell/scripts/omp.bash b/src/shell/scripts/omp.bash
index b793ba68..f80379a3 100644
--- a/src/shell/scripts/omp.bash
+++ b/src/shell/scripts/omp.bash
@@ -5,6 +5,10 @@ export POSH_PID=$$
 export CONDA_PROMPT_MODIFIER=false
 export OSTYPE=$OSTYPE
 
+if [[ $OSTYPE =~ ^(msys|cygwin) ]]; then
+    export POSH_PID=$(command cat /proc/$$/winpid)
+fi
+
 # global variables
 _omp_start_time=""
 _omp_stack_count=0
@@ -20,8 +24,9 @@ _omp_ftcs_marks=0
 
 # start timer on command start
 PS0='${_omp_start_time:0:$((_omp_start_time="$(_omp_start_timer)",0))}$(_omp_ftcs_command_start)'
+
 # set secondary prompt
-PS2="$("$_omp_executable" print secondary --shell=bash --shell-version="$BASH_VERSION")"
+_omp_secondary_prompt=$("$_omp_executable" print secondary --shell=bash --shell-version="$BASH_VERSION")
 
 function _omp_set_cursor_position() {
     # not supported in Midnight Commander
@@ -35,7 +40,7 @@ function _omp_set_cursor_position() {
 
     local COL
     local ROW
-    IFS=';' read -sdR -p $'\E[6n' ROW COL
+    IFS=';' read -rsdR -p $'\E[6n' ROW COL
 
     stty "$oldstty"
 
@@ -58,12 +63,43 @@ function set_poshcontext() {
     return
 }
 
-# regular prompt
+function _omp_print_primary() {
+    # Avoid unexpected expansions.
+    shopt -u promptvars
+
+    local prompt
+    if shopt -oq posix; then
+        # Disable in POSIX mode.
+        prompt='[NOTICE: Oh My Posh prompt is not supported in POSIX mode]\n\u@\h:\w\$ '
+    else
+        prompt=$("$_omp_executable" print primary --shell=bash --shell-version="$BASH_VERSION" --status="$_omp_status_cache" --pipestatus="${_omp_pipestatus_cache[*]}" --execution-time="$_omp_elapsed" --stack-count="$_omp_stack_count" --no-status="$_omp_no_exit_code" --terminal-width="${COLUMNS-0}" | tr -d '\0')
+    fi
+    echo "${prompt@P}"
+
+    # Allow command substitution in PS0.
+    shopt -s promptvars
+}
+
+function _omp_print_secondary() {
+    # Avoid unexpected expansions.
+    shopt -u promptvars
+
+    if shopt -oq posix; then
+        # Disable in POSIX mode.
+        echo '> '
+    else
+        echo "${_omp_secondary_prompt@P}"
+    fi
+
+    # Allow command substitution in PS0.
+    shopt -s promptvars
+}
+
 function _omp_hook() {
-    _omp_status_cache=$? _omp_pipestatus_cache=(${PIPESTATUS[@]})
+    _omp_status_cache=$? _omp_pipestatus_cache=("${PIPESTATUS[@]}")
 
     if [[ ${#BP_PIPESTATUS[@]} -ge ${#_omp_pipestatus_cache[@]} ]]; then
-        _omp_pipestatus_cache=(${BP_PIPESTATUS[@]})
+        _omp_pipestatus_cache=("${BP_PIPESTATUS[@]}")
     fi
 
     _omp_stack_count=$((${#DIRSTACK[@]} - 1))
@@ -82,11 +118,22 @@ function _omp_hook() {
     set_poshcontext
     _omp_set_cursor_position
 
-    PS1="$("$_omp_executable" print primary --shell=bash --shell-version="$BASH_VERSION" --status="$_omp_status_cache" --pipestatus="${_omp_pipestatus_cache[*]}" --execution-time="$_omp_elapsed" --stack-count="$_omp_stack_count" --no-status="$_omp_no_exit_code" --terminal-width="${COLUMNS-0}" | tr -d '\0')"
+    PS1='$(_omp_print_primary)'
+    PS2='$(_omp_print_secondary)'
 
     return $_omp_status_cache
 }
 
-if [[ $TERM != linux ]] && ! [[ $PROMPT_COMMAND =~ _omp_hook ]]; then
-    PROMPT_COMMAND="_omp_hook; $PROMPT_COMMAND"
-fi
+function _omp_install_hook() {
+    [[ $TERM = linux ]] && return
+
+    local cmd
+    for cmd in "${PROMPT_COMMAND[@]}"; do
+        if [[ $cmd = "_omp_hook" ]]; then
+            return
+        fi
+    done
+    PROMPT_COMMAND=(_omp_hook "${PROMPT_COMMAND[@]}")
+}
+
+_omp_install_hook
diff --git a/src/shell/scripts/omp.fish b/src/shell/scripts/omp.fish
index 09429394..ce64f6e5 100644
--- a/src/shell/scripts/omp.fish
+++ b/src/shell/scripts/omp.fish
@@ -7,6 +7,10 @@ set --global _omp_tooltip_command ''
 set --global _omp_current_rprompt ''
 set --global _omp_transient 0
 
+if command uname -s | string match -qr '^(CYGWIN|MSYS|MINGW)'
+    set --export POSH_PID (command cat /proc/$fish_pid/winpid)
+end
+
 set --global _omp_executable ::OMP::
 set --global _omp_ftcs_marks 0
 set --global _omp_transient_prompt 0
@@ -112,25 +116,25 @@ function _omp_preexec --on-event fish_preexec
 end
 
 # perform cleanup so a new initialization in current session works
-if bind \r --user 2>/dev/null | string match -q -e _omp_enter_key_handler
+if bind \r --user 2>/dev/null | string match -qe _omp_enter_key_handler
     bind -e \r -M default
     bind -e \r -M insert
     bind -e \r -M visual
 end
 
-if bind \n --user 2>/dev/null | string match -q -e _omp_enter_key_handler
+if bind \n --user 2>/dev/null | string match -qe _omp_enter_key_handler
     bind -e \n -M default
     bind -e \n -M insert
     bind -e \n -M visual
 end
 
-if bind \cc --user 2>/dev/null | string match -q -e _omp_ctrl_c_key_handler
+if bind \cc --user 2>/dev/null | string match -qe _omp_ctrl_c_key_handler
     bind -e \cc -M default
     bind -e \cc -M insert
     bind -e \cc -M visual
 end
 
-if bind \x20 --user 2>/dev/null | string match -q -e _omp_space_key_handler
+if bind \x20 --user 2>/dev/null | string match -qe _omp_space_key_handler
     bind -e \x20 -M default
     bind -e \x20 -M insert
 end
diff --git a/src/shell/scripts/omp.tcsh b/src/shell/scripts/omp.tcsh
index ebc466fb..c105bc55 100644
--- a/src/shell/scripts/omp.tcsh
+++ b/src/shell/scripts/omp.tcsh
@@ -2,6 +2,11 @@ setenv POWERLINE_COMMAND "oh-my-posh";
 setenv POSH_THEME ::CONFIG::;
 setenv POSH_SHELL_VERSION "";
 setenv POSH_PID $$;
+setenv OSTYPE "$OSTYPE";
+
+if ("$OSTYPE" =~ {msys,cygwin}*) then
+  setenv POSH_PID "`cat /proc/$$/winpid`";
+endif
 
 set POSH_COMMAND = ::OMP::;
 set USER_PRECMD = "`alias precmd`";
diff --git a/src/shell/scripts/omp.zsh b/src/shell/scripts/omp.zsh
index 59ebaac7..89273307 100644
--- a/src/shell/scripts/omp.zsh
+++ b/src/shell/scripts/omp.zsh
@@ -5,6 +5,11 @@ export POWERLINE_COMMAND="oh-my-posh"
 export CONDA_PROMPT_MODIFIER=false
 export POSH_PROMPT_COUNT=0
 export ZLE_RPROMPT_INDENT=0
+export OSTYPE=$OSTYPE
+
+if [[ $OSTYPE =~ ^(msys|cygwin) ]]; then
+  export POSH_PID=$(command cat /proc/$$/winpid)
+fi
 
 _omp_executable=::OMP::
 
@@ -13,7 +18,7 @@ _omp_cursor_positioning=0
 _omp_ftcs_marks=0
 
 # set secondary prompt
-PS2="$($_omp_executable print secondary --shell=zsh)"
+_omp_secondary_prompt=$($_omp_executable print secondary --shell=zsh)
 
 function _omp_set_cursor_position() {
   # not supported in Midnight Commander
@@ -73,6 +78,15 @@ function _omp_precmd() {
   set_poshcontext
   _omp_set_cursor_position
 
+  # We do this to avoid unexpected expansions in a prompt string.
+  unsetopt PROMPT_SUBST
+  unsetopt PROMPT_BANG
+
+  # Ensure that escape sequences work in a prompt string.
+  setopt PROMPT_PERCENT
+
+  PS2=$_omp_secondary_prompt
+
   eval "$($_omp_executable print primary --status="$_omp_status_cache" --pipestatus="${_omp_pipestatus_cache[*]}" --execution-time="$_omp_elapsed" --stack-count="$_omp_stack_count" --eval --shell=zsh --shell-version="$ZSH_VERSION" --no-status="$_omp_no_exit_code")"
   unset _omp_start_time
 }
diff --git a/src/terminal/writer.go b/src/terminal/writer.go
index 977a34ff..80f3f4bd 100644
--- a/src/terminal/writer.go
+++ b/src/terminal/writer.go
@@ -58,8 +58,6 @@ var (
 	isInvisible   bool
 	isHyperlink   bool
 
-	lastRune rune
-
 	Shell   string
 	Program string
 
@@ -187,24 +185,34 @@ func ClearAfter() string {
 }
 
 func FormatTitle(title string) string {
+	// These shells don't support setting the console title.
+	if Shell == shell.ELVISH || Shell == shell.XONSH {
+		return ""
+	}
+
 	title = trimAnsi(title)
 
 	if Plain {
 		return title
 	}
 
-	// we have to do this to prevent bash/zsh from misidentifying escape sequences
-	switch Shell {
-	case shell.BASH:
-		title = strings.NewReplacer("`", "\\`", `\`, `\\`).Replace(title)
-	case shell.ZSH:
-		title = strings.NewReplacer("`", "\\`", `%`, `%%`).Replace(title)
-	case shell.ELVISH, shell.XONSH:
-		// these shells don't support setting the title
-		return ""
+	if Shell != shell.BASH && Shell != shell.ZSH {
+		return fmt.Sprintf(formats.Title, title)
+	}
+
+	// We have to do this to prevent Bash/Zsh from misidentifying escape sequences.
+	s := new(strings.Builder)
+	for _, char := range title {
+		escaped, shouldEscape := formats.EscapeSequences[char]
+		if shouldEscape {
+			s.WriteString(escaped)
+			continue
+		}
+
+		s.WriteRune(char)
 	}
 
-	return fmt.Sprintf(formats.Title, title)
+	return fmt.Sprintf(formats.Title, s.String())
 }
 
 func EscapeText(text string) string {
@@ -392,17 +400,18 @@ func write(s rune) {
 		return
 	}
 
-	if !Interactive {
-		for special, escape := range formats.EscapeSequences {
-			if s == special && lastRune != escape {
-				builder.WriteRune(escape)
-			}
+	// UNSOLVABLE: When "Interactive" is true, the prompt length calculation in Bash/Zsh can be wrong, since the final string expansion is done by shells.
+	length += runewidth.RuneWidth(s)
+	// length += utf8.RuneCountInString(string(s))
+
+	if !Interactive && !Plain {
+		escaped, shouldEscape := formats.EscapeSequences[s]
+		if shouldEscape {
+			builder.WriteString(escaped)
+			return
 		}
 	}
 
-	// length += utf8.RuneCountInString(string(s))
-	length += runewidth.RuneWidth(s)
-	lastRune = s
 	builder.WriteRune(s)
 }
 
diff --git a/website/docs/configuration/general.mdx b/website/docs/configuration/general.mdx
index aede0b93..d6b4047e 100644
--- a/website/docs/configuration/general.mdx
+++ b/website/docs/configuration/general.mdx
@@ -136,7 +136,7 @@ For example, the following is a valid `--config` flag:
 | `shell_integration`         | `boolean`        | `false` | enable shell integration using FinalTerm's OSC sequences. Works in bash, cmd (Clink v1.14.25+), fish, powershell and zsh                                                                                                                                                     |
 | `enable_cursor_positioning` | `boolean`        | `false` | enable fetching the cursor position in bash and zsh to allow automatic hiding of leading newlines when at the top of the shell                                                                                                                                               |
 | `patch_pwsh_bleed`          | `boolean`        | `false` | patch a PowerShell bug where the background colors bleed into the next line at the end of the buffer (can be removed when [this][pwsh-bleed] is merged)                                                                                                                      |
-| `disable_notice`            | `boolean`        | `false` | disables the notice that a new upgrade is available when `auto_upgrade` is disabled, and the notice that no new upgrade is available when `auto_upgrade` is enabled                                                                                                          |
+| `disable_notice`            | `boolean`        | `false` | disables the notice that a new upgrade is available when `auto_upgrade` is disabled                                                                                                                                                                                          |
 | `auto_upgrade`              | `boolean`        | `false` | enable automatic upgrades for Oh My Posh (supports Windows, macOS and Linux)                                                                                                                                                                                                 |
 | `iterm_features`            | `[]string`       | `false` | enable iTerm2 specific features:<ul><li>`prompt_mark`: add the `iterm2_prompt_mark` [function][iterm2-si] for supported shells</li><li>`current_dir`: expose the current directory for iTerm2</li><li>`remote_host`: expose the current remote and user for iTerm2</li></ul> |
 
diff --git a/website/docs/configuration/segment.mdx b/website/docs/configuration/segment.mdx
index 38872469..50e0db81 100644
--- a/website/docs/configuration/segment.mdx
+++ b/website/docs/configuration/segment.mdx
@@ -50,11 +50,20 @@ understand how to configure a segment.
 | `templates`                | `[]Template` | in some cases having a single [template][templates] string is a bit cumbersome. Templates allows you to span the segment's [template][templates] string multiple lines where every [template][templates] is evaluated and depending on what you aim to achieve, there are two possible outcomes based on `templates_logic` |
 | `templates_logic`          | `string`     | <ul><li>`first_match`: return the first non-whitespace string and skip everything else</li><li>`join`:evaluate all templates and join all non-whitespace strings (**default**)</li></ul>                                                                                                                                   |
 | `properties`               | `[]Property` | see [Properties][properties] below                                                                                                                                                                                                                                                                                         |
-| `interactive`              | `boolean`    | when is true, the segment text is not escaped to allow the use of interactive prompt escape sequences - defaults to `false`                                                                                                                                                                                                |
+| `interactive`              | `boolean`    | when is true, the segment text is not escaped to allow the use of interactive prompt escape sequences in Bash/Zsh - defaults to `false`                                                                                                                                                                                    |
 | `alias`                    | `string`     | for use with [cross segment template properties][cstp]                                                                                                                                                                                                                                                                     |
 | `min_width`                | `int`        | if the terminal width is smaller than this value, the segment will be hidden. For your terminal width, see `oh-my-posh get width`. Defaults to `0` (disable)                                                                                                                                                               |
 | `max_width`                | `int`        | if the terminal width exceeds this value, the segment will be hidden. For your terminal width, see `oh-my-posh get width`. Defaults to `0` (disable)                                                                                                                                                                       |
 
+:::warning
+In Bash/Zsh, when the property `interactive` is `true` for a segment, the prompt length calculation can be wrong
+because of possible string expansions (e.g., `\w` in Bash and `%d` in Zsh which both expand to the current working
+directory), thus a right-aligned block is not being properly right-aligned.
+
+Unfortunately, it's not possible for Oh My Posh to know the final prompt length since the string expansion is done
+by your shell, so use this at your own risk.
+:::
+
 ## Style
 
 Style defines how a prompt is rendered. Looking at the most prompt
@@ -132,7 +141,7 @@ will not be rendered when in one of the excluded locations.
 ```
 
 The strings specified in these properties are evaluated as [regular expressions][regex]. You
-can use any valid regular expression construct, but the regular expression must match the entire directory
+can use any valid regular expression construct, but the regular expression must match the **ENTIRE** directory
 name. The following will match `/Users/posh/Projects/Foo` but not `/home/Users/posh/Projects/Foo`.
 
 ```json
@@ -156,8 +165,8 @@ You can also combine these properties:
 
 - Oh My Posh will accept both `/` and `\` as path separators for a folder and will match regardless of which
   is used by the current operating system.
-- Because the strings are evaluated as regular expressions, if you want to use a `\` in a Windows
-  directory name, you need to specify it as `\\\\`.
+- Because the strings are evaluated as regular expressions, if you want to use a backslash (`\`) in a Windows
+  directory name, you need to specify it as double backslashes, and if using JSON format you should escape it as `\\\\`.
 - The character `~` at the start of a specified folder will match the user's home directory.
 - The comparison is case-insensitive on Windows and macOS, but case-sensitive on other operating systems.
 


Test patch:
diff --git a/src/shell/init_test.go b/src/shell/init_test.go
index d9701dd5..de7b951d 100644
--- a/src/shell/init_test.go
+++ b/src/shell/init_test.go
@@ -35,7 +35,7 @@ func TestQuotePosixStr(t *testing.T) {
 		{str: `C:/tmp/omp's dir/oh-my-posh.exe`, expected: `$'C:/tmp/omp\'s dir/oh-my-posh.exe'`},
 	}
 	for _, tc := range tests {
-		assert.Equal(t, tc.expected, quotePosixStr(tc.str), fmt.Sprintf("quotePosixStr: %s", tc.str))
+		assert.Equal(t, tc.expected, QuotePosixStr(tc.str), fmt.Sprintf("quotePosixStr: %s", tc.str))
 	}
 }
 
diff --git a/src/terminal/writer_test.go b/src/terminal/writer_test.go
index a50a64c1..9b77f280 100644
--- a/src/terminal/writer_test.go
+++ b/src/terminal/writer_test.go
@@ -287,8 +287,6 @@ func TestWriteLength(t *testing.T) {
 		CurrentColors = tc.Colors
 		Colors = &color.Defaults{}
 
-		Init(shell.GENERIC)
-
 		Write(tc.Colors.Background, tc.Colors.Foreground, tc.Input)
 
 		_, got := String()