Browse Source

Add Navigation Menu to compose up

Signed-off-by: Joana Hrotko <[email protected]>
Joana Hrotko 2 years ago
parent
commit
e9dc82011f

+ 1 - 0
Dockerfile

@@ -101,6 +101,7 @@ RUN --mount=type=bind,target=. \
 FROM build-base AS test
 ARG CGO_ENABLED=0
 ARG BUILD_TAGS
+ENV COMPOSE_MENU=FALSE
 RUN --mount=type=bind,target=. \
     --mount=type=cache,target=/root/.cache \
     --mount=type=cache,target=/go/pkg/mod \

+ 1 - 0
Makefile

@@ -13,6 +13,7 @@
 #   limitations under the License.
 
 PKG := github.com/docker/compose/v2
+export COMPOSE_MENU = FALSE
 VERSION ?= $(shell git describe --match 'v[0-9]*' --dirty='.m' --always --tags)
 
 GO_LDFLAGS ?= -w -X ${PKG}/internal.Version=${VERSION}

+ 14 - 0
cmd/compose/compose.go

@@ -65,6 +65,8 @@ const (
 	ComposeIgnoreOrphans = "COMPOSE_IGNORE_ORPHANS"
 	// ComposeEnvFiles defines the env files to use if --env-file isn't used
 	ComposeEnvFiles = "COMPOSE_ENV_FILES"
+	// ComposeMenu defines if the navigation menu should be rendered. Can be also set via --menu
+	ComposeMenu = "COMPOSE_MENU"
 )
 
 type Backend interface {
@@ -620,3 +622,15 @@ var printerModes = []string{
 	ui.ModePlain,
 	ui.ModeQuiet,
 }
+
+func SetUnchangedOption(name string, experimentalFlag bool) bool {
+	var value bool
+	// If the var is defined we use that value first
+	if envVar, ok := os.LookupEnv(name); ok {
+		value = utils.StringToBool(envVar)
+	} else {
+		// if not, we try to get it from experimental feature flag
+		value = experimentalFlag
+	}
+	return value
+}

+ 33 - 24
cmd/compose/up.go

@@ -42,20 +42,22 @@ type composeOptions struct {
 
 type upOptions struct {
 	*composeOptions
-	Detach             bool
-	noStart            bool
-	noDeps             bool
-	cascadeStop        bool
-	exitCodeFrom       string
-	noColor            bool
-	noPrefix           bool
-	attachDependencies bool
-	attach             []string
-	noAttach           []string
-	timestamp          bool
-	wait               bool
-	waitTimeout        int
-	watch              bool
+	Detach                bool
+	noStart               bool
+	noDeps                bool
+	cascadeStop           bool
+	exitCodeFrom          string
+	noColor               bool
+	noPrefix              bool
+	attachDependencies    bool
+	attach                []string
+	noAttach              []string
+	timestamp             bool
+	wait                  bool
+	waitTimeout           int
+	watch                 bool
+	navigationMenu        bool
+	navigationMenuChanged bool
 }
 
 func (opts upOptions) apply(project *types.Project, services []string) (*types.Project, error) {
@@ -87,6 +89,7 @@ func upCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Service, ex
 		PreRunE: AdaptCmd(func(ctx context.Context, cmd *cobra.Command, args []string) error {
 			create.pullChanged = cmd.Flags().Changed("pull")
 			create.timeChanged = cmd.Flags().Changed("timeout")
+			up.navigationMenuChanged = cmd.Flags().Changed("menu")
 			return validateFlags(&up, &create)
 		}),
 		RunE: p.WithServices(dockerCli, func(ctx context.Context, project *types.Project, services []string) error {
@@ -128,6 +131,8 @@ func upCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Service, ex
 	flags.BoolVar(&up.wait, "wait", false, "Wait for services to be running|healthy. Implies detached mode.")
 	flags.IntVar(&up.waitTimeout, "wait-timeout", 0, "Maximum duration to wait for the project to be running|healthy")
 	flags.BoolVarP(&up.watch, "watch", "w", false, "Watch source code and rebuild/refresh containers when files are updated.")
+	flags.BoolVar(&up.navigationMenu, "menu", false, "Enable interactive shortcuts when running attached (Experimental). Incompatible with --detach.")
+	flags.MarkHidden("menu") //nolint:errcheck
 
 	return upCmd
 }
@@ -161,7 +166,7 @@ func runUp(
 	ctx context.Context,
 	dockerCli command.Cli,
 	backend api.Service,
-	_ *experimental.State,
+	experimentals *experimental.State,
 	createOptions createOptions,
 	upOptions upOptions,
 	buildOptions buildOptions,
@@ -181,6 +186,9 @@ func runUp(
 	if err != nil {
 		return err
 	}
+	if !upOptions.navigationMenuChanged {
+		upOptions.navigationMenu = SetUnchangedOption(ComposeMenu, experimentals.NavBar())
+	}
 
 	var build *api.BuildOptions
 	if !createOptions.noBuild {
@@ -253,15 +261,16 @@ func runUp(
 	return backend.Up(ctx, project, api.UpOptions{
 		Create: create,
 		Start: api.StartOptions{
-			Project:      project,
-			Attach:       consumer,
-			AttachTo:     attach,
-			ExitCodeFrom: upOptions.exitCodeFrom,
-			CascadeStop:  upOptions.cascadeStop,
-			Wait:         upOptions.wait,
-			WaitTimeout:  timeout,
-			Watch:        upOptions.watch,
-			Services:     services,
+			Project:        project,
+			Attach:         consumer,
+			AttachTo:       attach,
+			ExitCodeFrom:   upOptions.exitCodeFrom,
+			CascadeStop:    upOptions.cascadeStop,
+			Wait:           upOptions.wait,
+			WaitTimeout:    timeout,
+			Watch:          upOptions.watch,
+			Services:       services,
+			NavigationMenu: upOptions.navigationMenu,
 		},
 	})
 }

+ 67 - 0
cmd/formatter/ansi.go

@@ -0,0 +1,67 @@
+/*
+   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 (
+	"fmt"
+
+	"github.com/acarl005/stripansi"
+)
+
+func ansi(code string) string {
+	return fmt.Sprintf("\033%s", code)
+}
+func SaveCursor() {
+	fmt.Print(ansi("7"))
+}
+func RestoreCursor() {
+	fmt.Print(ansi("8"))
+}
+func HideCursor() {
+	fmt.Print(ansi("[?25l"))
+}
+func ShowCursor() {
+	fmt.Print(ansi("[?25h"))
+}
+func MoveCursor(y, x int) {
+	fmt.Print(ansi(fmt.Sprintf("[%d;%dH", y, x)))
+}
+func MoveCursorX(pos int) {
+	fmt.Print(ansi(fmt.Sprintf("[%dG", pos)))
+}
+func ClearLine() {
+	// Does not move cursor from its current position
+	fmt.Print(ansi("[2K"))
+}
+func MoveCursorUp(lines int) {
+	// Does not add new lines
+	fmt.Print(ansi(fmt.Sprintf("[%dA", lines)))
+}
+func MoveCursorDown(lines int) {
+	// Does not add new lines
+	fmt.Print(ansi(fmt.Sprintf("[%dB", lines)))
+}
+func NewLine() {
+	// Like \n
+	fmt.Print("\012")
+}
+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))
+}

+ 21 - 4
cmd/formatter/colors.go

@@ -35,6 +35,18 @@ var names = []string{
 	"white",
 }
 
+const (
+	BOLD      = "1"
+	FAINT     = "2"
+	ITALIC    = "3"
+	UNDERLINE = "4"
+)
+
+const (
+	RESET = "0"
+	CYAN  = "36"
+)
+
 const (
 	// Never use ANSI codes
 	Never = "never"
@@ -72,12 +84,17 @@ var monochrome = func(s string) string {
 	return s
 }
 
-func ansiColor(code, s string) string {
-	return fmt.Sprintf("%s%s%s", ansi(code), s, ansi("0"))
+func ansiColor(code, s string, formatOpts ...string) string {
+	return fmt.Sprintf("%s%s%s", ansiColorCode(code, formatOpts...), s, ansiColorCode("0"))
 }
 
-func ansi(code string) string {
-	return fmt.Sprintf("\033[%sm", code)
+// Everything about ansiColorCode color https://hyperskill.org/learn/step/18193
+func ansiColorCode(code string, formatOpts ...string) string {
+	res := "\033["
+	for _, c := range formatOpts {
+		res = fmt.Sprintf("%s%s;", res, c)
+	}
+	return fmt.Sprintf("%s%sm", res, code)
 }
 
 func makeColorFunc(code string) colorFunc {

+ 17 - 7
cmd/formatter/logs.go

@@ -102,19 +102,29 @@ func (l *logConsumer) Err(container, message string) {
 	l.write(l.stderr, container, message)
 }
 
+var navColor = makeColorFunc("90")
+
 func (l *logConsumer) write(w io.Writer, container, message string) {
 	if l.ctx.Err() != nil {
 		return
 	}
-	p := l.getPresenter(container)
-	timestamp := time.Now().Format(jsonmessage.RFC3339NanoFixed)
-	for _, line := range strings.Split(message, "\n") {
-		if l.timestamp {
-			fmt.Fprintf(w, "%s%s%s\n", p.prefix, timestamp, line)
-		} else {
-			fmt.Fprintf(w, "%s%s\n", p.prefix, line)
+	printFn := func() {
+		p := l.getPresenter(container)
+		timestamp := time.Now().Format(jsonmessage.RFC3339NanoFixed)
+		for _, line := range strings.Split(message, "\n") {
+			ClearLine()
+			if l.timestamp {
+				fmt.Fprintf(w, "%s%s%s\n", p.prefix, timestamp, line)
+			} else {
+				fmt.Fprintf(w, "%s%s\n", p.prefix, line)
+			}
 		}
 	}
+	if KeyboardManager != nil {
+		KeyboardManager.PrintKeyboardInfo(printFn)
+	} else {
+		printFn()
+	}
 }
 
 func (l *logConsumer) Status(container, msg string) {

+ 321 - 0
cmd/formatter/shortcut.go

@@ -0,0 +1,321 @@
+/*
+   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 (
+	"context"
+	"errors"
+	"fmt"
+	"math"
+	"os"
+	"syscall"
+	"time"
+
+	"github.com/buger/goterm"
+	"github.com/compose-spec/compose-go/v2/types"
+	"github.com/docker/compose/v2/internal/tracing"
+	"github.com/docker/compose/v2/pkg/api"
+	"github.com/docker/compose/v2/pkg/watch"
+	"github.com/eiannone/keyboard"
+	"github.com/hashicorp/go-multierror"
+	"github.com/skratchdot/open-golang/open"
+)
+
+const DISPLAY_ERROR_TIME = 10
+
+type KeyboardError struct {
+	err       error
+	timeStart time.Time
+}
+
+func (ke *KeyboardError) shouldDisplay() bool {
+	return ke.err != nil && int(time.Since(ke.timeStart).Seconds()) < DISPLAY_ERROR_TIME
+}
+
+func (ke *KeyboardError) printError(height int, info string) {
+	if ke.shouldDisplay() {
+		errMessage := ke.err.Error()
+
+		MoveCursor(height-linesOffset(info)-linesOffset(errMessage)-1, 0)
+		ClearLine()
+
+		fmt.Print(errMessage)
+	}
+}
+
+func (ke *KeyboardError) addError(prefix string, err error) {
+	ke.timeStart = time.Now()
+
+	prefix = ansiColor(CYAN, fmt.Sprintf("%s →", prefix), BOLD)
+	errorString := fmt.Sprintf("%s  %s", prefix, err.Error())
+
+	ke.err = errors.New(errorString)
+}
+
+func (ke *KeyboardError) error() string {
+	return ke.err.Error()
+}
+
+type KeyboardWatch struct {
+	Watcher  watch.Notify
+	Watching bool
+	WatchFn  func(ctx context.Context, project *types.Project, services []string, options api.WatchOptions) error
+	Ctx      context.Context
+	Cancel   context.CancelFunc
+}
+
+func (kw *KeyboardWatch) isWatching() bool {
+	return kw.Watching
+}
+
+func (kw *KeyboardWatch) switchWatching() {
+	kw.Watching = !kw.Watching
+}
+
+func (kw *KeyboardWatch) newContext(ctx context.Context) context.CancelFunc {
+	ctx, cancel := context.WithCancel(ctx)
+	kw.Ctx = ctx
+	kw.Cancel = cancel
+	return cancel
+}
+
+type KEYBOARD_LOG_LEVEL int
+
+const (
+	NONE  KEYBOARD_LOG_LEVEL = 0
+	INFO  KEYBOARD_LOG_LEVEL = 1
+	DEBUG KEYBOARD_LOG_LEVEL = 2
+)
+
+type LogKeyboard struct {
+	kError                KeyboardError
+	Watch                 KeyboardWatch
+	IsDockerDesktopActive bool
+	IsWatchConfigured     bool
+	logLevel              KEYBOARD_LOG_LEVEL
+	signalChannel         chan<- os.Signal
+}
+
+var KeyboardManager *LogKeyboard
+var eg multierror.Group
+
+func NewKeyboardManager(ctx context.Context, isDockerDesktopActive, isWatchConfigured bool,
+	sc chan<- os.Signal,
+	watchFn func(ctx context.Context,
+		project *types.Project,
+		services []string,
+		options api.WatchOptions,
+	) error,
+) {
+	km := LogKeyboard{}
+	km.IsDockerDesktopActive = isDockerDesktopActive
+	km.IsWatchConfigured = isWatchConfigured
+	km.logLevel = INFO
+
+	km.Watch.Watching = false
+	km.Watch.WatchFn = watchFn
+
+	km.signalChannel = sc
+
+	KeyboardManager = &km
+
+	HideCursor()
+}
+
+func (lk *LogKeyboard) PrintKeyboardInfo(printFn func()) {
+	printFn()
+
+	if lk.logLevel == INFO {
+		lk.printNavigationMenu()
+	}
+}
+
+// Creates space to print error and menu string
+func (lk *LogKeyboard) createBuffer(lines int) {
+	allocateSpace(lines)
+
+	if lk.kError.shouldDisplay() {
+		extraLines := linesOffset(lk.kError.error()) + 1
+		allocateSpace(extraLines)
+		lines += extraLines
+	}
+
+	infoMessage := lk.navigationMenu()
+	extraLines := linesOffset(infoMessage) + 1
+	allocateSpace(extraLines)
+	lines += extraLines
+
+	if lines > 0 {
+		MoveCursorUp(lines)
+	}
+}
+
+func (lk *LogKeyboard) printNavigationMenu() {
+	lk.clearNavigationMenu()
+	lk.createBuffer(0)
+
+	if lk.logLevel == INFO {
+		height := goterm.Height()
+		menu := lk.navigationMenu()
+
+		MoveCursorX(0)
+		SaveCursor()
+
+		lk.kError.printError(height, menu)
+
+		MoveCursor(height-linesOffset(menu), 0)
+		ClearLine()
+		fmt.Print(menu)
+
+		MoveCursorX(0)
+		RestoreCursor()
+	}
+}
+
+func (lk *LogKeyboard) navigationMenu() string {
+	var options string
+	var openDDInfo string
+	if lk.IsDockerDesktopActive {
+		openDDInfo = shortcutKeyColor("v") + navColor(" View in Docker Desktop")
+	}
+	var watchInfo string
+	if openDDInfo != "" {
+		watchInfo = navColor("   ")
+	}
+	var isEnabled = " Enable"
+	if lk.Watch.Watching {
+		isEnabled = " Disable"
+	}
+	watchInfo = watchInfo + shortcutKeyColor("w") + navColor(isEnabled+" Watch")
+	return options + openDDInfo + watchInfo
+}
+
+func (lk *LogKeyboard) clearNavigationMenu() {
+	height := goterm.Height()
+	MoveCursorX(0)
+	SaveCursor()
+	for i := 0; i < height; i++ {
+		MoveCursorDown(1)
+		ClearLine()
+	}
+	RestoreCursor()
+}
+
+func (lk *LogKeyboard) openDockerDesktop(ctx context.Context, project *types.Project) {
+	if !lk.IsDockerDesktopActive {
+		return
+	}
+	eg.Go(tracing.EventWrapFuncForErrGroup(ctx, "menu/gui", tracing.SpanOptions{},
+		func(ctx context.Context) error {
+			link := fmt.Sprintf("docker-desktop://dashboard/apps/%s", project.Name)
+			err := open.Run(link)
+			if err != nil {
+				err = fmt.Errorf("Could not open Docker Desktop")
+				lk.keyboardError("View", err)
+			}
+			return err
+		}),
+	)
+}
+
+func (lk *LogKeyboard) keyboardError(prefix string, err error) {
+	lk.kError.addError(prefix, err)
+
+	lk.printNavigationMenu()
+	timer1 := time.NewTimer((DISPLAY_ERROR_TIME + 1) * time.Second)
+	go func() {
+		<-timer1.C
+		lk.printNavigationMenu()
+	}()
+}
+
+func (lk *LogKeyboard) StartWatch(ctx context.Context, project *types.Project, options api.UpOptions) {
+	if !lk.IsWatchConfigured {
+		eg.Go(tracing.EventWrapFuncForErrGroup(ctx, "menu/watch", tracing.SpanOptions{},
+			func(ctx context.Context) error {
+				err := fmt.Errorf("Watch is not yet configured. Learn more: %s", ansiColor(CYAN, "https://docs.docker.com/compose/file-watch/"))
+				lk.keyboardError("Watch", err)
+				return err
+			}))
+		return
+	}
+	lk.Watch.switchWatching()
+	if !lk.Watch.isWatching() {
+		lk.Watch.Cancel()
+	} else {
+		eg.Go(tracing.EventWrapFuncForErrGroup(ctx, "menu/watch", tracing.SpanOptions{},
+			func(ctx context.Context) error {
+				lk.Watch.newContext(ctx)
+				buildOpts := *options.Create.Build
+				buildOpts.Quiet = true
+				return lk.Watch.WatchFn(lk.Watch.Ctx, project, options.Start.Services, api.WatchOptions{
+					Build: &buildOpts,
+					LogTo: options.Start.Attach,
+				})
+			}))
+	}
+}
+
+func (lk *LogKeyboard) KeyboardClose() {
+	_ = keyboard.Close()
+}
+
+func (lk *LogKeyboard) HandleKeyEvents(event keyboard.KeyEvent, ctx context.Context, project *types.Project, options api.UpOptions) {
+	switch kRune := event.Rune; kRune {
+	case 'v':
+		lk.openDockerDesktop(ctx, project)
+	case 'w':
+		lk.StartWatch(ctx, project, options)
+	}
+	switch key := event.Key; key {
+	case keyboard.KeyCtrlC:
+		lk.KeyboardClose()
+
+		lk.clearNavigationMenu()
+		ShowCursor()
+
+		lk.logLevel = NONE
+		if lk.Watch.Watching && lk.Watch.Cancel != nil {
+			lk.Watch.Cancel()
+			_ = eg.Wait().ErrorOrNil() // Need to print this ?
+		}
+		// will notify main thread to kill and will handle gracefully
+		lk.signalChannel <- syscall.SIGINT
+	case keyboard.KeyEnter:
+		lk.printNavigationMenu()
+	}
+}
+
+func allocateSpace(lines int) {
+	for i := 0; i < lines; i++ {
+		ClearLine()
+		NewLine()
+		MoveCursorX(0)
+	}
+}
+
+func linesOffset(s string) int {
+	return int(math.Floor(float64(lenAnsi(s)) / float64(goterm.Width())))
+}
+
+func shortcutKeyColor(key string) string {
+	foreground := "38;2"
+	black := "0;0;0"
+	background := "48;2"
+	white := "255;255;255"
+	return ansiColor(foreground+";"+black+";"+background+";"+white, key, BOLD)
+}

+ 11 - 0
docs/reference/docker_compose_up.yaml

@@ -108,6 +108,17 @@ options:
       experimentalcli: false
       kubernetes: false
       swarm: false
+    - option: menu
+      value_type: bool
+      default_value: "false"
+      description: |
+        Enable interactive shortcuts when running attached (Experimental). Incompatible with --detach.
+      deprecated: false
+      hidden: true
+      experimental: false
+      experimentalcli: false
+      kubernetes: false
+      swarm: false
     - option: no-attach
       value_type: stringArray
       default_value: '[]'

+ 3 - 0
go.mod

@@ -5,6 +5,7 @@ go 1.21
 require (
 	github.com/AlecAivazis/survey/v2 v2.3.7
 	github.com/Microsoft/go-winio v0.6.1
+	github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d
 	github.com/buger/goterm v1.0.4
 	github.com/compose-spec/compose-go/v2 v2.0.2
 	github.com/containerd/console v1.0.4
@@ -34,6 +35,7 @@ require (
 	github.com/otiai10/copy v1.14.0
 	github.com/r3labs/sse v0.0.0-20210224172625-26fe804710bc
 	github.com/sirupsen/logrus v1.9.3
+	github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
 	github.com/spf13/cobra v1.8.0
 	github.com/spf13/pflag v1.0.5
 	github.com/stretchr/testify v1.8.4
@@ -85,6 +87,7 @@ require (
 	github.com/docker/docker-credential-helpers v0.8.0 // indirect
 	github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c // indirect
 	github.com/docker/go-metrics v0.0.1 // indirect
+	github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203
 	github.com/emicklei/go-restful/v3 v3.11.0 // indirect
 	github.com/felixge/httpsnoop v1.0.4 // indirect
 	github.com/fvbommel/sortorder v1.0.2 // indirect

+ 6 - 0
go.sum

@@ -27,6 +27,8 @@ github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDe
 github.com/Shopify/logrus-bugsnag v0.0.0-20170309145241-6dbc35f2c30d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ=
 github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d h1:UrqY+r/OJnIp5u0s1SbQ8dVfLCZJsnvazdBP5hS4iRs=
 github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ=
+github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
+github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
 github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
 github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
 github.com/anchore/go-struct-converter v0.0.0-20221118182256-c68fdcfa2092 h1:aM1rlcoLz8y5B2r4tTLMiVTrMtpfY0O8EScKJxaSaEc=
@@ -150,6 +152,8 @@ github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDD
 github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU5CAUmr9zpesgbU6SWc8/B4mflAE4=
 github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE=
 github.com/dvsekhvalnov/jose2go v0.0.0-20170216131308-f21a8cedbbae/go.mod h1:7BvyPhdbLxMXIYTFPLsyJRFMsKmOZnQmzh6Gb+uquuM=
+github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 h1:XBBHcIb256gUJtLmY22n99HaZTz+r2Z51xUPi01m3wg=
+github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203/go.mod h1:E1jcSv8FaEny+OP/5k9UxZVw9YFWGj7eI4KR/iOBqCg=
 github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
 github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
 github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
@@ -440,6 +444,8 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd
 github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
 github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
 github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
+github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
 github.com/spdx/tools-golang v0.5.3 h1:ialnHeEYUC4+hkm5vJm4qz2x+oEJbS0mAMFrNXdQraY=
 github.com/spdx/tools-golang v0.5.3/go.mod h1:/ETOahiAo96Ob0/RAIBmFZw6XN0yTnyr/uFZm2NTMhI=
 github.com/spf13/cast v0.0.0-20150508191742-4d07383ffe94 h1:JmfC365KywYwHB946TTiQWEb8kqPY+pybPLoGE9GgVk=

+ 36 - 0
internal/tracing/keyboard_metrics.go

@@ -0,0 +1,36 @@
+/*
+   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 tracing
+
+import (
+	"context"
+
+	"go.opentelemetry.io/otel/attribute"
+)
+
+func KeyboardMetrics(ctx context.Context, enabled, isDockerDesktopActive, isWatchConfigured bool) {
+	commandAvailable := []string{}
+	if isDockerDesktopActive {
+		commandAvailable = append(commandAvailable, "gui")
+	}
+	if isWatchConfigured {
+		commandAvailable = append(commandAvailable, "watch")
+	}
+	AddAttributeToSpan(ctx,
+		attribute.Bool("navmenu.enabled", enabled),
+		attribute.StringSlice("navmenu.command_available", commandAvailable))
+}

+ 8 - 2
internal/tracing/wrap.go

@@ -19,6 +19,8 @@ package tracing
 import (
 	"context"
 
+	"github.com/acarl005/stripansi"
+	"go.opentelemetry.io/otel/attribute"
 	"go.opentelemetry.io/otel/codes"
 	semconv "go.opentelemetry.io/otel/semconv/v1.19.0"
 	"go.opentelemetry.io/otel/trace"
@@ -80,12 +82,16 @@ func EventWrapFuncForErrGroup(ctx context.Context, eventName string, opts SpanOp
 		eventOpts := opts.EventOptions()
 
 		err := fn(ctx)
-
 		if err != nil {
-			eventOpts = append(eventOpts, trace.WithAttributes(semconv.ExceptionMessage(err.Error())))
+			eventOpts = append(eventOpts, trace.WithAttributes(semconv.ExceptionMessage(stripansi.Strip(err.Error()))))
 		}
 		span.AddEvent(eventName, eventOpts...)
 
 		return err
 	}
 }
+
+func AddAttributeToSpan(ctx context.Context, attr ...attribute.KeyValue) {
+	span := trace.SpanFromContext(ctx)
+	span.SetAttributes(attr...)
+}

+ 3 - 2
pkg/api/api.go

@@ -217,8 +217,9 @@ type StartOptions struct {
 	Wait        bool
 	WaitTimeout time.Duration
 	// Services passed in the command line to be started
-	Services []string
-	Watch    bool
+	Services       []string
+	Watch          bool
+	NavigationMenu bool
 }
 
 // RestartOptions group options of the Restart API

+ 5 - 1
pkg/compose/compose.go

@@ -81,7 +81,7 @@ func (s *composeService) Close() error {
 	if s.dockerCli != nil {
 		errs = append(errs, s.dockerCli.Client().Close())
 	}
-	if s.desktopCli != nil {
+	if s.isDesktopIntegrationActive() {
 		errs = append(errs, s.desktopCli.Close())
 	}
 	return errors.Join(errs...)
@@ -320,3 +320,7 @@ func (s *composeService) RuntimeVersion(ctx context.Context) (string, error) {
 	return runtimeVersion.val, runtimeVersion.err
 
 }
+
+func (s *composeService) isDesktopIntegrationActive() bool {
+	return s.desktopCli != nil
+}

+ 1 - 1
pkg/compose/create.go

@@ -152,7 +152,7 @@ func (s *composeService) ensureProjectVolumes(ctx context.Context, project *type
 	}
 
 	err := func() error {
-		if s.experiments.AutoFileShares() && s.desktopCli != nil {
+		if s.experiments.AutoFileShares() && s.isDesktopIntegrationActive() {
 			// collect all the bind mount paths and try to set up file shares in
 			// Docker Desktop for them
 			var paths []string

+ 1 - 1
pkg/compose/down.go

@@ -145,7 +145,7 @@ func (s *composeService) ensureVolumesDown(ctx context.Context, project *types.P
 		})
 	}
 
-	if s.experiments.AutoFileShares() && s.desktopCli != nil {
+	if s.experiments.AutoFileShares() && s.isDesktopIntegrationActive() {
 		ops = append(ops, func() error {
 			desktop.RemoveFileSharesForProject(ctx, s.desktopCli, project.Name)
 			return nil

+ 23 - 1
pkg/compose/up.go

@@ -25,9 +25,11 @@ import (
 
 	"github.com/compose-spec/compose-go/v2/types"
 	"github.com/docker/cli/cli"
+	"github.com/docker/compose/v2/cmd/formatter"
 	"github.com/docker/compose/v2/internal/tracing"
 	"github.com/docker/compose/v2/pkg/api"
 	"github.com/docker/compose/v2/pkg/progress"
+	"github.com/eiannone/keyboard"
 	"github.com/hashicorp/go-multierror"
 )
 
@@ -73,6 +75,7 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options
 		first := true
 		gracefulTeardown := func() {
 			printer.Cancel()
+			formatter.ClearLine()
 			fmt.Fprintln(s.stdinfo(), "Gracefully stopping... (press Ctrl+C again to force)")
 			eg.Go(func() error {
 				err := s.Stop(context.Background(), project.Name, api.StopOptions{
@@ -85,6 +88,23 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options
 			})
 			first = false
 		}
+
+		var kEvents <-chan keyboard.KeyEvent
+		isWatchConfigured := s.shouldWatch(project)
+		isDockerDesktopActive := s.isDesktopIntegrationActive()
+
+		tracing.KeyboardMetrics(ctx, options.Start.NavigationMenu, isDockerDesktopActive, isWatchConfigured)
+		if options.Start.NavigationMenu {
+			kEvents, err = keyboard.GetKeys(100)
+			if err != nil {
+				panic(err)
+			}
+			formatter.NewKeyboardManager(ctx, isDockerDesktopActive, isWatchConfigured, signalChan, s.Watch)
+			if options.Start.Watch {
+				formatter.KeyboardManager.StartWatch(ctx, project, options)
+			}
+			defer formatter.KeyboardManager.KeyboardClose()
+		}
 		for {
 			select {
 			case <-doneCh:
@@ -105,6 +125,8 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options
 					})
 					return nil
 				}
+			case event := <-kEvents:
+				formatter.KeyboardManager.HandleKeyEvents(event, ctx, project, options)
 			}
 		}
 	})
@@ -124,7 +146,7 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options
 		return err
 	})
 
-	if options.Start.Watch {
+	if options.Start.Watch && !options.Start.NavigationMenu {
 		eg.Go(func() error {
 			buildOpts := *options.Create.Build
 			buildOpts.Quiet = true

+ 17 - 4
pkg/compose/watch.go

@@ -65,6 +65,17 @@ func (s *composeService) getSyncImplementation(project *types.Project) (sync.Syn
 
 	return sync.NewTar(project.Name, tarDockerClient{s: s}), nil
 }
+func (s *composeService) shouldWatch(project *types.Project) bool {
+	var shouldWatch bool
+	for i := range project.Services {
+		service := project.Services[i]
+
+		if service.Develop != nil && service.Develop.Watch != nil {
+			shouldWatch = true
+		}
+	}
+	return shouldWatch
+}
 
 func (s *composeService) Watch(ctx context.Context, project *types.Project, services []string, options api.WatchOptions) error { //nolint: gocyclo
 	var err error
@@ -159,17 +170,15 @@ func (s *composeService) Watch(ctx context.Context, project *types.Project, serv
 			return err
 		}
 		watching = true
-
 		eg.Go(func() error {
 			defer watcher.Close() //nolint:errcheck
 			return s.watch(ctx, project, service.Name, options, watcher, syncer, config.Watch)
 		})
 	}
-
 	if !watching {
 		return fmt.Errorf("none of the selected services is configured for watch, consider setting an 'develop' section")
 	}
-	options.LogTo.Log(api.WatchLogger, "watch enabled")
+	options.LogTo.Log(api.WatchLogger, "Watch enabled")
 
 	return eg.Wait()
 }
@@ -189,10 +198,12 @@ func (s *composeService) watch(ctx context.Context, project *types.Project, name
 
 	events := make(chan fileEvent)
 	batchEvents := batchDebounceEvents(ctx, s.clock, quietPeriod, events)
+	quit := make(chan bool)
 	go func() {
 		for {
 			select {
 			case <-ctx.Done():
+				quit <- true
 				return
 			case batch := <-batchEvents:
 				start := time.Now()
@@ -208,9 +219,11 @@ func (s *composeService) watch(ctx context.Context, project *types.Project, name
 
 	for {
 		select {
-		case <-ctx.Done():
+		case <-quit:
+			options.LogTo.Log(api.WatchLogger, "Watch disabled")
 			return nil
 		case err := <-watcher.Errors():
+			options.LogTo.Err(api.WatchLogger, "Watch disabled with errors")
 			return err
 		case event := <-watcher.Events():
 			hostPath := event.Path()