Repository: junegunn/fzf
Pull request #3094 (2022-12-17 15:27:26)

Add --listen=HTTP_PORT option to receive actions
```sh
# Start HTTP server on port 6266
fzf --listen 6266

# Send actions to the server
curl -XPOST localhost:6266 -d 'reload(seq 100)+change-prompt(hundred> )'
```
Feedbacks are welcome. Do you like it? Are you going to use it? What are you planning to do with this?

### Drawbacks

This will significantly increase the size of the binary (2.8MB to 5.7MB for darwin arm64). [UPX](https://upx.github.io/) might help with the issue. (However, https://github.com/upx/upx/issues/601)

### Alternatives

* Using signals as suggested in #2019 
     * Limited to only two bindings, SIGUSR1 and SIGUSR2
     * No SIGUSR1 or SIGUSR2 on Windows
* Non-HTTP socket server or named pipes
     * Less expressive and less extensible. We can extend the HTTP server to support other HTTP verbs in the future.
     * Likely less binary size? Haven't confirmed.
     * Portability issue

edi9999: Hi, interesting !

Yes, I think it is cool.
I will use it mainly for one purpose : to reload data from a source that updates itself in a background.
I'm not sure how the cursor position will adapt if the number of lines increases/decreases. It seems a lot like the React loop keys problem : https://reactjs.org/docs/lists-and-keys.html and I'm pretty sure it would be way to complex to handle keys like React does.

It is totally true that SIGNALS are too platform specific, Windows doesn't have them.

Specifically to me, the increase of binary size is not an issue, but it is true that just for one single feature, doubling the binary size seems like a lot.

Maybe it would be possible to simply use tcp (probably not since it doesn't give you enough garantee) ?

Another idea would be to have a websocket server instead of an HTTP server if we predict that there will be several messages sent in the future.
doronbehar: > Feedbacks are welcome. Do you like it? Are you going to use it? What are you planning to do with this?

I like this feature a lot! I think the usage I was originally intending for this feature was deleting Vim buffers from `fzf.vim`'s `:Buffers`. This feature is needed for this feature because naively using `--bind=X:execute('nvr ...")+reload(...)` did not work for me from some reason. The alternative I was thinking about was mapping <kbd>X</kbd> from within Vim to delete the buffer (and then only tell `fzf` to reload the list via the server / UNIX signal), and not via `fzf`'s `--bind`.

I'm not sure it will be easy or even possible to implement this feature even with it merged.. I also hadn't had any more usages in mind.

The general usage @edi9999 mentioned:

> I will use it mainly for one purpose : to reload data from a source that updates itself in a background.

Is basically a generalization of my usage.
junegunn: I rewrote the server without using `net/http` package. The implementation is simplistic and probably brittle, but the size of the binary has reduced to 3.3MB, which I think is acceptable.

@edi9999 

> I'm not sure how the cursor position will adapt

The cursor stays at the same vertical position after `reload` unless you update the query while reloading is in progress. It's an issue unrelated to this addition of `--listen`.

```sh
seq 100 | fzf --query 5 --sync --bind 'start:up+up+up+reload:sleep 1; seq 1000'
```
edi9999: Looks great to me !
Awesome that you could use directly tcp and minimize the binary size.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9e239f1d..163b41b0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,14 @@ CHANGELOG
 
 0.36.0
 ------
+- Added `--listen=HTTP_PORT` option to receive actions from external processes
+  ```sh
+  # Start HTTP server on port 6266
+  fzf --listen 6266
+
+  # Send actions to the server
+  curl -XPOST localhost:6266 -d 'reload(seq 100)+change-prompt(hundred> )'
+  ```
 - Added `next-selected` and `prev-selected` actions to move between selected
   items
   ```sh
diff --git a/man/man1/fzf.1 b/man/man1/fzf.1
index a4072326..876f28f6 100644
--- a/man/man1/fzf.1
+++ b/man/man1/fzf.1
@@ -721,6 +721,19 @@ ncurses finder only after the input stream is complete.
 e.g. \fBfzf --multi | fzf --sync\fR
 .RE
 .TP
+.B "--listen=HTTP_PORT"
+Start HTTP server on the given port to receive actions via POST requests.
+
+e.g.
+     \fB# Start HTTP server on port 6266
+     fzf --listen 6266
+
+     # Send action to the server
+     curl -XPOST localhost:6266 -d 'reload(seq 100)+change-prompt(hundred> )'
+     \fR
+
+The port number is exported as \fB$FZF_LISTEN_PORT\fR on the child processes.
+.TP
 .B "--version"
 Display version information and exit
 
diff --git a/src/options.go b/src/options.go
index 64cf5609..4c7774c9 100644
--- a/src/options.go
+++ b/src/options.go
@@ -113,6 +113,7 @@ const usage = `usage: fzf [options]
     --read0                Read input delimited by ASCII NUL characters
     --print0               Print output delimited by ASCII NUL characters
     --sync                 Synchronous search for multi-staged filtering
+    --listen=HTTP_PORT     Start HTTP server to receive actions (POST /)
     --version              Display version information and exit
 
   Environment variables
@@ -296,6 +297,7 @@ type Options struct {
 	PreviewLabel labelOpts
 	Unicode      bool
 	Tabstop      int
+	ListenPort   int
 	ClearOnExit  bool
 	Version      bool
 }
@@ -868,8 +870,9 @@ func parseTheme(defaultTheme *tui.ColorTheme, str string) *tui.ColorTheme {
 }
 
 var (
-	executeRegexp *regexp.Regexp
-	splitRegexp   *regexp.Regexp
+	executeRegexp    *regexp.Regexp
+	splitRegexp      *regexp.Regexp
+	actionNameRegexp *regexp.Regexp
 )
 
 func firstKey(keymap map[tui.Event]string) tui.Event {
@@ -891,45 +894,216 @@ func init() {
 	executeRegexp = regexp.MustCompile(
 		`(?si)[:+](execute(?:-multi|-silent)?|reload|preview|change-query|change-prompt|change-preview-window|change-preview|(?:re|un)bind):.+|[:+](execute(?:-multi|-silent)?|reload|preview|change-query|change-prompt|change-preview-window|change-preview|(?:re|un)bind)(\([^)]*\)|\[[^\]]*\]|~[^~]*~|![^!]*!|@[^@]*@|\#[^\#]*\#|\$[^\$]*\$|%[^%]*%|\^[^\^]*\^|&[^&]*&|\*[^\*]*\*|;[^;]*;|/[^/]*/|\|[^\|]*\|)`)
 	splitRegexp = regexp.MustCompile("[,:]+")
+	actionNameRegexp = regexp.MustCompile("(?i)^[a-z-]+")
 }
 
-func parseKeymap(keymap map[tui.Event][]*action, str string) {
-	masked := executeRegexp.ReplaceAllStringFunc(str, func(src string) string {
-		symbol := ":"
-		if strings.HasPrefix(src, "+") {
-			symbol = "+"
-		}
-		prefix := symbol + "execute"
-		if strings.HasPrefix(src[1:], "reload") {
-			prefix = symbol + "reload"
-		} else if strings.HasPrefix(src[1:], "change-preview-window") {
-			prefix = symbol + "change-preview-window"
-		} else if strings.HasPrefix(src[1:], "change-preview") {
-			prefix = symbol + "change-preview"
-		} else if strings.HasPrefix(src[1:], "preview") {
-			prefix = symbol + "preview"
-		} else if strings.HasPrefix(src[1:], "unbind") {
-			prefix = symbol + "unbind"
-		} else if strings.HasPrefix(src[1:], "rebind") {
-			prefix = symbol + "rebind"
-		} else if strings.HasPrefix(src[1:], "change-query") {
-			prefix = symbol + "change-query"
-		} else if strings.HasPrefix(src[1:], "change-prompt") {
-			prefix = symbol + "change-prompt"
-		} else if src[len(prefix)] == '-' {
-			c := src[len(prefix)+1]
-			if c == 's' || c == 'S' {
-				prefix += "-silent"
-			} else {
-				prefix += "-multi"
-			}
-		}
+func maskActionContents(action string) string {
+	masked := executeRegexp.ReplaceAllStringFunc(action, func(src string) string {
+		prefix := src[:1] + actionNameRegexp.FindString(src[1:])
 		return prefix + "(" + strings.Repeat(" ", len(src)-len(prefix)-2) + ")"
 	})
 	masked = strings.Replace(masked, "::", string([]rune{escapedColon, ':'}), -1)
 	masked = strings.Replace(masked, ",:", string([]rune{escapedComma, ':'}), -1)
 	masked = strings.Replace(masked, "+:", string([]rune{escapedPlus, ':'}), -1)
+	return masked
+}
+
+func parseSingleActionList(str string, exit func(string)) []*action {
+	// We prepend a colon to satisfy executeRegexp and remove it later
+	masked := maskActionContents(":" + str)[1:]
+	return parseActionList(masked, str, []*action{}, false, exit)
+}
 
+func parseActionList(masked string, original string, prevActions []*action, putAllowed bool, exit func(string)) []*action {
+	maskedStrings := strings.Split(masked, "+")
+	originalStrings := make([]string, len(maskedStrings))
+	idx := 0
+	for i, maskedString := range maskedStrings {
+		originalStrings[i] = original[idx : idx+len(maskedString)]
+		idx += len(maskedString) + 1
+	}
+	actions := make([]*action, 0, len(maskedStrings))
+	appendAction := func(types ...actionType) {
+		actions = append(actions, toActions(types...)...)
+	}
+	prevSpec := ""
+	for specIndex, spec := range originalStrings {
+		spec = prevSpec + spec
+		specLower := strings.ToLower(spec)
+		switch specLower {
+		case "ignore":
+			appendAction(actIgnore)
+		case "beginning-of-line":
+			appendAction(actBeginningOfLine)
+		case "abort":
+			appendAction(actAbort)
+		case "accept":
+			appendAction(actAccept)
+		case "accept-non-empty":
+			appendAction(actAcceptNonEmpty)
+		case "print-query":
+			appendAction(actPrintQuery)
+		case "refresh-preview":
+			appendAction(actRefreshPreview)
+		case "replace-query":
+			appendAction(actReplaceQuery)
+		case "backward-char":
+			appendAction(actBackwardChar)
+		case "backward-delete-char":
+			appendAction(actBackwardDeleteChar)
+		case "backward-delete-char/eof":
+			appendAction(actBackwardDeleteCharEOF)
+		case "backward-word":
+			appendAction(actBackwardWord)
+		case "clear-screen":
+			appendAction(actClearScreen)
+		case "delete-char":
+			appendAction(actDeleteChar)
+		case "delete-char/eof":
+			appendAction(actDeleteCharEOF)
+		case "deselect":
+			appendAction(actDeselect)
+		case "end-of-line":
+			appendAction(actEndOfLine)
+		case "cancel":
+			appendAction(actCancel)
+		case "clear-query":
+			appendAction(actClearQuery)
+		case "clear-selection":
+			appendAction(actClearSelection)
+		case "forward-char":
+			appendAction(actForwardChar)
+		case "forward-word":
+			appendAction(actForwardWord)
+		case "jump":
+			appendAction(actJump)
+		case "jump-accept":
+			appendAction(actJumpAccept)
+		case "kill-line":
+			appendAction(actKillLine)
+		case "kill-word":
+			appendAction(actKillWord)
+		case "unix-line-discard", "line-discard":
+			appendAction(actUnixLineDiscard)
+		case "unix-word-rubout", "word-rubout":
+			appendAction(actUnixWordRubout)
+		case "yank":
+			appendAction(actYank)
+		case "backward-kill-word":
+			appendAction(actBackwardKillWord)
+		case "toggle-down":
+			appendAction(actToggle, actDown)
+		case "toggle-up":
+			appendAction(actToggle, actUp)
+		case "toggle-in":
+			appendAction(actToggleIn)
+		case "toggle-out":
+			appendAction(actToggleOut)
+		case "toggle-all":
+			appendAction(actToggleAll)
+		case "toggle-search":
+			appendAction(actToggleSearch)
+		case "select":
+			appendAction(actSelect)
+		case "select-all":
+			appendAction(actSelectAll)
+		case "deselect-all":
+			appendAction(actDeselectAll)
+		case "close":
+			appendAction(actClose)
+		case "toggle":
+			appendAction(actToggle)
+		case "down":
+			appendAction(actDown)
+		case "up":
+			appendAction(actUp)
+		case "first", "top":
+			appendAction(actFirst)
+		case "last":
+			appendAction(actLast)
+		case "page-up":
+			appendAction(actPageUp)
+		case "page-down":
+			appendAction(actPageDown)
+		case "half-page-up":
+			appendAction(actHalfPageUp)
+		case "half-page-down":
+			appendAction(actHalfPageDown)
+		case "prev-history", "previous-history":
+			appendAction(actPrevHistory)
+		case "next-history":
+			appendAction(actNextHistory)
+		case "prev-selected":
+			appendAction(actPrevSelected)
+		case "next-selected":
+			appendAction(actNextSelected)
+		case "toggle-preview":
+			appendAction(actTogglePreview)
+		case "toggle-preview-wrap":
+			appendAction(actTogglePreviewWrap)
+		case "toggle-sort":
+			appendAction(actToggleSort)
+		case "preview-top":
+			appendAction(actPreviewTop)
+		case "preview-bottom":
+			appendAction(actPreviewBottom)
+		case "preview-up":
+			appendAction(actPreviewUp)
+		case "preview-down":
+			appendAction(actPreviewDown)
+		case "preview-page-up":
+			appendAction(actPreviewPageUp)
+		case "preview-page-down":
+			appendAction(actPreviewPageDown)
+		case "preview-half-page-up":
+			appendAction(actPreviewHalfPageUp)
+		case "preview-half-page-down":
+			appendAction(actPreviewHalfPageDown)
+		case "enable-search":
+			appendAction(actEnableSearch)
+		case "disable-search":
+			appendAction(actDisableSearch)
+		case "put":
+			if putAllowed {
+				appendAction(actRune)
+			} else {
+				exit("unable to put non-printable character")
+			}
+		default:
+			t := isExecuteAction(specLower)
+			if t == actIgnore {
+				if specIndex == 0 && specLower == "" {
+					actions = append(prevActions, actions...)
+				} else {
+					exit("unknown action: " + spec)
+				}
+			} else {
+				offset := len(actionNameRegexp.FindString(spec))
+				var actionArg string
+				if spec[offset] == ':' {
+					if specIndex == len(originalStrings)-1 {
+						actionArg = spec[offset+1:]
+						actions = append(actions, &action{t: t, a: actionArg})
+					} else {
+						prevSpec = spec + "+"
+						continue
+					}
+				} else {
+					actionArg = spec[offset+1 : len(spec)-1]
+					actions = append(actions, &action{t: t, a: actionArg})
+				}
+				if t == actUnbind || t == actRebind {
+					parseKeyChords(actionArg, spec[0:offset]+" target required")
+				}
+			}
+		}
+		prevSpec = ""
+	}
+	return actions
+}
+
+func parseKeymap(keymap map[tui.Event][]*action, str string, exit func(string)) {
+	masked := maskActionContents(str)
 	idx := 0
 	for _, pairStr := range strings.Split(masked, ",") {
 		origPairStr := str[idx : idx+len(pairStr)]
@@ -937,7 +1111,7 @@ func parseKeymap(keymap map[tui.Event][]*action, str string) {
 
 		pair := strings.SplitN(pairStr, ":", 2)
 		if len(pair) < 2 {
-			errorExit("bind action not specified: " + origPairStr)
+			exit("bind action not specified: " + origPairStr)
 		}
 		var key tui.Event
 		if len(pair[0]) == 1 && pair[0][0] == escapedColon {
@@ -950,219 +1124,14 @@ func parseKeymap(keymap map[tui.Event][]*action, str string) {
 			keys := parseKeyChords(pair[0], "key name required")
 			key = firstKey(keys)
 		}
-
-		idx2 := len(pair[0]) + 1
-		specs := strings.Split(pair[1], "+")
-		actions := make([]*action, 0, len(specs))
-		appendAction := func(types ...actionType) {
-			actions = append(actions, toActions(types...)...)
-		}
-		prevSpec := ""
-		for specIndex, maskedSpec := range specs {
-			spec := origPairStr[idx2 : idx2+len(maskedSpec)]
-			idx2 += len(maskedSpec) + 1
-			spec = prevSpec + spec
-			specLower := strings.ToLower(spec)
-			switch specLower {
-			case "ignore":
-				appendAction(actIgnore)
-			case "beginning-of-line":
-				appendAction(actBeginningOfLine)
-			case "abort":
-				appendAction(actAbort)
-			case "accept":
-				appendAction(actAccept)
-			case "accept-non-empty":
-				appendAction(actAcceptNonEmpty)
-			case "print-query":
-				appendAction(actPrintQuery)
-			case "refresh-preview":
-				appendAction(actRefreshPreview)
-			case "replace-query":
-				appendAction(actReplaceQuery)
-			case "backward-char":
-				appendAction(actBackwardChar)
-			case "backward-delete-char":
-				appendAction(actBackwardDeleteChar)
-			case "backward-delete-char/eof":
-				appendAction(actBackwardDeleteCharEOF)
-			case "backward-word":
-				appendAction(actBackwardWord)
-			case "clear-screen":
-				appendAction(actClearScreen)
-			case "delete-char":
-				appendAction(actDeleteChar)
-			case "delete-char/eof":
-				appendAction(actDeleteCharEOF)
-			case "deselect":
-				appendAction(actDeselect)
-			case "end-of-line":
-				appendAction(actEndOfLine)
-			case "cancel":
-				appendAction(actCancel)
-			case "clear-query":
-				appendAction(actClearQuery)
-			case "clear-selection":
-				appendAction(actClearSelection)
-			case "forward-char":
-				appendAction(actForwardChar)
-			case "forward-word":
-				appendAction(actForwardWord)
-			case "jump":
-				appendAction(actJump)
-			case "jump-accept":
-				appendAction(actJumpAccept)
-			case "kill-line":
-				appendAction(actKillLine)
-			case "kill-word":
-				appendAction(actKillWord)
-			case "unix-line-discard", "line-discard":
-				appendAction(actUnixLineDiscard)
-			case "unix-word-rubout", "word-rubout":
-				appendAction(actUnixWordRubout)
-			case "yank":
-				appendAction(actYank)
-			case "backward-kill-word":
-				appendAction(actBackwardKillWord)
-			case "toggle-down":
-				appendAction(actToggle, actDown)
-			case "toggle-up":
-				appendAction(actToggle, actUp)
-			case "toggle-in":
-				appendAction(actToggleIn)
-			case "toggle-out":
-				appendAction(actToggleOut)
-			case "toggle-all":
-				appendAction(actToggleAll)
-			case "toggle-search":
-				appendAction(actToggleSearch)
-			case "select":
-				appendAction(actSelect)
-			case "select-all":
-				appendAction(actSelectAll)
-			case "deselect-all":
-				appendAction(actDeselectAll)
-			case "close":
-				appendAction(actClose)
-			case "toggle":
-				appendAction(actToggle)
-			case "down":
-				appendAction(actDown)
-			case "up":
-				appendAction(actUp)
-			case "first", "top":
-				appendAction(actFirst)
-			case "last":
-				appendAction(actLast)
-			case "page-up":
-				appendAction(actPageUp)
-			case "page-down":
-				appendAction(actPageDown)
-			case "half-page-up":
-				appendAction(actHalfPageUp)
-			case "half-page-down":
-				appendAction(actHalfPageDown)
-			case "prev-history", "previous-history":
-				appendAction(actPrevHistory)
-			case "next-history":
-				appendAction(actNextHistory)
-			case "prev-selected":
-				appendAction(actPrevSelected)
-			case "next-selected":
-				appendAction(actNextSelected)
-			case "toggle-preview":
-				appendAction(actTogglePreview)
-			case "toggle-preview-wrap":
-				appendAction(actTogglePreviewWrap)
-			case "toggle-sort":
-				appendAction(actToggleSort)
-			case "preview-top":
-				appendAction(actPreviewTop)
-			case "preview-bottom":
-				appendAction(actPreviewBottom)
-			case "preview-up":
-				appendAction(actPreviewUp)
-			case "preview-down":
-				appendAction(actPreviewDown)
-			case "preview-page-up":
-				appendAction(actPreviewPageUp)
-			case "preview-page-down":
-				appendAction(actPreviewPageDown)
-			case "preview-half-page-up":
-				appendAction(actPreviewHalfPageUp)
-			case "preview-half-page-down":
-				appendAction(actPreviewHalfPageDown)
-			case "enable-search":
-				appendAction(actEnableSearch)
-			case "disable-search":
-				appendAction(actDisableSearch)
-			case "put":
-				if key.Type == tui.Rune && unicode.IsGraphic(key.Char) {
-					appendAction(actRune)
-				} else {
-					errorExit("unable to put non-printable character: " + pair[0])
-				}
-			default:
-				t := isExecuteAction(specLower)
-				if t == actIgnore {
-					if specIndex == 0 && specLower == "" {
-						actions = append(keymap[key], actions...)
-					} else {
-						errorExit("unknown action: " + spec)
-					}
-				} else {
-					var offset int
-					switch t {
-					case actReload:
-						offset = len("reload")
-					case actPreview:
-						offset = len("preview")
-					case actChangePreviewWindow:
-						offset = len("change-preview-window")
-					case actChangePreview:
-						offset = len("change-preview")
-					case actChangePrompt:
-						offset = len("change-prompt")
-					case actChangeQuery:
-						offset = len("change-query")
-					case actUnbind:
-						offset = len("unbind")
-					case actRebind:
-						offset = len("rebind")
-					case actExecuteSilent:
-						offset = len("execute-silent")
-					case actExecuteMulti:
-						offset = len("execute-multi")
-					default:
-						offset = len("execute")
-					}
-					var actionArg string
-					if spec[offset] == ':' {
-						if specIndex == len(specs)-1 {
-							actionArg = spec[offset+1:]
-							actions = append(actions, &action{t: t, a: actionArg})
-						} else {
-							prevSpec = spec + "+"
-							continue
-						}
-					} else {
-						actionArg = spec[offset+1 : len(spec)-1]
-						actions = append(actions, &action{t: t, a: actionArg})
-					}
-					if t == actUnbind || t == actRebind {
-						parseKeyChords(actionArg, spec[0:offset]+" target required")
-					}
-				}
-			}
-			prevSpec = ""
-		}
-		keymap[key] = actions
+		putAllowed := key.Type == tui.Rune && unicode.IsGraphic(key.Char)
+		keymap[key] = parseActionList(pair[1], origPairStr[len(pair[0])+1:], keymap[key], putAllowed, exit)
 	}
 }
 
 func isExecuteAction(str string) actionType {
 	matches := executeRegexp.FindAllStringSubmatch(":"+str, -1)
-	if matches == nil || len(matches) != 1 {
+	if matches == nil || len(matches) != 1 || len(matches[0][0]) != len(str)+1 {
 		return actIgnore
 	}
 	prefix := matches[0][1]
@@ -1455,7 +1424,7 @@ func parseOptions(opts *Options, allArgs []string) {
 		case "--tiebreak":
 			opts.Criteria = parseTiebreak(nextString(allArgs, &i, "sort criterion required"))
 		case "--bind":
-			parseKeymap(opts.Keymap, nextString(allArgs, &i, "bind expression required"))
+			parseKeymap(opts.Keymap, nextString(allArgs, &i, "bind expression required"), errorExit)
 		case "--color":
 			_, spec := optionalNextString(allArgs, &i)
 			if len(spec) == 0 {
@@ -1657,6 +1626,10 @@ func parseOptions(opts *Options, allArgs []string) {
 				nextString(allArgs, &i, "padding required (TRBL / TB,RL / T,RL,B / T,R,B,L)"))
 		case "--tabstop":
 			opts.Tabstop = nextInt(allArgs, &i, "tab stop required")
+		case "--listen":
+			opts.ListenPort = nextInt(allArgs, &i, "listen port required")
+		case "--no-listen":
+			opts.ListenPort = 0
 		case "--clear":
 			opts.ClearOnExit = true
 		case "--no-clear":
@@ -1723,7 +1696,7 @@ func parseOptions(opts *Options, allArgs []string) {
 			} else if match, value := optString(arg, "--color="); match {
 				opts.Theme = parseTheme(opts.Theme, value)
 			} else if match, value := optString(arg, "--bind="); match {
-				parseKeymap(opts.Keymap, value)
+				parseKeymap(opts.Keymap, value, errorExit)
 			} else if match, value := optString(arg, "--history="); match {
 				setHistory(value)
 			} else if match, value := optString(arg, "--history-size="); match {
@@ -1744,6 +1717,8 @@ func parseOptions(opts *Options, allArgs []string) {
 				opts.Padding = parseMargin("padding", value)
 			} else if match, value := optString(arg, "--tabstop="); match {
 				opts.Tabstop = atoi(value)
+			} else if match, value := optString(arg, "--listen="); match {
+				opts.ListenPort = atoi(value)
 			} else if match, value := optString(arg, "--hscroll-off="); match {
 				opts.HscrollOff = atoi(value)
 			} else if match, value := optString(arg, "--scroll-off="); match {
@@ -1773,6 +1748,10 @@ func parseOptions(opts *Options, allArgs []string) {
 		errorExit("tab stop must be a positive integer")
 	}
 
+	if opts.ListenPort < 0 || opts.ListenPort > 65535 {
+		errorExit("invalid listen port")
+	}
+
 	if len(opts.JumpLabels) == 0 {
 		errorExit("empty jump labels")
 	}
diff --git a/src/server.go b/src/server.go
new file mode 100644
index 00000000..eaa314c9
--- /dev/null
+++ b/src/server.go
@@ -0,0 +1,112 @@
+package fzf
+
+import (
+	"bufio"
+	"bytes"
+	"errors"
+	"fmt"
+	"net"
+	"strconv"
+	"strings"
+)
+
+const (
+	crlf           = "\r\n"
+	httpOk         = "HTTP/1.1 200 OK" + crlf
+	httpBadRequest = "HTTP/1.1 400 Bad Request" + crlf
+)
+
+func startHttpServer(port int, channel chan []*action) {
+	if port == 0 {
+		return
+	}
+
+	listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
+	if err != nil {
+		return
+	}
+
+	go func() {
+		for {
+			conn, err := listener.Accept()
+			if err != nil {
+				if errors.Is(err, net.ErrClosed) {
+					break
+				} else {
+					continue
+				}
+			}
+			conn.Write([]byte(handleHttpRequest(conn, channel)))
+			conn.Close()
+		}
+		listener.Close()
+	}()
+}
+
+// Here we are writing a simplistic HTTP server without using net/http
+// package to reduce the size of the binary.
+//
+// * No --listen:            2.8MB
+// * --listen with net/http: 5.7MB
+// * --listen w/o net/http:  3.3MB
+func handleHttpRequest(conn net.Conn, channel chan []*action) string {
+	line := 0
+	headerRead := false
+	contentLength := 0
+	body := ""
+	bad := func(message string) string {
+		message += "\n"
+		return httpBadRequest + fmt.Sprintf("Content-Length: %d%s", len(message), crlf+crlf+message)
+	}
+	scanner := bufio.NewScanner(conn)
+	scanner.Split(func(data []byte, atEOF bool) (int, []byte, error) {
+		found := bytes.Index(data, []byte(crlf))
+		if found >= 0 {
+			token := data[:found+len(crlf)]
+			return len(token), token, nil
+		}
+		if atEOF || len(body)+len(data) >= contentLength {
+			return 0, data, bufio.ErrFinalToken
+		}
+		return 0, nil, nil
+	})
+
+	for scanner.Scan() {
+		text := scanner.Text()
+		if line == 0 && !strings.HasPrefix(text, "POST / HTTP") {
+			return bad("invalid request method")
+		}
+		if text == crlf {
+			headerRead = true
+		}
+		if !headerRead {
+			pair := strings.SplitN(text, ":", 2)
+			if len(pair) == 2 && strings.ToLower(pair[0]) == "content-length" {
+				length, err := strconv.Atoi(strings.TrimSpace(pair[1]))
+				if err != nil {
+					return bad("invalid content length")
+				}
+				contentLength = length
+			}
+		} else if contentLength <= 0 {
+			break
+		} else {
+			body += text
+		}
+		line++
+	}
+
+	errorMessage := ""
+	actions := parseSingleActionList(strings.Trim(string(body), "\r\n"), func(message string) {
+		errorMessage = message
+	})
+	if len(errorMessage) > 0 {
+		return bad(errorMessage)
+	}
+	if len(actions) == 0 {
+		return bad("no action specified")
+	}
+
+	channel <- actions
+	return httpOk
+}
diff --git a/src/terminal.go b/src/terminal.go
index e0ff5b2b..1ff40412 100644
--- a/src/terminal.go
+++ b/src/terminal.go
@@ -167,6 +167,7 @@ type Terminal struct {
 	padding            [4]sizeSpec
 	strong             tui.Attr
 	unicode            bool
+	listenPort         int
 	borderShape        tui.BorderShape
 	cleanExit          bool
 	paused             bool
@@ -200,6 +201,7 @@ type Terminal struct {
 	sigstop            bool
 	startChan          chan fitpad
 	killChan           chan int
+	serverChan         chan []*action
 	slab               *util.Slab
 	theme              *tui.ColorTheme
 	tui                tui.Renderer
@@ -481,7 +483,8 @@ func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal {
 	}
 	var previewBox *util.EventBox
 	showPreviewWindow := len(opts.Preview.command) > 0 && !opts.Preview.hidden
-	if len(opts.Preview.command) > 0 || hasPreviewAction(opts) {
+	// We need to start previewer if HTTP server is enabled even when --preview option is not specified
+	if len(opts.Preview.command) > 0 || hasPreviewAction(opts) || opts.ListenPort > 0 {
 		previewBox = util.NewEventBox()
 	}
 	strongAttr := tui.Bold
@@ -556,6 +559,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal {
 		margin:             opts.Margin,
 		padding:            opts.Padding,
 		unicode:            opts.Unicode,
+		listenPort:         opts.ListenPort,
 		borderShape:        opts.BorderShape,
 		borderLabel:        nil,
 		borderLabelOpts:    opts.BorderLabel,
@@ -595,6 +599,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal {
 		theme:              opts.Theme,
 		startChan:          make(chan fitpad, 1),
 		killChan:           make(chan int),
+		serverChan:         make(chan []*action),
 		tui:                renderer,
 		initFunc:           func() { renderer.Init() },
 		executing:          util.NewAtomicBool(false)}
@@ -2256,6 +2261,9 @@ func (t *Terminal) Loop() {
 						env = append(env, "FZF_PREVIEW_"+lines)
 						env = append(env, columns)
 						env = append(env, "FZF_PREVIEW_"+columns)
+						if t.listenPort > 0 {
+							env = append(env, fmt.Sprintf("FZF_LISTEN_PORT=%d", t.listenPort))
+						}
 						cmd.Env = env
 					}
 
@@ -2492,6 +2500,16 @@ func (t *Terminal) Loop() {
 	looping := true
 	_, startEvent := t.keymap[tui.Start.AsEvent()]
 
+	startHttpServer(t.listenPort, t.serverChan)
+	eventChan := make(chan tui.Event)
+	needBarrier := true
+	barrier := make(chan bool)
+	go func() {
+		for {
+			<-barrier
+			eventChan <- t.tui.GetChar()
+		}
+	}()
 	for looping {
 		var newCommand *string
 		changed := false
@@ -2499,11 +2517,21 @@ func (t *Terminal) Loop() {
 		queryChanged := false
 
 		var event tui.Event
+		actions := []*action{}
 		if startEvent {
 			event = tui.Start.AsEvent()
 			startEvent = false
 		} else {
-			event = t.tui.GetChar()
+			if needBarrier {
+				barrier <- true
+			}
+			select {
+			case event = <-eventChan:
+				needBarrier = true
+			case actions = <-t.serverChan:
+				event = tui.Invalid.AsEvent()
+				needBarrier = false
+			}
 		}
 
 		t.mutex.Lock()
@@ -3042,8 +3070,15 @@ func (t *Terminal) Loop() {
 			return true
 		}
 
-		if t.jumping == jumpDisabled {
-			actions := t.keymap[event.Comparable()]
+		if t.jumping == jumpDisabled || len(actions) > 0 {
+			// Break out of jump mode if any action is submitted to the server
+			if t.jumping != jumpDisabled {
+				t.jumping = jumpDisabled
+				req(reqList)
+			}
+			if len(actions) == 0 {
+				actions = t.keymap[event.Comparable()]
+			}
 			if len(actions) == 0 && event.Type == tui.Rune {
 				doAction(&action{t: actRune})
 			} else if !doActions(actions) {


Test patch:
diff --git a/src/options_test.go b/src/options_test.go
index 0fb569fe..fbf319e0 100644
--- a/src/options_test.go
+++ b/src/options_test.go
@@ -262,13 +262,17 @@ func TestBind(t *testing.T) {
 		}
 	}
 	check(tui.CtrlA.AsEvent(), "", actBeginningOfLine)
+	errorString := ""
+	errorFn := func(e string) {
+		errorString = e
+	}
 	parseKeymap(keymap,
 		"ctrl-a:kill-line,ctrl-b:toggle-sort+up+down,c:page-up,alt-z:page-down,"+
 			"f1:execute(ls {+})+abort+execute(echo {+})+select-all,f2:execute/echo {}, {}, {}/,f3:execute[echo '({})'],f4:execute;less {};,"+
 			"alt-a:execute-Multi@echo (,),[,],/,:,;,%,{}@,alt-b:execute;echo (,),[,],/,:,@,%,{};,"+
 			"x:Execute(foo+bar),X:execute/bar+baz/"+
 			",f1:+first,f1:+top"+
-			",,:abort,::accept,+:execute:++\nfoobar,Y:execute(baz)+up")
+			",,:abort,::accept,+:execute:++\nfoobar,Y:execute(baz)+up", errorFn)
 	check(tui.CtrlA.AsEvent(), "", actKillLine)
 	check(tui.CtrlB.AsEvent(), "", actToggleSort, actUp, actDown)
 	check(tui.Key('c'), "", actPageUp)
@@ -286,12 +290,15 @@ func TestBind(t *testing.T) {
 	check(tui.Key('+'), "++\nfoobar,Y:execute(baz)+up", actExecute)
 
 	for idx, char := range []rune{'~', '!', '@', '#', '$', '%', '^', '&', '*', '|', ';', '/'} {
-		parseKeymap(keymap, fmt.Sprintf("%d:execute%cfoobar%c", idx%10, char, char))
+		parseKeymap(keymap, fmt.Sprintf("%d:execute%cfoobar%c", idx%10, char, char), errorFn)
 		check(tui.Key([]rune(fmt.Sprintf("%d", idx%10))[0]), "foobar", actExecute)
 	}
 
-	parseKeymap(keymap, "f1:abort")
+	parseKeymap(keymap, "f1:abort", errorFn)
 	check(tui.F1.AsEvent(), "", actAbort)
+	if len(errorString) > 0 {
+		t.Errorf("error parsing keymap: %s", errorString)
+	}
 }
 
 func TestColorSpec(t *testing.T) {
@@ -466,3 +473,29 @@ func TestValidateSign(t *testing.T) {
 		}
 	}
 }
+
+func TestParseSingleActionList(t *testing.T) {
+	actions := parseSingleActionList("Execute@foo+bar,baz@+up+up+reload:down+down", func(string) {})
+	if len(actions) != 4 {
+		t.Errorf("Invalid number of actions parsed:%d", len(actions))
+	}
+	if actions[0].t != actExecute || actions[0].a != "foo+bar,baz" {
+		t.Errorf("Invalid action parsed: %v", actions[0])
+	}
+	if actions[1].t != actUp || actions[2].t != actUp {
+		t.Errorf("Invalid action parsed: %v / %v", actions[1], actions[2])
+	}
+	if actions[3].t != actReload || actions[3].a != "down+down" {
+		t.Errorf("Invalid action parsed: %v", actions[3])
+	}
+}
+
+func TestParseSingleActionListError(t *testing.T) {
+	err := ""
+	parseSingleActionList("change-query(foobar)baz", func(e string) {
+		err = e
+	})
+	if len(err) == 0 {
+		t.Errorf("Failed to detect error")
+	}
+}
diff --git a/test/test_go.rb b/test/test_go.rb
index 5bffaaf9..4c424396 100755
--- a/test/test_go.rb
+++ b/test/test_go.rb
@@ -7,6 +7,7 @@ require 'English'
 require 'shellwords'
 require 'erb'
 require 'tempfile'
+require 'net/http'
 
 TEMPLATE = DATA.read
 UNSETS = %w[
@@ -2435,6 +2436,14 @@ class TestGoFZF < TestBase
     tmux.send_keys 'C-p'
     tmux.until { |lines| assert_includes lines, '>>2' }
   end
+
+  def test_listen
+    tmux.send_keys 'seq 10 | fzf --listen 6266', :Enter
+    tmux.until { |lines| assert_equal 10, lines.item_count }
+    Net::HTTP.post(URI('http://localhost:6266'), 'change-query(yo)+reload(seq 100)+change-prompt:hundred> ')
+    tmux.until { |lines| assert_equal 100, lines.item_count }
+    tmux.until { |lines| assert_equal 'hundred> yo', lines[-1] }
+  end
 end
 
 module TestShell