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/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/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/shell/scripts/omp.bash b/src/shell/scripts/omp.bash index b793ba68..38bc1c8e 100644 --- a/src/shell/scripts/omp.bash +++ b/src/shell/scripts/omp.bash @@ -20,8 +20,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 @@ -58,7 +59,38 @@ 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[@]}) @@ -82,7 +114,8 @@ 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 } diff --git a/src/shell/scripts/omp.zsh b/src/shell/scripts/omp.zsh index 59ebaac7..7967a784 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 -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 +73,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) } - 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, 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/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() diff --git a/website/docs/configuration/segment.mdx b/website/docs/configuration/segment.mdx index 38872469..d80fb2bf 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` | | | `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