From 7142b1c5574a48ec51c5a53fd7fb4812397bcf51 Mon Sep 17 00:00:00 2001 From: Andrej Koelewijn Date: Thu, 23 Apr 2026 11:12:47 +0000 Subject: [PATCH] feat: add color to REPL prompts, banner, and error messages Introduce a colorPalette that detects whether stdout is a real TTY (using golang.org/x/term) and respects the NO_COLOR env var. When active it applies: - bold-cyan prompt (mdl>) and gray continuation prompt (...>) - bold "MDL REPL" title and gray hint lines in the banner - red "Error:" and "Parse error:" messages Colors are automatically suppressed when output is piped or redirected, so scripted use and JSON output are unaffected. Co-Authored-By: Claude Sonnet 4.6 --- mdl/repl/color.go | 73 +++++++++++++++++++++++++++++++++++++++++++++++ mdl/repl/repl.go | 21 ++++++++------ 2 files changed, 85 insertions(+), 9 deletions(-) create mode 100644 mdl/repl/color.go diff --git a/mdl/repl/color.go b/mdl/repl/color.go new file mode 100644 index 00000000..cef1f22a --- /dev/null +++ b/mdl/repl/color.go @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 + +package repl + +import ( + "os" + + "golang.org/x/term" +) + +// ANSI escape codes. +const ( + ansiReset = "\033[0m" + ansiBold = "\033[1m" + ansiBoldCyan = "\033[1;36m" + ansiRed = "\033[31m" + ansiGray = "\033[90m" +) + +// rlIgnoreStart / rlIgnoreEnd bracket invisible characters in readline prompts +// so readline counts the display width correctly. +const ( + rlIgnoreStart = "\001" + rlIgnoreEnd = "\002" +) + +// colorPalette applies ANSI colors when stdout is a real TTY and NO_COLOR is unset. +type colorPalette struct { + enabled bool +} + +// newColorPalette returns a palette active only when stdout is a terminal and +// the NO_COLOR environment variable is absent. +func newColorPalette() colorPalette { + if os.Getenv("NO_COLOR") != "" { + return colorPalette{} + } + return colorPalette{enabled: term.IsTerminal(int(os.Stdout.Fd()))} +} + +func (c colorPalette) wrap(code, s string) string { + if !c.enabled { + return s + } + return code + s + ansiReset +} + +// Bold returns s in bold. +func (c colorPalette) Bold(s string) string { return c.wrap(ansiBold, s) } + +// Red returns s in red (used for errors). +func (c colorPalette) Red(s string) string { return c.wrap(ansiRed, s) } + +// Gray returns s in dark gray (used for secondary text). +func (c colorPalette) Gray(s string) string { return c.wrap(ansiGray, s) } + +// PromptPrimary returns a readline-safe bold-cyan prompt string. +// The \001…\002 markers tell readline not to count the escape bytes in the +// visible line width, preventing cursor positioning bugs. +func (c colorPalette) PromptPrimary(s string) string { + if !c.enabled { + return s + } + return rlIgnoreStart + ansiBoldCyan + rlIgnoreEnd + s + rlIgnoreStart + ansiReset + rlIgnoreEnd +} + +// PromptContinue returns a readline-safe gray continuation prompt string. +func (c colorPalette) PromptContinue(s string) string { + if !c.enabled { + return s + } + return rlIgnoreStart + ansiGray + rlIgnoreEnd + s + rlIgnoreStart + ansiReset + rlIgnoreEnd +} diff --git a/mdl/repl/repl.go b/mdl/repl/repl.go index d82313d7..2fd80ba7 100644 --- a/mdl/repl/repl.go +++ b/mdl/repl/repl.go @@ -29,6 +29,7 @@ type REPL struct { prompt string rl *readline.Instance logger *diaglog.Logger + c colorPalette } // SetLogger sets the diagnostics logger for the REPL and its executor. @@ -41,11 +42,13 @@ func (r *REPL) SetLogger(l *diaglog.Logger) { func New(input io.Reader, output io.Writer) *REPL { exec := executor.New(output) exec.SetBackendFactory(func() backend.FullBackend { return mprbackend.New() }) + c := newColorPalette() return &REPL{ executor: exec, input: input, output: output, prompt: "mdl> ", + c: c, } } @@ -80,7 +83,7 @@ func (r *REPL) Run() error { if errors.Is(err, executor.ErrExit) { return nil } - fmt.Fprintf(r.output, "Error: %v\n", err) + fmt.Fprintf(r.output, "%s\n", r.c.Red("Error: "+err.Error())) } buffer.Reset() } @@ -92,7 +95,7 @@ func (r *REPL) Run() error { if strings.TrimSpace(input) != "" { err := r.execute(input) if err != nil && !errors.Is(err, executor.ErrExit) { - fmt.Fprintf(r.output, "Error: %v\n", err) + fmt.Fprintf(r.output, "%s\n", r.c.Red("Error: "+err.Error())) } } } @@ -130,17 +133,17 @@ func (r *REPL) RunWithReadline() error { var buffer strings.Builder - fmt.Fprintln(r.output, "MDL REPL - Mendix Definition Language") - fmt.Fprintln(r.output, "Type 'help' or '?' for commands, 'exit' or 'quit' to quit") - fmt.Fprintln(r.output, "Tab: autocomplete, ↑↓: history, Ctrl+R: search history") + fmt.Fprintln(r.output, r.c.Bold("MDL REPL")+" — Mendix Definition Language") + fmt.Fprintln(r.output, r.c.Gray("Type 'help' or '?' for commands, 'exit' or 'quit' to quit")) + fmt.Fprintln(r.output, r.c.Gray("Tab: autocomplete, ↑↓: history, Ctrl+R: search history")) fmt.Fprintln(r.output) for { // Set prompt based on whether we're continuing a multi-line statement if buffer.Len() == 0 { - rl.SetPrompt(r.prompt) + rl.SetPrompt(r.c.PromptPrimary(r.prompt)) } else { - rl.SetPrompt("...> ") + rl.SetPrompt(r.c.PromptContinue("...> ")) } // Read line with readline (supports history, arrow keys, etc.) @@ -189,7 +192,7 @@ func (r *REPL) RunWithReadline() error { fmt.Fprintln(r.output, "Goodbye!") return nil } - fmt.Fprintf(r.output, "Error: %v\n", err) + fmt.Fprintf(r.output, "%s\n", r.c.Red("Error: "+err.Error())) } buffer.Reset() } @@ -218,7 +221,7 @@ func (r *REPL) execute(input string) error { prog, errs := visitor.Build(input) if len(errs) > 0 { for _, err := range errs { - fmt.Fprintf(r.output, "Parse error: %v\n", err) + fmt.Fprintf(r.output, "%s\n", r.c.Red("Parse error: "+err.Error())) } r.logger.ParseError(input, errs) return nil // Don't return error, just print and continue