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) <noreply@anthropic.com>
Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
This commit is contained in:
Guillaume Lours 2026-04-14 16:23:14 +02:00 committed by Nicolas De loof
parent 6ed7625d43
commit efb090183f
4 changed files with 153 additions and 11 deletions

View file

@ -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)
}
const dockerLogsHint = "View and search logs for all containers in one place\nwith Docker Desktop's Logs view. " + 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)
}
// 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(),
})
}

View file

@ -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)

View file

@ -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\\"
}

View file

@ -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)
}