Repository: JanDeDobbeleer/oh-my-posh
Pull request #5473 (2024-08-19 06:10:44)

Revert broken commits
### 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

@lewis-yeung as I have no idea which commit really introduces the issue (or due to both), I’m reverting both of them as I can’t test.

<!---

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


lewis-yeung: Wait, you only have to revert #5456. :)

Patch:
diff --git a/src/prompt/extra.go b/src/prompt/extra.go
index 0bd69dc3..7b8f6c67 100644
--- a/src/prompt/extra.go
+++ b/src/prompt/extra.go
@@ -2,6 +2,7 @@ package prompt
 
 import (
 	"fmt"
+	"strings"
 
 	"github.com/jandedobbeleer/oh-my-posh/src/color"
 	"github.com/jandedobbeleer/oh-my-posh/src/config"
@@ -100,10 +101,11 @@ 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", shell.QuotePosixStr(str))
+			prompt := fmt.Sprintf("PS1=\"%s\"", strings.ReplaceAll(str, "\"", "\"\""))
 			// empty RPROMPT
-			prompt += "\nRPROMPT=''"
+			prompt += "\nRPROMPT=\"\""
 			return prompt
 		}
 		return str
diff --git a/src/prompt/primary.go b/src/prompt/primary.go
index edefd305..98498c2f 100644
--- a/src/prompt/primary.go
+++ b/src/prompt/primary.go
@@ -23,12 +23,14 @@ func (e *Engine) Primary() string {
 		// Warp doesn't support RPROMPT so we need to write it manually
 		if e.isWarp() {
 			e.writePrimaryRightPrompt()
-			prompt := fmt.Sprintf("PS1=%s", shell.QuotePosixStr(e.string()))
+			// escape double quotes contained in the prompt
+			prompt := fmt.Sprintf("PS1=\"%s\"", strings.ReplaceAll(e.string(), `"`, `\"`))
 			return prompt
 		}
 
-		prompt := fmt.Sprintf("PS1=%s", shell.QuotePosixStr(e.string()))
-		prompt += fmt.Sprintf("\nRPROMPT=%s", shell.QuotePosixStr(e.rprompt))
+		// escape double quotes contained in the prompt
+		prompt := fmt.Sprintf("PS1=\"%s\"", strings.ReplaceAll(e.string(), `"`, `\"`))
+		prompt += fmt.Sprintf("\nRPROMPT=\"%s\"", e.rprompt)
 
 		return prompt
 	default:
diff --git a/src/shell/bash.go b/src/shell/bash.go
index cc9014c1..d4bd44f6 100644
--- a/src/shell/bash.go
+++ b/src/shell/bash.go
@@ -32,7 +32,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 51f34e0a..734c73fb 100644
--- a/src/shell/formats.go
+++ b/src/shell/formats.go
@@ -51,7 +51,8 @@ func GetFormats(shell string) *Formats {
 			ITermCurrentDir:       "\\[\x1b]1337;CurrentDir=%s\x07\\]",
 			ITermRemoteHost:       "\\[\x1b]1337;RemoteHost=%s@%s\x07\\]",
 			EscapeSequences: map[rune]rune{
-				'\\': '\\',
+				96: 92, // backtick
+				92: 92, // backslash
 			},
 		}
 	case ZSH, TCSH:
@@ -100,7 +101,8 @@ func GetFormats(shell string) *Formats {
 
 	if shell == ZSH {
 		formats.EscapeSequences = map[rune]rune{
-			'%': '%',
+			96: 92, // backtick
+			37: 37, // %
 		}
 	}
 
diff --git a/src/shell/init.go b/src/shell/init.go
index cc60fbc1..566f616a 100644
--- a/src/shell/init.go
+++ b/src/shell/init.go
@@ -92,12 +92,12 @@ func PrintInit(env runtime.Environment, features Features, startTime *time.Time)
 		configFile = quotePwshStr(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)
@@ -112,8 +112,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:
 		script = elvishInit
diff --git a/src/shell/scripts/omp.bash b/src/shell/scripts/omp.bash
index b0a64284..b793ba68 100644
--- a/src/shell/scripts/omp.bash
+++ b/src/shell/scripts/omp.bash
@@ -20,9 +20,8 @@ _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
-_omp_secondary_prompt=$("$_omp_executable" print secondary --shell=bash --shell-version="$BASH_VERSION")
+PS2="$("$_omp_executable" print secondary --shell=bash --shell-version="$BASH_VERSION")"
 
 function _omp_set_cursor_position() {
     # not supported in Midnight Commander
@@ -36,7 +35,7 @@ function _omp_set_cursor_position() {
 
     local COL
     local ROW
-    IFS=';' read -rsdR -p $'\E[6n' ROW COL
+    IFS=';' read -sdR -p $'\E[6n' ROW COL
 
     stty "$oldstty"
 
@@ -61,10 +60,10 @@ function set_poshcontext() {
 
 # regular prompt
 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))
@@ -83,29 +82,11 @@ function _omp_hook() {
     set_poshcontext
     _omp_set_cursor_position
 
-    # We do this to avoid unexpected expansions in a prompt string.
-    shopt -u promptvars
-
-    if shopt -oq posix; then
-        # Disable in POSIX mode.
-        PS1='[NOTICE: Oh My Posh prompt is not supported in POSIX mode]\n\u@\h:\w\$ '
-        PS2='> '
-    else
-        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')
-        PS2=$_omp_secondary_prompt
-    fi
+    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')"
 
     return $_omp_status_cache
 }
 
-function _omp_install_hook() {
-    local cmd
-    for cmd in "${PROMPT_COMMAND[@]}"; do
-        if [[ $cmd = "_omp_hook" ]]; then
-            return
-        fi
-    done
-    PROMPT_COMMAND=(_omp_hook "${PROMPT_COMMAND[@]}")
-}
-
-[[ $TERM != linux ]] && _omp_install_hook
+if [[ $TERM != linux ]] && ! [[ $PROMPT_COMMAND =~ _omp_hook ]]; then
+    PROMPT_COMMAND="_omp_hook; $PROMPT_COMMAND"
+fi
diff --git a/src/shell/scripts/omp.zsh b/src/shell/scripts/omp.zsh
index 7967a784..59ebaac7 100644
--- a/src/shell/scripts/omp.zsh
+++ b/src/shell/scripts/omp.zsh
@@ -13,7 +13,7 @@ _omp_cursor_positioning=0
 _omp_ftcs_marks=0
 
 # set secondary prompt
-_omp_secondary_prompt=$($_omp_executable print secondary --shell=zsh)
+PS2="$($_omp_executable print secondary --shell=zsh)"
 
 function _omp_set_cursor_position() {
   # not supported in Midnight Commander
@@ -73,15 +73,6 @@ 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 76679c14..977a34ff 100644
--- a/src/terminal/writer.go
+++ b/src/terminal/writer.go
@@ -58,6 +58,8 @@ var (
 	isInvisible   bool
 	isHyperlink   bool
 
+	lastRune rune
+
 	Shell   string
 	Program string
 
@@ -194,9 +196,9 @@ func FormatTitle(title string) string {
 	// we have to do this to prevent bash/zsh from misidentifying escape sequences
 	switch Shell {
 	case shell.BASH:
-		title = strings.ReplaceAll(title, `\`, `\\`)
+		title = strings.NewReplacer("`", "\\`", `\`, `\\`).Replace(title)
 	case shell.ZSH:
-		title = strings.ReplaceAll(title, "%", "%%")
+		title = strings.NewReplacer("`", "\\`", `%`, `%%`).Replace(title)
 	case shell.ELVISH, shell.XONSH:
 		// these shells don't support setting the title
 		return ""
@@ -390,15 +392,17 @@ func write(s rune) {
 		return
 	}
 
-	if !Interactive && !Plain {
-		escapeChar, shouldEscape := formats.EscapeSequences[s]
-		if shouldEscape {
-			builder.WriteRune(escapeChar)
+	if !Interactive {
+		for special, escape := range formats.EscapeSequences {
+			if s == special && lastRune != escape {
+				builder.WriteRune(escape)
+			}
 		}
 	}
 
 	// length += utf8.RuneCountInString(string(s))
 	length += runewidth.RuneWidth(s)
+	lastRune = s
 	builder.WriteRune(s)
 }
 
diff --git a/website/docs/configuration/segment.mdx b/website/docs/configuration/segment.mdx
index fd0cd20e..1ee55e4c 100644
--- a/website/docs/configuration/segment.mdx
+++ b/website/docs/configuration/segment.mdx
@@ -50,20 +50,11 @@ 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 in Bash/Zsh - defaults to `false`                                                                                                                                                                                    |
+| `interactive`              | `boolean`    | when is true, the segment text is not escaped to allow the use of interactive prompt escape sequences - 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


Test patch:
diff --git a/src/shell/init_test.go b/src/shell/init_test.go
index f16b16c7..141456a5 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))
 	}
 }