diff --git a/bindings/vala/app.vala b/bindings/vala/app.vala
index 58698f9..695e688 100644
--- a/bindings/vala/app.vala
+++ b/bindings/vala/app.vala
@@ -628,6 +628,9 @@ class Window : Gtk.ApplicationWindow
 
 #if VALA_0_24
     if (event != null) {
+      var hyperlink = terminal.get_hyperlink(event);
+      if (hyperlink != null)
+        menu.append("Copy _Hyperlink", "win.copy-match::" + hyperlink);
       var match = terminal.match_check_event(event, null);
       if (match != null)
         menu.append("Copy _Match", "win.copy-match::" + match);
diff --git a/doc/reference/vte-docs.xml b/doc/reference/vte-docs.xml
index 89a4e15..9e66f2b 100644
--- a/doc/reference/vte-docs.xml
+++ b/doc/reference/vte-docs.xml
@@ -102,6 +102,10 @@
     <title>Index of new symbols in 0.48</title>
     <xi:include href="xml/api-index-0.48.xml"><xi:fallback /></xi:include>
   </index>
+  <index id="api-index-0-50" role="0.50">
+    <title>Index of new symbols in 0.50</title>
+    <xi:include href="xml/api-index-0.50.xml"><xi:fallback /></xi:include>
+  </index>
 
   <xi:include href="xml/annotation-glossary.xml"><xi:fallback /></xi:include>
 
diff --git a/doc/reference/vte-sections.txt b/doc/reference/vte-sections.txt
index d4bedb1..a32155c 100644
--- a/doc/reference/vte-sections.txt
+++ b/doc/reference/vte-sections.txt
@@ -54,6 +54,7 @@ vte_terminal_get_text
 vte_terminal_get_text_include_trailing_spaces
 vte_terminal_get_text_range
 vte_terminal_get_cursor_position
+vte_terminal_get_hyperlink
 vte_terminal_match_add_regex
 vte_terminal_match_remove
 vte_terminal_match_remove_all
diff --git a/src/caps.cc b/src/caps.cc
index 4757126..3c3fd65 100644
--- a/src/caps.cc
+++ b/src/caps.cc
@@ -229,6 +229,7 @@ const char _vte_xterm_capability_strings[] =
         ENTRY(OSC "4;%s" BEL, "change-color-bel")
         ENTRY(OSC "6;%s" BEL, "set-current-file-uri")
         ENTRY(OSC "7;%s" BEL, "set-current-directory-uri")
+        ENTRY(OSC "8;%s;%s" BEL, "set-current-hyperlink")
         ENTRY(OSC "10;%s" BEL, "change-foreground-color-bel")
         ENTRY(OSC "11;%s" BEL, "change-background-color-bel")
         ENTRY(OSC "12;%s" BEL, "change-cursor-background-color-bel")
@@ -266,6 +267,7 @@ const char _vte_xterm_capability_strings[] =
         ENTRY(OSC "4;%s" ST, "change-color-st")
         ENTRY(OSC "6;%s" ST, "set-current-file-uri")
         ENTRY(OSC "7;%s" ST, "set-current-directory-uri")
+        ENTRY(OSC "8;%s;%s" ST, "set-current-hyperlink")
         ENTRY(OSC "10;%s" ST, "change-foreground-color-st")
         ENTRY(OSC "11;%s" ST, "change-background-color-st")
         ENTRY(OSC "12;%s" ST, "change-cursor-background-color-st")
diff --git a/src/debug.c b/src/debug.c
index b8dc395..6fc9648 100644
--- a/src/debug.c
+++ b/src/debug.c
@@ -52,7 +52,8 @@ _vte_debug_init(void)
     { "widget-size",  VTE_DEBUG_WIDGET_SIZE  },
     { "style",        VTE_DEBUG_STYLE        },
     { "resize",       VTE_DEBUG_RESIZE       },
-    { "regex",        VTE_DEBUG_REGEX        }
+    { "regex",        VTE_DEBUG_REGEX        },
+    { "hyperlink",    VTE_DEBUG_HYPERLINK    },
   };
 
   _vte_debug_flags = g_parse_debug_string (g_getenv("VTE_DEBUG"),
diff --git a/src/debug.h b/src/debug.h
index c211942..13c8737 100644
--- a/src/debug.h
+++ b/src/debug.h
@@ -61,7 +61,8 @@ typedef enum {
 	VTE_DEBUG_WIDGET_SIZE   = 1 << 21,
         VTE_DEBUG_STYLE         = 1 << 22,
 	VTE_DEBUG_RESIZE        = 1 << 23,
-        VTE_DEBUG_REGEX         = 1 << 24
+        VTE_DEBUG_REGEX         = 1 << 24,
+        VTE_DEBUG_HYPERLINK     = 1 << 25,
 } VteDebugFlags;
 
 void _vte_debug_init(void);
diff --git a/src/ring.cc b/src/ring.cc
index e090610..2bcbe1d 100644
--- a/src/ring.cc
+++ b/src/ring.cc
@@ -29,6 +29,8 @@
  * VteRing: A buffer ring
  */
 
+#define hyperlink_get(ring, id) g_array_index((ring)->hyperlinks, GString, (id))
+
 #ifdef VTE_DEBUG
 static void
 _vte_ring_validate (VteRing * ring)
@@ -53,6 +55,8 @@ _vte_ring_validate (VteRing * ring)
 void
 _vte_ring_init (VteRing *ring, gulong max_rows, gboolean has_streams)
 {
+	GString *empty_str;
+
 	_vte_debug_print(VTE_DEBUG_RING, "New ring %p.\n", ring);
 
 	memset (ring, 0, sizeof (*ring));
@@ -80,6 +84,14 @@ _vte_ring_init (VteRing *ring, gulong max_rows, gboolean has_streams)
 
         ring->visible_rows = 0;
 
+        ring->hyperlinks = g_array_new(FALSE, FALSE, sizeof (GString));
+        empty_str = g_string_new_len("", 0);
+        g_array_append_val(ring->hyperlinks, *empty_str);
+        g_string_free(empty_str, FALSE);
+        ring->hyperlink_highest_used_idx = 0;
+        ring->hyperlink_current_idx = 0;
+        ring->hyperlink_hover_idx = 0;
+
 	_vte_ring_validate(ring);
 }
 
@@ -101,9 +113,153 @@ _vte_ring_fini (VteRing *ring)
 
 	g_string_free (ring->utf8_buffer, TRUE);
 
+        for (i = 0; i < ring->hyperlinks->len; i++)
+                g_free (hyperlink_get(ring, i).str);
+        g_array_free (ring->hyperlinks, TRUE);
+
 	_vte_row_data_fini (&ring->cached_row);
 }
 
