diff --git a/src/cli/print.go b/src/cli/print.go index cbcc692ddac7..7e9308a22e4c 100644 --- a/src/cli/print.go +++ b/src/cli/print.go @@ -42,7 +42,7 @@ func init() { func createPrintCmd() *cobra.Command { printCmd := &cobra.Command{ - Use: "print [debug|primary|secondary|transient|right|tooltip|valid|error|preview]", + Use: "print [debug|primary|secondary|transient|right|tooltip|valid|error|preview|tmux-left|tmux-right]", Short: "Print the prompt/context", Long: "Print one of the prompts based on the location/use-case.", ValidArgs: []string{ @@ -55,6 +55,8 @@ func createPrintCmd() *cobra.Command { prompt.VALID, prompt.ERROR, prompt.PREVIEW, + prompt.TMUXLEFT, + prompt.TMUXRIGHT, }, Args: NoArgsOrOneValidArg, Run: func(cmd *cobra.Command, args []string) { @@ -123,6 +125,10 @@ func createPrintCmd() *cobra.Command { fmt.Print(eng.ExtraPrompt(prompt.Error)) case prompt.PREVIEW: fmt.Print(eng.Preview()) + case prompt.TMUXLEFT: + fmt.Print(eng.TmuxStatusLeft()) + case prompt.TMUXRIGHT: + fmt.Print(eng.TmuxStatusRight()) default: _ = cmd.Help() } diff --git a/src/color/tmux.go b/src/color/tmux.go new file mode 100644 index 000000000000..e7c209cac2dc --- /dev/null +++ b/src/color/tmux.go @@ -0,0 +1,81 @@ +package color + +import ( + "fmt" + "strconv" + "strings" +) + +// TmuxColors wraps a String implementation and converts ANSI color codes +// to tmux native format strings (e.g., "fg=#rrggbb", "bg=colour42"). +// This allows the rendering engine to emit tmux #[fg=...] / #[bg=...] tokens +// instead of ANSI escape sequences when rendering for the tmux status bar. +type TmuxColors struct { + inner String +} + +// NewTmuxColors creates a TmuxColors that wraps the given String implementation. +func NewTmuxColors(inner String) *TmuxColors { + return &TmuxColors{inner: inner} +} + +func (t *TmuxColors) Resolve(colorString Ansi) (Ansi, error) { + return t.inner.Resolve(colorString) +} + +func (t *TmuxColors) ToAnsi(c Ansi, isBackground bool) Ansi { + ansiCode := t.inner.ToAnsi(c, isBackground) + return convertAnsiToTmux(ansiCode, isBackground) +} + +// ansiCodeToColorName maps ANSI numeric codes (both fg and bg variants) to color names. +var ansiCodeToColorName = func() map[Ansi]string { + m := make(map[Ansi]string, len(ansiColorCodes)*2) + for name, codes := range ansiColorCodes { + m[codes[0]] = string(name) // fg code → name + m[codes[1]] = string(name) // bg code → name + } + return m +}() + +// convertAnsiToTmux converts an ANSI color code string (as returned by Defaults.ToAnsi) +// into a tmux format token like "fg=#rrggbb" or "bg=colour42". +func convertAnsiToTmux(code Ansi, isBackground bool) Ansi { + if code.IsEmpty() || code.IsTransparent() { + return code + } + + prefix := "fg" + if isBackground { + prefix = "bg" + } + + s := string(code) + + // True color: "38;2;R;G;B" (fg) or "48;2;R;G;B" (bg) + if (strings.HasPrefix(s, "38;2;") || strings.HasPrefix(s, "48;2;")) && strings.Count(s, ";") == 4 { + rgb := s[5:] + parts := strings.SplitN(rgb, ";", 3) + if len(parts) == 3 { + r, errR := strconv.ParseUint(parts[0], 10, 8) + g, errG := strconv.ParseUint(parts[1], 10, 8) + b, errB := strconv.ParseUint(parts[2], 10, 8) + if errR == nil && errG == nil && errB == nil { + return Ansi(fmt.Sprintf("%s=#%02x%02x%02x", prefix, r, g, b)) + } + } + } + + // 256-color: "38;5;N" (fg) or "48;5;N" (bg) + if strings.HasPrefix(s, "38;5;") || strings.HasPrefix(s, "48;5;") { + n := s[5:] + return Ansi(fmt.Sprintf("%s=colour%s", prefix, n)) + } + + // Named color (e.g., "30"=black fg, "40"=black bg, "90"=darkGray fg, …) + if name, ok := ansiCodeToColorName[code]; ok { + return Ansi(fmt.Sprintf("%s=%s", prefix, name)) + } + + return emptyColor +} diff --git a/src/color/tmux_test.go b/src/color/tmux_test.go new file mode 100644 index 000000000000..fa52eeaccc31 --- /dev/null +++ b/src/color/tmux_test.go @@ -0,0 +1,232 @@ +package color + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// mockColors is a simple String implementation for testing TmuxColors. +type mockColors struct { + result Ansi +} + +func (m *mockColors) ToAnsi(_ Ansi, _ bool) Ansi { return m.result } +func (m *mockColors) Resolve(c Ansi) (Ansi, error) { return c, nil } + +func TestConvertAnsiToTmux(t *testing.T) { + tests := []struct { + name string + code Ansi + isBackground bool + expected Ansi + }{ + // True color foreground + { + name: "true color fg red", + code: "38;2;255;0;0", + isBackground: false, + expected: "fg=#ff0000", + }, + { + name: "true color fg white", + code: "38;2;255;255;255", + isBackground: false, + expected: "fg=#ffffff", + }, + // True color background + { + name: "true color bg blue", + code: "48;2;0;0;255", + isBackground: true, + expected: "bg=#0000ff", + }, + { + name: "true color bg mixed", + code: "48;2;18;52;86", + isBackground: true, + expected: "bg=#123456", + }, + // 256-color foreground + { + name: "256-color fg", + code: "38;5;42", + isBackground: false, + expected: "fg=colour42", + }, + // 256-color background + { + name: "256-color bg", + code: "48;5;200", + isBackground: true, + expected: "bg=colour200", + }, + // Named colors (foreground) + { + name: "named black fg", + code: "30", + isBackground: false, + expected: "fg=black", + }, + { + name: "named red fg", + code: "31", + isBackground: false, + expected: "fg=red", + }, + { + name: "named white fg", + code: "37", + isBackground: false, + expected: "fg=white", + }, + // Named colors (background) + { + name: "named black bg", + code: "40", + isBackground: true, + expected: "bg=black", + }, + { + name: "named green bg", + code: "42", + isBackground: true, + expected: "bg=green", + }, + // Bright/high-intensity named colors + { + name: "darkGray fg", + code: "90", + isBackground: false, + expected: "fg=darkGray", + }, + { + name: "lightBlue bg", + code: "104", + isBackground: true, + expected: "bg=lightBlue", + }, + // Special values pass through + { + name: "empty color", + code: emptyColor, + isBackground: false, + expected: emptyColor, + }, + { + name: "transparent", + code: Transparent, + isBackground: false, + expected: Transparent, + }, + // Unknown code returns empty + { + name: "unknown code", + code: "99", + isBackground: false, + expected: emptyColor, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := convertAnsiToTmux(tc.code, tc.isBackground) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestTmuxColorsToAnsi(t *testing.T) { + tests := []struct { + name string + innerResult Ansi + isBackground bool + expected Ansi + }{ + { + name: "hex fg via inner", + innerResult: "38;2;255;128;0", + isBackground: false, + expected: "fg=#ff8000", + }, + { + name: "hex bg via inner", + innerResult: "48;2;0;128;255", + isBackground: true, + expected: "bg=#0080ff", + }, + { + name: "256-color via inner", + innerResult: "38;5;100", + isBackground: false, + expected: "fg=colour100", + }, + { + name: "named color via inner", + innerResult: "32", + isBackground: false, + expected: "fg=green", + }, + { + name: "transparent passes through", + innerResult: Transparent, + isBackground: false, + expected: Transparent, + }, + { + name: "empty passes through", + innerResult: emptyColor, + isBackground: false, + expected: emptyColor, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mock := &mockColors{result: tc.innerResult} + tc2 := NewTmuxColors(mock) + result := tc2.ToAnsi("anycolor", tc.isBackground) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestTmuxColorsResolve(t *testing.T) { + mock := &mockColors{} + tc := NewTmuxColors(mock) + result, err := tc.Resolve("p:mycolor") + assert.NoError(t, err) + assert.Equal(t, Ansi("p:mycolor"), result) +} + +func TestTmuxColorsWithDefaults(t *testing.T) { + // Integration test using a real Defaults instance with TrueColor enabled + saved := TrueColor + TrueColor = true + t.Cleanup(func() { TrueColor = saved }) + + defaults := &Defaults{} + tc := NewTmuxColors(defaults) + + // Hex color → tmux format + fg := tc.ToAnsi("#ff0000", false) + assert.Equal(t, Ansi("fg=#ff0000"), fg) + + bg := tc.ToAnsi("#0000ff", true) + assert.Equal(t, Ansi("bg=#0000ff"), bg) + + // Named color → tmux format + fgRed := tc.ToAnsi("red", false) + assert.Equal(t, Ansi("fg=red"), fgRed) + + bgBlue := tc.ToAnsi("blue", true) + assert.Equal(t, Ansi("bg=blue"), bgBlue) + + // 256-color → tmux format + fg256 := tc.ToAnsi("42", false) + assert.Equal(t, Ansi("fg=colour42"), fg256) + + // Transparent passes through + fgTransparent := tc.ToAnsi(Transparent, false) + assert.Equal(t, Transparent, fgTransparent) +} diff --git a/src/config/config.go b/src/config/config.go index a4087ba1ec57..858c18bd6956 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -83,7 +83,8 @@ type Config struct { PatchPwshBleed bool `json:"patch_pwsh_bleed,omitempty" toml:"patch_pwsh_bleed,omitempty" yaml:"patch_pwsh_bleed,omitempty"` AutoUpgrade bool `json:"-" toml:"-" yaml:"-"` EnableCursorPositioning bool `json:"enable_cursor_positioning,omitempty" toml:"enable_cursor_positioning,omitempty" yaml:"enable_cursor_positioning,omitempty"` - Streaming int `json:"streaming,omitempty" toml:"streaming,omitempty" yaml:"streaming,omitempty"` + Streaming int `json:"streaming,omitempty" toml:"streaming,omitempty" yaml:"streaming,omitempty"` + Tmux *TmuxConfig `json:"tmux,omitempty" toml:"tmux,omitempty" yaml:"tmux,omitempty"` } func (cfg *Config) MakeColors(env runtime.Environment) color.String { diff --git a/src/config/segment_types.go b/src/config/segment_types.go index 782b6671e5ef..82bc19f6de8f 100644 --- a/src/config/segment_types.go +++ b/src/config/segment_types.go @@ -130,6 +130,7 @@ func init() { gob.Register(&segments.Terraform{}) gob.Register(&segments.Text{}) gob.Register(&segments.Time{}) + gob.Register(&segments.Tmux{}) gob.Register(&segments.Todoist{}) gob.Register(&segments.UI5Tooling{}) gob.Register(&segments.Umbraco{}) @@ -343,6 +344,8 @@ const ( TEXT SegmentType = "text" // TIME writes the current timestamp TIME SegmentType = "time" + // TMUX writes the current tmux session name and optionally the window list + TMUX SegmentType = "tmux" // TODOIST segment TODOIST SegmentType = "todoist" // UI5 Tooling segment @@ -470,6 +473,7 @@ var Segments = map[SegmentType]func() SegmentWriter{ TERRAFORM: func() SegmentWriter { return &segments.Terraform{} }, TEXT: func() SegmentWriter { return &segments.Text{} }, TIME: func() SegmentWriter { return &segments.Time{} }, + TMUX: func() SegmentWriter { return &segments.Tmux{} }, TODOIST: func() SegmentWriter { return &segments.Todoist{} }, UI5TOOLING: func() SegmentWriter { return &segments.UI5Tooling{} }, UMBRACO: func() SegmentWriter { return &segments.Umbraco{} }, diff --git a/src/config/tmux.go b/src/config/tmux.go new file mode 100644 index 000000000000..c4f4032d9947 --- /dev/null +++ b/src/config/tmux.go @@ -0,0 +1,12 @@ +package config + +// TmuxConfig holds the configuration for rendering tmux status bar sections. +type TmuxConfig struct { + StatusLeft TmuxStatusSection `json:"status_left" yaml:"status_left" toml:"status_left"` + StatusRight TmuxStatusSection `json:"status_right" yaml:"status_right" toml:"status_right"` +} + +// TmuxStatusSection holds a list of blocks to render for one tmux status section. +type TmuxStatusSection struct { + Blocks []*Block `json:"blocks" yaml:"blocks" toml:"blocks"` +} diff --git a/src/prompt/engine.go b/src/prompt/engine.go index e97fc6cc5d0e..a065e5fef1a1 100644 --- a/src/prompt/engine.go +++ b/src/prompt/engine.go @@ -45,6 +45,8 @@ const ( VALID = "valid" ERROR = "error" PREVIEW = "preview" + TMUXLEFT = "tmux-left" + TMUXRIGHT = "tmux-right" ) func (e *Engine) write(txt string) { @@ -568,6 +570,9 @@ func New(flags *runtime.Flags) *Engine { terminal.Init(sh) terminal.BackgroundColor = cfg.TerminalBackground.ResolveTemplate() terminal.Colors = cfg.MakeColors(env) + if env.Shell() == shell.TMUX { + terminal.Colors = color.NewTmuxColors(terminal.Colors) + } terminal.Plain = flags.Plain eng := &Engine{ diff --git a/src/prompt/tmux.go b/src/prompt/tmux.go new file mode 100644 index 000000000000..f7484de46541 --- /dev/null +++ b/src/prompt/tmux.go @@ -0,0 +1,38 @@ +package prompt + +import "github.com/jandedobbeleer/oh-my-posh/src/config" + +// TmuxStatusLeft renders the tmux status-left section from the config's tmux.status_left blocks. +func (e *Engine) TmuxStatusLeft() string { + if e.Config.Tmux == nil { + return "" + } + + return e.renderTmuxSection(e.Config.Tmux.StatusLeft.Blocks) +} + +// TmuxStatusRight renders the tmux status-right section from the config's tmux.status_right blocks. +func (e *Engine) TmuxStatusRight() string { + if e.Config.Tmux == nil { + return "" + } + + return e.renderTmuxSection(e.Config.Tmux.StatusRight.Blocks) +} + +// renderTmuxSection renders a slice of blocks using the existing block rendering pipeline +// and returns the concatenated result. Shell integration sequences are intentionally +// skipped here since tmux status bars do not use OSC 133 marks. +func (e *Engine) renderTmuxSection(blocks []*config.Block) string { + if len(blocks) == 0 { + return "" + } + + cycle = &e.Config.Cycle + + for _, block := range blocks { + e.renderBlock(block, true) + } + + return e.string() +} diff --git a/src/segments/tmux.go b/src/segments/tmux.go new file mode 100644 index 000000000000..130466a1a2f9 --- /dev/null +++ b/src/segments/tmux.go @@ -0,0 +1,96 @@ +package segments + +import ( + "strings" + + "github.com/jandedobbeleer/oh-my-posh/src/segments/options" +) + +const ( + fetchWindows options.Option = "fetch_windows" +) + +// TmuxWindow holds information about a single tmux window. +type TmuxWindow struct { + Index string + Name string + Active bool +} + +// Tmux displays the current tmux session name and optionally the window list. +type Tmux struct { + Base + + SessionName string + Windows []TmuxWindow +} + +func (t *Tmux) Template() string { + return " \ue7a2 {{ .SessionName }}{{ if .Windows }} | {{ range .Windows }}{{if .Active}}*{{end}}{{.Index}}:{{.Name}} {{end}}{{end}} " +} + +func (t *Tmux) Enabled() bool { + if !t.fetchSessionName() { + return false + } + + if t.options.Bool(fetchWindows, false) { + t.Windows = t.fetchWindowList() + } + + return true +} + +func (t *Tmux) fetchSessionName() bool { + // Try the tmux command for the exact session name (works when tmux is in PATH). + if name, err := t.env.RunCommand("tmux", "display-message", "-p", "#S"); err == nil { + t.SessionName = strings.TrimSpace(name) + if t.SessionName != "" { + return true + } + } + + // When running from the tmux status bar (#(...) format), the tmux binary may not + // be in the minimal PATH used by /bin/sh. Use $TMUX to confirm we are inside tmux, + // then fall back to the tmux format alias #S — tmux expands it to the actual session + // name when processing the status bar format string. + if t.env.Getenv("TMUX") == "" { + return false + } + + t.SessionName = "#S" + return true +} + +func (t *Tmux) fetchWindowList() []TmuxWindow { + output, err := t.env.RunCommand("tmux", "list-windows", "-F", "#{window_index}\t#{window_name}\t#{window_active}") + if err != nil { + return nil + } + + return t.parseWindows(output) +} + +func (t *Tmux) parseWindows(output string) []TmuxWindow { + lines := strings.Split(strings.TrimSpace(output), "\n") + windows := make([]TmuxWindow, 0, len(lines)) + + for _, line := range lines { + if line == "" { + continue + } + + parts := strings.Split(line, "\t") + if len(parts) < 3 { + continue + } + + windows = append(windows, TmuxWindow{ + Index: parts[0], + Name: parts[1], + Active: parts[2] == "1", + }) + } + + return windows +} diff --git a/src/segments/tmux_test.go b/src/segments/tmux_test.go new file mode 100644 index 000000000000..52642682981d --- /dev/null +++ b/src/segments/tmux_test.go @@ -0,0 +1,170 @@ +package segments + +import ( + "errors" + "testing" + + "github.com/jandedobbeleer/oh-my-posh/src/runtime/mock" + "github.com/jandedobbeleer/oh-my-posh/src/segments/options" + + "github.com/stretchr/testify/assert" +) + +const tmuxListWindowsFmt = "#{window_index}\t#{window_name}\t#{window_active}" + +func TestTmuxEnabled(t *testing.T) { + cases := []struct { + Case string + CommandOut string + CommandErr error + TmuxEnv string + Expected bool + SessionName string + }{ + { + Case: "tmux command succeeds", + CommandOut: "mysession\n", + Expected: true, + SessionName: "mysession", + }, + { + Case: "tmux command succeeds with whitespace", + CommandOut: " main \n", + Expected: true, + SessionName: "main", + }, + { + Case: "tmux command fails, fallback to #S when TMUX env set", + CommandErr: errors.New("not in tmux"), + TmuxEnv: "/tmp/tmux-1000/default,12345,0", + Expected: true, + SessionName: "#S", + }, + { + Case: "tmux command fails, TMUX env set but malformed — still in tmux", + CommandErr: errors.New("not in tmux"), + TmuxEnv: "bad-value", + Expected: true, + SessionName: "#S", + }, + { + Case: "tmux command fails, TMUX env empty — not in tmux", + CommandErr: errors.New("not in tmux"), + TmuxEnv: "", + Expected: false, + }, + } + + for _, tc := range cases { + env := new(mock.Environment) + env.On("RunCommand", "tmux", []string{"display-message", "-p", "#S"}). + Return(tc.CommandOut, tc.CommandErr) + env.On("Getenv", "TMUX").Return(tc.TmuxEnv) + + seg := &Tmux{} + seg.Init(options.Map{}, env) + + result := seg.Enabled() + + assert.Equal(t, tc.Expected, result, tc.Case) + if tc.Expected { + assert.Equal(t, tc.SessionName, seg.SessionName, tc.Case) + } + } +} + +func TestTmuxNoWindowsByDefault(t *testing.T) { + env := new(mock.Environment) + env.On("RunCommand", "tmux", []string{"display-message", "-p", "#S"}). + Return("work\n", nil) + + seg := &Tmux{} + seg.Init(options.Map{}, env) + + enabled := seg.Enabled() + assert.True(t, enabled) + assert.Nil(t, seg.Windows, "Windows should be nil when fetch_windows is false") +} + +func TestTmuxFetchWindows(t *testing.T) { + env := new(mock.Environment) + env.On("RunCommand", "tmux", []string{"display-message", "-p", "#S"}). + Return("work\n", nil) + env.On("RunCommand", "tmux", []string{"list-windows", "-F", tmuxListWindowsFmt}). + Return("0\tbash\t1\n1\tnvim\t0\n", nil) + + seg := &Tmux{} + seg.Init(options.Map{fetchWindows: true}, env) + + enabled := seg.Enabled() + assert.True(t, enabled) + assert.Len(t, seg.Windows, 2) + assert.Equal(t, "0", seg.Windows[0].Index) + assert.Equal(t, "bash", seg.Windows[0].Name) + assert.True(t, seg.Windows[0].Active) + assert.Equal(t, "1", seg.Windows[1].Index) + assert.Equal(t, "nvim", seg.Windows[1].Name) + assert.False(t, seg.Windows[1].Active) +} + +func TestTmuxFetchWindowsCommandFails(t *testing.T) { + env := new(mock.Environment) + env.On("RunCommand", "tmux", []string{"display-message", "-p", "#S"}). + Return("work\n", nil) + env.On("RunCommand", "tmux", []string{"list-windows", "-F", tmuxListWindowsFmt}). + Return("", errors.New("not in tmux")) + + seg := &Tmux{} + seg.Init(options.Map{fetchWindows: true}, env) + + enabled := seg.Enabled() + // Segment is still enabled — session name was fetched successfully. + assert.True(t, enabled) + assert.Nil(t, seg.Windows) +} + +func TestTmuxFetchWindowsEmptyOutput(t *testing.T) { + env := new(mock.Environment) + env.On("RunCommand", "tmux", []string{"display-message", "-p", "#S"}). + Return("work\n", nil) + env.On("RunCommand", "tmux", []string{"list-windows", "-F", tmuxListWindowsFmt}). + Return("", nil) + + seg := &Tmux{} + seg.Init(options.Map{fetchWindows: true}, env) + + enabled := seg.Enabled() + assert.True(t, enabled) + assert.Empty(t, seg.Windows) +} + +func TestTmuxParseWindows(t *testing.T) { + seg := &Tmux{} + + windows := seg.parseWindows("0\tbash\t1\n1\tnvim\t0\n2\thtop\t0") + assert.Len(t, windows, 3) + assert.Equal(t, "0", windows[0].Index) + assert.Equal(t, "bash", windows[0].Name) + assert.True(t, windows[0].Active) + assert.Equal(t, "1", windows[1].Index) + assert.Equal(t, "nvim", windows[1].Name) + assert.False(t, windows[1].Active) + assert.Equal(t, "2", windows[2].Index) + assert.Equal(t, "htop", windows[2].Name) + assert.False(t, windows[2].Active) +} + +func TestTmuxParseWindowsSkipsMalformedLines(t *testing.T) { + seg := &Tmux{} + + windows := seg.parseWindows("0\tbash\t1\nbad-line\n1\tnvim\t0") + assert.Len(t, windows, 2) + assert.Equal(t, "bash", windows[0].Name) + assert.Equal(t, "nvim", windows[1].Name) +} + +func TestTmuxTemplate(t *testing.T) { + seg := &Tmux{} + assert.Contains(t, seg.Template(), "{{ .SessionName }}") + assert.Contains(t, seg.Template(), ".Windows") +} diff --git a/src/shell/constants.go b/src/shell/constants.go index d0f06d54f71a..d3865b5ca780 100644 --- a/src/shell/constants.go +++ b/src/shell/constants.go @@ -11,4 +11,5 @@ const ( ELVISH = "elvish" XONSH = "xonsh" CLAUDE = "claude" + TMUX = "tmux" ) diff --git a/src/shell/formats.go b/src/shell/formats.go index b47149aaada1..7b41381b66dd 100644 --- a/src/shell/formats.go +++ b/src/shell/formats.go @@ -25,6 +25,13 @@ type Formats struct { ITermPromptMark string ITermCurrentDir string ITermRemoteHost string + + // Color format fields + ColorEscape string // printf format for a color token, e.g. "\x1b[%sm" or "#[%s]" + ColorReset string // full color reset sequence, e.g. "\x1b[0m" or "#[default]" + ColorBgReset string // background-only reset, e.g. "\x1b[49m" or "#[bg=default]" + TransparentStart string // start of transparent fg effect (may contain %s for bg color) + TransparentEnd string // end of transparent fg effect } func GetFormats(shell string) *Formats { @@ -53,6 +60,11 @@ func GetFormats(shell string) *Formats { EscapeSequences: map[rune]string{ '\\': `\\`, }, + ColorEscape: "\x1b[%sm", + ColorReset: "\x1b[0m", + ColorBgReset: "\x1b[49m", + TransparentStart: "\x1b[0m\x1b[%s;49m\x1b[7m", + TransparentEnd: "\x1b[27m", } case ZSH: formats = &Formats{ @@ -76,6 +88,20 @@ func GetFormats(shell string) *Formats { EscapeSequences: map[rune]string{ '%': "%%", }, + ColorEscape: "\x1b[%sm", + ColorReset: "\x1b[0m", + ColorBgReset: "\x1b[49m", + TransparentStart: "\x1b[0m\x1b[%s;49m\x1b[7m", + TransparentEnd: "\x1b[27m", + } + case TMUX: + formats = &Formats{ + Escape: "%s", + ColorEscape: "#[%s]", + ColorReset: "#[default]", + ColorBgReset: "#[bg=default]", + TransparentStart: "", // no reverse-video transparency in tmux status bar + TransparentEnd: "", } default: formats = &Formats{ @@ -90,14 +116,19 @@ func GetFormats(shell string) *Formats { // when in fish on Linux, it seems hyperlinks ending with \\ print a \ // unlike on macOS. However, this is a fish bug, so do not try to fix it here: // https://github.com/JanDeDobbeleer/oh-my-posh/pull/3288#issuecomment-1369137068 - HyperlinkStart: "\x1b]8;;", - HyperlinkCenter: "\x1b\\", - HyperlinkEnd: "\x1b]8;;\x1b\\", - Osc99: "\x1b]9;9;%s\x1b\\", - Osc7: "\x1b]7;file://%s/%s\x1b\\", - Osc51: "\x1b]51;A%s@%s:%s\x1b\\", - ITermCurrentDir: "\x1b]1337;CurrentDir=%s\x07", - ITermRemoteHost: "\x1b]1337;RemoteHost=%s@%s\x07", + HyperlinkStart: "\x1b]8;;", + HyperlinkCenter: "\x1b\\", + HyperlinkEnd: "\x1b]8;;\x1b\\", + Osc99: "\x1b]9;9;%s\x1b\\", + Osc7: "\x1b]7;file://%s/%s\x1b\\", + Osc51: "\x1b]51;A%s@%s:%s\x1b\\", + ITermCurrentDir: "\x1b]1337;CurrentDir=%s\x07", + ITermRemoteHost: "\x1b]1337;RemoteHost=%s@%s\x07", + ColorEscape: "\x1b[%sm", + ColorReset: "\x1b[0m", + ColorBgReset: "\x1b[49m", + TransparentStart: "\x1b[0m\x1b[%s;49m\x1b[7m", + TransparentEnd: "\x1b[27m", } } diff --git a/src/terminal/writer.go b/src/terminal/writer.go index 5074a133bd4b..fc98d489d340 100644 --- a/src/terminal/writer.go +++ b/src/terminal/writer.go @@ -25,7 +25,7 @@ type style struct { } var ( - knownStyles = []*style{ + ansiKnownStyles = []*style{ {AnchorStart: ``, AnchorEnd: ``, Start: "\x1b[1m", End: "\x1b[22m"}, {AnchorStart: ``, AnchorEnd: ``, Start: "\x1b[4m", End: "\x1b[24m"}, {AnchorStart: ``, AnchorEnd: ``, Start: "\x1b[53m", End: "\x1b[55m"}, @@ -36,6 +36,19 @@ var ( {AnchorStart: ``, AnchorEnd: ``, Start: "\x1b[7m", End: "\x1b[27m"}, } + tmuxKnownStyles = []*style{ + {AnchorStart: ``, AnchorEnd: ``, Start: "#[bold]", End: "#[nobold]"}, + {AnchorStart: ``, AnchorEnd: ``, Start: "#[underscore]", End: "#[nounderscore]"}, + {AnchorStart: ``, AnchorEnd: ``, Start: "#[overline]", End: "#[nooverline]"}, + {AnchorStart: ``, AnchorEnd: ``, Start: "#[italics]", End: "#[noitalics]"}, + {AnchorStart: ``, AnchorEnd: ``, Start: "#[strikethrough]", End: "#[nostrikethrough]"}, + {AnchorStart: ``, AnchorEnd: ``, Start: "#[dim]", End: "#[nodim]"}, + {AnchorStart: ``, AnchorEnd: ``, Start: "#[blink]", End: "#[noblink]"}, + {AnchorStart: ``, AnchorEnd: ``, Start: "#[reverse]", End: "#[noreverse]"}, + } + + knownStyles []*style + resetStyle = &style{AnchorStart: "RESET", AnchorEnd: ``, End: "\x1b[0m"} backgroundStyle = &style{AnchorStart: "BACKGROUND", AnchorEnd: ``, End: "\x1b[49m"} @@ -66,11 +79,7 @@ var ( ) const ( - AnchorRegex = `^(?P<(?P[^,<>]+)?,?(?P[^<>]+)?>)` - colorise = "\x1b[%sm" - transparentStart = "\x1b[0m\x1b[%s;49m\x1b[7m" - transparentEnd = "\x1b[27m" - backgroundEnd = "\x1b[49m" + AnchorRegex = `^(?P<(?P[^,<>]+)?,?(?P[^<>]+)?>)` AnsiRegex = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" @@ -110,6 +119,15 @@ func Init(sh string) { color.TrueColor = Program != AppleTerminal formats = shell.GetFormats(Shell) + + resetStyle.End = formats.ColorReset + backgroundStyle.End = formats.ColorBgReset + + if sh == shell.TMUX { + knownStyles = tmuxKnownStyles + } else { + knownStyles = ansiKnownStyles + } } func getTerminalName() string { @@ -442,18 +460,20 @@ func writeSegmentColors() { switch { case fg.IsTransparent() && len(BackgroundColor) != 0: background := Colors.ToAnsi(BackgroundColor, false) - writeEscapedAnsiString(fmt.Sprintf(colorise, background)) - writeEscapedAnsiString(fmt.Sprintf(colorise, bg.ToForeground())) + writeEscapedAnsiString(fmt.Sprintf(formats.ColorEscape, background)) + writeEscapedAnsiString(fmt.Sprintf(formats.ColorEscape, bg.ToForeground())) case fg.IsTransparent() && !bg.IsEmpty(): isTransparent = true - writeEscapedAnsiString(fmt.Sprintf(transparentStart, bg)) + if formats.TransparentStart != "" { + writeEscapedAnsiString(fmt.Sprintf(formats.TransparentStart, bg)) + } default: if !bg.IsEmpty() && !bg.IsTransparent() { - writeEscapedAnsiString(fmt.Sprintf(colorise, bg)) + writeEscapedAnsiString(fmt.Sprintf(formats.ColorEscape, bg)) } if !fg.IsEmpty() && !fg.IsTransparent() { - writeEscapedAnsiString(fmt.Sprintf(colorise, fg)) + writeEscapedAnsiString(fmt.Sprintf(formats.ColorEscape, fg)) } } @@ -508,28 +528,30 @@ func writeAnchorOverride(match map[string]string, background color.Ansi, i int) if currentColor.Foreground().IsTransparent() && len(BackgroundColor) != 0 { background := Colors.ToAnsi(BackgroundColor, false) - writeEscapedAnsiString(fmt.Sprintf(colorise, background)) - writeEscapedAnsiString(fmt.Sprintf(colorise, currentColor.Background().ToForeground())) + writeEscapedAnsiString(fmt.Sprintf(formats.ColorEscape, background)) + writeEscapedAnsiString(fmt.Sprintf(formats.ColorEscape, currentColor.Background().ToForeground())) return position } if currentColor.Foreground().IsTransparent() && !currentColor.Background().IsTransparent() { isTransparent = true - writeEscapedAnsiString(fmt.Sprintf(transparentStart, currentColor.Background())) + if formats.TransparentStart != "" { + writeEscapedAnsiString(fmt.Sprintf(formats.TransparentStart, currentColor.Background())) + } return position } if currentColor.Background() != backgroundColor { // end the colors in case we have a transparent background if currentColor.Background().IsTransparent() { - writeEscapedAnsiString(backgroundEnd) + writeEscapedAnsiString(formats.ColorBgReset) } else { - writeEscapedAnsiString(fmt.Sprintf(colorise, currentColor.Background())) + writeEscapedAnsiString(fmt.Sprintf(formats.ColorEscape, currentColor.Background())) } } if currentColor.Foreground() != foregroundColor { - writeEscapedAnsiString(fmt.Sprintf(colorise, currentColor.Foreground())) + writeEscapedAnsiString(fmt.Sprintf(formats.ColorEscape, currentColor.Foreground())) } return position @@ -557,12 +579,12 @@ func endColorOverride(position int) int { previousBg := currentColor.Background() previousFg := currentColor.Foreground() - if isTransparent { - writeEscapedAnsiString(transparentEnd) + if isTransparent && formats.TransparentEnd != "" { + writeEscapedAnsiString(formats.TransparentEnd) } if previousBg != bg { - background := fmt.Sprintf(colorise, previousBg) + background := fmt.Sprintf(formats.ColorEscape, previousBg) if previousBg.IsClear() { background = backgroundStyle.End } @@ -571,7 +593,7 @@ func endColorOverride(position int) int { } if previousFg != fg { - writeEscapedAnsiString(fmt.Sprintf(colorise, previousFg)) + writeEscapedAnsiString(fmt.Sprintf(formats.ColorEscape, previousFg)) } return position @@ -585,8 +607,8 @@ func endColorOverride(position int) int { return position } - if isTransparent { - writeEscapedAnsiString(transparentEnd) + if isTransparent && formats.TransparentEnd != "" { + writeEscapedAnsiString(formats.TransparentEnd) } if backgroundColor.IsClear() { @@ -594,11 +616,11 @@ func endColorOverride(position int) int { } if currentColor.Background() != backgroundColor && !backgroundColor.IsClear() { - writeEscapedAnsiString(fmt.Sprintf(colorise, backgroundColor)) + writeEscapedAnsiString(fmt.Sprintf(formats.ColorEscape, backgroundColor)) } if (currentColor.Foreground() != foregroundColor || isTransparent) && !foregroundColor.IsClear() { - writeEscapedAnsiString(fmt.Sprintf(colorise, foregroundColor)) + writeEscapedAnsiString(fmt.Sprintf(formats.ColorEscape, foregroundColor)) } isTransparent = false diff --git a/themes/schema.json b/themes/schema.json index e4cc3b1740b0..c8fde2966991 100644 --- a/themes/schema.json +++ b/themes/schema.json @@ -457,6 +457,7 @@ "terraform", "text", "time", + "tmux", "todoist", "ui5tooling", "umbraco", @@ -4472,6 +4473,32 @@ } } }, + { + "if": { + "properties": { + "type": { + "const": "tmux" + } + } + }, + "then": { + "title": "Tmux Segment", + "description": "https://ohmyposh.dev/docs/segments/cli/tmux", + "properties": { + "options": { + "properties": { + "fetch_windows": { + "type": "boolean", + "title": "Fetch Windows", + "description": "Populate the Windows list with the current tmux window list", + "default": false + } + }, + "unevaluatedProperties": false + } + } + } + }, { "if": { "properties": { @@ -5001,6 +5028,35 @@ "description": "https://ohmyposh.dev/docs/experimental/streaming", "default": 100 }, + "tmux": { + "type": "object", + "title": "Tmux Config", + "description": "https://ohmyposh.dev/docs/configuration/tmux", + "properties": { + "status_left": { + "type": "object", + "title": "Tmux Status Left", + "properties": { + "blocks": { + "type": "array", + "title": "Block array", + "items": { "$ref": "#/definitions/block" } + } + } + }, + "status_right": { + "type": "object", + "title": "Tmux Status Right", + "properties": { + "blocks": { + "type": "array", + "title": "Block array", + "items": { "$ref": "#/definitions/block" } + } + } + } + } + }, "blocks": { "type": "array", "title": "Block array", diff --git a/website/docs/configuration/general.mdx b/website/docs/configuration/general.mdx index bbf9c633be65..c614d071c4bd 100644 --- a/website/docs/configuration/general.mdx +++ b/website/docs/configuration/general.mdx @@ -143,6 +143,7 @@ For example, the following is a valid `--config` flag: | `version` | `int` | `4` | the config version, currently at `4` | | `extends` | `string` | | the configuration to [extend] from | | `streaming` | `int` | | enable streaming mode with a timeout in milliseconds for pending segments. See [streaming] | +| `tmux` | [`TmuxConfig`](/docs/configuration/tmux) | | configure the tmux status bar sections rendered by `oh-my-posh print tmux-left` and `tmux-right` | ### Maps diff --git a/website/docs/configuration/tmux.mdx b/website/docs/configuration/tmux.mdx new file mode 100644 index 000000000000..16f8f11459c2 --- /dev/null +++ b/website/docs/configuration/tmux.mdx @@ -0,0 +1,158 @@ +--- +id: tmux +title: Tmux +sidebar_label: Tmux +--- + +## What + +Oh My Posh can render your entire [tmux][tmux] status bar — session name, window list, git +status, path, and clock — using the same block/segment/style pipeline that powers your +terminal prompt. + +The `tmux:` config key holds two independent sections: `status_left` and `status_right`. +Each section accepts the same `blocks` array as the top-level prompt config, so any +segment that works in your prompt also works in the tmux status bar. + +## How it works + +tmux supports `#(command)` in its format strings, which runs a shell command and pastes its +raw output into the status bar. Oh My Posh's `--shell tmux` flag produces unwrapped ANSI +escape sequences (no shell-specific prompt wrappers), so the output appears correctly inside +`status-left` and `status-right`. + +The window list is rendered by the [`tmux`][tmux-segment] segment when `fetch_windows: true` +is set. This calls `tmux list-windows` in a **single invocation**, so all window data is +available to the template at once. + +## Sample Configuration + +import Config from "@site/src/components/Config.js"; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; + + + +## Settings + +| Name | Type | Description | +| -------------- | --------------------------------- | ---------------------------------- | +| `status_left` | [`TmuxStatusSection`](#section) | blocks rendered for `status-left` | +| `status_right` | [`TmuxStatusSection`](#section) | blocks rendered for `status-right` | + +### TmuxStatusSection {#section} + +| Name | Type | Description | +| -------- | ----------------- | ------------------------------------------------ | +| `blocks` | `[]`[`Block`][block] | the blocks to render, identical to the top-level `blocks` array | + +## tmux.conf integration + +Add the following to `~/.tmux.conf` (adjust the config path to match yours): + +```bash +set -g status-interval 5 +set -g status-left-length 400 +set -g status-right-length 200 + +set -g status-left "#(oh-my-posh print tmux-left --config ~/.config/ohmyposh/config.yaml --shell tmux)" +set -g status-right "#(oh-my-posh print tmux-right --config ~/.config/ohmyposh/config.yaml --shell tmux --pwd \"#{pane_current_path}\")" + +# Clear tmux's own window list formatting so OMP controls it entirely +set -g window-status-format "" +set -g window-status-current-format "" + +# Refresh the status bar immediately when windows are opened, closed, or renamed +set-hook -g window-linked "refresh-client -S" +set-hook -g window-unlinked "refresh-client -S" +set-hook -g window-renamed "refresh-client -S" +set-hook -g client-session-changed "refresh-client -S" +``` + +Then reload your config: + +```bash +tmux source-file ~/.tmux.conf +``` + +:::tip +`#{pane_current_path}` is a tmux format variable that is expanded *before* the shell command +runs. Passing it via `--pwd` means segments like [git][git] and [path][path] in `status_right` +always see the active pane's current directory. +::: + +## Available tmux segments + +| Segment | Description | +| --------------------------- | ---------------------------------------------------------------- | +| [`tmux`][tmux-segment] | Current session name; set `fetch_windows: true` for window list | + +Any other Oh My Posh segment (git, path, time, etc.) also works inside `status_left` and +`status_right` blocks. + +[tmux]: https://github.com/tmux/tmux +[block]: /docs/configuration/block +[git]: /docs/segments/scm/git +[path]: /docs/segments/system/path +[tmux-segment]: /docs/segments/cli/tmux diff --git a/website/docs/installation/prompt.mdx b/website/docs/installation/prompt.mdx index 1c45821ff75f..83668cb2dba7 100644 --- a/website/docs/installation/prompt.mdx +++ b/website/docs/installation/prompt.mdx @@ -27,6 +27,7 @@ oh-my-posh get shell { label: 'fish', value: 'fish', }, { label: 'nu', value: 'nu', }, { label: 'powershell', value: 'powershell', }, + { label: 'tmux', value: 'tmux', }, { label: 'xonsh', value: 'xonsh', }, { label: 'zsh', value: 'zsh', }, ] @@ -192,6 +193,47 @@ Once added, reload your profile for the changes to take effect. . $PROFILE ``` + + + +To use Oh My Posh as the tmux status bar renderer, add the following to your `~/.tmux.conf`: + +```bash +set -g status-interval 5 +set -g status-left-length 400 +set -g status-right-length 200 + +# OMP renders both sides; the window list lives inside status-left +set -g status-left "#(oh-my-posh print tmux-left --config ~/.config/ohmyposh/config.yaml --shell tmux)" +set -g status-right "#(oh-my-posh print tmux-right --config ~/.config/ohmyposh/config.yaml --shell tmux --pwd \"#{pane_current_path}\")" + +# Suppress tmux's own per-window format strings so OMP controls the window list +set -g window-status-format "" +set -g window-status-current-format "" + +# Refresh the status bar immediately on window changes +set-hook -g window-linked "refresh-client -S" +set-hook -g window-unlinked "refresh-client -S" +set-hook -g window-renamed "refresh-client -S" +set-hook -g client-session-changed "refresh-client -S" +``` + +Once added, reload your tmux config: + +```bash +tmux source-file ~/.tmux.conf +``` + +:::tip +`#{pane_current_path}` is expanded by tmux before running the command, so `--pwd` always +reflects the active pane's current directory — useful for git and path segments in +`status-right`. +::: + +For the `tmux:` configuration block in your OMP config file, see [tmux integration][tmux]. + +[tmux]: /docs/configuration/tmux + diff --git a/website/docs/segments/cli/tmux.mdx b/website/docs/segments/cli/tmux.mdx new file mode 100644 index 000000000000..2f94cdb93dac --- /dev/null +++ b/website/docs/segments/cli/tmux.mdx @@ -0,0 +1,75 @@ +--- +id: tmux +title: Tmux +sidebar_label: Tmux +--- + +## What + +Display the current [tmux][tmux] session name and optionally the window list. + +## Sample Configuration + +import Config from "@site/src/components/Config.js"; + + + +With windows enabled: + + + +## Options + +| Name | Type | Default | Description | +| --------------- | ------ | ------- | -------------------------------------------------- | +| `fetch_windows` | `bool` | `false` | populate `.Windows` with the current window list | + +## Template ([info][templates]) + +:::note default template + +```template + \ue7a2 {{ .SessionName }}{{ if .Windows }} | {{ range .Windows }}{{if .Active}}*{{end}}{{.Index}}:{{.Name}} {{end}}{{end}} +``` + +::: + +### Properties + +| Name | Type | Description | +| -------------- | --------------- | ------------------------------------------------------------ | +| `.SessionName` | `string` | the current tmux session name | +| `.Windows` | `[]TmuxWindow` | list of windows; only populated when `fetch_windows` is true | + +### TmuxWindow + +| Name | Type | Description | +| --------- | -------- | ---------------------------------------- | +| `.Index` | `string` | the window index | +| `.Name` | `string` | the window name | +| `.Active` | `bool` | whether this is the currently active window | + +[tmux]: https://github.com/tmux/tmux +[templates]: /docs/configuration/templates diff --git a/website/sidebars.js b/website/sidebars.js index 9d29a0ac3ca0..f994df1fbac0 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -38,6 +38,7 @@ export default { "configuration/secondary-prompt", "configuration/debug-prompt", "configuration/transient", + "configuration/tmux", "configuration/line-error", "configuration/tooltips", "configuration/sample", @@ -81,6 +82,7 @@ export default { "segments/cli/talosctl", "segments/cli/tauri", "segments/cli/terraform", + "segments/cli/tmux", "segments/cli/ui5tooling", "segments/cli/umbraco", "segments/cli/unity",