Skip to content

Commit efb0901

Browse files
gloursclaude
authored andcommitted
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>
1 parent 6ed7625 commit efb0901

4 files changed

Lines changed: 153 additions & 11 deletions

File tree

cmd/compose/hooks.go

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,23 +19,58 @@ package compose
1919
import (
2020
"encoding/json"
2121
"io"
22+
"os"
2223

2324
"github.com/docker/cli/cli-plugins/hooks"
2425
"github.com/docker/cli/cli-plugins/metadata"
2526
"github.com/spf13/cobra"
27+
28+
"github.com/docker/compose/v5/cmd/formatter"
2629
)
2730

2831
const deepLink = "docker-desktop://dashboard/logs"
2932

30-
const composeLogsHint = "Filter, search, and stream logs from all your Compose services\nin one place with Docker Desktop's Logs view. " + deepLink
33+
func composeLogsHint() string {
34+
return "Filter, search, and stream logs from all your Compose services\nin one place with Docker Desktop's Logs view. " + hintLink(deepLink)
35+
}
36+
37+
func dockerLogsHint() string {
38+
return "View and search logs for all containers in one place\nwith Docker Desktop's Logs view. " + hintLink(deepLink)
39+
}
40+
41+
// hintLink returns a clickable OSC 8 terminal hyperlink when ANSI is allowed,
42+
// or the plain URL when ANSI output is suppressed via NO_COLOR or COMPOSE_ANSI.
43+
func hintLink(url string) string {
44+
if shouldDisableAnsi() {
45+
return url
46+
}
47+
return formatter.OSC8Link(url, url)
48+
}
3149

32-
const dockerLogsHint = "View and search logs for all containers in one place\nwith Docker Desktop's Logs view. " + deepLink
50+
// shouldDisableAnsi checks whether ANSI escape sequences should be explicitly
51+
// suppressed via environment variables. The hook runs as a separate subprocess
52+
// where the normal PersistentPreRunE (which calls formatter.SetANSIMode) is
53+
// skipped, so we check NO_COLOR and COMPOSE_ANSI directly.
54+
//
55+
// TTY detection is intentionally omitted: the hook produces a JSON response
56+
// whose template is rendered by the parent Docker CLI process via
57+
// PrintNextSteps (which itself emits bold ANSI unconditionally). The hook
58+
// subprocess cannot reliably detect whether the parent's output is a terminal.
59+
func shouldDisableAnsi() bool {
60+
if noColor, ok := os.LookupEnv("NO_COLOR"); ok && noColor != "" {
61+
return true
62+
}
63+
if v, ok := os.LookupEnv("COMPOSE_ANSI"); ok && v == formatter.Never {
64+
return true
65+
}
66+
return false
67+
}
3368

3469
// hookHint defines a hint that can be returned by the hooks handler.
3570
// When checkFlags is nil, the hint is always returned for the matching command.
3671
// When checkFlags is set, the hint is only returned if the check passes.
3772
type hookHint struct {
38-
template string
73+
template func() string
3974
checkFlags func(flags map[string]string) bool
4075
}
4176

@@ -96,6 +131,6 @@ func handleHook(args []string, w io.Writer) error {
96131
enc.SetEscapeHTML(false)
97132
return enc.Encode(hooks.Response{
98133
Type: hooks.NextSteps,
99-
Template: hint.template,
134+
Template: hint.template(),
100135
})
101136
}

cmd/compose/hooks_test.go

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,13 @@ package compose
1919
import (
2020
"bytes"
2121
"encoding/json"
22+
"strings"
2223
"testing"
2324

2425
"github.com/docker/cli/cli-plugins/hooks"
2526
"gotest.tools/v3/assert"
27+
28+
"github.com/docker/compose/v5/cmd/formatter"
2629
)
2730

2831
func TestHandleHook_NoArgs(t *testing.T) {
@@ -52,7 +55,7 @@ func TestHandleHook_UnknownCommand(t *testing.T) {
5255
func TestHandleHook_LogsCommand(t *testing.T) {
5356
tests := []struct {
5457
rootCmd string
55-
wantHint string
58+
wantHint func() string
5659
}{
5760
{rootCmd: "compose logs", wantHint: composeLogsHint},
5861
{rootCmd: "logs", wantHint: dockerLogsHint},
@@ -68,7 +71,7 @@ func TestHandleHook_LogsCommand(t *testing.T) {
6871

6972
msg := unmarshalResponse(t, buf.Bytes())
7073
assert.Equal(t, msg.Type, hooks.NextSteps)
71-
assert.Equal(t, msg.Template, tt.wantHint)
74+
assert.Equal(t, msg.Template, tt.wantHint())
7275
})
7376
}
7477
}
@@ -112,14 +115,61 @@ func TestHandleHook_ComposeUpDetached(t *testing.T) {
112115

113116
if tt.wantHint {
114117
msg := unmarshalResponse(t, buf.Bytes())
115-
assert.Equal(t, msg.Template, composeLogsHint)
118+
assert.Equal(t, msg.Template, composeLogsHint())
116119
} else {
117120
assert.Equal(t, buf.String(), "")
118121
}
119122
})
120123
}
121124
}
122125

