diff --git a/NEWS.md b/NEWS.md index 43b59a56..665c1724 100644 --- a/NEWS.md +++ b/NEWS.md @@ -63,7 +63,8 @@ Features: variables are now defined inside **lnav** and refer to the location of the user's configuration directory and the directory where cached data is stored, respectively. -* The `
` tag is now recognized in Markdown files.
+* The `
` and `` tags are now recognized in
+  Markdown files.
 * The `style` attribute in `` tags is now supported.
   The following CSS properties and values are supported:
   * `color` and `background-color` with CSS color names
diff --git a/README.md b/README.md
index 5f1185a1..2138d07d 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
 [![Coverage Status](https://coveralls.io/repos/github/tstack/lnav/badge.svg?branch=master)](https://coveralls.io/github/tstack/lnav?branch=master)
 [![lnav](https://snapcraft.io/lnav/badge.svg)](https://snapcraft.io/lnav)
 
-[](https://discord.gg/erBPnKwz7R)
+[Discord Logo](https://discord.gg/erBPnKwz7R)
 
 _This is the source repository for **lnav**, visit [https://lnav.org](https://lnav.org) for a high level overview._
 
diff --git a/src/base/ansi_scrubber.cc b/src/base/ansi_scrubber.cc
index 5306618d..0a334c14 100644
--- a/src/base/ansi_scrubber.cc
+++ b/src/base/ansi_scrubber.cc
@@ -34,6 +34,7 @@
 #include "ansi_scrubber.hh"
 
 #include "ansi_vars.hh"
+#include "base/lnav_log.hh"
 #include "base/opt_util.hh"
 #include "config.h"
 #include "pcrepp/pcre2pp.hh"
@@ -44,7 +45,7 @@ static const lnav::pcre2pp::code&
 ansi_regex()
 {
     static const auto retval = lnav::pcre2pp::code::from_const(
-        "\x1b\\[([\\d=;\\?]*)([a-zA-Z])|(?:\\X\x08\\X)+");
+        R"(\x1b\[([\d=;\?]*)([a-zA-Z])|\x1b\](\d+);(.*?)(?:\x07|\x1b\\)|(?:\X\x08\X)+)");
 
     return retval;
 }
@@ -124,6 +125,8 @@ scrub_ansi_string(std::string& str, string_attrs_t* sa)
     const auto& regex = ansi_regex();
     int64_t origin_offset = 0;
     int last_origin_offset_end = 0;
+    nonstd::optional href;
+    size_t href_start = 0;
 
     replace(str.begin(), str.end(), '\0', ' ');
     auto matcher = regex.capture_from(str).into(md);
@@ -239,134 +242,164 @@ scrub_ansi_string(std::string& str, string_attrs_t* sa)
             continue;
         }
 
-        if (!md[1]) {
-            continue;
-        }
-
-        auto seq = md[1].value();
-        auto terminator = md[2].value();
         struct line_range lr;
         text_attrs attrs;
         bool has_attrs = false;
         nonstd::optional role;
-        size_t lpc;
-
-        switch (terminator[0]) {
-            case 'm':
-                for (lpc = seq.sf_begin;
-                     lpc != std::string::npos && lpc < (size_t) seq.sf_end;)
-                {
-                    auto ansi_code_res = scn::scan_value(
-                        scn::string_view{&str[lpc], &str[seq.sf_end]});
-
-                    if (ansi_code_res) {
-                        auto ansi_code = ansi_code_res.value();
-                        if (90 <= ansi_code && ansi_code <= 97) {
-                            ansi_code -= 60;
-                            attrs.ta_attrs |= A_STANDOUT;
-                        }
-                        if (30 <= ansi_code && ansi_code <= 37) {
-                            attrs.ta_fg_color = ansi_code - 30;
+
+        if (md[3]) {
+            auto osc_id = scn::scan_value(md[3]->to_string_view());
+
+            if (osc_id) {
+                switch (osc_id.value()) {
+                    case 8:
+                        auto split_res
+                            = md[4]->split_pair(string_fragment::tag1{';'});
+                        if (split_res) {
+                            // auto params = split_res->first;
+                            auto uri = split_res->second;
+
+                            if (href) {
+                                if (sa != nullptr) {
+                                    sa->emplace_back(
+                                        line_range{(int) href_start,
+                                                   (int) str.size()},
+                                        VC_HYPERLINK.value(href.value()));
+                                }
+                                href = nonstd::nullopt;
+                            }
+                            if (!uri.empty()) {
+                                href = uri.to_string();
+                                href_start = sf.sf_begin;
+                            }
                         }
-                        if (40 <= ansi_code && ansi_code <= 47) {
-                            attrs.ta_bg_color = ansi_code - 40;
+                        break;
+                }
+            }
+        } else if (md[1]) {
+            auto seq = md[1].value();
+            auto terminator = md[2].value();
+
+            switch (terminator[0]) {
+                case 'm':
+                    for (auto lpc = seq.sf_begin;
+                         lpc != std::string::npos && lpc < (size_t) seq.sf_end;)
+                    {
+                        auto ansi_code_res = scn::scan_value(
+                            scn::string_view{&str[lpc], &str[seq.sf_end]});
+
+                        if (ansi_code_res) {
+                            auto ansi_code = ansi_code_res.value();
+                            if (90 <= ansi_code && ansi_code <= 97) {
+                                ansi_code -= 60;
+                                attrs.ta_attrs |= A_STANDOUT;
+                            }
+                            if (30 <= ansi_code && ansi_code <= 37) {
+                                attrs.ta_fg_color = ansi_code - 30;
+                            }
+                            if (40 <= ansi_code && ansi_code <= 47) {
+                                attrs.ta_bg_color = ansi_code - 40;
+                            }
+                            switch (ansi_code) {
+                                case 1:
+                                    attrs.ta_attrs |= A_BOLD;
+                                    break;
+
+                                case 2:
+                                    attrs.ta_attrs |= A_DIM;
+                                    break;
+
+                                case 4:
+                                    attrs.ta_attrs |= A_UNDERLINE;
+                                    break;
+
+                                case 7:
+                                    attrs.ta_attrs |= A_REVERSE;
+                                    break;
+                            }
                         }
-                        switch (ansi_code) {
-                            case 1:
-                                attrs.ta_attrs |= A_BOLD;
-                                break;
-
-                            case 2:
-                                attrs.ta_attrs |= A_DIM;
-                                break;
-
-                            case 4:
-                                attrs.ta_attrs |= A_UNDERLINE;
-                                break;
-
-                            case 7:
-                                attrs.ta_attrs |= A_REVERSE;
-                                break;
+                        lpc = str.find(';', lpc);
+                        if (lpc != std::string::npos) {
+                            lpc += 1;
                         }
                     }
-                    lpc = str.find(';', lpc);
-                    if (lpc != std::string::npos) {
-                        lpc += 1;
-                    }
-                }
-                has_attrs = true;
-                break;
+                    has_attrs = true;
+                    break;
 
-            case 'C': {
-                auto spaces_res
-                    = scn::scan_value(seq.to_string_view());
+                case 'C': {
+                    auto spaces_res
+                        = scn::scan_value(seq.to_string_view());
 
-                if (spaces_res && spaces_res.value() > 0) {
-                    str.insert((std::string::size_type) sf.sf_end,
-                               spaces_res.value(),
-                               ' ');
+                    if (spaces_res && spaces_res.value() > 0) {
+                        str.insert((std::string::size_type) sf.sf_end,
+                                   spaces_res.value(),
+                                   ' ');
+                    }
+                    break;
                 }
-                break;
-            }
 
-            case 'H': {
-                unsigned int row = 0, spaces = 0;
+                case 'H': {
+                    unsigned int row = 0, spaces = 0;
 
-                if (scn::scan(seq.to_string_view(), "{};{}", row, spaces)
-                    && spaces > 1)
-                {
-                    int ispaces = spaces - 1;
-                    if (ispaces > sf.sf_begin) {
-                        str.insert((unsigned long) sf.sf_end,
-                                   ispaces - sf.sf_begin,
-                                   ' ');
+                    if (scn::scan(seq.to_string_view(), "{};{}", row, spaces)
+                        && spaces > 1)
+                    {
+                        int ispaces = spaces - 1;
+                        if (ispaces > sf.sf_begin) {
+                            str.insert((unsigned long) sf.sf_end,
+                                       ispaces - sf.sf_begin,
+                                       ' ');
+                        }
                     }
+                    break;
                 }
-                break;
-            }
 
-            case 'O': {
-                auto role_res = scn::scan_value(seq.to_string_view());
+                case 'O': {
+                    auto role_res = scn::scan_value(seq.to_string_view());
 
-                if (role_res) {
-                    role_t role_tmp = (role_t) role_res.value();
-                    if (role_tmp > role_t::VCR_NONE
-                        && role_tmp < role_t::VCR__MAX)
-                    {
-                        role = role_tmp;
-                        has_attrs = true;
+                    if (role_res) {
+                        role_t role_tmp = (role_t) role_res.value();
+                        if (role_tmp > role_t::VCR_NONE
+                            && role_tmp < role_t::VCR__MAX)
+                        {
+                            role = role_tmp;
+                            has_attrs = true;
+                        }
                     }
+                    break;
                 }
-                break;
             }
         }
-        str.erase(str.begin() + sf.sf_begin, str.begin() + sf.sf_end);
-        if (sa != nullptr) {
-            shift_string_attrs(*sa, sf.sf_begin, -sf.length());
-
-            if (has_attrs) {
-                for (auto rit = sa->rbegin(); rit != sa->rend(); rit++) {
-                    if (rit->sa_range.lr_end != -1) {
-                        continue;
+        if (md[1] || md[3]) {
+            str.erase(str.begin() + sf.sf_begin, str.begin() + sf.sf_end);
+            if (sa != nullptr) {
+                shift_string_attrs(*sa, sf.sf_begin, -sf.length());
+
+                if (has_attrs) {
+                    for (auto rit = sa->rbegin(); rit != sa->rend(); rit++) {
+                        if (rit->sa_range.lr_end != -1) {
+                            continue;
+                        }
+                        rit->sa_range.lr_end = sf.sf_begin;
                     }
-                    rit->sa_range.lr_end = sf.sf_begin;
-                }
-                lr.lr_start = sf.sf_begin;
-                lr.lr_end = -1;
-                if (!attrs.empty()) {
-                    sa->emplace_back(lr, VC_STYLE.value(attrs));
+                    lr.lr_start = sf.sf_begin;
+                    lr.lr_end = -1;
+                    if (!attrs.empty()) {
+                        sa->emplace_back(lr, VC_STYLE.value(attrs));
+                    }
+                    role | [&lr, &sa](role_t r) {
+                        sa->emplace_back(lr, VC_ROLE.value(r));
+                    };
                 }
-                role | [&lr, &sa](role_t r) {
-                    sa->emplace_back(lr, VC_ROLE.value(r));
-                };
+                sa->emplace_back(
+                    line_range{last_origin_offset_end, sf.sf_begin},
+                    SA_ORIGIN_OFFSET.value(origin_offset));
+                last_origin_offset_end = sf.sf_begin;
+                origin_offset += sf.length();
             }
-            sa->emplace_back(line_range{last_origin_offset_end, sf.sf_begin},
-                             SA_ORIGIN_OFFSET.value(origin_offset));
-            last_origin_offset_end = sf.sf_begin;
-            origin_offset += sf.length();
-        }
 
-        matcher.reload_input(str, sf.sf_begin);
+            matcher.reload_input(str, sf.sf_begin);
+        }
     }
 
     if (sa != nullptr && last_origin_offset_end > 0) {
diff --git a/src/base/lnav.console.cc b/src/base/lnav.console.cc
index d3a43069..2277ebae 100644
--- a/src/base/lnav.console.cc
+++ b/src/base/lnav.console.cc
@@ -312,6 +312,7 @@ println(FILE* file, const attr_line_t& al)
         auto line_style = fmt::text_style{};
         auto fg_style = fmt::text_style{};
         auto start = last_point.value();
+        nonstd::optional href;
 
         for (const auto& attr : al.get_attrs()) {
             if (!attr.sa_range.contains(start)
@@ -321,7 +322,10 @@ println(FILE* file, const attr_line_t& al)
             }
 
             try {
-                if (attr.sa_type == &VC_BACKGROUND) {
+                if (attr.sa_type == &VC_HYPERLINK) {
+                    auto saw = string_attr_wrapper(&attr);
+                    href = saw.get();
+                } else if (attr.sa_type == &VC_BACKGROUND) {
                     auto saw = string_attr_wrapper(&attr);
                     auto color_opt = curses_color_to_terminal_color(saw.get());
 
@@ -484,6 +488,9 @@ println(FILE* file, const attr_line_t& al)
             line_style |= default_bg_style;
         }
 
+        if (href) {
+            fmt::print(file, FMT_STRING("\x1b]8;;{}\x1b\\"), href.value());
+        }
         if (start < str.size()) {
             auto actual_end = std::min(str.size(), static_cast(point));
             fmt::print(file,
@@ -491,6 +498,9 @@ println(FILE* file, const attr_line_t& al)
                        FMT_STRING("{}"),
                        str.substr(start, actual_end - start));
         }
+        if (href) {
+            fmt::print(file, FMT_STRING("\x1b]8;;\x1b\\"));
+        }
         last_point = point;
     }
     fmt::print(file, "\n");
diff --git a/src/base/string_attr_type.cc b/src/base/string_attr_type.cc
index f7d7551b..2e1df8d2 100644
--- a/src/base/string_attr_type.cc
+++ b/src/base/string_attr_type.cc
@@ -50,3 +50,4 @@ string_attr_type VC_GRAPHIC("graphic");
 string_attr_type VC_BLOCK_ELEM("block-elem");
 string_attr_type VC_FOREGROUND("foreground");
 string_attr_type VC_BACKGROUND("background");
+string_attr_type VC_HYPERLINK("hyperlink");
diff --git a/src/base/string_attr_type.hh b/src/base/string_attr_type.hh
index 7a027185..6a39424d 100644
--- a/src/base/string_attr_type.hh
+++ b/src/base/string_attr_type.hh
@@ -233,6 +233,7 @@ extern string_attr_type VC_GRAPHIC;
 extern string_attr_type VC_BLOCK_ELEM;
 extern string_attr_type VC_FOREGROUND;
 extern string_attr_type VC_BACKGROUND;
+extern string_attr_type VC_HYPERLINK;
 
 namespace lnav {
 
@@ -246,6 +247,14 @@ preformatted(S str)
     return std::make_pair(std::move(str), SA_PREFORMATTED.template value());
 }
 
+template
+inline std::pair
+href(S str, std::string href)
+{
+    return std::make_pair(std::move(str),
+                          VC_HYPERLINK.template value(std::move(href)));
+}
+
 }  // namespace attrs
 }  // namespace string
 
@@ -699,6 +708,13 @@ inline std::pair operator"" _snippet_border(
                           VC_ROLE.template value(role_t::VCR_SNIPPET_BORDER));
 }
 
+inline std::pair operator"" _link(
+    const char* str, std::size_t len)
+{
+    return std::make_pair(std::string(str, len),
+                          VC_HYPERLINK.template value(std::string(str, len)));
+}
+
 }  // namespace literals
 
 }  // namespace roles
diff --git a/src/field_overlay_source.cc b/src/field_overlay_source.cc
index 3cb7242d..9ea11374 100644
--- a/src/field_overlay_source.cc
+++ b/src/field_overlay_source.cc
@@ -134,7 +134,7 @@ field_overlay_source::build_field_lines(const listview_curses& lv,
     time_lr.lr_end = time_str.length();
     time_line.with_attr(
         string_attr(time_lr, VC_STYLE.value(text_attrs{A_BOLD})));
-    time_str.append(" -- ");
+    time_str.append(" \u2014 ");
     time_lr.lr_start = time_str.length();
     time_str.append(humanize::time::point::from_tv(ll->get_timeval())
                         .with_convert_to_local(true)
@@ -157,14 +157,13 @@ field_overlay_source::build_field_lines(const listview_curses& lv,
 
         dts.set_base_time(format->lf_date_time.dts_base_time,
                           format->lf_date_time.dts_base_tm.et_tm);
+        dts.dts_zoned_to_local = format->lf_date_time.dts_zoned_to_local;
         if (format->lf_date_time.scan(time_src,
                                       time_range.length(),
                                       format->get_timestamp_formats(),
                                       &tm,
-                                      actual_tv,
-                                      false)
-            || dts.scan(
-                time_src, time_range.length(), nullptr, &tm, actual_tv, false))
+                                      actual_tv)
+            || dts.scan(time_src, time_range.length(), nullptr, &tm, actual_tv))
         {
             sql_strftime(
                 orig_timestamp, sizeof(orig_timestamp), actual_tv, 'T');
diff --git a/src/formats/vmk_log.json b/src/formats/vmk_log.json
index 1339dcf1..962575e0 100644
--- a/src/formats/vmk_log.json
+++ b/src/formats/vmk_log.json
@@ -12,6 +12,7 @@
                 "pattern": "^(?(?:\\S{3,8}\\s+\\d{1,2} \\d{2}:\\d{2}:\\d{2}|\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{3})?Z))\\s+(?\\w+)\\((?\\d+)\\)(?:\\[\\+\\]|\\+)? (?:vmkernel|vmkwarning):\\s* (?:cpu(?\\d+):(?\\d+)(?: opID=(?[^\\)]+))?\\))?((?:(?:WARNING|ALERT)|(?[^:]+)): )?(?.*)"
             }
         },
+        "ordered-by-time": false,
         "level-field": "level",
         "level": {
             "debug": "^Db$",
diff --git a/src/hotkeys.cc b/src/hotkeys.cc
index 64994afe..c6960a4f 100644
--- a/src/hotkeys.cc
+++ b/src/hotkeys.cc
@@ -376,36 +376,43 @@ handle_paging_key(int ch)
             break;
 
         case 'J':
-            if (lnav_data.ld_last_user_mark.find(tc)
-                    == lnav_data.ld_last_user_mark.end()
-                || !tc->is_line_visible(
-                    vis_line_t(lnav_data.ld_last_user_mark[tc])))
-            {
+            if (tc->is_selectable()) {
+                tc->toggle_user_mark(&textview_curses::BM_USER,
+                                     tc->get_selection());
                 lnav_data.ld_select_start[tc] = tc->get_selection();
                 lnav_data.ld_last_user_mark[tc] = tc->get_selection();
-            } else {
-                vis_line_t height;
-                unsigned long width;
-
-                tc->get_dimensions(height, width);
-                if (lnav_data.ld_last_user_mark[tc] > (tc->get_bottom() - 2)
-                    && tc->get_selection() + height < tc->get_inner_height())
-                {
-                    tc->shift_top(1_vl);
+                if (tc->get_selection() + 1_vl < tc->get_inner_height()) {
+                    tc->set_selection(tc->get_selection() + 1_vl);
                 }
-                if (lnav_data.ld_last_user_mark[tc] + 1
-                    >= tc->get_inner_height())
+            } else {
+                if (lnav_data.ld_last_user_mark.find(tc)
+                        == lnav_data.ld_last_user_mark.end()
+                    || !tc->is_line_visible(
+                        vis_line_t(lnav_data.ld_last_user_mark[tc])))
                 {
-                    break;
+                    lnav_data.ld_select_start[tc] = tc->get_selection();
+                    lnav_data.ld_last_user_mark[tc] = tc->get_selection();
+                } else {
+                    vis_line_t height;
+                    unsigned long width;
+
+                    tc->get_dimensions(height, width);
+                    if (lnav_data.ld_last_user_mark[tc] > (tc->get_bottom() - 2)
+                        && tc->get_selection() + height
+                            < tc->get_inner_height())
+                    {
+                        tc->shift_top(1_vl);
+                    }
+                    if (lnav_data.ld_last_user_mark[tc] + 1
+                        >= tc->get_inner_height())
+                    {
+                        break;
+                    }
+                    lnav_data.ld_last_user_mark[tc] += 1;
                 }
-                lnav_data.ld_last_user_mark[tc] += 1;
-            }
-            tc->toggle_user_mark(&textview_curses::BM_USER,
-                                 vis_line_t(lnav_data.ld_last_user_mark[tc]));
-            if (tc->is_selectable()
-                && tc->get_selection() + 1_vl < tc->get_inner_height())
-            {
-                tc->set_selection(tc->get_selection() + 1_vl);
+                tc->toggle_user_mark(
+                    &textview_curses::BM_USER,
+                    vis_line_t(lnav_data.ld_last_user_mark[tc]));
             }
             tc->reload_data();
 
diff --git a/src/lnav.cc b/src/lnav.cc
index 418df896..be7f272e 100644
--- a/src/lnav.cc
+++ b/src/lnav.cc
@@ -721,7 +721,9 @@ make it easier to navigate through files quickly.
         .append(lnav::roles::file(lnav::paths::workdir().string()))
         .append("\n\n")
         .append("Documentation"_h1)
-        .append(": https://docs.lnav.org\n")
+        .append(": ")
+        .append("https://docs.lnav.org"_hyperlink)
+        .append("\n")
         .append("Contact"_h1)
         .append("\n")
         .append("  ")
@@ -739,7 +741,7 @@ make it easier to navigate through files quickly.
 static void
 clear_last_user_mark(listview_curses* lv)
 {
-    textview_curses* tc = (textview_curses*) lv;
+    auto* tc = (textview_curses*) lv;
     if (lnav_data.ld_select_start.find(tc) != lnav_data.ld_select_start.end()
         && !tc->is_line_visible(vis_line_t(lnav_data.ld_last_user_mark[tc])))
     {
diff --git a/src/lnav.indexing.cc b/src/lnav.indexing.cc
index d1b69b4e..1c3df91b 100644
--- a/src/lnav.indexing.cc
+++ b/src/lnav.indexing.cc
@@ -353,7 +353,7 @@ rebuild_indexes(nonstd::optional deadline)
         }
     }
 
-    lnav_data.ld_view_stack.top() | [&closed_files](auto tc) {
+    lnav_data.ld_view_stack.top() | [&closed_files, &retval](auto tc) {
         if (!closed_files.empty() && tc == &lnav_data.ld_views[LNV_GANTT]) {
             auto* gantt_source = lnav_data.ld_views[LNV_GANTT].get_sub_source();
             if (gantt_source != nullptr) {
@@ -361,9 +361,11 @@ rebuild_indexes(nonstd::optional deadline)
             }
         }
 
-        auto* tss = tc->get_sub_source();
-        lnav_data.ld_filter_status_source.update_filtered(tss);
-        lnav_data.ld_scroll_broadcaster(tc);
+        if (retval > 0) {
+            auto* tss = tc->get_sub_source();
+            lnav_data.ld_filter_status_source.update_filtered(tss);
+            lnav_data.ld_scroll_broadcaster(tc);
+        }
     };
 
     return retval;
diff --git a/src/log_format.cc b/src/log_format.cc
index 7f4945d2..d941dd3b 100644
--- a/src/log_format.cc
+++ b/src/log_format.cc
@@ -1287,16 +1287,41 @@ external_log_format::scan(logfile& lf,
             {
                 this->lf_date_time.relock(ls);
                 continue;
-            } else {
-                log_debug("%s:%d:date-time re-locked to %d",
-                          lf.get_unique_path().c_str(),
-                          dst.size(),
-                          this->lf_date_time.dts_fmt_lock);
             }
+            if (last != nullptr) {
+                auto old_flags = this->lf_timestamp_flags & DATE_TIME_SET_FLAGS;
+                auto new_flags = log_time_tm.et_flags & DATE_TIME_SET_FLAGS;
+
+                // It is unlikely a valid timestamp would lose much
+                // precision.
+                if (new_flags != old_flags) {
+                    continue;
+                }
+            }
+
+            log_debug("%s:%d:date-time re-locked to %d",
+                      lf.get_unique_path().c_str(),
+                      dst.size(),
+                      this->lf_date_time.dts_fmt_lock);
         }
 
         this->lf_timestamp_flags = log_time_tm.et_flags;
 
+        if (!(this->lf_timestamp_flags
+              & (ETF_MILLIS_SET | ETF_MICROS_SET | ETF_NANOS_SET))
+            && !dst.empty() && dst.back().get_time() == log_tv.tv_sec
+            && dst.back().get_millis() != 0)
+        {
+            auto log_ms = std::chrono::milliseconds(dst.back().get_millis());
+
+            log_time_tm.et_nsec
+                = std::chrono::duration_cast(log_ms)
+                      .count();
+            log_tv.tv_usec
+                = std::chrono::duration_cast(log_ms)
+                      .count();
+        }
+
         if (!((log_time_tm.et_flags & ETF_DAY_SET)
               && (log_time_tm.et_flags & ETF_MONTH_SET)
               && (log_time_tm.et_flags & ETF_YEAR_SET)))
diff --git a/src/log_format_impls.cc b/src/log_format_impls.cc
index 2033373e..cff53278 100644
--- a/src/log_format_impls.cc
+++ b/src/log_format_impls.cc
@@ -130,6 +130,24 @@ class generic_log_format : public log_format {
                 this->check_for_new_year(dst, log_time, log_tv);
             }
 
+            if (!(this->lf_timestamp_flags
+                  & (ETF_MILLIS_SET | ETF_MICROS_SET | ETF_NANOS_SET))
+                && !dst.empty() && dst.back().get_time() == log_tv.tv_sec
+                && dst.back().get_millis() != 0)
+            {
+                auto log_ms
+                    = std::chrono::milliseconds(dst.back().get_millis());
+
+                log_time.et_nsec
+                    = std::chrono::duration_cast(
+                          log_ms)
+                          .count();
+                log_tv.tv_usec
+                    = std::chrono::duration_cast(
+                          log_ms)
+                          .count();
+            }
+
             dst.emplace_back(li.li_file_range.fr_offset, log_tv, level_val);
             return scan_match{0};
         }
diff --git a/src/md2attr_line.cc b/src/md2attr_line.cc
index 44393a91..314697e7 100644
--- a/src/md2attr_line.cc
+++ b/src/md2attr_line.cc
@@ -42,6 +42,7 @@
 #include "view_curses.hh"
 
 using namespace lnav::roles::literals;
+using namespace md4cpp::literals;
 
 static const std::map CODE_NAME_TO_TEXT_FORMAT
     = {
@@ -462,6 +463,8 @@ md2attr_line::enter_span(const md4cpp::event_handler::span& sp)
     if (sp.is()) {
         last_block.append(" ");
         this->ml_code_depth += 1;
+    } else if (sp.is()) {
+        last_block.append(":framed_picture:"_emoji).append("  ");
     }
     return Ok();
 }
@@ -517,7 +520,14 @@ md2attr_line::leave_span(const md4cpp::event_handler::span& sp)
     } else if (sp.is()) {
         auto* a_detail = sp.get();
         auto href_str = std::string(a_detail->href.text, a_detail->href.size);
-
+        line_range lr{
+            static_cast(this->ml_span_starts.back()),
+            static_cast(last_block.length()),
+        };
+        last_block.with_attr({
+            lr,
+            VC_HYPERLINK.value(href_str),
+        });
         this->append_url_footnote(href_str);
     } else if (sp.is()) {
         auto* img_detail = sp.get();
@@ -617,9 +627,10 @@ span_style_border(border_side side, const string_fragment& value)
     return attr_line_t(ch).with_attr_for_all(VC_STYLE.value(border_attrs));
 }
 
-static attr_line_t
-to_attr_line(const pugi::xml_node& doc)
+attr_line_t
+md2attr_line::to_attr_line(const pugi::xml_node& doc)
 {
+    static const auto NAME_IMG = string_fragment::from_const("img");
     static const auto NAME_SPAN = string_fragment::from_const("span");
     static const auto NAME_PRE = string_fragment::from_const("pre");
     static const auto NAME_FG = string_fragment::from_const("color");
@@ -639,7 +650,62 @@ to_attr_line(const pugi::xml_node& doc)
         retval.append(doc.text().get());
     }
     for (const auto& child : doc.children()) {
-        if (child.name() == NAME_SPAN) {
+        if (child.name() == NAME_IMG) {
+            nonstd::optional src_href;
+            std::string link_label;
+            auto img_src = child.attribute("src");
+            auto img_alt = child.attribute("alt");
+            if (img_alt) {
+                link_label = img_alt.value();
+            } else if (img_src) {
+                link_label = ghc::filesystem::path(img_src.value())
+                                 .filename()
+                                 .string();
+            } else {
+                link_label = "img";
+            }
+
+            if (img_src) {
+                auto src_value = std::string(img_src.value());
+                if (is_url(src_value)) {
+                    src_href = src_value;
+                } else {
+                    auto src_path = ghc::filesystem::path(src_value);
+                    std::error_code ec;
+
+                    if (src_path.is_relative() && this->ml_source_path) {
+                        src_path = this->ml_source_path.value().parent_path()
+                            / src_path;
+                    }
+                    auto canon_path = ghc::filesystem::canonical(src_path, ec);
+                    if (!ec) {
+                        src_path = canon_path;
+                    }
+
+                    src_href = fmt::format(FMT_STRING("file://{}"),
+                                           src_path.string());
+                }
+            }
+
+            if (src_href) {
+                retval.append(":framed_picture:"_emoji)
+                    .append("  ")
+                    .append(
+                        lnav::string::attrs::href(link_label, src_href.value()))
+                    .appendf(FMT_STRING("[{}]"), this->ml_footnotes.size() + 1);
+
+                auto href
+                    = attr_line_t()
+                          .append(lnav::roles::hyperlink(src_href.value()))
+                          .append(" ");
+                href.with_attr_for_all(
+                    VC_ROLE.value(role_t::VCR_FOOTNOTE_TEXT));
+                href.with_attr_for_all(SA_PREFORMATTED.value());
+                this->ml_footnotes.emplace_back(href);
+            } else {
+                retval.append(link_label);
+            }
+        } else if (child.name() == NAME_SPAN) {
             nonstd::optional left_border;
             nonstd::optional right_border;
             auto styled_span = attr_line_t(child.text().get());
@@ -738,7 +804,7 @@ to_attr_line(const pugi::xml_node& doc)
             auto pre_al = attr_line_t();
 
             for (const auto& sub : child.children()) {
-                auto child_al = to_attr_line(sub);
+                auto child_al = this->to_attr_line(sub);
                 if (pre_al.empty() && startswith(child_al.get_string(), "\n")) {
                     child_al.erase(0, 1);
                 }
@@ -781,6 +847,7 @@ md2attr_line::text(MD_TEXTTYPE tt, const string_fragment& sf)
             break;
         }
         case MD_TEXT_HTML: {
+            auto last_block_start_length = last_block.length();
             last_block.append(sf);
 
             struct open_tag {
@@ -789,8 +856,9 @@ md2attr_line::text(MD_TEXTTYPE tt, const string_fragment& sf)
             struct close_tag {
                 std::string ct_name;
             };
+            struct empty_tag {};
 
-            mapbox::util::variant tag{
+            mapbox::util::variant tag{
                 mapbox::util::no_init{}};
 
             if (sf.startswith("'; })
-                        .first.to_string(),
-                };
+                if (sf.endswith("/>")) {
+                    tag = empty_tag{};
+                } else {
+                    tag = open_tag{
+                        sf.substr(1)
+                            .split_when(
+                                [](char ch) { return ch == ' ' || ch == '>'; })
+                            .first.to_string(),
+                    };
+                }
             }
 
             if (tag.valid()) {
                 tag.match(
-                    [this, &sf, &last_block](const open_tag& ot) {
+                    [this, last_block_start_length](const open_tag& ot) {
                         if (!this->ml_html_starts.empty()) {
                             return;
                         }
                         this->ml_html_starts.emplace_back(
-                            ot.ot_name, last_block.length() - sf.length());
+                            ot.ot_name, last_block_start_length);
                     },
                     [this, &last_block](const close_tag& ct) {
                         if (this->ml_html_starts.empty()) {
@@ -845,9 +917,31 @@ md2attr_line::text(MD_TEXTTYPE tt, const string_fragment& sf)
                         } else {
                             last_block.erase(
                                 this->ml_html_starts.back().second);
-                            last_block.append(to_attr_line(doc));
+                            last_block.append(this->to_attr_line(doc));
                         }
                         this->ml_html_starts.pop_back();
+                    },
+                    [this, &sf, &last_block, last_block_start_length](
+                        const empty_tag&) {
+                        const auto html_span = sf.to_string();
+
+                        pugi::xml_document doc;
+
+                        auto load_res = doc.load_string(html_span.c_str());
+                        if (!load_res) {
+                            log_error("XML parsing failure at %d: %s",
+                                      load_res.offset,
+                                      load_res.description());
+
+                            auto error_line = sf.find_boundaries_around(
+                                load_res.offset, string_fragment::tag1{'\n'});
+                            log_error("  %.*s",
+                                      error_line.length(),
+                                      error_line.data());
+                        } else {
+                            last_block.erase(last_block_start_length);
+                            last_block.append(this->to_attr_line(doc));
+                        }
                     });
             }
             break;
diff --git a/src/md2attr_line.hh b/src/md2attr_line.hh
index 6a2ef8e6..212b3ac2 100644
--- a/src/md2attr_line.hh
+++ b/src/md2attr_line.hh
@@ -34,6 +34,10 @@
 #include "ghc/filesystem.hpp"
 #include "md4cpp.hh"
 
+namespace pugi {
+class xml_node;
+}
+
 class md2attr_line : public md4cpp::typed_event_handler {
 public:
     md2attr_line() { this->ml_blocks.resize(1); }
@@ -77,6 +81,7 @@ private:
 
     void append_url_footnote(std::string href);
     void flush_footnotes();
+    attr_line_t to_attr_line(const pugi::xml_node& doc);
 
     nonstd::optional ml_source_path;
     std::vector ml_blocks;
diff --git a/src/view_curses.cc b/src/view_curses.cc
index 0b2e9a83..c2be62ba 100644
--- a/src/view_curses.cc
+++ b/src/view_curses.cc
@@ -123,9 +123,9 @@ struct utf_to_display_adjustment {
     int uda_offset;
 
     utf_to_display_adjustment(int utf_origin, int offset)
-        : uda_origin(utf_origin), uda_offset(offset){
-
-                                  };
+        : uda_origin(utf_origin), uda_offset(offset)
+    {
+    }
 };
 
 void
@@ -185,6 +185,12 @@ view_curses::mvwattrline(WINDOW* window,
                 break;
             }
 
+            case '\x1b':
+                expanded_line.append("\u238b");
+                utf_adjustments.emplace_back(lpc, -1);
+                char_index += 1;
+                break;
+
             case '\r':
             case '\n':
                 expanded_line.push_back(' ');
diff --git a/test/expected/test_cmds.sh_b6a3bb78e9d60e5e1f5ce5b18e40d2f1662707ab.out b/test/expected/test_cmds.sh_b6a3bb78e9d60e5e1f5ce5b18e40d2f1662707ab.out
index 12076194..5a47da2b 100644
--- a/test/expected/test_cmds.sh_b6a3bb78e9d60e5e1f5ce5b18e40d2f1662707ab.out
+++ b/test/expected/test_cmds.sh_b6a3bb78e9d60e5e1f5ce5b18e40d2f1662707ab.out
@@ -424,7 +424,7 @@ can always use  q  to pop the top view off of the stack.
  CTRL+], ESCAPE       Abort command-line entry started with  / ,  : ,  ;
                       , or  | .
 
- ▌Note: The regular expression format used by lnav is PCRE[1]  
+ ▌Note: The regular expression format used by lnav is ]8;;http://perldoc.perl.org/perlre.html\PCRE]8;;\[1]  
  ▌(Perl-Compatible Regular Expressions).                       
  ▌                                                             
  ▌ ▌[1] - http://perldoc.perl.org/perlre.html                  
diff --git a/test/expected/test_logfile.sh_08d731a04c877a34819b35de185e30a74c9fd497.out b/test/expected/test_logfile.sh_08d731a04c877a34819b35de185e30a74c9fd497.out
index 0465ab4c..1d2ec4e8 100644
--- a/test/expected/test_logfile.sh_08d731a04c877a34819b35de185e30a74c9fd497.out
+++ b/test/expected/test_logfile.sh_08d731a04c877a34819b35de185e30a74c9fd497.out
@@ -1,3 +1,3 @@
-2600-12-03 09:23:00 0:
-2600-12-03 09:23:00 0:
-2600-12-03 09:23:00 0:
+2600-01-03 09:23:00 0:
+00:2 0 00:00:00 0:
+2600-01-03 09:23:00 0:
diff --git a/test/expected/test_sql_views_vtab.sh_32acc1a8bb5028636fdbf08f077f9a835ab51bec.out b/test/expected/test_sql_views_vtab.sh_32acc1a8bb5028636fdbf08f077f9a835ab51bec.out
index 1d4600e4..d25ba3ed 100644
--- a/test/expected/test_sql_views_vtab.sh_32acc1a8bb5028636fdbf08f077f9a835ab51bec.out
+++ b/test/expected/test_sql_views_vtab.sh_32acc1a8bb5028636fdbf08f077f9a835ab51bec.out
@@ -11,7 +11,7 @@ Run  ./autogen.sh  if compiling from a cloned repository.
 
 See Also
 
-Angle-grinder[1] is a tool to slice and dice log files on the
+]8;;https://github.com/rcoh/angle-grinder\Angle-grinder]8;;\[1] is a tool to slice and dice log files on the
 command-line. If you're familiar with the SumoLogic query language,
 you might find this tool more comfortable to work with.
 
diff --git a/test/expected/test_text_file.sh_5b51b55dff7332c5bee2c9b797c401c5614d574a.out b/test/expected/test_text_file.sh_5b51b55dff7332c5bee2c9b797c401c5614d574a.out
index d0f87f26..4eb96103 100644
--- a/test/expected/test_text_file.sh_5b51b55dff7332c5bee2c9b797c401c5614d574a.out
+++ b/test/expected/test_text_file.sh_5b51b55dff7332c5bee2c9b797c401c5614d574a.out
@@ -1,4 +1,4 @@
-Build[1][2] Docs[3][4] Coverage Status[5][6] lnav[7][8]
+]8;;https://github.com/tstack/lnav/actions?query=workflow%3Aci-build\🖼  Build[1]]8;;\[2] ]8;;https://docs.lnav.org\🖼  Docs[3]]8;;\[4] ]8;;https://coveralls.io/github/tstack/lnav?branch=master\🖼  Coverage Status[5]]8;;\[6] ]8;;https://snapcraft.io/lnav\🖼  lnav[7]]8;;\[8]
 
  ▌[1] - https://github.com/tstack/lnav/workflows/ci-build/badge.svg               
  ▌[2] - https://github.com/tstack/lnav/actions?query=workflow%3Aci-build          
@@ -9,13 +9,12 @@
  ▌[7] - https://snapcraft.io/lnav/badge.svg                                       
  ▌[8] - https://snapcraft.io/lnav                                                 
 
-[1]
+]8;;https://discord.gg/erBPnKwz7R\🖼  ]8;;\]8;;https://discord.gg/erBPnKwz7R\Discord Logo]8;;\]8;;https://discord.gg/erBPnKwz7R\[1]]8;;\[2]
 
- ▌[1] - https://discord.gg/erBPnKwz7R 
+ ▌[1] - https://assets-global.website-files.com/6257adef93867e50d84d30e2/62594fddd654fc29fcc07359_cb48d2a8d4991281d7a6a95d2f58195e.svg 
+ ▌[2] - https://discord.gg/erBPnKwz7R                                                                                                  
 
-This is the source repository for lnav, visit https://lnav.org[1] for
+This is the source repository for lnav, visit ]8;;https://lnav.org\https://lnav.org]8;;\[1] for
 a high level overview.
 
  ▌[1] - https://lnav.org 
@@ -32,7 +31,7 @@ to no setup.
 The following screenshot shows a syslog file. Log lines are displayed
 with highlights. Errors are red and warnings are yellow.
 
-Screenshot[1][2]
+]8;;docs/assets/images/lnav-syslog.png\🖼  Screenshot[1]]8;;\[2]
 
  ▌[1] - file://{top_srcdir}/docs/assets/images/lnav-syslog-thumb.png 
  ▌[2] - file://{top_srcdir}/docs/assets/images/lnav-syslog.png       
@@ -49,8 +48,8 @@ with highlights. Errors are red and warnings are yellow.
 
 Installation
 
-Download a statically-linked binary for Linux/MacOS from the release
-page[1]
+]8;;https://github.com/tstack/lnav/releases/latest#release-artifacts\Download a statically-linked binary for Linux/MacOS from the release]8;;\
+]8;;https://github.com/tstack/lnav/releases/latest#release-artifacts\page]8;;\[1]
 
  ▌[1] - https://github.com/tstack/lnav/releases/latest#release-artifacts 
 
@@ -111,9 +110,9 @@ log lines fed into  lnav  via  journalctl 's  -b  option.
 Please file issues on this repository or use the discussions section.
 The following alternatives are also available:
 
- • support@lnav.org[1]
- • Discord[2]
- • Google Groups[3]
+ • ]8;;mailto:support@lnav.org\support@lnav.org]8;;\[1]
+ • ]8;;https://discord.gg/erBPnKwz7R\Discord]8;;\[2]
+ • ]8;;https://groups.google.com/g/lnav\Google Groups]8;;\[3]
 
  ▌[1] - mailto:support@lnav.org          
  ▌[2] - https://discord.gg/erBPnKwz7R    
@@ -121,9 +120,9 @@ The following alternatives are also available:
 
 Links
 
- • Main Site[1]
- • Documentation[2] on Read the Docs
- • Internal Architecture[3]
+ • ]8;;https://lnav.org\Main Site]8;;\[1]
+ • ]8;;https://docs.lnav.org\Documentation]8;;\[2] on Read the Docs
+ • ]8;;ARCHITECTURE.md\Internal Architecture]8;;\[3]
 
  ▌[1] - https://lnav.org                                 
  ▌[2] - https://docs.lnav.org                            
@@ -131,7 +130,7 @@ The following alternatives are also available:
 
 Contributing
 
- • Become a Sponsor on GitHub[1]
+ • ]8;;https://github.com/sponsors/tstack\Become a Sponsor on GitHub]8;;\[1]
 
  ▌[1] - https://github.com/sponsors/tstack 
 
@@ -170,7 +169,7 @@ Run  ./autogen.sh  if compiling from a cloned repository.
 
 See Also
 
-Angle-grinder[1] is a tool to slice and dice log files on the
+]8;;https://github.com/rcoh/angle-grinder\Angle-grinder]8;;\[1] is a tool to slice and dice log files on the
 command-line. If you're familiar with the SumoLogic query language,
 you might find this tool more comfortable to work with.
 
diff --git a/test/expected/test_text_file.sh_6a24078983cf1b7a80b6fb65d5186cd125498136.out b/test/expected/test_text_file.sh_6a24078983cf1b7a80b6fb65d5186cd125498136.out
index 9e9836d8..0057ec78 100644
--- a/test/expected/test_text_file.sh_6a24078983cf1b7a80b6fb65d5186cd125498136.out
+++ b/test/expected/test_text_file.sh_6a24078983cf1b7a80b6fb65d5186cd125498136.out
@@ -3,7 +3,7 @@
 The following screenshot shows a syslog file. Log lines are displayed
 with highlights. Errors are red and warnings are yellow.
 
-Screenshot[1][2]
+]8;;docs/assets/images/lnav-syslog.png\🖼  Screenshot[1]]8;;\[2]
 
  ▌[1] - file://{top_srcdir}/docs/assets/images/lnav-syslog-thumb.png 
  ▌[2] - file://{top_srcdir}/docs/assets/images/lnav-syslog.png       
@@ -20,8 +20,8 @@ with highlights. Errors are red and warnings are yellow.
 
 Installation
 
-Download a statically-linked binary for Linux/MacOS from the release
-page[1]
+]8;;https://github.com/tstack/lnav/releases/latest#release-artifacts\Download a statically-linked binary for Linux/MacOS from the release]8;;\
+]8;;https://github.com/tstack/lnav/releases/latest#release-artifacts\page]8;;\[1]
 
  ▌[1] - https://github.com/tstack/lnav/releases/latest#release-artifacts 
 
@@ -82,9 +82,9 @@ log lines fed into  lnav  via  journalctl 's  -b  option.
 Please file issues on this repository or use the discussions section.
 The following alternatives are also available:
 
- • support@lnav.org[1]
- • Discord[2]
- • Google Groups[3]
+ • ]8;;mailto:support@lnav.org\support@lnav.org]8;;\[1]
+ • ]8;;https://discord.gg/erBPnKwz7R\Discord]8;;\[2]
+ • ]8;;https://groups.google.com/g/lnav\Google Groups]8;;\[3]
 
  ▌[1] - mailto:support@lnav.org          
  ▌[2] - https://discord.gg/erBPnKwz7R    
@@ -92,9 +92,9 @@ The following alternatives are also available:
 
 Links
 
- • Main Site[1]
- • Documentation[2] on Read the Docs
- • Internal Architecture[3]
+ • ]8;;https://lnav.org\Main Site]8;;\[1]
+ • ]8;;https://docs.lnav.org\Documentation]8;;\[2] on Read the Docs
+ • ]8;;ARCHITECTURE.md\Internal Architecture]8;;\[3]
 
  ▌[1] - https://lnav.org                                 
  ▌[2] - https://docs.lnav.org                            
@@ -102,7 +102,7 @@ The following alternatives are also available:
 
 Contributing
 
- • Become a Sponsor on GitHub[1]
+ • ]8;;https://github.com/sponsors/tstack\Become a Sponsor on GitHub]8;;\[1]
 
  ▌[1] - https://github.com/sponsors/tstack 
 
@@ -141,7 +141,7 @@ Run  ./autogen.sh  if compiling from a cloned repository.
 
 See Also
 
-Angle-grinder[1] is a tool to slice and dice log files on the
+]8;;https://github.com/rcoh/angle-grinder\Angle-grinder]8;;\[1] is a tool to slice and dice log files on the
 command-line. If you're familiar with the SumoLogic query language,
 you might find this tool more comfortable to work with.
 
diff --git a/test/expected/test_text_file.sh_d59b67113864ef5e77267d7fd8ad4072f5aef0fc.out b/test/expected/test_text_file.sh_d59b67113864ef5e77267d7fd8ad4072f5aef0fc.out
index 39994981..30b1d02d 100644
--- a/test/expected/test_text_file.sh_d59b67113864ef5e77267d7fd8ad4072f5aef0fc.out
+++ b/test/expected/test_text_file.sh_d59b67113864ef5e77267d7fd8ad4072f5aef0fc.out
@@ -5,6 +5,13 @@
  • Two
  • Three
 
+🖼  ]8;;file://{top_srcdir}/docs/lnav-tui.png\lnav-tui.png]8;;\[1]
+
+🖼  ]8;;file://{top_srcdir}/docs/lnav-architecture.png\The internal architecture of lnav]8;;\[2]
+
+ ▌[1] - file://{top_srcdir}/docs/lnav-tui.png          
+ ▌[2] - file://{top_srcdir}/docs/lnav-architecture.png 
+
 Bold red
 
 Underline
diff --git a/test/expected/test_text_file.sh_e088ea61a5382458cc48a2607e2639e52b0be1da.out b/test/expected/test_text_file.sh_e088ea61a5382458cc48a2607e2639e52b0be1da.out
index 9e9836d8..0057ec78 100644
--- a/test/expected/test_text_file.sh_e088ea61a5382458cc48a2607e2639e52b0be1da.out
+++ b/test/expected/test_text_file.sh_e088ea61a5382458cc48a2607e2639e52b0be1da.out
@@ -3,7 +3,7 @@
 The following screenshot shows a syslog file. Log lines are displayed
 with highlights. Errors are red and warnings are yellow.
 
-Screenshot[1][2]
+]8;;docs/assets/images/lnav-syslog.png\🖼  Screenshot[1]]8;;\[2]
 
  ▌[1] - file://{top_srcdir}/docs/assets/images/lnav-syslog-thumb.png 
  ▌[2] - file://{top_srcdir}/docs/assets/images/lnav-syslog.png       
@@ -20,8 +20,8 @@ with highlights. Errors are red and warnings are yellow.
 
 Installation
 
-Download a statically-linked binary for Linux/MacOS from the release
-page[1]
+]8;;https://github.com/tstack/lnav/releases/latest#release-artifacts\Download a statically-linked binary for Linux/MacOS from the release]8;;\
+]8;;https://github.com/tstack/lnav/releases/latest#release-artifacts\page]8;;\[1]
 
  ▌[1] - https://github.com/tstack/lnav/releases/latest#release-artifacts 
 
@@ -82,9 +82,9 @@ log lines fed into  lnav  via  journalctl 's  -b  option.
 Please file issues on this repository or use the discussions section.
 The following alternatives are also available:
 
- • support@lnav.org[1]
- • Discord[2]
- • Google Groups[3]
+ • ]8;;mailto:support@lnav.org\support@lnav.org]8;;\[1]
+ • ]8;;https://discord.gg/erBPnKwz7R\Discord]8;;\[2]
+ • ]8;;https://groups.google.com/g/lnav\Google Groups]8;;\[3]
 
  ▌[1] - mailto:support@lnav.org          
  ▌[2] - https://discord.gg/erBPnKwz7R    
@@ -92,9 +92,9 @@ The following alternatives are also available:
 
 Links
 
- • Main Site[1]
- • Documentation[2] on Read the Docs
- • Internal Architecture[3]
+ • ]8;;https://lnav.org\Main Site]8;;\[1]
+ • ]8;;https://docs.lnav.org\Documentation]8;;\[2] on Read the Docs
+ • ]8;;ARCHITECTURE.md\Internal Architecture]8;;\[3]
 
  ▌[1] - https://lnav.org                                 
  ▌[2] - https://docs.lnav.org                            
@@ -102,7 +102,7 @@ The following alternatives are also available:
 
 Contributing
 
- • Become a Sponsor on GitHub[1]
+ • ]8;;https://github.com/sponsors/tstack\Become a Sponsor on GitHub]8;;\[1]
 
  ▌[1] - https://github.com/sponsors/tstack 
 
@@ -141,7 +141,7 @@ Run  ./autogen.sh  if compiling from a cloned repository.
 
 See Also
 
-Angle-grinder[1] is a tool to slice and dice log files on the
+]8;;https://github.com/rcoh/angle-grinder\Angle-grinder]8;;\[1] is a tool to slice and dice log files on the
 command-line. If you're familiar with the SumoLogic query language,
 you might find this tool more comfortable to work with.
 
diff --git a/test/test_ansi_scrubber.cc b/test/test_ansi_scrubber.cc
index 87969a3e..8aa21e95 100644
--- a/test/test_ansi_scrubber.cc
+++ b/test/test_ansi_scrubber.cc
@@ -90,6 +90,27 @@ main(int argc, char* argv[])
         }
     }
 
+    {
+        auto hlink = std::string(
+            "\033]8;;http://example.com\033\\This is a "
+            "link\033]8;;\033\\\n");
+        string_attrs_t sa;
+        scrub_ansi_string(hlink, &sa);
+
+        printf("hlink %d %d %s", hlink.size(), sa.size(), hlink.c_str());
+        assert(sa.size() == 4);
+        for (const auto& attr : sa) {
+            printf("attr %d:%d %s\n",
+                   attr.sa_range.lr_start,
+                   attr.sa_range.lr_end,
+                   attr.sa_type->sat_name);
+            if (attr.sa_type == &VC_HYPERLINK) {
+                printf("  value: %s\n",
+                       attr.sa_value.get().c_str());
+            }
+        }
+    }
+
     string_attrs_t sa;
     string str_cp;
 
diff --git a/test/textfile_0.md b/test/textfile_0.md
index 4f652fff..7bda8e1a 100644
--- a/test/textfile_0.md
+++ b/test/textfile_0.md
@@ -8,6 +8,10 @@
 * Two
 * Three
 
+
+
+The internal architecture of lnav
+
 Bold red
 
 Underline