From 09b976240cfac03dc867133619d4c141d4b2b285 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Tue, 6 Oct 2026 03:31:36 -0400 Subject: [PATCH] fix(cli): preserve shaping joiners in human-readable results Signed-off-by: Rudy Celekli --- CHANGELOG.md | 1 + internal/cli/joiners_test.go | 40 ++++++++++++++++++++++++++++++++++++ internal/cli/render.go | 12 ++++++++++- internal/cli/run.go | 2 +- 4 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 internal/cli/joiners_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index cb0f614..5fc444c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- CLI: preserve Unicode shaping joiners in human-readable names and emoji while retaining strict terminal-control sanitization in diagnostics. - Client: reject responses larger than 1 MiB without decoding truncated data, and report unhandled HTTP redirects as API errors instead of successful results. - Homebrew: install through a Formula (`brew install openclaw/tap/goplaces`) instead of the retired Cask; quarantine is preserved. diff --git a/internal/cli/joiners_test.go b/internal/cli/joiners_test.go new file mode 100644 index 0000000..cea88a2 --- /dev/null +++ b/internal/cli/joiners_test.go @@ -0,0 +1,40 @@ +package cli + +import ( + "bytes" + "strings" + "testing" + + "github.com/steipete/goplaces" +) + +func TestRenderSearchPreservesUnicodeShapingJoiners(t *testing.T) { + name := "👩\u200d🔬 می\u200cخواهم" + output := renderSearch(NewColor(false), goplaces.SearchResponse{Results: []goplaces.PlaceSummary{{Name: name, Address: "👩\u200d🔬"}}}) + if !strings.Contains(output, name) { + t.Fatalf("name changed: %q", output) + } + if strings.Count(output, "\u200d") != 2 { + t.Fatalf("address joiner lost: %q", output) + } +} + +func TestRenderJoinersStillRemovesTerminalAndBidiControls(t *testing.T) { + name := "👩\u200d🔬 safe\u202egnirts\u2066\u200b\ufeff\x1b[31m\x07" + output := renderSearch(NewColor(false), goplaces.SearchResponse{Results: []goplaces.PlaceSummary{{Name: name}}}) + if !strings.Contains(output, "👩\u200d🔬") { + t.Fatalf("emoji shaping lost: %q", output) + } + if strings.ContainsAny(output, "\u202e\u2066\u200b\ufeff\x1b\x07") { + t.Fatalf("unsafe display controls: %q", output) + } +} + +func TestErrorSanitizerKeepsStrictFormatBoundary(t *testing.T) { + var buffer bytes.Buffer + writeError(&buffer, "safe\u200c\u200d\u202e\u2066\u200b\ufeff\x1b") + output := buffer.String() + if output != "safe\n" { + t.Fatalf("error sanitizer changed: %q", output) + } +} diff --git a/internal/cli/render.go b/internal/cli/render.go index f2cf5f1..8edc8b2 100644 --- a/internal/cli/render.go +++ b/internal/cli/render.go @@ -448,7 +448,17 @@ func directionsStepLine(step goplaces.DirectionsStep) string { return strings.Join(parts, " · ") } +// Human output preserves shaping joiners used in names and emoji, while +// diagnostics keep the stricter format-control boundary. func sanitizeTerminalText(value string) string { + return sanitizeText(value, true) +} + +func sanitizeTerminalErrorText(value string) string { + return sanitizeText(value, false) +} + +func sanitizeText(value string, preserveJoiners bool) string { if value == "" { return "" } @@ -463,7 +473,7 @@ func sanitizeTerminalText(value string) string { if unicode.IsControl(r) { continue } - if unicode.Is(unicode.Cf, r) { + if unicode.Is(unicode.Cf, r) && (!preserveJoiners || (r != '\u200c' && r != '\u200d')) { continue } out.WriteRune(r) diff --git a/internal/cli/run.go b/internal/cli/run.go index 9880d4a..47db180 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -144,5 +144,5 @@ func handleError(writer io.Writer, err error) int { } func writeError(writer io.Writer, message string) { - _, _ = fmt.Fprintln(writer, sanitizeTerminalText(message)) + _, _ = fmt.Fprintln(writer, sanitizeTerminalErrorText(message)) }