126+
func TestHandleHook_HintContainsOSC8Link(t *testing.T) {
127+
// Ensure ANSI is not suppressed by the test runner environment
128+
t.Setenv("NO_COLOR", "")
129+
t.Setenv("COMPOSE_ANSI", "")
130+
data := marshalHookData(t, hooks.Request{
131+
RootCmd: "compose logs",
132+
})
133+
var buf bytes.Buffer
134+
err := handleHook([]string{data}, &buf)
135+
assert.NilError(t, err)
136+
137+
msg := unmarshalResponse(t, buf.Bytes())
138+
// Verify the template contains the OSC 8 hyperlink sequence
139+
wantLink := formatter.OSC8Link(deepLink, deepLink)
140+
assert.Assert(t, len(wantLink) > len(deepLink), "OSC8Link should wrap the URL with escape sequences")
141+
assert.Assert(t, strings.Contains(msg.Template, wantLink), "hint should contain OSC 8 hyperlink")
142+
}
143+
144+
func TestHandleHook_NoColorDisablesOsc8(t *testing.T) {
145+
t.Setenv("NO_COLOR", "1")
146+
data := marshalHookData(t, hooks.Request{
147+
RootCmd: "compose logs",
148+
})
149+
var buf bytes.Buffer
150+
err := handleHook([]string{data}, &buf)
151+
assert.NilError(t, err)
152+
153+
msg := unmarshalResponse(t, buf.Bytes())
154+
// With NO_COLOR set, the hint should contain the plain URL without escape sequences
155+
assert.Assert(t, strings.Contains(msg.Template, deepLink), "hint should contain the deep link URL")
156+
assert.Assert(t, !strings.Contains(msg.Template, "\033"), "hint should not contain ANSI escape sequences")
157+
}
158+
159+
func TestHandleHook_ComposeAnsiNeverDisablesOsc8(t *testing.T) {
160+
t.Setenv("COMPOSE_ANSI", "never")
161+
data := marshalHookData(t, hooks.Request{
162+
RootCmd: "compose logs",
163+
})
164+
var buf bytes.Buffer
165+
err := handleHook([]string{data}, &buf)
166+
assert.NilError(t, err)
167+
168+
msg := unmarshalResponse(t, buf.Bytes())
169+
assert.Assert(t, strings.Contains(msg.Template, deepLink), "hint should contain the deep link URL")
170+
assert.Assert(t, !strings.Contains(msg.Template, "\033"), "hint should not contain ANSI escape sequences")
171+
}
172+
123173
func marshalHookData(t *testing.T, data hooks.Request) string {
124174
t.Helper()
125175
b, err := json.Marshal(data)

cmd/formatter/ansi.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,20 @@ func moveCursorDown(lines int) {
8787
}
8888

8989
func newLine() {
90-
// Like \n
9190
fmt.Print("\012")
9291
}
9392

93+
// lenAnsi returns the visible length of s after stripping ANSI escape codes.
9494
func lenAnsi(s string) int {
95-
// len has into consideration ansi codes, if we want
96-
// the len of the actual len(string) we need to strip
97-
// all ansi codes
9895
return len(stripansi.Strip(s))
9996
}
97+
98+
// OSC8Link wraps text in an OSC 8 terminal hyperlink escape sequence with
99+
// underline styling, making it clickable in supported terminal emulators.
100+
// When ANSI output is disabled, returns the plain text without escape sequences.
101+
func OSC8Link(url, text string) string {
102+
if disableAnsi {
103+
return text
104+
}
105+
return "\033]8;;" + url + "\033\\\033[4m" + text + "\033[24m\033]8;;\033\\"
106+
}

cmd/formatter/ansi_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
Copyright 2024 Docker Compose CLI authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package formatter
18+
19+
import (
20+
"testing"
21+
22+
"gotest.tools/v3/assert"
23+
)
24+
25+
func TestOSC8Link(t *testing.T) {
26+
disableAnsi = false
27+
t.Cleanup(func() { disableAnsi = false })
28+
29+
got := OSC8Link("http://example.com", "click here")
30+
want := "\x1b]8;;http://example.com\x1b\\\x1b[4mclick here\x1b[24m\x1b]8;;\x1b\\"
31+
assert.Equal(t, got, want)
32+
}
33+
34+
func TestOSC8Link_AnsiDisabled(t *testing.T) {
35+
disableAnsi = true
36+
t.Cleanup(func() { disableAnsi = false })
37+
38+
got := OSC8Link("http://example.com", "click here")
39+
assert.Equal(t, got, "click here")
40+
}
41+
42+
func TestOSC8Link_URLAsDisplayText(t *testing.T) {
43+
disableAnsi = false
44+
t.Cleanup(func() { disableAnsi = false })
45+
46+
url := "docker-desktop://dashboard/logs"
47+
got := OSC8Link(url, url)
48+
want := "\x1b]8;;docker-desktop://dashboard/logs\x1b\\\x1b[4mdocker-desktop://dashboard/logs\x1b[24m\x1b]8;;\x1b\\"
49+
assert.Equal(t, got, want)
50+
}

0 commit comments

Comments
 (0)