From 821d3a66c312e959951f49b95865b88068467c99 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Tue, 14 Apr 2026 16:23:14 +0200 Subject: [PATCH] feat: make hook hint deep links clickable using OSC 8 terminal hyperlinks Wrap the docker-desktop://dashboard/logs URL in OSC 8 escape sequences with underline styling so it appears as a clickable link in supported terminals. Respects NO_COLOR and COMPOSE_ANSI=never to suppress escapes. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Guillaume Lours --- cmd/compose/hooks.go | 43 ++++++++++++++++++++++++++--- cmd/compose/hooks_test.go | 56 ++++++++++++++++++++++++++++++++++++-- cmd/formatter/ansi.go | 15 +++++++--- cmd/formatter/ansi_test.go | 50 ++++++++++++++++++++++++++++++++++ 4 files changed, 153 insertions(+), 11 deletions(-) create mode 100644 cmd/formatter/ansi_test.go diff --git a/cmd/compose/hooks.go b/cmd/compose/hooks.go index 59099d2d89f..02521f19b0b 100644 --- a/cmd/compose/hooks.go +++ b/cmd/compose/hooks.go @@ -19,23 +19,58 @@ package compose import ( "encoding/json" "io" + "os" "github.com/docker/cli/cli-plugins/hooks" "github.com/docker/cli/cli-plugins/metadata" "github.com/spf13/cobra" + + "github.com/docker/compose/v5/cmd/formatter" ) const deepLink = "docker-desktop://dashboard/logs" -const composeLogsHint = "Filter, search, and stream logs from all your Compose services\nin one place with Docker Desktop's Logs view. " + deepLink +func composeLogsHint() string { + return "Filter, search, and stream logs from all your Compose services\nin one place with Docker Desktop's Logs view. " + hintLink(deepLink) +} + +func dockerLogsHint() string { + return "View and search logs for all containers in one place\nwith Docker Desktop's Logs view. " + hintLink(deepLink) +} + +// hintLink returns a clickable OSC 8 terminal hyperlink when ANSI is allowed, +// or the plain URL when ANSI output is suppressed via NO_COLOR or COMPOSE_ANSI. +func hintLink(url string) string { + if shouldDisableAnsi() { + return url + } + return formatter.OSC8Link(url, url) +} -const dockerLogsHint = "View and search logs for all containers in one place\nwith Docker Desktop's Logs view. " + deepLink +// shouldDisableAnsi checks whether ANSI escape sequences should be explicitly +// suppressed via environment variables. The hook runs as a separate subprocess +// where the normal PersistentPreRunE (which calls formatter.SetANSIMode) is +// skipped, so we check NO_COLOR and COMPOSE_ANSI directly. +// +// TTY detection is intentionally omitted: the hook produces a JSON response +// whose template is rendered by the parent Docker CLI process via +// PrintNextSteps (which itself emits bold ANSI unconditionally). The hook +// subprocess cannot reliably detect whether the parent's output is a terminal. +func shouldDisableAnsi() bool { + if noColor, ok := os.LookupEnv("NO_COLOR"); ok && noColor != "" { + return true + } + if v, ok := os.LookupEnv("COMPOSE_ANSI"); ok && v == formatter.Never { + return true + } + return false +} // hookHint defines a hint that can be returned by the hooks handler. // When checkFlags is nil, the hint is always returned for the matching command. // When checkFlags is set, the hint is only returned if the check passes. type hookHint struct { - template string + template func() string checkFlags func(flags map[string]string) bool } @@ -96,6 +131,6 @@ func handleHook(args []string, w io.Writer) error { enc.SetEscapeHTML(false) return enc.Encode(hooks.Response{ Type: hooks.NextSteps, - Template: hint.template, + Template: hint.template(), }) } diff --git a/cmd/compose/hooks_test.go b/cmd/compose/hooks_test.go index 75660dec4df..0df020b09f1 100644 --- a/cmd/compose/hooks_test.go +++ b/cmd/compose/hooks_test.go @@ -19,10 +19,13 @@ package compose import ( "bytes" "encoding/json" + "strings" "testing" "github.com/docker/cli/cli-plugins/hooks" "gotest.tools/v3/assert" + + "github.com/docker/compose/v5/cmd/formatter" ) func TestHandleHook_NoArgs(t *testing.T) { @@ -52,7 +55,7 @@ func TestHandleHook_UnknownCommand(t *testing.T) { func TestHandleHook_LogsCommand(t *testing.T) { tests := []struct { rootCmd string - wantHint string + wantHint func() string }{ {rootCmd: "compose logs", wantHint: composeLogsHint}, {rootCmd: "logs", wantHint: dockerLogsHint}, @@ -68,7 +71,7 @@ func TestHandleHook_LogsCommand(t *testing.T) { msg := unmarshalResponse(t, buf.Bytes()) assert.Equal(t, msg.Type, hooks.NextSteps) - assert.Equal(t, msg.Template, tt.wantHint) + assert.Equal(t, msg.Template, tt.wantHint()) }) } } @@ -112,7 +115,7 @@ func TestHandleHook_ComposeUpDetached(t *testing.T) { if tt.wantHint { msg := unmarshalResponse(t, buf.Bytes()) - assert.Equal(t, msg.Template, composeLogsHint) + assert.Equal(t, msg.Template, composeLogsHint()) } else { assert.Equal(t, buf.String(), "") } @@ -120,6 +123,53 @@ func TestHandleHook_ComposeUpDetached(t *testing.T) { } } +func TestHandleHook_HintContainsOSC8Link(t *testing.T) { + // Ensure ANSI is not suppressed by the test runner environment + t.Setenv("NO_COLOR", "") + t.Setenv("COMPOSE_ANSI", "") + data := marshalHookData(t, hooks.Request{ + RootCmd: "compose logs", + }) + var buf bytes.Buffer + err := handleHook([]string{data}, &buf) + assert.NilError(t, err) + + msg := unmarshalResponse(t, buf.Bytes()) + // Verify the template contains the OSC 8 hyperlink sequence + wantLink := formatter.OSC8Link(deepLink, deepLink) + assert.Assert(t, len(wantLink) > len(deepLink), "OSC8Link should wrap the URL with escape sequences") + assert.Assert(t, strings.Contains(msg.Template, wantLink), "hint should contain OSC 8 hyperlink") +} + +func TestHandleHook_NoColorDisablesOsc8(t *testing.T) { + t.Setenv("NO_COLOR", "1") + data := marshalHookData(t, hooks.Request{ + RootCmd: "compose logs", + }) + var buf bytes.Buffer + err := handleHook([]string{data}, &buf) + assert.NilError(t, err) + + msg := unmarshalResponse(t, buf.Bytes()) + // With NO_COLOR set, the hint should contain the plain URL without escape sequences + assert.Assert(t, strings.Contains(msg.Template, deepLink), "hint should contain the deep link URL") + assert.Assert(t, !strings.Contains(msg.Template, "\033"), "hint should not contain ANSI escape sequences") +} + +func TestHandleHook_ComposeAnsiNeverDisablesOsc8(t *testing.T) { + t.Setenv("COMPOSE_ANSI", "never") + data := marshalHookData(t, hooks.Request{ + RootCmd: "compose logs", + }) + var buf bytes.Buffer + err := handleHook([]string{data}, &buf) + assert.NilError(t, err) + + msg := unmarshalResponse(t, buf.Bytes()) + assert.Assert(t, strings.Contains(msg.Template, deepLink), "hint should contain the deep link URL") + assert.Assert(t, !strings.Contains(msg.Template, "\033"), "hint should not contain ANSI escape sequences") +} + func marshalHookData(t *testing.T, data hooks.Request) string { t.Helper() b, err := json.Marshal(data) diff --git a/cmd/formatter/ansi.go b/cmd/formatter/ansi.go index ad1031946ef..48ec433f68c 100644 --- a/cmd/formatter/ansi.go +++ b/cmd/formatter/ansi.go @@ -87,13 +87,20 @@ func moveCursorDown(lines int) { } func newLine() { - // Like \n fmt.Print("\012") } +// lenAnsi returns the visible length of s after stripping ANSI escape codes. func lenAnsi(s string) int { - // len has into consideration ansi codes, if we want - // the len of the actual len(string) we need to strip - // all ansi codes return len(stripansi.Strip(s)) } + +// OSC8Link wraps text in an OSC 8 terminal hyperlink escape sequence with +// underline styling, making it clickable in supported terminal emulators. +// When ANSI output is disabled, returns the plain text without escape sequences. +func OSC8Link(url, text string) string { + if disableAnsi { + return text + } + return "\033]8;;" + url + "\033\\\033[4m" + text + "\033[24m\033]8;;\033\\" +} diff --git a/cmd/formatter/ansi_test.go b/cmd/formatter/ansi_test.go new file mode 100644 index 00000000000..7ef668b546a --- /dev/null +++ b/cmd/formatter/ansi_test.go @@ -0,0 +1,50 @@ +/* + Copyright 2024 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package formatter + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestOSC8Link(t *testing.T) { + disableAnsi = false + t.Cleanup(func() { disableAnsi = false }) + + got := OSC8Link("http://example.com", "click here") + want := "\x1b]8;;http://example.com\x1b\\\x1b[4mclick here\x1b[24m\x1b]8;;\x1b\\" + assert.Equal(t, got, want) +} + +func TestOSC8Link_AnsiDisabled(t *testing.T) { + disableAnsi = true + t.Cleanup(func() { disableAnsi = false }) + + got := OSC8Link("http://example.com", "click here") + assert.Equal(t, got, "click here") +} + +func TestOSC8Link_URLAsDisplayText(t *testing.T) { + disableAnsi = false + t.Cleanup(func() { disableAnsi = false }) + + url := "docker-desktop://dashboard/logs" + got := OSC8Link(url, url) + want := "\x1b]8;;docker-desktop://dashboard/logs\x1b\\\x1b[4mdocker-desktop://dashboard/logs\x1b[24m\x1b]8;;\x1b\\" + assert.Equal(t, got, want) +}