From 1bbebe61c564b2accaf9b46e965dd00b73acf6ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matt=20=E2=80=98TK=E2=80=99=20Taylor?= Date: Mon, 15 Dec 2025 13:59:46 +0000 Subject: [PATCH 1/2] feat: add PromptColor setting for customizable shell prompt color Add a new Set PromptColor setting that allows users to customize the shell prompt color via a hex value (e.g., Set PromptColor "#f6821f"). Changes: - Add PROMPT_COLOR token to token/token.go - Refactor shell.go to support configurable prompt colors via ShellConfig() - Add hexToRGB() helper function for color conversion - Update tty.go to pass prompt color to shell configuration - Add PromptColor field to Options struct with default #5B56E0 - Add ExecuteSetPromptColor function in command.go - Update evaluator.go to handle PromptColor before VHS starts The prompt color is applied to all supported shells (bash, zsh, fish, nushell, osh, xonsh, powershell, pwsh). The cmdexe shell doesn't support color customization. --- command.go | 7 ++++ evaluator.go | 4 +- shell.go | 111 +++++++++++++++++++++++++++++++------------------ token/token.go | 5 ++- tty.go | 9 ++-- vhs.go | 4 +- 6 files changed, 92 insertions(+), 48 deletions(-) diff --git a/command.go b/command.go index 4e5b0d06..41b32744 100644 --- a/command.go +++ b/command.go @@ -479,6 +479,7 @@ var Settings = map[string]CommandFunc{ "WaitPattern": ExecuteSetWaitPattern, "WaitTimeout": ExecuteSetWaitTimeout, "CursorBlink": ExecuteSetCursorBlink, + "PromptColor": ExecuteSetPromptColor, } // ExecuteSet applies the settings on the running vhs specified by the @@ -748,6 +749,12 @@ func ExecuteSetCursorBlink(c parser.Command, v *VHS) error { return nil } +// ExecuteSetPromptColor sets the prompt color. +func ExecuteSetPromptColor(c parser.Command, v *VHS) error { + v.Options.PromptColor = c.Args + return nil +} + // ExecuteScreenshot is a CommandFunc that indicates a new screenshot must be taken. func ExecuteScreenshot(c parser.Command, v *VHS) error { v.ScreenshotNextFrame(c.Args) diff --git a/evaluator.go b/evaluator.go index 5768b854..86705486 100644 --- a/evaluator.go +++ b/evaluator.go @@ -30,7 +30,7 @@ func Evaluate(ctx context.Context, tape string, out io.Writer, opts ...Evaluator v := New() for _, cmd := range cmds { - if cmd.Type == token.SET && cmd.Options == "Shell" || cmd.Type == token.ENV { + if cmd.Type == token.SET && (cmd.Options == "Shell" || cmd.Options == "PromptColor") || cmd.Type == token.ENV { err := Execute(cmd, &v) if err != nil { return []error{err} @@ -56,7 +56,7 @@ func Evaluate(ctx context.Context, tape string, out io.Writer, opts ...Evaluator for i, cmd := range cmds { if cmd.Type == token.SET || cmd.Type == token.OUTPUT || cmd.Type == token.REQUIRE { _, _ = fmt.Fprintln(out, Highlight(cmd, false)) - if cmd.Options != "Shell" { + if cmd.Options != "Shell" && cmd.Options != "PromptColor" { err := Execute(cmd, &v) if err != nil { return []error{err} diff --git a/shell.go b/shell.go index fb836f3a..37b26845 100644 --- a/shell.go +++ b/shell.go @@ -1,5 +1,10 @@ package main +import ( + "fmt" + "strings" +) + // Supported shells of VH. const ( bash = "bash" @@ -13,64 +18,90 @@ const ( zsh = "zsh" ) +// DefaultPromptColor is the default color for the shell prompt. +const DefaultPromptColor = "#5B56E0" + // Shell is a type that contains a prompt and the command to set up the shell. type Shell struct { - Command []string - Env []string + Name string } -// Shells contains a mapping from shell names to their Shell struct. -var Shells = map[string]Shell{ - bash: { - Env: []string{"PS1=\\[\\e[38;2;90;86;224m\\]> \\[\\e[0m\\]", "BASH_SILENCE_DEPRECATION_WARNING=1"}, - Command: []string{"bash", "--noprofile", "--norc", "--login", "+o", "history"}, - }, - zsh: { - Env: []string{`PROMPT=%F{#5B56E0}> %F{reset_color}`}, - Command: []string{"zsh", "--histnostore", "--no-rcs"}, - }, - fish: { - Command: []string{ +// ShellConfig returns the shell configuration with the given prompt color. +func ShellConfig(name, promptColor string) (env []string, command []string) { + // Parse hex color to RGB components + r, g, b := hexToRGB(promptColor) + hexNoHash := strings.TrimPrefix(promptColor, "#") + + switch name { + case bash: + return []string{ + fmt.Sprintf("PS1=\\[\\e[38;2;%d;%d;%dm\\]> \\[\\e[0m\\]", r, g, b), + "BASH_SILENCE_DEPRECATION_WARNING=1", + }, + []string{"bash", "--noprofile", "--norc", "--login", "+o", "history"} + case zsh: + return []string{fmt.Sprintf(`PROMPT=%%F{#%s}> %%F{reset_color}`, hexNoHash)}, + []string{"zsh", "--histnostore", "--no-rcs"} + case fish: + return nil, []string{ "fish", "--login", "--no-config", "--private", "-C", "function fish_greeting; end", - "-C", `function fish_prompt; set_color 5B56E0; echo -n "> "; set_color normal; end`, - }, - }, - powershell: { - Command: []string{ + "-C", fmt.Sprintf(`function fish_prompt; set_color %s; echo -n "> "; set_color normal; end`, hexNoHash), + } + case powershell: + return nil, []string{ "powershell", "-NoLogo", "-NoExit", "-NoProfile", "-Command", - `Set-PSReadLineOption -HistorySaveStyle SaveNothing; function prompt { Write-Host '>' -NoNewLine -ForegroundColor Blue; return ' ' }`, - }, - }, - pwsh: { - Command: []string{ + fmt.Sprintf(`Set-PSReadLineOption -HistorySaveStyle SaveNothing; function prompt { Write-Host '>' -NoNewLine -ForegroundColor ([System.Drawing.Color]::FromArgb(%d,%d,%d)); return ' ' }`, r, g, b), + } + case pwsh: + return nil, []string{ "pwsh", "-Login", "-NoLogo", "-NoExit", "-NoProfile", "-Command", - `Set-PSReadLineOption -HistorySaveStyle SaveNothing; Function prompt { Write-Host -ForegroundColor Blue -NoNewLine '>'; return ' ' }`, - }, - }, - cmdexe: { - Command: []string{"cmd.exe", "/k", "prompt=^> "}, - }, - nushell: { - Command: []string{"nu", "--execute", "$env.PROMPT_COMMAND = {'\033[;38;2;91;86;224m>\033[m '}; $env.PROMPT_COMMAND_RIGHT = {''}"}, - }, - osh: { - Env: []string{"PS1=\\[\\e[38;2;90;86;224m\\]> \\[\\e[0m\\]"}, - Command: []string{"osh", "--norc"}, - }, - xonsh: { - Command: []string{"xonsh", "--no-rc", "-D", "PROMPT=\033[;38;2;91;86;224m>\033[m "}, - }, + fmt.Sprintf(`Set-PSReadLineOption -HistorySaveStyle SaveNothing; Function prompt { Write-Host -ForegroundColor ([System.Drawing.Color]::FromArgb(%d,%d,%d)) -NoNewLine '>'; return ' ' }`, r, g, b), + } + case cmdexe: + return nil, []string{"cmd.exe", "/k", "prompt=^> "} + case nushell: + return nil, []string{"nu", "--execute", fmt.Sprintf("$env.PROMPT_COMMAND = {'\033[;38;2;%d;%d;%dm>\033[m '}; $env.PROMPT_COMMAND_RIGHT = {''}", r, g, b)} + case osh: + return []string{fmt.Sprintf("PS1=\\[\\e[38;2;%d;%d;%dm\\]> \\[\\e[0m\\]", r, g, b)}, + []string{"osh", "--norc"} + case xonsh: + return nil, []string{"xonsh", "--no-rc", "-D", fmt.Sprintf("PROMPT=\033[;38;2;%d;%d;%dm>\033[m ", r, g, b)} + default: + return nil, nil + } +} + +// hexToRGB converts a hex color string to RGB components. +func hexToRGB(hex string) (r, g, b int) { + hex = strings.TrimPrefix(hex, "#") + if len(hex) == 6 { + fmt.Sscanf(hex, "%02x%02x%02x", &r, &g, &b) + } + return +} + +// Shells contains a mapping from shell names to their Shell struct. +var Shells = map[string]Shell{ + bash: {Name: bash}, + zsh: {Name: zsh}, + fish: {Name: fish}, + powershell: {Name: powershell}, + pwsh: {Name: pwsh}, + cmdexe: {Name: cmdexe}, + nushell: {Name: nushell}, + osh: {Name: osh}, + xonsh: {Name: xonsh}, } diff --git a/token/token.go b/token/token.go index d0d98a1a..7c32f1de 100644 --- a/token/token.go +++ b/token/token.go @@ -105,6 +105,7 @@ const ( WAIT_TIMEOUT = "WAIT_TIMEOUT" WAIT_PATTERN = "WAIT_PATTERN" CURSOR_BLINK = "CURSOR_BLINK" + PROMPT_COLOR = "PROMPT_COLOR" ) // Keywords maps keyword strings to tokens. @@ -163,6 +164,7 @@ var Keywords = map[string]Type{ "Wait": WAIT, "Source": SOURCE, "CursorBlink": CURSOR_BLINK, + "PromptColor": PROMPT_COLOR, "true": BOOLEAN, "false": BOOLEAN, "Screenshot": SCREENSHOT, @@ -177,7 +179,8 @@ func IsSetting(t Type) bool { case SHELL, FONT_FAMILY, FONT_SIZE, LETTER_SPACING, LINE_HEIGHT, FRAMERATE, TYPING_SPEED, THEME, PLAYBACK_SPEED, HEIGHT, WIDTH, PADDING, LOOP_OFFSET, MARGIN_FILL, MARGIN, WINDOW_BAR, - WINDOW_BAR_SIZE, BORDER_RADIUS, CURSOR_BLINK, WAIT_TIMEOUT, WAIT_PATTERN: + WINDOW_BAR_SIZE, BORDER_RADIUS, CURSOR_BLINK, WAIT_TIMEOUT, WAIT_PATTERN, + PROMPT_COLOR: return true default: return false diff --git a/tty.go b/tty.go index c8af5dea..8d429b81 100644 --- a/tty.go +++ b/tty.go @@ -24,7 +24,7 @@ func randomPort() int { } // buildTtyCmd builds the ttyd exec.Command on the given port. -func buildTtyCmd(port int, shell Shell) *exec.Cmd { +func buildTtyCmd(port int, shell Shell, promptColor string) *exec.Cmd { args := []string{ //nolint:prealloc fmt.Sprintf("--port=%d", port), "--interface", "127.0.0.1", @@ -36,11 +36,12 @@ func buildTtyCmd(port int, shell Shell) *exec.Cmd { "--writable", } - args = append(args, shell.Command...) + env, command := ShellConfig(shell.Name, promptColor) + args = append(args, command...) cmd := exec.Command("ttyd", args...) - if shell.Env != nil { - cmd.Env = append(shell.Env, os.Environ()...) + if env != nil { + cmd.Env = append(env, os.Environ()...) } return cmd } diff --git a/vhs.go b/vhs.go index 235e7b42..50bd047c 100644 --- a/vhs.go +++ b/vhs.go @@ -52,6 +52,7 @@ type Options struct { CursorBlink bool Screenshot ScreenshotOptions Style StyleOptions + PromptColor string } const ( @@ -107,6 +108,7 @@ func DefaultVHSOptions() Options { Screenshot: screenshot, WaitTimeout: defaultWaitTimeout, WaitPattern: defaultWaitPattern, + PromptColor: DefaultPromptColor, } } @@ -131,7 +133,7 @@ func (vhs *VHS) Start() error { } port := randomPort() - vhs.tty = buildTtyCmd(port, vhs.Options.Shell) + vhs.tty = buildTtyCmd(port, vhs.Options.Shell, vhs.Options.PromptColor) if err := vhs.tty.Start(); err != nil { return fmt.Errorf("could not start tty: %w", err) } From 0f4da3f7614e9cdbc83bff13f2bfe65c01b09bd8 Mon Sep 17 00:00:00 2001 From: Matt 'TK' Taylor Date: Fri, 20 Feb 2026 15:07:31 +0800 Subject: [PATCH 2/2] test: add ShellConfig and hexToRGB tests Cover hex parsing edge cases (with/without #, invalid length, empty), ShellConfig output for all 9 shells, custom colour embedding in bash and zsh, default colour round-trip, and unknown shell handling. Co-Authored-By: Claude Opus 4.6 --- shell_test.go | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 shell_test.go diff --git a/shell_test.go b/shell_test.go new file mode 100644 index 00000000..cf7b9a6d --- /dev/null +++ b/shell_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "strings" + "testing" +) + +func TestHexToRGB(t *testing.T) { + tests := []struct { + hex string + r, g, b int + }{ + {"#5B56E0", 91, 86, 224}, + {"5B56E0", 91, 86, 224}, + {"#F6821F", 246, 130, 31}, + {"#000000", 0, 0, 0}, + {"#FFFFFF", 255, 255, 255}, + {"", 0, 0, 0}, + {"#FFF", 0, 0, 0}, // invalid length + } + + for _, tc := range tests { + r, g, b := hexToRGB(tc.hex) + if r != tc.r || g != tc.g || b != tc.b { + t.Errorf("hexToRGB(%q) = (%d,%d,%d), want (%d,%d,%d)", + tc.hex, r, g, b, tc.r, tc.g, tc.b) + } + } +} + +func TestShellConfigReturnsNonNil(t *testing.T) { + // Every known shell should return a non-nil command from ShellConfig. + shellNames := []string{ + bash, zsh, fish, powershell, pwsh, cmdexe, nushell, osh, xonsh, + } + + for _, name := range shellNames { + t.Run(name, func(t *testing.T) { + _, command := ShellConfig(name, DefaultPromptColor) + if len(command) == 0 { + t.Errorf("ShellConfig(%q, %q) returned empty command", name, DefaultPromptColor) + } + }) + } +} + +func TestShellConfigCustomColor(t *testing.T) { + // Shells that use RGB values should embed the custom colour. + // bash uses PS1 with ANSI 38;2;R;G;B escape, so changing the colour + // should produce different RGB values in the env string. + env, _ := ShellConfig(bash, "#FF8000") + if len(env) == 0 { + t.Fatal("expected env for bash") + } + // #FF8000 = 255,128,0 + if !strings.Contains(env[0], "255;128;0") { + t.Errorf("bash PS1 does not contain expected RGB values for #FF8000: %s", env[0]) + } + + // zsh uses hex in the PROMPT string + env, _ = ShellConfig(zsh, "#FF8000") + if len(env) == 0 { + t.Fatal("expected env for zsh") + } + if !strings.Contains(env[0], "FF8000") { + t.Errorf("zsh PROMPT does not contain hex colour FF8000: %s", env[0]) + } +} + +func TestShellConfigDefaultColor(t *testing.T) { + // With the default colour, bash should produce the original RGB values. + // #5B56E0 = 91,86,224 + env, _ := ShellConfig(bash, DefaultPromptColor) + if len(env) == 0 { + t.Fatal("expected env for bash") + } + if !strings.Contains(env[0], "91;86;224") { + t.Errorf("bash PS1 with default colour does not contain expected RGB: %s", env[0]) + } +} + +func TestShellConfigUnknownShell(t *testing.T) { + env, command := ShellConfig("unknown-shell", DefaultPromptColor) + if env != nil || command != nil { + t.Errorf("expected nil for unknown shell, got env=%v command=%v", env, command) + } +}