+/*
+ * Do a round of garbage collection. Hyperlinks that no longer occur in the ring are wiped out.
+ */
+static void
+_vte_ring_hyperlink_gc (VteRing *ring)
+{
+	guint i, j, id;
+	VteRowData *row;
+	char *used;
+
+        _vte_debug_print (VTE_DEBUG_HYPERLINK,
+                          "hyperlink: GC starting (highest used idx is %d)\n",
+                          ring->hyperlink_highest_used_idx);
+
+        if (ring->hyperlink_highest_used_idx == 0) {
+                _vte_debug_print (VTE_DEBUG_HYPERLINK,
+                                  "hyperlink: GC done (no links at all, nothing to do)\n");
+                return;
+        }
+
+        /* No use in fiddling with bitmaps. One char for each idx to see if it's used. */
+        used = g_newa(char, 1 + ring->hyperlink_highest_used_idx);
+        memset(used, 0, 1 + ring->hyperlink_highest_used_idx);
+
+        /* A few special values not to be garbage collected. */
+        used[ring->hyperlink_current_idx] = 1;
+        used[ring->hyperlink_hover_idx] = 1;
+        used[ring->last_attr.hyperlink_idx] = 1;
+
+        // FIXME rather go from ring->start to ring->end
+        // FIXME what to do with cached_row???
+	for (i = 0; i <= ring->mask; i++) {
+	        row = &ring->array[i];
+	        for (j = 0; j < row->len; j++) {
+	                id = row->cells[j].attr.hyperlink_idx;
+	                used[id] = 1;
+	        }
+	}
+
+        for (i = 1; i <= ring->hyperlink_highest_used_idx; i++) {
+                if (!used[i] && hyperlink_get(ring, i).len != 0) {
+                        _vte_debug_print (VTE_DEBUG_HYPERLINK,
+                                          "hyperlink: GC purging link %d to id;uri=«%s»\n",
+                                          i, hyperlink_get(ring, i).str);
+                        /* Wipe out the ID and URI itself so it doesn't linger on in the memory for a long time */
+                        memset(hyperlink_get(ring, i).str, 0, hyperlink_get(ring, i).len);
+                        g_string_assign(&hyperlink_get(ring, i), "");
+                }
+	}
+
+        while (ring->hyperlink_highest_used_idx >= 1 && hyperlink_get(ring, ring->hyperlink_highest_used_idx).len == 0) {
+               ring->hyperlink_highest_used_idx--;
+        }
+
+        _vte_debug_print (VTE_DEBUG_HYPERLINK,
+                          "hyperlink: GC done (highest used idx is now %d)\n",
+                          ring->hyperlink_highest_used_idx);
+}
+
+/*
+ * Find existing idx or allocate a new one.
+ * FIXME do something more effective than a linear search
+ */
+static guint
+_vte_ring_get_hyperlink_idx_no_update_current (VteRing *ring, const char *hyperlink)
+{
+        guint i;
+	gsize len;
+	GString *str;
+
+	if (!hyperlink || !hyperlink[0])
+		return 0;
+
+	len = strlen(hyperlink);
+
+        /* Linear search for this particular URI */
+        for (i = 1; i <= ring->hyperlink_highest_used_idx; i++) {
+                if (hyperlink_get(ring, i).len == len &&
+                    strcmp(hyperlink_get(ring, i).str, hyperlink) == 0) {
+                        _vte_debug_print (VTE_DEBUG_HYPERLINK,
+                                          "get_hyperlink_idx: already existing idx %d for id;uri=«%s»\n",
+                                          i, hyperlink);
+                        return i;
+                }
+        }
+
+        // FIXME it's the second time we're GCing if coming from _vte_ring_get_hyperlink_idx
+        _vte_ring_hyperlink_gc(ring);
+
+        /* Another linear search for an empty slot where a GString is already allocated */
+        for (i = 1; i < ring->hyperlinks->len; i++) {
+                if (hyperlink_get(ring, i).len == 0) {
+                        _vte_debug_print (VTE_DEBUG_HYPERLINK,
+                                          "get_hyperlink_idx: reassigning old idx %d for id;uri=«%s»\n",
+                                          i, hyperlink);
+		        /* Grow size if required, however, never shrink to avoid long-term memory fragmentation. */
+			g_string_append_len (&hyperlink_get(ring, i), hyperlink, len);
+			ring->hyperlink_highest_used_idx = MAX (ring->hyperlink_highest_used_idx, i);
+			return i;
+		}
+	}
+
+        /* All allocated slots contain a different URI. Gotta allocate a new one */
+	g_assert_cmpuint(ring->hyperlink_highest_used_idx, ==, ring->hyperlinks->len - 1);
+
+	if (ring->hyperlink_highest_used_idx == VTE_HYPERLINK_COUNT_MAX) {
+	        _vte_debug_print (VTE_DEBUG_HYPERLINK,
+	                          "get_hyperlink_idx: idx 0 (ran out of available idxs) for id;uri=«%s»\n",
+	                          hyperlink);
+		return 0;
+	}
+
+        i = ++ring->hyperlink_highest_used_idx;
+        _vte_debug_print (VTE_DEBUG_HYPERLINK,
+                          "get_hyperlink_idx: brand new idx %d for id;uri=«%s»\n",
+                          i, hyperlink);
+        str = g_string_new_len (hyperlink, len);
+        g_array_append_val(ring->hyperlinks, *str);
+        g_string_free(str, FALSE);
+
+	g_assert_cmpuint(ring->hyperlink_highest_used_idx, ==, ring->hyperlinks->len - 1);
+
+	return i;
+}
+
+/*
+ * Find existing idx or allocate a new one and return its id. Return 0 if hyperlink is NULL.
+ * The current idx is also updated, in order not to be garbage collected.
+ */
+guint
+_vte_ring_get_hyperlink_idx (VteRing *ring, const char *hyperlink)
+{
+	ring->hyperlink_current_idx = 0;
+	_vte_ring_hyperlink_gc(ring);
+
+	ring->hyperlink_current_idx = _vte_ring_get_hyperlink_idx_no_update_current(ring, hyperlink);
+	return ring->hyperlink_current_idx;
+}
+
+
 typedef struct _VteRowRecord {
 	gsize text_start_offset;  /* offset where text of this row begins */
 	gsize attr_start_offset;  /* offset of the first character's attributes */
@@ -136,7 +292,9 @@ _vte_ring_freeze_row (VteRing *ring, gulong position, const VteRowData *row)
 	VteRowRecord record;
 	VteCell *cell;
 	GString *buffer = ring->utf8_buffer;
+	GString *hyperlink;
 	int i;
+	gboolean do_hyperlink_gc = FALSE;
 
 	_vte_debug_print (VTE_DEBUG_RING, "Freezing row %lu.\n", position);
 
@@ -166,28 +324,46 @@ _vte_ring_freeze_row (VteRing *ring, gulong position, const VteRowData *row)
 		attr = cell->attr;
 		if (G_LIKELY (!attr.fragment)) {
 			VteCellAttrChange attr_change;
+			guint16 hyperlink_length;
 
 			if (memcmp(&ring->last_attr, &attr, sizeof (VteCellAttr)) != 0) {
 				ring->last_attr_text_start_offset = record.text_start_offset + buffer->len;
 				memset(&attr_change, 0, sizeof (attr_change));
 				attr_change.text_end_offset = ring->last_attr_text_start_offset;
-				attr_change.attr = ring->last_attr;
+				memcpy(&attr_change.attr, &ring->last_attr, 8);  // FIXME refactor
+				hyperlink = &hyperlink_get(ring, ring->last_attr.hyperlink_idx);
+				attr_change.attr.hyperlink_length = hyperlink->len;
 				_vte_stream_append (ring->attr_stream, (const char *) &attr_change, sizeof (attr_change));
+				if (G_UNLIKELY (hyperlink->len != 0)) {
+					_vte_stream_append (ring->attr_stream, hyperlink->str, hyperlink->len);
+					do_hyperlink_gc = TRUE;
+				}
+				/* guint16 */ hyperlink_length = attr_change.attr.hyperlink_length;
+				_vte_stream_append (ring->attr_stream, (const char *) &hyperlink_length, 2);
 				if (!buffer->len)
 					/* This row doesn't use last_attr, adjust */
-					record.attr_start_offset += sizeof (attr_change);
+					record.attr_start_offset += sizeof (attr_change) + hyperlink_length + 2;
 				ring->last_attr = attr;
 			}
 
 			num_chars = _vte_unistr_strlen (cell->c);
 			if (num_chars > 1) {
+				/* Combining chars */
 				attr.columns = 0;
 				ring->last_attr_text_start_offset = record.text_start_offset + buffer->len
 								  + g_unichar_to_utf8 (_vte_unistr_get_base (cell->c), NULL);
 				memset(&attr_change, 0, sizeof (attr_change));
 				attr_change.text_end_offset = ring->last_attr_text_start_offset;
-				attr_change.attr = ring->last_attr;
+				memcpy(&attr_change.attr, &ring->last_attr, 8);  // FIXME refactor
+				hyperlink = &hyperlink_get(ring, ring->last_attr.hyperlink_idx);
+				attr_change.attr.hyperlink_length = hyperlink->len;
 				_vte_stream_append (ring->attr_stream, (const char *) &attr_change, sizeof (attr_change));
+				if (G_UNLIKELY (hyperlink->len != 0)) {
+					_vte_stream_append (ring->attr_stream, hyperlink->str, hyperlink->len);
+					do_hyperlink_gc = TRUE;
+				}
+				/* guint16 */ hyperlink_length = attr_change.attr.hyperlink_length;
+				_vte_stream_append (ring->attr_stream, (const char *) &hyperlink_length, 2);
 				ring->last_attr = attr;
 			}
 
@@ -201,10 +377,15 @@ _vte_ring_freeze_row (VteRing *ring, gulong position, const VteRowData *row)
 
 	_vte_stream_append (ring->text_stream, buffer->str, buffer->len);
 	_vte_ring_append_row_record (ring, &record, position);
+
+	if (do_hyperlink_gc)
+		_vte_ring_hyperlink_gc(ring);
 }
 
+/* Optionally places the hyperlink target URI under the given column in the caller-allocated buffer. */
 static void
-_vte_ring_thaw_row (VteRing *ring, gulong position, VteRowData *row, gboolean do_truncate)
+_vte_ring_thaw_row (VteRing *ring, gulong position, VteRowData *row, gboolean do_truncate,
+                    int hyperlink_column, char *hyperlink_target)
 {
 	VteRowRecord records[2], record;
 	VteCellAttr attr;
@@ -212,6 +393,7 @@ _vte_ring_thaw_row (VteRing *ring, gulong position, VteRowData *row, gboolean do
 	VteCell cell;
 	const char *p, *q, *end;
 	GString *buffer = ring->utf8_buffer;
+	char hyperlink_buf[VTE_HYPERLINK_TOTAL_LENGTH_MAX + 1];
 
 	_vte_debug_print (VTE_DEBUG_RING, "Thawing row %lu.\n", position);
 
@@ -236,26 +418,52 @@ _vte_ring_thaw_row (VteRing *ring, gulong position, VteRowData *row, gboolean do
 	record = records[0];
 
 	if (G_LIKELY (buffer->len && buffer->str[buffer->len - 1] == '\n'))
-		buffer->len--;
+		buffer->len--;  // GEEZ don't do this to a GString!
 	else
 		row->attr.soft_wrapped = TRUE;
 
 	p = buffer->str;
 	end = p + buffer->len;
 	while (p < end) {
-
 		if (record.text_start_offset >= ring->last_attr_text_start_offset) {
 			attr = ring->last_attr;
+			strcpy(hyperlink_buf, hyperlink_get(ring, attr.hyperlink_idx).str);
 		} else {
 			if (record.text_start_offset >= attr_change.text_end_offset) {
 				if (!_vte_stream_read (ring->attr_stream, record.attr_start_offset, (char *) &attr_change, sizeof (attr_change)))
 					return;
 				record.attr_start_offset += sizeof (attr_change);
+				g_assert_cmpuint (attr_change.attr.hyperlink_length, <=, VTE_HYPERLINK_TOTAL_LENGTH_MAX);  // FIXME silently bail out
+				if (attr_change.attr.hyperlink_length && !_vte_stream_read (ring->attr_stream, record.attr_start_offset, hyperlink_buf, attr_change.attr.hyperlink_length))
+					return;
+				hyperlink_buf[attr_change.attr.hyperlink_length] = '\0';
+				record.attr_start_offset += attr_change.attr.hyperlink_length + 2;
+			}
+			memcpy(&attr, &attr_change.attr, 8);  // FIXME refactor
+			if (attr_change.attr.hyperlink_length) {
+				if (do_truncate) {
+				        /* Find the existing idx or allocate a new one, just as when receiving an OSC 8 escape sequence. */
+					attr.hyperlink_idx = _vte_ring_get_hyperlink_idx_no_update_current (ring, hyperlink_buf);
+				} else {
+					/* Use one of two special values, depending on whether the target is the same as hyperlink_hover_idx's. */
+					attr.hyperlink_idx = VTE_HYPERLINK_IDX_TARGET_IN_STREAM;
+					if (ring->hyperlink_hover_idx != 0 && strcmp(hyperlink_buf, hyperlink_get(ring, ring->hyperlink_hover_idx).str) == 0) {
+						// FIXME make sure hyperlink_buf is initialized!!!
+						attr.hyperlink_idx = _vte_ring_get_hyperlink_idx_no_update_current(ring, hyperlink_buf);
+					}
+				}
+			} else {
+				attr.hyperlink_idx = 0;
 			}
-			attr = attr_change.attr;
 		}
 
 		cell.attr = attr;
+                _VTE_DEBUG_IF(VTE_DEBUG_RING | VTE_DEBUG_HYPERLINK) {
+                        /* Debug: Reverse the colors for the stream's contents. */
+                        if (!do_truncate) {
+                                cell.attr.reverse = !cell.attr.reverse;
+                        }
+                }
 		cell.c = g_utf8_get_char (p);
 
 		q = g_utf8_next_char (p);
@@ -268,38 +476,62 @@ _vte_ring_thaw_row (VteRing *ring, gulong position, VteRowData *row, gboolean do
 				row->cells[row->len - 1].c = _vte_unistr_append_unichar (row->cells[row->len - 1].c, cell.c);
 			} else {
 				cell.attr.columns = 1;
+				if (hyperlink_target != NULL && row->len == hyperlink_column)
+				        strcpy(hyperlink_target, hyperlink_buf);
 				_vte_row_data_append (row, &cell);
 			}
 		} else {
+			if (hyperlink_target != NULL && row->len == hyperlink_column)
+			        strcpy(hyperlink_target, hyperlink_buf);
 			_vte_row_data_append (row, &cell);
 			if (cell.attr.columns > 1) {
 				/* Add the fragments */
 				int i, columns = cell.attr.columns;
 				cell.attr.fragment = 1;
 				cell.attr.columns = 1;
-				for (i = 1; i < columns; i++)
+				for (i = 1; i < columns; i++) {
+					if (hyperlink_target != NULL && row->len == hyperlink_column)
+					        strcpy(hyperlink_target, hyperlink_buf);
 					_vte_row_data_append (row, &cell);
+				}
 			}
 		}
 	}
 
+        /* FIXME this is extremely complicated (by design), figure out something better.
+           This is the only place where we need to walk backwards in attr_stream,
+           which is the reason for the hyperlink's length being repeated after the hyperlink. */
 	if (do_truncate) {
 		gsize attr_stream_truncate_at = records[0].attr_start_offset;
 		_vte_debug_print (VTE_DEBUG_RING, "Truncating\n");
 		if (records[0].text_start_offset <= ring->last_attr_text_start_offset) {
 			/* Check the previous attr record. If its text ends where truncating, this attr record also needs to be removed. */
-			if (_vte_stream_read (ring->attr_stream, attr_stream_truncate_at - sizeof (attr_change), (char *) &attr_change, sizeof (attr_change))) {
-				if (records[0].text_start_offset == attr_change.text_end_offset) {
-					_vte_debug_print (VTE_DEBUG_RING, "... at attribute change\n");
-					attr_stream_truncate_at -= sizeof (attr_change);
+			guint16 hyperlink_length;
+			if (_vte_stream_read (ring->attr_stream, attr_stream_truncate_at - 2, (char *) &hyperlink_length, 2)) {
+				g_assert_cmpuint (hyperlink_length, <=, VTE_HYPERLINK_TOTAL_LENGTH_MAX);  // FIXME silently bail out
+				if (_vte_stream_read (ring->attr_stream, attr_stream_truncate_at - 2 - hyperlink_length - sizeof (attr_change), (char *) &attr_change, sizeof (attr_change))) {
+					if (records[0].text_start_offset == attr_change.text_end_offset) {
+						_vte_debug_print (VTE_DEBUG_RING, "... at attribute change\n");
+						attr_stream_truncate_at -= sizeof (attr_change) + hyperlink_length + 2;
+					}
 				}
 			}
 			/* Reconstruct last_attr from the first record of attr_stream that we cut off,
 			   last_attr_text_start_offset from the last record that we keep. */
 			if (_vte_stream_read (ring->attr_stream, attr_stream_truncate_at, (char *) &attr_change, sizeof (attr_change))) {
-				ring->last_attr = attr_change.attr;
-				if (_vte_stream_read (ring->attr_stream, attr_stream_truncate_at - sizeof (attr_change), (char *) &attr_change, sizeof (attr_change))) {
-					ring->last_attr_text_start_offset = attr_change.text_end_offset;
+				memcpy(&ring->last_attr, &attr_change.attr, 8);  // FIXME refactor
+				ring->last_attr.hyperlink_idx = 0;
+				if (attr_change.attr.hyperlink_length && _vte_stream_read (ring->attr_stream, attr_stream_truncate_at + sizeof (attr_change), (char *) &hyperlink_buf, attr_change.attr.hyperlink_length)) {
+					hyperlink_buf[attr_change.attr.hyperlink_length] = '\0';
+					ring->last_attr.hyperlink_idx = _vte_ring_get_hyperlink_idx (ring, hyperlink_buf);
+				}
+				if (_vte_stream_read (ring->attr_stream, attr_stream_truncate_at - 2, (char *) &hyperlink_length, 2)) {
+					g_assert_cmpuint (hyperlink_length, <=, VTE_HYPERLINK_TOTAL_LENGTH_MAX);  // FIXME silently bail out
+					if (_vte_stream_read (ring->attr_stream, attr_stream_truncate_at - 2 - hyperlink_length - sizeof (attr_change), (char *) &attr_change, sizeof (attr_change))) {
+						ring->last_attr_text_start_offset = attr_change.text_end_offset;
+					} else {
+						ring->last_attr_text_start_offset = 0;
+					}
 				} else {
 					ring->last_attr_text_start_offset = 0;
 				}
@@ -353,15 +585,65 @@ _vte_ring_index (VteRing *ring, gulong position)
 	if (G_LIKELY (position >= ring->writable))
 		return _vte_ring_writable_index (ring, position);
 
-	if (ring->cached_row_num != position) {
+	if (true || ring->cached_row_num != position) {
 		_vte_debug_print(VTE_DEBUG_RING, "Caching row %lu.\n", position);
-		_vte_ring_thaw_row (ring, position, &ring->cached_row, FALSE);
+		_vte_ring_thaw_row (ring, position, &ring->cached_row, FALSE, -1, NULL);
 		ring->cached_row_num = position;
 	}
 
 	return &ring->cached_row;
 }
 
+/* Places the hyperlink target in the caller-allocated buffer.
+ * Works both with frozen and thawed rows.
+ */
+void
+_vte_ring_get_hyperlink (VteRing *ring, gulong position, int col, char *hyperlink_target)
+{
+	GString *hyperlink;
+	guint16 id;
+
+	g_assert (col >= 0);
+
+	*hyperlink_target = '\0';
+
+	if (G_LIKELY (position >= ring->writable)) {
+		VteRowData *row = _vte_ring_writable_index (ring, position);
+		if (col >= _vte_row_data_length(row))
+			return;
+		id = row->cells[col].attr.hyperlink_idx;
+		if (id == 0)
+			return;
+		g_assert_cmpuint (id, <=, ring->hyperlink_highest_used_idx);
+	        /* GC'd idxs have a GString allocated with len == 0, the caller must never ask about such an idx */
+	        g_assert_cmpuint (hyperlink_get(ring, id).len, !=, 0);
+		hyperlink = &hyperlink_get(ring, row->cells[col].attr.hyperlink_idx);
+		strcpy(hyperlink_target, hyperlink->str);
+		return;
+	}
+
+	/* Gotta re-cache even if the same row is cached because we don't cache the hyperlink */
+	_vte_ring_thaw_row (ring, position, &ring->cached_row, FALSE, col, hyperlink_target);
+	ring->cached_row_num = position;
+	return;
+}
+
+/* FIXME document...
+ */
+guint
+_vte_ring_set_active_hyperlink(VteRing *ring, gulong position, int col)
+{
+        char hyperlink_buf[VTE_HYPERLINK_TOTAL_LENGTH_MAX + 1];
+
+        if (position == (gulong) -1 || col == -1) {
+                ring->hyperlink_hover_idx = 0;
+	        return 0;
+        }
+        _vte_ring_get_hyperlink(ring, position, col, hyperlink_buf);
+        ring->hyperlink_hover_idx = _vte_ring_get_hyperlink_idx_no_update_current(ring, hyperlink_buf);
+        return ring->hyperlink_hover_idx;
+}
+
 static void _vte_ring_ensure_writable (VteRing *ring, gulong position);
 static void _vte_ring_ensure_writable_room (VteRing *ring);
 
@@ -402,7 +684,7 @@ _vte_ring_thaw_one_row (VteRing *ring)
 
 	row = _vte_ring_writable_index (ring, ring->writable);
 
-	_vte_ring_thaw_row (ring, ring->writable, row, TRUE);
+	_vte_ring_thaw_row (ring, ring->writable, row, TRUE, -1, NULL);
 }
 
 static void
@@ -874,7 +1156,8 @@ _vte_ring_rewrap (VteRing *ring,
 
 	attr_offset = old_record.attr_start_offset;
 	if (!_vte_stream_read(ring->attr_stream, attr_offset, (char *) &attr_change, sizeof (attr_change))) {
-		attr_change.attr = ring->last_attr;
+		memcpy(&attr_change.attr, &ring->last_attr, 8);  // FIXME refactor
+		attr_change.attr.hyperlink_length = hyperlink_get(ring, ring->last_attr.hyperlink_idx).len;  // FIXME double check me - what's this code about???
 		attr_change.text_end_offset = _vte_stream_head (ring->text_stream);
 	}
 
@@ -918,9 +1201,10 @@ _vte_ring_rewrap (VteRing *ring,
 		/* Wrap the paragraph */
 		if (attr_change.text_end_offset <= text_offset) {
 			/* Attr change at paragraph boundary, advance to next attr. */
-			attr_offset += sizeof (attr_change);
+			attr_offset += sizeof (attr_change) + attr_change.attr.hyperlink_length + 2;
 			if (!_vte_stream_read(ring->attr_stream, attr_offset, (char *) &attr_change, sizeof (attr_change))) {
-				attr_change.attr = ring->last_attr;
+				memcpy(&attr_change.attr, &ring->last_attr, 8);  // FIXME refactor
+				attr_change.attr.hyperlink_length = hyperlink_get(ring, ring->last_attr.hyperlink_idx).len;  // FIXME double check me - what's this code about???
 				attr_change.text_end_offset = _vte_stream_head (ring->text_stream);
 			}
 		}
@@ -934,9 +1218,10 @@ _vte_ring_rewrap (VteRing *ring,
 			gsize runlength;  /* number of bytes we process in one run: identical attributes, within paragraph */
 			if (attr_change.text_end_offset <= text_offset) {
 				/* Attr change at line boundary, advance to next attr. */
-				attr_offset += sizeof (attr_change);
+				attr_offset += sizeof (attr_change) + attr_change.attr.hyperlink_length + 2;
 				if (!_vte_stream_read(ring->attr_stream, attr_offset, (char *) &attr_change, sizeof (attr_change))) {
-					attr_change.attr = ring->last_attr;
+					memcpy(&attr_change.attr, &ring->last_attr, 8);  // FIXME refactor
+					attr_change.attr.hyperlink_length = hyperlink_get(ring, ring->last_attr.hyperlink_idx).len;  // FIXME double check me - what's this code about???
 					attr_change.text_end_offset = _vte_stream_head (ring->text_stream);
 				}
 			}
diff --git a/src/ring.h b/src/ring.h
index b56d82e..f464fa2 100644
--- a/src/ring.h
+++ b/src/ring.h
@@ -38,7 +38,7 @@ typedef struct _VteVisualPosition {
 
 typedef struct _VteCellAttrChange {
 	gsize text_end_offset;  /* offset of first character no longer using this attr */
-	VteCellAttr attr;
+	VteStreamCellAttr attr;
 } VteCellAttrChange;
 
 
@@ -56,7 +56,19 @@ struct _VteRing {
 	gulong writable, mask;
 	VteRowData *array;
 
-	/* Storage */
+	/* Storage:
+	 *
+	 * row_stream contains records of VteRowRecord for each physical row.
+	 * (This stream is regenerated when the contents rewrap on resize.)
+	 *
+	 * text_stream is the text in UTF-8.
+	 *
+	 * attr_stream contains entries that consist of:
+	 *  - a VteCellAttrChange
+	 *  - a string of attr.hyperlink_length length which is either empty, or contains the
+	 *    ID and URI separated with a semicolon. Not NUL terminated.
+	 *  - 2 bytes repeating attr.hyperlink_length, so that we can walk backwards.
+	 */
 	VteStream *attr_stream, *text_stream, *row_stream;
 	gsize last_attr_text_start_offset;
 	VteCellAttr last_attr;
@@ -67,6 +79,10 @@ struct _VteRing {
 
 	gboolean has_streams;
         gulong visible_rows;  /* to keep at least a screenful of lines in memory, bug 646098 comment 12 */
+        GArray *hyperlinks;  /* contains GStrings. [0] is an empty GString, [1] to [VTE_HYPERLINK_COUNT_MAX] contain the id;uri pairs */
+        guint hyperlink_highest_used_idx;
+        guint hyperlink_current_idx;
+        guint hyperlink_hover_idx;
 };
 
 #define _vte_ring_contains(__ring, __position) \
@@ -81,6 +97,9 @@ VteRowData *_vte_ring_index_writable (VteRing *ring, gulong position);
 
 void _vte_ring_init (VteRing *ring, gulong max_rows, gboolean has_streams);
 void _vte_ring_fini (VteRing *ring);
+guint _vte_ring_get_hyperlink_idx (VteRing *ring, const char *hyperlink);
+void _vte_ring_get_hyperlink (VteRing *ring, gulong position, int col, char *hyperlink_target);
+guint _vte_ring_set_active_hyperlink(VteRing *ring, gulong position, int col);
 long _vte_ring_reset (VteRing *ring);
 void _vte_ring_resize (VteRing *ring, gulong max_rows);
 void _vte_ring_shrink (VteRing *ring, gulong max_len);
diff --git a/src/vte.cc b/src/vte.cc
index 3f2b9bb..01b8c5d 100644
--- a/src/vte.cc
+++ b/src/vte.cc
@@ -267,9 +267,12 @@ VteTerminalPrivate::ring_remove(vte::grid::row_t position)
 
 /* Reset defaults for character insertion. */
 void
-VteTerminalPrivate::reset_default_attributes()
+VteTerminalPrivate::reset_default_attributes(bool reset_hyperlink)
 {
+        guint16 hyperlink_idx_save = m_defaults.attr.hyperlink_idx;
         m_defaults = m_color_defaults = m_fill_defaults = basic_cell;
+        if (!reset_hyperlink)
+	        m_defaults.attr.hyperlink_idx = hyperlink_idx_save;
 }
 
 //FIXMEchpe this function is bad
@@ -1818,6 +1821,28 @@ VteTerminalPrivate::regex_match_check(GdkEvent *event,
         return regex_match_check(col, row - (long)m_screen->scroll_delta, tag);
 }
 
+char *
+VteTerminalPrivate::get_hyperlink(GdkEvent *event)
+{
+        long col, row;
+        char buf[VTE_HYPERLINK_TOTAL_LENGTH_MAX + 1];
+        char *separator;
+
+        if (!rowcol_from_event(event, &col, &row))
+                return NULL;
+
+        _vte_ring_get_hyperlink(m_screen->row_data, row, col, buf);
+        _vte_debug_print (VTE_DEBUG_HYPERLINK,
+                          "get_hyperlink: «%s»\n",
+                          buf);
+
+        /* URI is after the first semicolon */
+        separator = strchr(buf, ';');
+        if (!separator || !separator[1])
+	        return NULL;
+        return g_strdup(separator + 1);
+}
+
 bool
 VteTerminalPrivate::regex_match_check_extra(GdkEvent *event,
                                             VteRegex **regexes,
@@ -2266,8 +2291,13 @@ VteTerminalPrivate::set_pointer_visible(bool visible)
 			_vte_debug_print(VTE_DEBUG_CURSOR,
 					"Setting mousing cursor.\n");
 			gdk_window_set_cursor(m_event_window, m_mouse_mousing_cursor);
-		} else
-		if ( (guint)m_match_tag < m_match_regexes->len) {
+		} else if (m_screen->hyperlink_hover_idx != 0) {
+			/* FIXME Shouldn't hyperlink cursor take precedence over mousing cursor? */
+			_vte_debug_print(VTE_DEBUG_CURSOR,
+					"Setting hyperlink mouse cursor.\n");
+			gdk_window_set_cursor(m_event_window, m_mouse_hyperlink_cursor);
+		} else if ((guint)m_match_tag < m_match_regexes->len) {
+			/* FIXME Shouldn't regex match cursor take precedence over mousing cursor? */
                         struct vte_match_regex *regex =
                                 &g_array_index(m_match_regexes,
 					       struct vte_match_regex,
@@ -5985,7 +6015,8 @@ vte_terminal_cellattr_equal(VteCellAttr const *attr1,
 	        attr1->strikethrough == attr2->strikethrough &&
 	        attr1->reverse       == attr2->reverse   &&
 	        attr1->blink         == attr2->blink     &&
-	        attr1->invisible     == attr2->invisible);
+	        attr1->invisible     == attr2->invisible &&
+	        attr1->hyperlink_idx  == attr2->hyperlink_idx);
 }
 
 /*
@@ -6877,6 +6908,8 @@ bool
 VteTerminalPrivate::widget_motion_notify(GdkEventMotion *event)
 {
 	bool handled = false;
+	long row, col;
+	guint new_hyperlink_hover_idx;
 
         GdkEvent* base_event = reinterpret_cast<GdkEvent*>(event);
         auto pos = view_coords_from_event(base_event);
@@ -6895,6 +6928,27 @@ VteTerminalPrivate::widget_motion_notify(GdkEventMotion *event)
 		match_hilite(pos);
 		/* Show the cursor. */
 		set_pointer_visible(true);
+
+		if (rowcol_from_event(base_event, &col, &row)) {
+			// FIXME this is called too often (on every pixel movement, should be called when crossing a cell only
+			new_hyperlink_hover_idx = _vte_ring_set_active_hyperlink(m_screen->row_data, row, col);
+			if (new_hyperlink_hover_idx != m_screen->hyperlink_hover_idx) {
+				char *tooltip = NULL;
+				if (new_hyperlink_hover_idx != 0)
+					tooltip = get_hyperlink(base_event);
+				_vte_debug_print(VTE_DEBUG_HYPERLINK,
+				                 "Tooltip text: «%s»\n",
+				                 tooltip);
+				gtk_widget_set_tooltip_text(m_widget, tooltip);
+				g_free(tooltip);
+
+				_vte_debug_print(VTE_DEBUG_HYPERLINK,
+				                 "Hover idx: %d\n",
+				                 new_hyperlink_hover_idx);
+				m_screen->hyperlink_hover_idx = new_hyperlink_hover_idx;
+				invalidate_all();
+			}
+		}
 	}
 
 	switch (event->type) {
@@ -7796,7 +7850,7 @@ VteTerminalPrivate::VteTerminalPrivate(VteTerminal *t) :
 	_vte_ring_init (m_normal_screen.row_data, VTE_SCROLLBACK_INIT, TRUE);
 	m_screen = &m_normal_screen;
 
-	reset_default_attributes();
+	reset_default_attributes(true);
 
         /* Initialize charset modes. */
         m_character_replacements[0] = VTE_CHARACTER_REPLACEMENT_NONE;
@@ -7908,6 +7962,8 @@ VteTerminalPrivate::VteTerminalPrivate(VteTerminal *t) :
         m_font_scale = 1.;
 	m_has_fonts = FALSE;
 
+        m_hyperlink_auto_id = 0;
+
 	/* Not all backends generate GdkVisibilityNotify, so mark the
 	 * window as unobscured initially. */
 	m_visibility_state = GDK_VISIBILITY_UNOBSCURED;
@@ -8047,6 +8103,8 @@ VteTerminalPrivate::widget_unrealize()
 	m_mouse_default_cursor = NULL;
 	g_object_unref(m_mouse_mousing_cursor);
 	m_mouse_mousing_cursor = NULL;
+	g_object_unref(m_mouse_hyperlink_cursor);
+	m_mouse_hyperlink_cursor = NULL;
 	g_object_unref(m_mouse_inviso_cursor);
 	m_mouse_inviso_cursor = NULL;
 
@@ -8344,6 +8402,11 @@ VteTerminalPrivate::widget_realize()
 	m_mouse_cursor_visible = TRUE;
 	m_mouse_default_cursor = widget_cursor_new(VTE_DEFAULT_CURSOR);
 	m_mouse_mousing_cursor = widget_cursor_new(VTE_MOUSING_CURSOR);
+	if (_vte_debug_on(VTE_DEBUG_HYPERLINK))
+		/* Differ from the standard regex match cursor in debug mode. */
+		m_mouse_hyperlink_cursor = widget_cursor_new(VTE_HYPERLINK_CURSOR_DEBUG);
+	else
+		m_mouse_hyperlink_cursor = widget_cursor_new(VTE_HYPERLINK_CURSOR);
 	m_mouse_inviso_cursor = widget_cursor_new(GDK_BLANK_CURSOR);
 
 	/* Create a GDK window for the widget. */
@@ -8561,6 +8624,7 @@ VteTerminalPrivate::draw_cells(struct _vte_draw_text_request *items,
                                bool italic,
                                bool underline,
                                bool strikethrough,
+                               bool hyperlink,
                                bool hilite,
                                bool boxed,
                                int column_width,
@@ -8579,9 +8643,11 @@ VteTerminalPrivate::draw_cells(struct _vte_draw_text_request *items,
 		}
 		tmp = g_string_free (str, FALSE);
 		g_printerr ("draw_cells('%s', fore=%d, back=%d, bold=%d,"
-				" ul=%d, strike=%d, hilite=%d, boxed=%d)\n",
+				" ul=%d, strike=%d,"
+				" hyperlink=%d, hilite=%d, boxed=%d)\n",
 				tmp, fore, back, bold,
-				underline, strikethrough, hilite, boxed);
+				underline, strikethrough,
+				hyperlink, hilite, boxed);
 		g_free (tmp);
 	}
 
@@ -8614,7 +8680,7 @@ VteTerminalPrivate::draw_cells(struct _vte_draw_text_request *items,
 			_vte_draw_get_style(bold, italic));
 
 	/* Draw whatever SFX are required. */
-	if (underline | strikethrough | hilite | boxed) {
+	if (underline | strikethrough | hyperlink | hilite | boxed) {
 		i = 0;
 		do {
 			x = items[i].x;
@@ -8648,7 +8714,16 @@ VteTerminalPrivate::draw_cells(struct _vte_draw_text_request *items,
                                                        y + row_height - 1,
                                                        VTE_LINE_WIDTH,
                                                        &fg, VTE_DRAW_OPAQUE);
-			}
+			} else if (hyperlink) {
+                                for (double j = 0.125; j < columns; j += 0.5) {
+                                        _vte_draw_fill_rectangle(m_draw,
+                                                                 x + j * column_width,
+                                                                 y + row_height - 1,
+                                                                 column_width * 0.25,
+                                                                 1,
+                                                                 &fg, VTE_DRAW_OPAQUE);
+                                }
+                        }
 			if (boxed) {
                                 _vte_draw_draw_rectangle(m_draw,
 						x, y,
@@ -8879,6 +8954,7 @@ VteTerminalPrivate::draw_cells_with_attributes(struct _vte_draw_text_request *it
 					cells[j].attr.italic,
 					cells[j].attr.underline,
 					cells[j].attr.strikethrough,
+					cells[j].attr.hyperlink_idx != 0,
 					FALSE, FALSE, column_width, height);
 		j += g_unichar_to_utf8(items[i].c, scratch_buf);
 	}
@@ -8905,7 +8981,8 @@ VteTerminalPrivate::draw_rows(VteScreen *screen_,
         vte::grid::column_t i, j;
         long x, y;
 	guint fore, nfore, back, nback;
-	gboolean underline, nunderline, bold, nbold, italic, nitalic, hilite, nhilite,
+	gboolean underline, nunderline, bold, nbold, italic, nitalic,
+		 hyperlink, nhyperlink, hilite, nhilite,
 		 selected, nselected, strikethrough, nstrikethrough;
 	guint item_count;
 	const VteCell *cell;
@@ -9040,7 +9117,8 @@ VteTerminalPrivate::draw_rows(VteScreen *screen_,
 			while (cell->c == 0 || cell->attr.invisible ||
 					(cell->c == ' ' &&
 					 !cell->attr.underline &&
-					 !cell->attr.strikethrough) ||
+					 !cell->attr.strikethrough &&
+					 cell->attr.hyperlink_idx == 0) ||
 					cell->attr.fragment) {
 				if (++i >= end_column) {
 					goto fg_skip_row;
@@ -9055,9 +9133,12 @@ VteTerminalPrivate::draw_rows(VteScreen *screen_,
 			determine_colors(cell, selected, &fore, &back);
 			underline = cell->attr.underline;
 			strikethrough = cell->attr.strikethrough;
+			hyperlink = (cell->attr.hyperlink_idx != 0);
 			bold = cell->attr.bold;
 			italic = cell->attr.italic;
-			if (m_show_match) {
+			if (cell->attr.hyperlink_idx != 0 && cell->attr.hyperlink_idx == m_screen->hyperlink_hover_idx) {
+				hilite = true;
+			} else if (m_screen->hyperlink_hover_idx == 0 && m_show_match) {
 				hilite = m_match_span.contains(row, i);
 			} else {
 				hilite = false;
@@ -9089,7 +9170,7 @@ VteTerminalPrivate::draw_rows(VteScreen *screen_,
 						/* only break the run if we
 						 * are drawing attributes
 						 */
-						if (underline || strikethrough || hilite) {
+						if (underline || strikethrough || hyperlink || hilite) {
 							break;
 						} else {
 							j++;
@@ -9121,9 +9202,15 @@ VteTerminalPrivate::draw_rows(VteScreen *screen_,
 					if (nstrikethrough != strikethrough) {
 						break;
 					}
+					nhyperlink = (cell->attr.hyperlink_idx != 0);
+					if (nhyperlink != hyperlink) {
+						break;
+					}
 					/* Break up matched/not-matched text. */
 					nhilite = false;
-					if (m_show_match) {
+					if (cell->attr.hyperlink_idx != 0 && cell->attr.hyperlink_idx == m_screen->hyperlink_hover_idx) {
+						nhilite = true;
+					} else if (m_screen->hyperlink_hover_idx == 0 && m_show_match) {
 						nhilite = m_match_span.contains(row, j);
 					}
 					if (nhilite != hilite) {
@@ -9172,7 +9259,7 @@ fg_draw:
 					item_count,
 					fore, back, FALSE, FALSE,
 					bold, italic, underline,
-					strikethrough, hilite, FALSE,
+					strikethrough, hyperlink, hilite, FALSE,
 					column_width, row_height);
 			item_count = 1;
 			/* We'll need to continue at the first cell which didn't
@@ -9377,6 +9464,7 @@ VteTerminalPrivate::paint_cursor()
                                                         cell->attr.italic,
                                                         cell->attr.underline,
                                                         cell->attr.strikethrough,
+                                                        cell->attr.hyperlink_idx != 0,
                                                         FALSE,
                                                         FALSE,
                                                         width,
@@ -9463,6 +9551,7 @@ VteTerminalPrivate::paint_im_preedit_string()
 						FALSE,
 						FALSE,
 						FALSE,
+						FALSE,
 						TRUE,
 						width, height);
 		}
@@ -10024,7 +10113,7 @@ VteTerminalPrivate::reset(bool clear_tabstops,
 		m_palette[i].sources[VTE_COLOR_SOURCE_ESCAPE].is_set = FALSE;
 	/* Reset the default attributes.  Reset the alternate attribute because
 	 * it's not a real attribute, but we need to treat it as one here. */
-	reset_default_attributes();
+	reset_default_attributes(true);
         /* Reset charset modes. */
         m_character_replacements[0] = VTE_CHARACTER_REPLACEMENT_NONE;
         m_character_replacements[1] = VTE_CHARACTER_REPLACEMENT_NONE;
diff --git a/src/vte/vteterminal.h b/src/vte/vteterminal.h
index 3b22c4a..bb1b50f 100644
--- a/src/vte/vteterminal.h
+++ b/src/vte/vteterminal.h
@@ -371,6 +371,9 @@ char *vte_terminal_match_check_event(VteTerminal *terminal,
                                      GdkEvent *event,
                                      int *tag) _VTE_GNUC_NONNULL(1) _VTE_GNUC_NONNULL(2) G_GNUC_MALLOC;
 _VTE_PUBLIC
+char *vte_terminal_get_hyperlink(VteTerminal *terminal,
+                                 GdkEvent *event) _VTE_GNUC_NONNULL(1) _VTE_GNUC_NONNULL(2) G_GNUC_MALLOC;
+_VTE_PUBLIC
 gboolean vte_terminal_event_check_regex_simple(VteTerminal *terminal,
                                                GdkEvent *event,
                                                VteRegex **regexes,
diff --git a/src/vteapp.c b/src/vteapp.c
index ac321be..f0233a0 100644
--- a/src/vteapp.c
+++ b/src/vteapp.c
@@ -143,6 +143,7 @@ button_pressed(GtkWidget *widget, GdkEventButton *event, gpointer data)
 {
 	VteTerminal *terminal;
 	char *match;
+	char *hyperlink;
 	int tag;
         gboolean has_extra_match;
         char *extra_match = NULL;
@@ -151,6 +152,14 @@ button_pressed(GtkWidget *widget, GdkEventButton *event, gpointer data)
 	case 3:
 		terminal = VTE_TERMINAL(widget);
 
+		hyperlink = vte_terminal_get_hyperlink(terminal,
+		                                       (GdkEvent*)event);
+		if (hyperlink)
+			g_print("Hyperlink: %s\n", hyperlink);
+		else
+			g_print("Not hyperlink\n");
+		g_free(hyperlink);
+
 		match = vte_terminal_match_check_event(terminal,
                                                        (GdkEvent*)event,
                                                        &tag);
diff --git a/src/vtedefines.hh b/src/vtedefines.hh
index 7f56291..06b9110 100644
--- a/src/vtedefines.hh
+++ b/src/vtedefines.hh
@@ -66,6 +66,8 @@
 #define VTE_SCROLLBACK_INIT		512
 #define VTE_DEFAULT_CURSOR		GDK_XTERM
 #define VTE_MOUSING_CURSOR		GDK_LEFT_PTR
+#define VTE_HYPERLINK_CURSOR		GDK_HAND2
+#define VTE_HYPERLINK_CURSOR_DEBUG	GDK_SPIDER
 #define VTE_TAB_MAX			999
 #define VTE_ADJUSTMENT_PRIORITY		G_PRIORITY_DEFAULT_IDLE
 #define VTE_INPUT_RETRY_PRIORITY	G_PRIORITY_HIGH
@@ -93,3 +95,26 @@
 
 #define VTE_FONT_SCALE_MIN (.25)
 #define VTE_FONT_SCALE_MAX (4.)
+
+/* Maximum length of a URI in the OSC 8 escape sequences. There's no de jure limit,
+ * 2000-ish the de facto standard, and Internet Explorer supports 2083.
+ * See also the comment of VTE_HYPERLINK_TOTAL_LENGTH_MAX. */
+#define VTE_HYPERLINK_URI_LENGTH_MAX        2083
+
+/* Maximum number of URIs in the ring for a given screen (as in "normal" vs "alternate" screen)
+ * at a time. A few more technical idxs are also required, see below.
+ * Make sure there are enough bits to store this in VteCellAttr.hyperlink_idx */
+#define VTE_HYPERLINK_COUNT_MAX         4092
+
+/* Used when thawing a row from the stream in order to display it, to denote
+ * hyperlinks whose target is currently irrelevant. */
+#define VTE_HYPERLINK_IDX_TARGET_IN_STREAM       (VTE_HYPERLINK_COUNT_MAX + 1)
+
+/* Max length allowed in the id= parameter of an OSC 8 sequence.
+ * See also the comment of VTE_HYPERLINK_TOTAL_LENGTH_MAX. */
+#define VTE_HYPERLINK_ID_LENGTH_MAX    250
+
+/* Max length of all the hyperlink data stored in the streams as a string.
+ * Currently the hyperlink data is the ID and URI and a separator in between.
+ * Make sure there are enough bits to store this in VteStreamCellAttr.hyperlink_length */
+#define VTE_HYPERLINK_TOTAL_LENGTH_MAX  (VTE_HYPERLINK_ID_LENGTH_MAX + 1 + VTE_HYPERLINK_URI_LENGTH_MAX)
diff --git a/src/vtegtk.cc b/src/vtegtk.cc
index 0247950..bb51329 100644
--- a/src/vtegtk.cc
+++ b/src/vtegtk.cc
@@ -1804,6 +1804,31 @@ vte_terminal_match_check_event(VteTerminal *terminal,
 }
 
 /**
+ * vte_terminal_get_hyperlink:
+ * @terminal: a #VteTerminal
+ * @event: a #GdkEvent
+ *
+ * Returns a nonempty string: the target of the explicit hyperlink (printed using the OSC 8
+ * escape sequence) at the position of the event, or %NULL.
+ *
+ * Proper use of the escape sequence should result in URI-encoded URIs with a proper scheme
+ * like "http://", "https://", "file://", "mailto:" etc. This is, however, not enforced by VTE.
+ * The caller must tolerate the returned string potentially not being a valid URI, or not being
+ * a valid UTF-8 string.
+ *
+ * Returns: (transfer full): a newly allocated string containing the target of the hyperlink
+ *
+ * Since: 0.50
+ */
+char *
+vte_terminal_get_hyperlink(VteTerminal *terminal,
+                           GdkEvent *event)
+{
+        g_return_val_if_fail(VTE_IS_TERMINAL(terminal), FALSE);
+        return IMPL(terminal)->get_hyperlink(event);
+}
+
+/**
  * vte_terminal_event_check_regex_simple:
  * @terminal: a #VteTerminal
  * @event: a #GdkEvent
diff --git a/src/vteinternal.hh b/src/vteinternal.hh
index ff5b93c..23899f3 100644
--- a/src/vteinternal.hh
+++ b/src/vteinternal.hh
@@ -120,6 +120,7 @@ struct _VteScreen {
         VteVisualPosition cursor;  /* absolute value, from the beginning of the terminal history */
         double scroll_delta;	/* scroll offset */
         long insert_delta;	/* insertion offset */
+        guint hyperlink_hover_idx;
 
         /* Stuff saved along with the cursor */
         struct {
@@ -477,6 +478,7 @@ public:
         gboolean m_mouse_cursor_visible;
         GdkCursor* m_mouse_default_cursor;
         GdkCursor* m_mouse_mousing_cursor;
+        GdkCursor* m_mouse_hyperlink_cursor;
 	GdkCursor* m_mouse_inviso_cursor;
 
 	/* Input method support. */
@@ -528,6 +530,9 @@ public:
         guint m_hscroll_policy : 1; /* unused */
         guint m_vscroll_policy : 1;
 
+        /* hyperlinks */
+        long m_hyperlink_auto_id;
+
 public:
 
         // FIXMEchpe inline!
@@ -690,6 +695,7 @@ public:
                         bool italic,
                         bool underline,
                         bool strikethrough,
+                        bool hyperlink,
                         bool hilite,
                         bool boxed,
                         int column_width,
@@ -885,7 +891,7 @@ public:
         bool cell_is_selected(vte::grid::column_t col,
                               vte::grid::row_t) const;
 
-        void reset_default_attributes();
+        void reset_default_attributes(bool reset_hyperlink);
 
         void ensure_font();
         void update_font();
@@ -968,6 +974,8 @@ public:
         void match_hilite_update(vte::view::coords const& pos);
         void match_hilite(vte::view::coords const& pos);
 
+	char *get_hyperlink(GdkEvent *event);
+
         bool rowcol_from_event(GdkEvent *event,
                                long *column,
                                long *row);
@@ -1173,6 +1181,7 @@ public:
         inline void seq_send_secondary_device_attributes();
         inline void set_current_directory_uri_changed(char* uri /* adopted */);
         inline void set_current_file_uri_changed(char* uri /* adopted */);
+        inline void set_current_hyperlink(char* hyperlink_params /* adopted */, char* uri /* adopted */);
         inline void set_keypad_mode(VteKeymode mode);
         inline void seq_erase_in_display(long param);
         inline void seq_erase_in_line(long param);
diff --git a/src/vterowdata.h b/src/vterowdata.h
index 2e73fd4..0f46e23 100644
--- a/src/vterowdata.h
+++ b/src/vterowdata.h
@@ -37,6 +37,7 @@ G_BEGIN_DECLS
  * optimize the compact representation.
  *
  * When adding new attributes, remember to update basic_cell below too.
+ * Also, keep in sync with VteStreamCellAttr!
  */
 
 typedef struct _VteCellAttr {
@@ -59,9 +60,31 @@ typedef struct _VteCellAttr {
 	guint64 dim: 1;		/* also known as faint, half intensity etc. */
 
 	guint64 invisible: 1;
-        /* 1 bit unused */
+	guint64 padding_unused_1: 1;
+	/* 8-byte boundary */
+        guint64 hyperlink_idx: 12;      /* a unique index at a time for the ring's cells,
+		                           0 means not a hyperlink, actual hyperlinks have
+		                           indicess between 1 and VTE_HYPERLINK_COUNT_MAX inclusive,
+		                           plus a few more above this have special roles. */
+	guint64 padding_unused_2: 52;
 } VteCellAttr;
-G_STATIC_ASSERT (sizeof (VteCellAttr) == 8);
+G_STATIC_ASSERT (sizeof (VteCellAttr) == 16);
+
+/*
+ * VteStreamCellAttr: Variant of VteCellAttr to be stored in attr_stream.
+ *
+ * Keep in sync with VteCellAttr!
+ */
+typedef struct _VTE_GNUC_PACKED _VteStreamCellAttr {
+        guint64 fragment: 1;
+        guint64 columns: VTE_TAB_WIDTH_BITS;
+        guint64 remaining_main_attributes: 59;  /* All the non-hyperlink related attributes from VteCellAttr.
+                                                   We don't individually access them in the stream, so there's
+                                                   no point in repeating each field separately. */
+        /* 8-byte boundary */
+        guint16 hyperlink_length: 12;   /* make sure it fits VTE_HYPERLINK_TOTAL_LENGTH_MAX */
+} VteStreamCellAttr;
+G_STATIC_ASSERT (sizeof (VteStreamCellAttr) == 10);
 
 /*
  * VteCell: A single cell's data
@@ -71,7 +94,7 @@ typedef struct _VTE_GNUC_PACKED _VteCell {
 	vteunistr c;
 	VteCellAttr attr;
 } VteCell;
-G_STATIC_ASSERT (sizeof (VteCell) == 12);
+G_STATIC_ASSERT (sizeof (VteCell) == 20);
 
 static const VteCell basic_cell = {
 	0,
@@ -90,7 +113,10 @@ static const VteCell basic_cell = {
 		0, /* blink */
 		0, /* half */
 
-		0  /* invisible */
+		0, /* invisible */
+		0, /* padding_unused_1 */
+		0, /* hyperlink_idx */
+		0, /* padding_unused_2 */
 	}
 };
 
diff --git a/src/vteseq-n.gperf b/src/vteseq-n.gperf
index 573f045..3e60835 100644
--- a/src/vteseq-n.gperf
+++ b/src/vteseq-n.gperf
@@ -169,5 +169,6 @@ struct vteseq_n_struct {
 #"reset-mouse-cursor-foreground-color", VTE_SEQUENCE_HANDLER_NULL
 "set-current-directory-uri", VTE_SEQUENCE_HANDLER(vte_sequence_handler_set_current_directory_uri)
 "set-current-file-uri", VTE_SEQUENCE_HANDLER(vte_sequence_handler_set_current_file_uri)
+"set-current-hyperlink", VTE_SEQUENCE_HANDLER(vte_sequence_handler_set_current_hyperlink)
 "urxvt-777", VTE_SEQUENCE_HANDLER(vte_sequence_handler_urxvt_777)
 "iterm2-1337", VTE_SEQUENCE_HANDLER(vte_sequence_handler_iterm2_1337)
diff --git a/src/vteseq.cc b/src/vteseq.cc
index 663833a..9bdd231 100644
--- a/src/vteseq.cc
+++ b/src/vteseq.cc
@@ -387,6 +387,15 @@ VteTerminalPrivate::seq_switch_screen(VteScreen *new_screen)
 {
         /* if (new_screen == m_screen) return; ? */
 
+        /* The two screens use different hyperlink pools, so carrying on the idx
+         * wouldn't make sense and could lead to crashes.
+         * Ideally we'd carry the target URI itself, but I'm just lazy.
+         * Also, run a GC before we switch away from that screen. */
+        m_screen->hyperlink_hover_idx = _vte_ring_set_active_hyperlink(m_screen->row_data, -1, -1);
+        g_assert (m_screen->hyperlink_hover_idx == 0);
+        m_defaults.attr.hyperlink_idx = _vte_ring_get_hyperlink_idx(m_screen->row_data, NULL);
+        g_assert (m_defaults.attr.hyperlink_idx == 0);
+
         /* cursor.row includes insert_delta, adjust accordingly */
         auto cr = m_screen->cursor.row - m_screen->insert_delta;
         m_screen = new_screen;
@@ -2127,7 +2136,7 @@ vte_sequence_handler_character_attributes (VteTerminalPrivate *that, GValueArray
 		param = g_value_get_long(value);
 		switch (param) {
 		case 0:
-			that->reset_default_attributes();
+			that->reset_default_attributes(false);
 			break;
 		case 1:
                         that->m_defaults.attr.bold = 1;
@@ -2271,7 +2280,7 @@ vte_sequence_handler_character_attributes (VteTerminalPrivate *that, GValueArray
 	}
 	/* If we had no parameters, default to the defaults. */
 	if (i == 0) {
-		that->reset_default_attributes();
+		that->reset_default_attributes(false);
 	}
 	/* Save the new colors. */
         that->m_color_defaults.attr.fore = that->m_defaults.attr.fore;
@@ -2450,6 +2459,103 @@ VteTerminalPrivate::set_current_file_uri_changed(char* uri /* adopted */)
         m_current_file_uri_changed = uri;
 }
 
+static void
+vte_sequence_handler_set_current_hyperlink (VteTerminalPrivate *that, GValueArray *params)
+{
+        GValue *value;
+        char *hyperlink_params;
+        char *uri;
+
+        hyperlink_params = NULL;
+        uri = NULL;
+        if (params != NULL && params->n_values > 1) {
+                value = g_value_array_get_nth(params, 0);
+
+                if (G_VALUE_HOLDS_POINTER(value)) {
+                        hyperlink_params = that->ucs4_to_utf8((const guchar *)g_value_get_pointer (value));
+                } else if (G_VALUE_HOLDS_STRING(value)) {
+                        /* Copy the string into the buffer. */
+                        hyperlink_params = g_value_dup_string(value);
+                }
+
+                value = g_value_array_get_nth(params, 1);
+
+                if (G_VALUE_HOLDS_POINTER(value)) {
+                        uri = that->ucs4_to_utf8((const guchar *)g_value_get_pointer (value));
+                } else if (G_VALUE_HOLDS_STRING(value)) {
+                        /* Copy the string into the buffer. */
+                        uri = g_value_dup_string(value);
+                }
+        }
+
+        that->set_current_hyperlink(hyperlink_params, uri);
+}
+
+void
+VteTerminalPrivate::set_current_hyperlink(char *hyperlink_params /* adopted */, char* uri /* adopted */)
+{
+        guint idx;
+        char *id = NULL;
+        char idbuf[24];
+
+        /* Get the "id" parameter */
+        if (hyperlink_params) {
+                if (strncmp(hyperlink_params, "id=", 3) == 0) {
+                        id = hyperlink_params + 3;
+                } else {
+                        id = strstr(hyperlink_params, ",id=");
+                        if (id)
+                                id += 4;
+                }
+        }
+        if (id) {
+                *strchrnul(id, ',') = '\0';
+        }
+        _vte_debug_print (VTE_DEBUG_HYPERLINK,
+                          "OSC 8: id=«%s» uri=«%s»\n",
+                          id, uri);
+
+        if (uri && strlen(uri) > VTE_HYPERLINK_URI_LENGTH_MAX) {
+	        _vte_debug_print (VTE_DEBUG_HYPERLINK,
+                                  "Overlong URI ignored: «%s»\n",
+                                  uri);
+		uri[0] = '\0';
+        }
+
+        if (id && strlen(id) > VTE_HYPERLINK_ID_LENGTH_MAX) {
+	        _vte_debug_print (VTE_DEBUG_HYPERLINK,
+                                  "Overlong \"id\" ignored: «%s»\n",
+                                  id);
+		id[0] = '\0';
+        }
+
+        if (uri && uri[0]) {
+	        /* The hyperlink, as we carry around and store in the streams, is "id;uri" */
+	        char *hyperlink;
+
+	        if (!id || !id[0]) {
+	                /* Automatically generate a unique ID string. The comma makes sure
+	                 * it cannot conflict with an explicitly specified one. */
+	                sprintf(idbuf, ",%ld", m_hyperlink_auto_id++);
+	                id = idbuf;
+	                _vte_debug_print (VTE_DEBUG_HYPERLINK,
+	                                  "Autogenerated id=«%s»\n",
+	                                  id);
+	        }
+	        hyperlink = g_strdup_printf("%s;%s", id, uri);
+	        idx = _vte_ring_get_hyperlink_idx(m_screen->row_data, hyperlink);
+	        g_free (hyperlink);
+        } else {
+	        /* idx = 0; also remove the previous current_idx so that it can be GC'd now. */
+	        idx = _vte_ring_get_hyperlink_idx(m_screen->row_data, NULL);
+        }
+
+        m_defaults.attr.hyperlink_idx = idx;
+
+        g_free(hyperlink_params);
+        g_free(uri);
+}
+
 /* Restrict the scrolling region. */
 static void
 vte_sequence_handler_set_scrolling_region_from_start (VteTerminalPrivate *that, GValueArray *params)
