Repository: microsoft/terminal (language: cpp)

Issue:
Copy with Control Sequences
# Description of the new feature/enhancement
Option to copy text from the terminal with ANSI sequences. Useful when you need to copy styled output to another file like a markdown codefence with ansi-support (ex. https://github.com/expressive-code/expressive-code/pull/29)

See similar option in [iTerm2](https://iterm2.com/documentation-menu-items.html): Edit > Copy with Control Sequences

# Proposed technical implementation details (optional)

A new "Copy with Control Sequences" command that includes escape sequences in the copied text. Either include original ANSI-sequences if possible or recreate (best-effort) like iTerm2.


Discussion / hints:
Hey there! I'm interested in helping out with this, and I've gotten a very basic proof-of-concept version working locally: https://github.com/microsoft/terminal/compare/main...FuegoFro:windows_terminal:copy_with_control_sequences?expand=1

But before I keep working on it I wanted to check in on a few things:
- I see that this doesn't have the `Help Wanted` label, what does that mean regarding outside contributions?
- In the `CONTRIBUTING.md` it mentions that the `Issue-Feature` label indicates it likely requires a spec; does that hold true here, or does this seem like the kind of thing that could be worked out on a PR directly?
- Maybe this is just a different phrasing of the above, but what would be the best way to proceed here? Should I open an RFC/WIP PR for high-level feedback? Continue discussion here? Take a stab at a spec? Flesh out tests/docs/functionality on that branch?

I appreciate any and all guidance here! 🙏

(also as a side note, I just wanted to say how much I appreciate the docs and tooling here; I was able to go from "I've never worked with Windows-specific tooling" to "I have this up and running and can see the changes I've made" in a day, which is awesome 🎉)
Oh man, this is so cool! Thanks so much for working on it!

Absolutely -- we usually mark things "Help Wanted" if we think they'll be _approachable_ for a community member. Getting down into the nitty-gritty of the text buffer _usually_ isn't approachable.

In the time since you started working on that (maybe? I could be wrong!) @lhecker added a somewhat generic or genericizable function to TextBuffer that does the same thing. It's currently used for session save/restore. I wonder if y'all can converge!

I'm so glad you were able to get going[^1] and I'm looking forward to your eventual PR 🙂 

[^1]: (I am eternally afraid that our project is too terrifically complex to get started on, so thanks for hatcheting through the wilderness)
Ah amazing, thank you so much for the quick response!

That function is exactly what I wanted, so glad I can reuse it and not reimplement that all myself! 😅 Thanks for pointing me at it 😊 

Given there wasn't mention of a spec in the above response, I've gone ahead and opened https://github.com/microsoft/terminal/pull/17059 with an implementation that reuses the serialize function. Aside from a few questions I've left there, I think it should be ready for feedback!
If I may just offer some outside view on this.
First, I think allowing copy/paste with VT control sequences is a great idea, definitely something that should be supported in some way.
The goal of the clipboard is to provide as many formats as possible to enable as many copy/paste scenarios as possible.
The question really is only in which way...

VT being an in-band control in the text stream, copying it as `CF_UNICODETEXT` makes sense in several scenario, for example to copy formatted text to a script source file, or a code editor. We cannot expect all the editors to support a special clipboard format, so having the ability to copy them as `CF_UNICODETEXT` is required for those.
The problem is that there are also even more scenarios where we want to copy text without the control sequences, and the target application that doesn't know VT cannot provide a "paste as plain text" option since that precisely picks the `CF_UNICODETEXT` over a richer format. So copying with VT should be an option, not the default. We need `CF_UNICODETEXT` to contain plain text unless explicitly requested for all other apps to properly support "paste as plain text".
I think the default copy (right-click) should be text only, a modifier key (Shift, Ctrl, Alt) could be used to explicitly copy with the control sequences. Another solution could be for the right-click to always show a popup menu, which could also provide more discoverable options for paste, and maybe someday to insert special characters not easily accessible on the keyboard, or use a special item in the tab's menu.

Now on to the second solution, using a special ANSI/VT clipboard format. This would be a great solution for terminal-oriented apps that want to explicitly support these control sequences. I don't think this is an alternative to the `CF_UNICODETEXT`, the ANSI/VT format should *always* be copied. This means apps that can handle VT control sequences can always chose to process them regardless of how the text was copied from Terminal, and it also makes it explicit that that format contains VT, similarly to how RTF isn't an option but always copied alongside plain text.
Having that specific clipboard format always available when copying from Terminal would not make the "copy with VT in `CF_UNICODETEXT`" unnecessary, but would make it necessary only when the goal is to paste with the control sequences in a text editor or another app that isn't specifically terminal-oriented, making the extra operation to explicitly copy as "text with VT"  less of a hassle as it would be required less often.

So to summarize, my ideal solution would be:
- Copying keeps plain-text (without VT) in `CF_UNICODETEXT`, alonside a "CF_UNICODEVT" format that contains the text+VT for apps explicitly requesting VT when available, just like it also copies as `CF_RTF`.
- A special "copy with VT" option to store the text+VT in both `CF_UNICODETEXT` and "CF_UNICODEVT" to enable copying it when needed to apps not designed for VT.

This makes the VT always available to apps that expect it, and enables advanced users/devs to copy with VT to apps that are not designed for it.

I would also add a `CF_BITMAP` version, containing a visual copy of the selection, making it much easier to do screenshot-like copy/paste in documentation about text-mode utilities. I'll be submitting an issue with that suggestion in more details.

On a related note, the `Export text` option should also allow the user to select plain text or VT text when saving the .txt file.
> VT being an in-band control in the text stream, copying it as `CF_UNICODETEXT` makes sense in several scenario, for example to copy formatted text to a script source file, or a code editor.

The same can be said of HTML and RTF though. If we're going to support that for one format, we should support it for all of them.
@j4james I agree, and browsers have the _Developer Tools_ to enable copy of HTML as `CF_UNICODETEXT` for advanced users, and always copy as a special HTML format for apps that look for it specifically because they can handle it. This makes the copy/paste experience between Edge and Word more advanced and convenient than plain text, and is exactly what I'm advocating for in Terminal.

Providing exports to HTML, RTF, and Bitmap from Terminal would make a lot of sense to improve the copy/paste scenarios with apps that do not handle VT.
And I don't think it would be very difficult to achieve, providing the font name, changing colors, and a few text attributes. It doesn't have to be perfect and map every single feature of VT, keeping the colors and some attributes would already be better than the plain text we currently have.
Having all those formats in the clipboard would make the life of users writing technical documents so much easier!

Fix patch:
diff --git a/doc/cascadia/profiles.schema.json b/doc/cascadia/profiles.schema.json
index ec903dc5b80..478c75129b4 100644
--- a/doc/cascadia/profiles.schema.json
+++ b/doc/cascadia/profiles.schema.json
@@ -879,6 +879,11 @@
               "default": false,
               "description": "If true, the copied content will be copied as a single line (even if there are hard line breaks present in the text). If false, newlines persist from the selected text."
             },
+            "withControlSequences": {
+              "type": "boolean",
+              "default": false,
+              "description": "If true, copied content will contain ANSI escape code control sequences representing the styling of the content."
+            },
             "dismissSelection": {
               "type": "boolean",
               "default": true,
diff --git a/src/buffer/out/textBuffer.cpp b/src/buffer/out/textBuffer.cpp
index 802e48e7c3e..a920854b732 100644
--- a/src/buffer/out/textBuffer.cpp
+++ b/src/buffer/out/textBuffer.cpp
@@ -1917,6 +1917,41 @@ std::wstring TextBuffer::GetPlainText(const CopyRequest& req) const
     return selectedText;
 }
 
+// Retrieves the text data from the buffer *with* ANSI escape code control sequences and presents it in
+//      a clipboard-ready format.
+// Arguments:
+// - req - the copy request having the bounds of the selected region and other related configuration flags.
+// Return Value:
+// - The text and control sequence data from the selected region of the text buffer. Empty if the copy request
+//      is invalid.
+std::wstring TextBuffer::GetWithControlSequences(const CopyRequest& req) const
+{
+    if (req.beg > req.end)
+    {
+        return {};
+    }
+
+    std::wstring selectedText;
+    std::optional<TextAttribute> previousTextAttr;
+    bool delayedLineBreak = false;
+
+    const auto firstRow = req.beg.y;
+    const auto lastRow = req.end.y;
+
+    for (til::CoordType currentRow = firstRow; currentRow <= lastRow; currentRow++)
+    {
+        const auto& row = GetRowByOffset(currentRow);
+
+        const auto [startX, endX, reqAddLineBreak] = _RowCopyHelper(req, currentRow, row);
+        const bool isLastRow = currentRow == lastRow;
+        const bool addLineBreak = reqAddLineBreak && !isLastRow;
+
+        _SerializeRow(row, startX, endX, addLineBreak, isLastRow, selectedText, previousTextAttr, delayedLineBreak);
+    }
+
+    return selectedText;
+}
+
 // Routine Description:
 // - Generates a CF_HTML compliant structure from the selected region of the buffer
 // Arguments:
@@ -2348,7 +2383,7 @@ void TextBuffer::_AppendRTFText(std::string& contentBuilder, const std::wstring_
     }
 }
 
-void TextBuffer::Serialize(const wchar_t* destination) const
+void TextBuffer::SerializeToPath(const wchar_t* destination) const
 {
     const wil::unique_handle file{ CreateFileW(destination, GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_DELETE, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr) };
     THROW_LAST_ERROR_IF(!file);
@@ -2358,279 +2393,318 @@ void TextBuffer::Serialize(const wchar_t* destination) const
     buffer.reserve(writeThreshold + writeThreshold / 2);
     buffer.push_back(L'\uFEFF');
 
-    const til::CoordType lastRowWithText = GetLastNonSpaceCharacter(nullptr).y;
-    CharacterAttributes previousAttr = CharacterAttributes::Unused1;
-    TextColor previousFg;
-    TextColor previousBg;
-    TextColor previousUl;
-    uint16_t previousHyperlinkId = 0;
+    std::optional<TextAttribute> previousTextAttr;
     bool delayedLineBreak = false;
 
+    const til::CoordType firstRow = 0;
+    const til::CoordType lastRow = GetLastNonSpaceCharacter(nullptr).y;
+
     // This iterates through each row. The exit condition is at the end
     // of the for() loop so that we can properly handle file flushing.
-    for (til::CoordType currentRow = 0;; currentRow++)
+    for (til::CoordType currentRow = firstRow;; currentRow++)
     {
         const auto& row = GetRowByOffset(currentRow);
 
-        if (const auto lr = row.GetLineRendition(); lr != LineRendition::SingleWidth)
+        const auto isLastRow = currentRow == lastRow;
+        const auto startX = 0;
+        const auto endX = row.GetReadableColumnCount();
+        const bool addLineBreak = !row.WasWrapForced() || isLastRow;
+
+        _SerializeRow(row, startX, endX, addLineBreak, isLastRow, buffer, previousTextAttr, delayedLineBreak);
+
+        if (buffer.size() >= writeThreshold || isLastRow)
         {
-            static constexpr std::wstring_view mappings[] = {
-                L"\x1b#6", // LineRendition::DoubleWidth
-                L"\x1b#3", // LineRendition::DoubleHeightTop
-                L"\x1b#4", // LineRendition::DoubleHeightBottom
-            };
-            const auto idx = std::clamp(static_cast<int>(lr) - 1, 0, 2);
-            buffer.append(til::at(mappings, idx));
+            const auto fileSize = gsl::narrow<DWORD>(buffer.size() * sizeof(wchar_t));
+            DWORD bytesWritten = 0;
+            THROW_IF_WIN32_BOOL_FALSE(WriteFile(file.get(), buffer.data(), fileSize, &bytesWritten, nullptr));
+            THROW_WIN32_IF_MSG(ERROR_WRITE_FAULT, bytesWritten != fileSize, "failed to write");
+            buffer.clear();
         }
 
-        const auto& runs = row.Attributes().runs();
-        const auto beg = runs.begin();
-        const auto end = runs.end();
-        auto it = beg;
-        const auto last = end - 1;
-        const auto lastCharX = row.MeasureRight();
-        til::CoordType oldX = 0;
-
-        for (; it != end; ++it)
+        if (isLastRow)
         {
-            const auto attr = it->value.GetCharacterAttributes();
-            const auto hyperlinkId = it->value.GetHyperlinkId();
-            const auto fg = it->value.GetForeground();
-            const auto bg = it->value.GetBackground();
-            const auto ul = it->value.GetUnderlineColor();
+            break;
+        }
+    }
+}
 
-            if (previousAttr != attr)
-            {
-                auto attrDelta = attr ^ previousAttr;
-
-                // There's no escape sequence that only turns off either bold/intense or dim/faint. SGR 22 turns off both.
-                // This results in two issues in our generic "Mapping" code below. Assuming, both Intense and Faint were on...
-                // * ...and either turned off, it would emit SGR 22 which turns both attributes off = Wrong.
-                // * ...and both are now off, it would emit SGR 22 twice.
-                //
-                // This extra branch takes care of both issues. If both attributes turned off it'll emit a single \x1b[22m,
-                // if faint turned off \x1b[22;1m (intense is still on), and \x1b[22;2m if intense turned off (vice versa).
-                if (WI_AreAllFlagsSet(previousAttr, CharacterAttributes::Intense | CharacterAttributes::Faint) &&
-                    WI_IsAnyFlagSet(attrDelta, CharacterAttributes::Intense | CharacterAttributes::Faint))
-                {
-                    wchar_t buf[8] = L"\x1b[22m";
-                    size_t len = 5;
+// Serializes one row of the text buffer including ANSI escape code control sequences.
+// Arguments:
+// - row - A reference to the row being serialized.
+// - startX - The first column (inclusive) to include in the serialized content.
+// - endX - The last column (exclusive) to include in the serialized content.
+// - addLineBreak - Whether to add a line break at the end of the serialized row.
+// - isLastRow - Whether this is the final row to be serialized.
+// - buffer - A string to write the serialized row into.
+// - previousTextAttr - Used for tracking state across multiple calls to `_SerializeRow` for sequential rows.
+//      The value will be mutated by the call. The initial call should contain `nullopt`, and subsequent calls
+//      should pass the value that was written by the previous call.
+// - delayedLineBreak - Similarly used for tracking state across multiple calls, and similarly will be mutated
+//      by the call. The initial call should pass `false` and subsequent calls should pass the value that was
+//      written by the previous call.
+void TextBuffer::_SerializeRow(const ROW& row, const til::CoordType startX, const til::CoordType endX, const bool addLineBreak, const bool isLastRow, std::wstring& buffer, std::optional<TextAttribute>& previousTextAttr, bool& delayedLineBreak) const
+{
+    if (const auto lr = row.GetLineRendition(); lr != LineRendition::SingleWidth)
+    {
+        static constexpr std::wstring_view mappings[] = {
+            L"\x1b#6", // LineRendition::DoubleWidth
+            L"\x1b#3", // LineRendition::DoubleHeightTop
+            L"\x1b#4", // LineRendition::DoubleHeightBottom
+        };
+        const auto idx = std::clamp(static_cast<int>(lr) - 1, 0, 2);
+        buffer.append(til::at(mappings, idx));
+    }
 
-                    if (WI_IsAnyFlagSet(attr, CharacterAttributes::Intense | CharacterAttributes::Faint))
-                    {
-                        buf[4] = L';';
-                        buf[5] = WI_IsAnyFlagSet(attr, CharacterAttributes::Intense) ? L'1' : L'2';
-                        buf[6] = L'm';
-                        len = 7;
-                    }
+    const auto startXU16 = gsl::narrow_cast<uint16_t>(startX);
+    const auto endXU16 = gsl::narrow_cast<uint16_t>(endX);
+    const auto runs = row.Attributes().slice(startXU16, endXU16).runs();
 
-                    buffer.append(&buf[0], len);
-                    WI_ClearAllFlags(attrDelta, CharacterAttributes::Intense | CharacterAttributes::Faint);
-                }
+    const auto beg = runs.begin();
+    const auto end = runs.end();
+    auto it = beg;
+    // Don't try to get `end - 1` if it's an empty iterator; in this case we're going to ignore the `last`
+    // value anyway so just use `end`.
+    const auto last = it == end ? end : end - 1;
+    const auto lastCharX = row.MeasureRight();
+    til::CoordType oldX = startX;
+
+    for (; it != end; ++it)
+    {
+        const auto effectivePreviousTextAttr = previousTextAttr.value_or(TextAttribute{ CharacterAttributes::Unused1, TextColor{}, TextColor{}, 0, TextColor{} });
+        const auto previousAttr = effectivePreviousTextAttr.GetCharacterAttributes();
+        const auto previousHyperlinkId = effectivePreviousTextAttr.GetHyperlinkId();
+        const auto previousFg = effectivePreviousTextAttr.GetForeground();
+        const auto previousBg = effectivePreviousTextAttr.GetBackground();
+        const auto previousUl = effectivePreviousTextAttr.GetUnderlineColor();
+
+        const auto attr = it->value.GetCharacterAttributes();
+        const auto hyperlinkId = it->value.GetHyperlinkId();
+        const auto fg = it->value.GetForeground();
+        const auto bg = it->value.GetBackground();
+        const auto ul = it->value.GetUnderlineColor();
+
+        if (previousAttr != attr)
+        {
+            auto attrDelta = attr ^ previousAttr;
 
-                {
-                    struct Mapping
-                    {
-                        CharacterAttributes attr;
-                        uint8_t change[2]; // [0] = off, [1] = on
-                    };
-                    static constexpr Mapping mappings[] = {
-                        { CharacterAttributes::Intense, { 22, 1 } },
-                        { CharacterAttributes::Italics, { 23, 3 } },
-                        { CharacterAttributes::Blinking, { 25, 5 } },
-                        { CharacterAttributes::Invisible, { 28, 8 } },
-                        { CharacterAttributes::CrossedOut, { 29, 9 } },
-                        { CharacterAttributes::Faint, { 22, 2 } },
-                        { CharacterAttributes::TopGridline, { 55, 53 } },
-                        { CharacterAttributes::ReverseVideo, { 27, 7 } },
-                    };
-                    for (const auto& mapping : mappings)
-                    {
-                        if (WI_IsAnyFlagSet(attrDelta, mapping.attr))
-                        {
-                            const auto n = til::at(mapping.change, WI_IsAnyFlagSet(attr, mapping.attr));
-                            fmt::format_to(std::back_inserter(buffer), FMT_COMPILE(L"\x1b[{}m"), n);
-                        }
-                    }
-                }
+            // There's no escape sequence that only turns off either bold/intense or dim/faint. SGR 22 turns off both.
+            // This results in two issues in our generic "Mapping" code below. Assuming, both Intense and Faint were on...
+            // * ...and either turned off, it would emit SGR 22 which turns both attributes off = Wrong.
+            // * ...and both are now off, it would emit SGR 22 twice.
+            //
+            // This extra branch takes care of both issues. If both attributes turned off it'll emit a single \x1b[22m,
+            // if faint turned off \x1b[22;1m (intense is still on), and \x1b[22;2m if intense turned off (vice versa).
+            if (WI_AreAllFlagsSet(previousAttr, CharacterAttributes::Intense | CharacterAttributes::Faint) &&
+                WI_IsAnyFlagSet(attrDelta, CharacterAttributes::Intense | CharacterAttributes::Faint))
+            {
+                wchar_t buf[8] = L"\x1b[22m";
+                size_t len = 5;
 
-                if (WI_IsAnyFlagSet(attrDelta, CharacterAttributes::UnderlineStyle))
+                if (WI_IsAnyFlagSet(attr, CharacterAttributes::Intense | CharacterAttributes::Faint))
                 {
-                    static constexpr std::wstring_view mappings[] = {
-                        L"\x1b[24m", // UnderlineStyle::NoUnderline
-                        L"\x1b[4m", // UnderlineStyle::SinglyUnderlined
-                        L"\x1b[21m", // UnderlineStyle::DoublyUnderlined
-                        L"\x1b[4:3m", // UnderlineStyle::CurlyUnderlined
-                        L"\x1b[4:4m", // UnderlineStyle::DottedUnderlined
-                        L"\x1b[4:5m", // UnderlineStyle::DashedUnderlined
-                    };
-
-                    auto idx = WI_EnumValue(it->value.GetUnderlineStyle());
-                    if (idx >= std::size(mappings))
-                    {
-                        idx = 1; // UnderlineStyle::SinglyUnderlined
-                    }
-
-                    buffer.append(til::at(mappings, idx));
+                    buf[4] = L';';
+                    buf[5] = WI_IsAnyFlagSet(attr, CharacterAttributes::Intense) ? L'1' : L'2';
+                    buf[6] = L'm';
+                    len = 7;
                 }
 
-                previousAttr = attr;
+                buffer.append(&buf[0], len);
+                WI_ClearAllFlags(attrDelta, CharacterAttributes::Intense | CharacterAttributes::Faint);
             }
 
-            if (previousFg != fg)
             {
-                switch (fg.GetType())
+                struct Mapping
                 {
-                case ColorType::IsDefault:
-                    buffer.append(L"\x1b[39m");
-                    break;
-                case ColorType::IsIndex16:
+                    CharacterAttributes attr;
+                    uint8_t change[2]; // [0] = off, [1] = on
+                };
+                static constexpr Mapping mappings[] = {
+                    { CharacterAttributes::Intense, { 22, 1 } },
+                    { CharacterAttributes::Italics, { 23, 3 } },
+                    { CharacterAttributes::Blinking, { 25, 5 } },
+                    { CharacterAttributes::Invisible, { 28, 8 } },
+                    { CharacterAttributes::CrossedOut, { 29, 9 } },
+                    { CharacterAttributes::Faint, { 22, 2 } },
+                    { CharacterAttributes::TopGridline, { 55, 53 } },
+                    { CharacterAttributes::ReverseVideo, { 27, 7 } },
+                };
+                for (const auto& mapping : mappings)
                 {
-                    uint8_t index = WI_IsFlagSet(fg.GetIndex(), 8) ? 90 : 30;
-                    index += fg.GetIndex() & 7;
-                    fmt::format_to(std::back_inserter(buffer), FMT_COMPILE(L"\x1b[{}m"), index);
-                    break;
-                }
-                case ColorType::IsIndex256:
-                    fmt::format_to(std::back_inserter(buffer), FMT_COMPILE(L"\x1b[38;5;{}m"), fg.GetIndex());
-                    break;
-                case ColorType::IsRgb:
-                    fmt::format_to(std::back_inserter(buffer), FMT_COMPILE(L"\x1b[38;2;{};{};{}m"), fg.GetR(), fg.GetG(), fg.GetB());
-                    break;
-                default:
-                    break;
+                    if (WI_IsAnyFlagSet(attrDelta, mapping.attr))
+                    {
+                        const auto n = til::at(mapping.change, WI_IsAnyFlagSet(attr, mapping.attr));
+                        fmt::format_to(std::back_inserter(buffer), FMT_COMPILE(L"\x1b[{}m"), n);
+                    }
                 }
-                previousFg = fg;
             }
 
-            if (previousBg != bg)
+            if (WI_IsAnyFlagSet(attrDelta, CharacterAttributes::UnderlineStyle))
             {
-                switch (bg.GetType())
-                {
-                case ColorType::IsDefault:
-                    buffer.append(L"\x1b[49m");
-                    break;
-                case ColorType::IsIndex16:
+                static constexpr std::wstring_view mappings[] = {
+                    L"\x1b[24m", // UnderlineStyle::NoUnderline
+                    L"\x1b[4m", // UnderlineStyle::SinglyUnderlined
+                    L"\x1b[21m", // UnderlineStyle::DoublyUnderlined
+                    L"\x1b[4:3m", // UnderlineStyle::CurlyUnderlined
+                    L"\x1b[4:4m", // UnderlineStyle::DottedUnderlined
+                    L"\x1b[4:5m", // UnderlineStyle::DashedUnderlined
+                };
+
+                auto idx = WI_EnumValue(it->value.GetUnderlineStyle());
+                if (idx >= std::size(mappings))
                 {
-                    uint8_t index = WI_IsFlagSet(bg.GetIndex(), 8) ? 100 : 40;
-                    index += bg.GetIndex() & 7;
-                    fmt::format_to(std::back_inserter(buffer), FMT_COMPILE(L"\x1b[{}m"), index);
-                    break;
-                }
-                case ColorType::IsIndex256:
-                    fmt::format_to(std::back_inserter(buffer), FMT_COMPILE(L"\x1b[48;5;{}m"), bg.GetIndex());
-                    break;
-                case ColorType::IsRgb:
-                    fmt::format_to(std::back_inserter(buffer), FMT_COMPILE(L"\x1b[48;2;{};{};{}m"), bg.GetR(), bg.GetG(), bg.GetB());
-                    break;
-                default:
-                    break;
+                    idx = 1; // UnderlineStyle::SinglyUnderlined
                 }
-                previousBg = bg;
+
+                buffer.append(til::at(mappings, idx));
             }
+        }
 
-            if (previousUl != ul)
+        if (previousFg != fg)
+        {
+            switch (fg.GetType())
             {
-                switch (fg.GetType())
-                {
-                case ColorType::IsDefault:
-                    buffer.append(L"\x1b[59m");
-                    break;
-                case ColorType::IsIndex256:
-                    fmt::format_to(std::back_inserter(buffer), FMT_COMPILE(L"\x1b[58:5:{}m"), ul.GetIndex());
-                    break;
-                case ColorType::IsRgb:
-                    fmt::format_to(std::back_inserter(buffer), FMT_COMPILE(L"\x1b[58:2::{}:{}:{}m"), ul.GetR(), ul.GetG(), ul.GetB());
-                    break;
-                default:
-                    break;
-                }
-                previousUl = ul;
+            case ColorType::IsDefault:
+                buffer.append(L"\x1b[39m");
+                break;
+            case ColorType::IsIndex16:
+            {
+                uint8_t index = WI_IsFlagSet(fg.GetIndex(), 8) ? 90 : 30;
+                index += fg.GetIndex() & 7;
+                fmt::format_to(std::back_inserter(buffer), FMT_COMPILE(L"\x1b[{}m"), index);
+                break;
             }
+            case ColorType::IsIndex256:
+                fmt::format_to(std::back_inserter(buffer), FMT_COMPILE(L"\x1b[38;5;{}m"), fg.GetIndex());
+                break;
+            case ColorType::IsRgb:
+                fmt::format_to(std::back_inserter(buffer), FMT_COMPILE(L"\x1b[38;2;{};{};{}m"), fg.GetR(), fg.GetG(), fg.GetB());
+                break;
+            default:
+                break;
+            }
+        }
 
-            if (previousHyperlinkId != hyperlinkId)
+        if (previousBg != bg)
+        {
+            switch (bg.GetType())
             {
-                if (hyperlinkId)
-                {
-                    const auto uri = GetHyperlinkUriFromId(hyperlinkId);
-                    if (!uri.empty())
-                    {
-                        buffer.append(L"\x1b]8;;");
-                        buffer.append(uri);
-                        buffer.append(L"\x1b\\");
-                        previousHyperlinkId = hyperlinkId;
-                    }
-                }
-                else
-                {
-                    buffer.append(L"\x1b]8;;\x1b\\");
-                    previousHyperlinkId = 0;
-                }
+            case ColorType::IsDefault:
+                buffer.append(L"\x1b[49m");
+                break;
+            case ColorType::IsIndex16:
+            {
+                uint8_t index = WI_IsFlagSet(bg.GetIndex(), 8) ? 100 : 40;
+                index += bg.GetIndex() & 7;
+                fmt::format_to(std::back_inserter(buffer), FMT_COMPILE(L"\x1b[{}m"), index);
+                break;
             }
+            case ColorType::IsIndex256:
+                fmt::format_to(std::back_inserter(buffer), FMT_COMPILE(L"\x1b[48;5;{}m"), bg.GetIndex());
+                break;
+            case ColorType::IsRgb:
+                fmt::format_to(std::back_inserter(buffer), FMT_COMPILE(L"\x1b[48;2;{};{};{}m"), bg.GetR(), bg.GetG(), bg.GetB());
+                break;
+            default:
+                break;
+            }
+        }
 
-            // Initially, the buffer is initialized with the default attributes, but once it begins to scroll,
-            // newly scrolled in rows are initialized with the current attributes. This means we need to set
-            // the current attributes to those of the upcoming row before the row comes up. Or inversely:
-            // We let the row come up, let it set its attributes and only then print the newline.
-            if (delayedLineBreak)
+        if (previousUl != ul)
+        {
+            switch (fg.GetType())
             {
-                buffer.append(L"\r\n");
-                delayedLineBreak = false;
+            case ColorType::IsDefault:
+                buffer.append(L"\x1b[59m");
+                break;
+            case ColorType::IsIndex256:
+                fmt::format_to(std::back_inserter(buffer), FMT_COMPILE(L"\x1b[58:5:{}m"), ul.GetIndex());
+                break;
+            case ColorType::IsRgb:
+                fmt::format_to(std::back_inserter(buffer), FMT_COMPILE(L"\x1b[58:2::{}:{}:{}m"), ul.GetR(), ul.GetG(), ul.GetB());
+                break;
+            default:
+                break;
             }
+        }
 
-            auto newX = oldX + it->length;
-
-            // Since our text buffer doesn't store the original input text, the information over the amount of trailing
-            // whitespaces was lost. If we don't do anything here then a row that just says "Hello" would be serialized
-            // to "Hello                    ...". If the user restores the buffer dump with a different window size,
-            // this would result in some fairly ugly reflow. This code attempts to at least trim trailing whitespaces.
-            //
-            // As mentioned above for `delayedLineBreak`, rows are initialized with their first attribute, BUT
-            // only if the viewport has begun to scroll. Otherwise, they're initialized with the default attributes.
-            // In other words, we can only skip \x1b[K = Erase in Line, if both the first/last attribute are the default attribute.
-            static constexpr TextAttribute defaultAttr;
-            const auto trimTrailingWhitespaces = it == last && lastCharX < newX;
-            const auto clearToEndOfLine = trimTrailingWhitespaces && (beg->value != defaultAttr || last->value != defaultAttr);
-
-            if (trimTrailingWhitespaces)
+        if (previousHyperlinkId != hyperlinkId)
+        {
+            if (hyperlinkId)
             {
-                newX = lastCharX;
+                const auto uri = GetHyperlinkUriFromId(hyperlinkId);
+                if (!uri.empty())
+                {
+                    buffer.append(L"\x1b]8;;");
+                    buffer.append(uri);
+                    buffer.append(L"\x1b\\");
+                }
             }
-
-            buffer.append(row.GetText(oldX, newX));
-
-            if (clearToEndOfLine)
+            else
             {
-                buffer.append(L"\x1b[K");
+                buffer.append(L"\x1b]8;;\x1b\\");
             }
+        }
 
-            oldX = newX;
+        previousTextAttr = it->value;
+
+        // Initially, the buffer is initialized with the default attributes, but once it begins to scroll,
+        // newly scrolled in rows are initialized with the current attributes. This means we need to set
+        // the current attributes to those of the upcoming row before the row comes up. Or inversely:
+        // We let the row come up, let it set its attributes and only then print the newline.
+        if (delayedLineBreak)
+        {
+            buffer.append(L"\r\n");
+            delayedLineBreak = false;
         }
 
-        const auto moreRowsRemaining = currentRow < lastRowWithText;
-        delayedLineBreak = !row.WasWrapForced();
+        auto newX = oldX + it->length;
 
-        if (!moreRowsRemaining)
+        // Since our text buffer doesn't store the original input text, the information over the amount of trailing
+        // whitespaces was lost. If we don't do anything here then a row that just says "Hello" would be serialized
+        // to "Hello                    ...". If the user restores the buffer dump with a different window size,
+        // this would result in some fairly ugly reflow. This code attempts to at least trim trailing whitespaces.
+        //
+        // As mentioned above for `delayedLineBreak`, rows are initialized with their first attribute, BUT
+        // only if the viewport has begun to scroll. Otherwise, they're initialized with the default attributes.
+        // In other words, we can only skip \x1b[K = Erase in Line, if both the first/last attribute are the default attribute.
+        static constexpr TextAttribute defaultAttr;
+        const auto trimTrailingWhitespaces = it == last && lastCharX < newX;
+        const auto clearToEndOfLine = trimTrailingWhitespaces && (beg->value != defaultAttr || last->value != defaultAttr);
+
+        if (trimTrailingWhitespaces)
         {
-            if (previousHyperlinkId)
-            {
-                buffer.append(L"\x1b]8;;\x1b\\");
-            }
-            buffer.append(L"\x1b[m\r\n");
+            newX = lastCharX;
         }
 
-        if (buffer.size() >= writeThreshold || !moreRowsRemaining)
+        buffer.append(row.GetText(oldX, newX));
+
+        if (clearToEndOfLine)
         {
-            const auto fileSize = gsl::narrow<DWORD>(buffer.size() * sizeof(wchar_t));
-            DWORD bytesWritten = 0;
-            THROW_IF_WIN32_BOOL_FALSE(WriteFile(file.get(), buffer.data(), fileSize, &bytesWritten, nullptr));
-            THROW_WIN32_IF_MSG(ERROR_WRITE_FAULT, bytesWritten != fileSize, "failed to write");
-            buffer.clear();
+            buffer.append(L"\x1b[K");
         }
 
-        if (!moreRowsRemaining)
+        oldX = newX;
+    }
+
+    // Handle empty rows (with no runs). See above for more details about `delayedLineBreak`.
+    if (delayedLineBreak)
+    {
+        buffer.append(L"\r\n");
+        delayedLineBreak = false;
+    }
+
+    delayedLineBreak = !row.WasWrapForced() && addLineBreak;
+
+    if (isLastRow)
+    {
+        if (previousTextAttr.has_value() && previousTextAttr->GetHyperlinkId())
         {
-            break;
+            buffer.append(L"\x1b]8;;\x1b\\");
+        }
+        buffer.append(L"\x1b[0m");
+        if (addLineBreak)
+        {
+            buffer.append(L"\r\n");
         }
     }
 }
diff --git a/src/buffer/out/textBuffer.hpp b/src/buffer/out/textBuffer.hpp
index a39ebd4db40..c97de9e7523 100644
--- a/src/buffer/out/textBuffer.hpp
+++ b/src/buffer/out/textBuffer.hpp
@@ -266,6 +266,8 @@ class TextBuffer final
 
     std::wstring GetPlainText(const CopyRequest& req) const;
 
+    std::wstring GetWithControlSequences(const CopyRequest& req) const;
+
     std::string GenHTML(const CopyRequest& req,
                         const int fontHeightPoints,
                         const std::wstring_view fontFaceName,
@@ -280,7 +282,7 @@ class TextBuffer final
                        const bool isIntenseBold,
                        std::function<std::tuple<COLORREF, COLORREF, COLORREF>(const TextAttribute&)> GetAttributeColors) const noexcept;
 
-    void Serialize(const wchar_t* destination) const;
+    void SerializeToPath(const wchar_t* destination) const;
 
     struct PositionInformation
     {
@@ -332,6 +334,8 @@ class TextBuffer final
 
     std::tuple<til::CoordType, til::CoordType, bool> _RowCopyHelper(const CopyRequest& req, const til::CoordType iRow, const ROW& row) const;
 
+    void _SerializeRow(const ROW& row, const til::CoordType startX, const til::CoordType endX, const bool addLineBreak, const bool isLastRow, std::wstring& buffer, std::optional<TextAttribute>& previousTextAttr, bool& delayedLineBreak) const;
+
     static void _AppendRTFText(std::string& contentBuilder, const std::wstring_view& text);
 
     Microsoft::Console::Render::Renderer* _renderer = nullptr;
diff --git a/src/cascadia/TerminalApp/AppActionHandlers.cpp b/src/cascadia/TerminalApp/AppActionHandlers.cpp
index 8675fb98bac..0bfbcff8ad7 100644
--- a/src/cascadia/TerminalApp/AppActionHandlers.cpp
+++ b/src/cascadia/TerminalApp/AppActionHandlers.cpp
@@ -548,7 +548,7 @@ namespace winrt::TerminalApp::implementation
     {
         if (const auto& realArgs = args.ActionArgs().try_as<CopyTextArgs>())
         {
-            const auto handled = _CopyText(realArgs.DismissSelection(), realArgs.SingleLine(), realArgs.CopyFormatting());
+            const auto handled = _CopyText(realArgs.DismissSelection(), realArgs.SingleLine(), realArgs.WithControlSequences(), realArgs.CopyFormatting());
             args.Handled(handled);
         }
     }
diff --git a/src/cascadia/TerminalApp/TerminalPage.cpp b/src/cascadia/TerminalApp/TerminalPage.cpp
index b3111e9b639..13391e44f42 100644
--- a/src/cascadia/TerminalApp/TerminalPage.cpp
+++ b/src/cascadia/TerminalApp/TerminalPage.cpp
@@ -2971,14 +2971,15 @@ namespace winrt::TerminalApp::implementation
     // Arguments:
     // - dismissSelection: if not enabled, copying text doesn't dismiss the selection
     // - singleLine: if enabled, copy contents as a single line of text
+    // - withControlSequences: if enabled, the copied plain text contains color/style ANSI escape codes from the selection
     // - formats: dictate which formats need to be copied
     // Return Value:
     // - true iff we we able to copy text (if a selection was active)
-    bool TerminalPage::_CopyText(const bool dismissSelection, const bool singleLine, const Windows::Foundation::IReference<CopyFormat>& formats)
+    bool TerminalPage::_CopyText(const bool dismissSelection, const bool singleLine, const bool withControlSequences, const Windows::Foundation::IReference<CopyFormat>& formats)
     {
         if (const auto& control{ _GetActiveControl() })
         {
-            return control.CopySelectionToClipboard(dismissSelection, singleLine, formats);
+            return control.CopySelectionToClipboard(dismissSelection, singleLine, withControlSequences, formats);
         }
         return false;
     }
diff --git a/src/cascadia/TerminalApp/TerminalPage.h b/src/cascadia/TerminalApp/TerminalPage.h
index 9e5d3029c7f..28ccfd8ec8c 100644
--- a/src/cascadia/TerminalApp/TerminalPage.h
+++ b/src/cascadia/TerminalApp/TerminalPage.h
@@ -429,7 +429,7 @@ namespace winrt::TerminalApp::implementation
         bool _IsUriSupported(const winrt::Windows::Foundation::Uri& parsedUri);
 
         void _ShowCouldNotOpenDialog(winrt::hstring reason, winrt::hstring uri);
-        bool _CopyText(const bool dismissSelection, const bool singleLine, const Windows::Foundation::IReference<Microsoft::Terminal::Control::CopyFormat>& formats);
+        bool _CopyText(const bool dismissSelection, const bool singleLine, const bool withControlSequences, const Windows::Foundation::IReference<Microsoft::Terminal::Control::CopyFormat>& formats);
 
         safe_void_coroutine _SetTaskbarProgressHandler(const IInspectable sender, const IInspectable eventArgs);
 
diff --git a/src/cascadia/TerminalControl/ControlCore.cpp b/src/cascadia/TerminalControl/ControlCore.cpp
index 9cc7732f452..d7beb39a504 100644
--- a/src/cascadia/TerminalControl/ControlCore.cpp
+++ b/src/cascadia/TerminalControl/ControlCore.cpp
@@ -574,7 +574,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation
             else if (vkey == VK_RETURN && !mods.IsCtrlPressed() && !mods.IsAltPressed())
             {
                 // [Shift +] Enter --> copy text
-                CopySelectionToClipboard(mods.IsShiftPressed(), nullptr);
+                CopySelectionToClipboard(mods.IsShiftPressed(), false, nullptr);
                 _terminal->ClearSelection();
                 _updateSelectionUI();
                 return true;
@@ -1315,9 +1315,11 @@ namespace winrt::Microsoft::Terminal::Control::implementation
     //     Windows Clipboard (CascadiaWin32:main.cpp).
     // Arguments:
     // - singleLine: collapse all of the text to one line
+    // - withControlSequences: if enabled, the copied plain text contains color/style ANSI escape codes from the selection
     // - formats: which formats to copy (defined by action's CopyFormatting arg). nullptr
     //             if we should defer which formats are copied to the global setting
     bool ControlCore::CopySelectionToClipboard(bool singleLine,
+                                               bool withControlSequences,
                                                const Windows::Foundation::IReference<CopyFormat>& formats)
     {
         ::Microsoft::Terminal::Core::Terminal::TextCopyData payload;
@@ -1339,7 +1341,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation
 
             // extract text from buffer
             // RetrieveSelectedTextFromBuffer will lock while it's reading
-            payload = _terminal->RetrieveSelectedTextFromBuffer(singleLine, copyHtml, copyRtf);
+            payload = _terminal->RetrieveSelectedTextFromBuffer(singleLine, withControlSequences, copyHtml, copyRtf);
         }
 
         copyToClipboard(payload.plainText, payload.html, payload.rtf);
diff --git a/src/cascadia/TerminalControl/ControlCore.h b/src/cascadia/TerminalControl/ControlCore.h
index 1719f34c880..609aed42834 100644
--- a/src/cascadia/TerminalControl/ControlCore.h
+++ b/src/cascadia/TerminalControl/ControlCore.h
@@ -123,7 +123,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation
 
         void SendInput(std::wstring_view wstr);
         void PasteText(const winrt::hstring& hstr);
-        bool CopySelectionToClipboard(bool singleLine, const Windows::Foundation::IReference<CopyFormat>& formats);
+        bool CopySelectionToClipboard(bool singleLine, bool withControlSequences, const Windows::Foundation::IReference<CopyFormat>& formats);
         void SelectAll();
         void ClearSelection();
         bool ToggleBlockSelection();
diff --git a/src/cascadia/TerminalControl/ControlInteractivity.cpp b/src/cascadia/TerminalControl/ControlInteractivity.cpp
index f18851c2fbb..c99d69ae6a4 100644
--- a/src/cascadia/TerminalControl/ControlInteractivity.cpp
+++ b/src/cascadia/TerminalControl/ControlInteractivity.cpp
@@ -194,9 +194,11 @@ namespace winrt::Microsoft::Terminal::Control::implementation
     //     Windows Clipboard (CascadiaWin32:main.cpp).
     // Arguments:
     // - singleLine: collapse all of the text to one line
+    // - withControlSequences: if enabled, the copied plain text contains color/style ANSI escape codes from the selection
     // - formats: which formats to copy (defined by action's CopyFormatting arg). nullptr
     //             if we should defer which formats are copied to the global setting
     bool ControlInteractivity::CopySelectionToClipboard(bool singleLine,
+                                                        bool withControlSequences,
                                                         const Windows::Foundation::IReference<CopyFormat>& formats)
     {
         if (_core)
@@ -213,7 +215,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation
             // Mark the current selection as copied
             _selectionNeedsToBeCopied = false;
 
-            return _core->CopySelectionToClipboard(singleLine, formats);
+            return _core->CopySelectionToClipboard(singleLine, withControlSequences, formats);
         }
 
         return false;
@@ -312,7 +314,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation
             else
             {
                 // Try to copy the text and clear the selection
-                const auto successfulCopy = CopySelectionToClipboard(shiftEnabled, nullptr);
+                const auto successfulCopy = CopySelectionToClipboard(shiftEnabled, false, nullptr);
                 _core->ClearSelection();
                 if (_core->CopyOnSelect() || !successfulCopy)
                 {
@@ -445,7 +447,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation
             // IMPORTANT!
             // DO NOT clear the selection here!
             // Otherwise, the selection will be cleared immediately after you make it.
-            CopySelectionToClipboard(false, nullptr);
+            CopySelectionToClipboard(false, false, nullptr);
         }
 
         _singleClickTouchdownPos = std::nullopt;
diff --git a/src/cascadia/TerminalControl/ControlInteractivity.h b/src/cascadia/TerminalControl/ControlInteractivity.h
index 22eaa95c397..a1b3d7993cf 100644
--- a/src/cascadia/TerminalControl/ControlInteractivity.h
+++ b/src/cascadia/TerminalControl/ControlInteractivity.h
@@ -83,6 +83,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation
 #pragma endregion
 
         bool CopySelectionToClipboard(bool singleLine,
+                                      bool withControlSequences,
                                       const Windows::Foundation::IReference<CopyFormat>& formats);
         void RequestPasteTextFromClipboard();
         void SetEndSelectionPoint(const Core::Point pixelPosition);
diff --git a/src/cascadia/TerminalControl/ControlInteractivity.idl b/src/cascadia/TerminalControl/ControlInteractivity.idl
index 926c1e3b0a9..dcc2b793420 100644
--- a/src/cascadia/TerminalControl/ControlInteractivity.idl
+++ b/src/cascadia/TerminalControl/ControlInteractivity.idl
@@ -32,7 +32,7 @@ namespace Microsoft.Terminal.Control
 
         InteractivityAutomationPeer OnCreateAutomationPeer();
 
-        Boolean CopySelectionToClipboard(Boolean singleLine, Windows.Foundation.IReference<CopyFormat> formats);
+        Boolean CopySelectionToClipboard(Boolean singleLine, Boolean withControlSequences, Windows.Foundation.IReference<CopyFormat> formats);
         void RequestPasteTextFromClipboard();
         void SetEndSelectionPoint(Microsoft.Terminal.Core.Point point);
 
diff --git a/src/cascadia/TerminalControl/HwndTerminal.cpp b/src/cascadia/TerminalControl/HwndTerminal.cpp
index 905de2ce988..b3cc4edf688 100644
--- a/src/cascadia/TerminalControl/HwndTerminal.cpp
+++ b/src/cascadia/TerminalControl/HwndTerminal.cpp
@@ -116,7 +116,7 @@ try
                     const auto lock = publicTerminal->_terminal->LockForWriting();
                     if (publicTerminal->_terminal->IsSelectionActive())
                     {
-                        const auto bufferData = publicTerminal->_terminal->RetrieveSelectedTextFromBuffer(false, true, true);
+                        const auto bufferData = publicTerminal->_terminal->RetrieveSelectedTextFromBuffer(false, false, true, true);
                         LOG_IF_FAILED(publicTerminal->_CopyTextToSystemClipboard(bufferData.plainText, bufferData.html, bufferData.rtf));
                         publicTerminal->_ClearSelection();
                         return 0;
diff --git a/src/cascadia/TerminalControl/TermControl.cpp b/src/cascadia/TerminalControl/TermControl.cpp
index f76c8c59403..88857713a1f 100644
--- a/src/cascadia/TerminalControl/TermControl.cpp
+++ b/src/cascadia/TerminalControl/TermControl.cpp
@@ -2543,16 +2543,17 @@ namespace winrt::Microsoft::Terminal::Control::implementation
     // Arguments:
     // - dismissSelection: dismiss the text selection after copy
     // - singleLine: collapse all of the text to one line
+    // - withControlSequences: if enabled, the copied plain text contains color/style ANSI escape codes from the selection
     // - formats: which formats to copy (defined by action's CopyFormatting arg). nullptr
     //             if we should defer which formats are copied to the global setting
-    bool TermControl::CopySelectionToClipboard(bool dismissSelection, bool singleLine, const Windows::Foundation::IReference<CopyFormat>& formats)
+    bool TermControl::CopySelectionToClipboard(bool dismissSelection, bool singleLine, bool withControlSequences, const Windows::Foundation::IReference<CopyFormat>& formats)
     {
         if (_IsClosing())
         {
             return false;
         }
 
-        const auto successfulCopy = _interactivity.CopySelectionToClipboard(singleLine, formats);
+        const auto successfulCopy = _interactivity.CopySelectionToClipboard(singleLine, withControlSequences, formats);
 
         if (dismissSelection)
         {
@@ -4189,7 +4190,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation
                                           const IInspectable& /*args*/)
     {
         // formats = nullptr -> copy all formats
-        _interactivity.CopySelectionToClipboard(false, nullptr);
+        _interactivity.CopySelectionToClipboard(false, false, nullptr);
         ContextMenu().Hide();
         SelectionContextMenu().Hide();
     }
diff --git a/src/cascadia/TerminalControl/TermControl.h b/src/cascadia/TerminalControl/TermControl.h
index d8a44161dbf..7e35585aa59 100644
--- a/src/cascadia/TerminalControl/TermControl.h
+++ b/src/cascadia/TerminalControl/TermControl.h
@@ -60,7 +60,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation
 
         hstring GetProfileName() const;
 
-        bool CopySelectionToClipboard(bool dismissSelection, bool singleLine, const Windows::Foundation::IReference<CopyFormat>& formats);
+        bool CopySelectionToClipboard(bool dismissSelection, bool singleLine, bool withControlSequences, const Windows::Foundation::IReference<CopyFormat>& formats);
         void PasteTextFromClipboard();
         void SelectAll();
         bool ToggleBlockSelection();
diff --git a/src/cascadia/TerminalControl/TermControl.idl b/src/cascadia/TerminalControl/TermControl.idl
index c60b5884540..8ff69f33c5e 100644
--- a/src/cascadia/TerminalControl/TermControl.idl
+++ b/src/cascadia/TerminalControl/TermControl.idl
@@ -87,7 +87,7 @@ namespace Microsoft.Terminal.Control
         event Windows.Foundation.TypedEventHandler<Object, Object> CloseTerminalRequested;
         event Windows.Foundation.TypedEventHandler<Object, Object> RestartTerminalRequested;
 
-        Boolean CopySelectionToClipboard(Boolean dismissSelection, Boolean singleLine, Windows.Foundation.IReference<CopyFormat> formats);
+        Boolean CopySelectionToClipboard(Boolean dismissSelection, Boolean singleLine, Boolean withControlSequences, Windows.Foundation.IReference<CopyFormat> formats);
         void PasteTextFromClipboard();
         void SelectAll();
         Boolean ToggleBlockSelection();
diff --git a/src/cascadia/TerminalCore/Terminal.cpp b/src/cascadia/TerminalCore/Terminal.cpp
index c61c339b79e..c77ab1ab764 100644
--- a/src/cascadia/TerminalCore/Terminal.cpp
+++ b/src/cascadia/TerminalCore/Terminal.cpp
@@ -1570,7 +1570,7 @@ std::wstring Terminal::CurrentCommand() const
 
 void Terminal::SerializeMainBuffer(const wchar_t* destination) const
 {
-    _mainBuffer->Serialize(destination);
+    _mainBuffer->SerializeToPath(destination);
 }
 
 void Terminal::ColorSelection(const TextAttribute& attr, winrt::Microsoft::Terminal::Core::MatchMode matchMode)
diff --git a/src/cascadia/TerminalCore/Terminal.hpp b/src/cascadia/TerminalCore/Terminal.hpp
index 7fdf0995013..8ac499c97e2 100644
--- a/src/cascadia/TerminalCore/Terminal.hpp
+++ b/src/cascadia/TerminalCore/Terminal.hpp
@@ -320,7 +320,7 @@ class Microsoft::Terminal::Core::Terminal final :
     til::point SelectionEndForRendering() const;
     const SelectionEndpoint SelectionEndpointTarget() const noexcept;
 
-    TextCopyData RetrieveSelectedTextFromBuffer(const bool singleLine, const bool html = false, const bool rtf = false) const;
+    TextCopyData RetrieveSelectedTextFromBuffer(const bool singleLine, const bool withControlSequences = false, const bool html = false, const bool rtf = false) const;
 #pragma endregion
 
 #ifndef NDEBUG
diff --git a/src/cascadia/TerminalCore/TerminalSelection.cpp b/src/cascadia/TerminalCore/TerminalSelection.cpp
index 2cb3ee08bfd..6a800f329dd 100644
--- a/src/cascadia/TerminalCore/TerminalSelection.cpp
+++ b/src/cascadia/TerminalCore/TerminalSelection.cpp
@@ -844,12 +844,13 @@ void Terminal::ClearSelection()
 // - Optionally, get the highlighted text in HTML and RTF formats
 // Arguments:
 // - singleLine: collapse all of the text to one line. (Turns off trailing whitespace trimming)
+// - withControlSequences: if enabled, the copied plain text contains color/style ANSI escape codes from the selection
 // - html: also get text in HTML format
 // - rtf: also get text in RTF format
 // Return Value:
 // - Plain and formatted selected text from buffer. Empty string represents no data for that format.
 // - If extended to multiple lines, each line is separated by \r\n
-Terminal::TextCopyData Terminal::RetrieveSelectedTextFromBuffer(const bool singleLine, const bool html, const bool rtf) const
+Terminal::TextCopyData Terminal::RetrieveSelectedTextFromBuffer(const bool singleLine, const bool withControlSequences, const bool html, const bool rtf) const
 {
     TextCopyData data;
 
@@ -867,7 +868,14 @@ Terminal::TextCopyData Terminal::RetrieveSelectedTextFromBuffer(const bool singl
     const auto& textBuffer = _activeBuffer();
 
     const auto req = TextBuffer::CopyRequest::FromConfig(textBuffer, _selection->start, _selection->end, singleLine, _selection->blockSelection, _trimBlockSelection);
-    data.plainText = textBuffer.GetPlainText(req);
+    if (withControlSequences)
+    {
+        data.plainText = textBuffer.GetWithControlSequences(req);
+    }
+    else
+    {
+        data.plainText = textBuffer.GetPlainText(req);
+    }
 
     if (html || rtf)
     {
diff --git a/src/cascadia/TerminalSettingsModel/ActionArgs.cpp b/src/cascadia/TerminalSettingsModel/ActionArgs.cpp
index 8b3e58a11b5..98411fd9b98 100644
--- a/src/cascadia/TerminalSettingsModel/ActionArgs.cpp
+++ b/src/cascadia/TerminalSettingsModel/ActionArgs.cpp
@@ -207,6 +207,11 @@ namespace winrt::Microsoft::Terminal::Settings::Model::implementation
             str.append(RS_(L"CopyTextCommandKey"));
         }
 
+        if (WithControlSequences())
+        {
+            str.append(L", withControlSequences: true");
+        }
+
         if (!DismissSelection())
         {
             str.append(L", dismissSelection: false");
diff --git a/src/cascadia/TerminalSettingsModel/ActionArgs.h b/src/cascadia/TerminalSettingsModel/ActionArgs.h
index 84c23dd2d7d..cd854b19cf3 100644
--- a/src/cascadia/TerminalSettingsModel/ActionArgs.h
+++ b/src/cascadia/TerminalSettingsModel/ActionArgs.h
@@ -103,9 +103,10 @@ protected:                                                                  \
 // false, if we don't really care if the parameter is required or not.
 
 ////////////////////////////////////////////////////////////////////////////////
-#define COPY_TEXT_ARGS(X)                                      \
-    X(bool, DismissSelection, "dismissSelection", false, true) \
-    X(bool, SingleLine, "singleLine", false, false)            \
+#define COPY_TEXT_ARGS(X)                                               \
+    X(bool, DismissSelection, "dismissSelection", false, true)          \
+    X(bool, SingleLine, "singleLine", false, false)                     \
+    X(bool, WithControlSequences, "withControlSequences", false, false) \
     X(Windows::Foundation::IReference<Control::CopyFormat>, CopyFormatting, "copyFormatting", false, nullptr)
 
 ////////////////////////////////////////////////////////////////////////////////
diff --git a/src/cascadia/TerminalSettingsModel/ActionArgs.idl b/src/cascadia/TerminalSettingsModel/ActionArgs.idl
index 02f0c4d0413..5f6bf22cd5f 100644
--- a/src/cascadia/TerminalSettingsModel/ActionArgs.idl
+++ b/src/cascadia/TerminalSettingsModel/ActionArgs.idl
@@ -180,6 +180,7 @@ namespace Microsoft.Terminal.Settings.Model
         CopyTextArgs();
         Boolean DismissSelection { get; };
         Boolean SingleLine { get; };
+        Boolean WithControlSequences { get; };
         Windows.Foundation.IReference<Microsoft.Terminal.Control.CopyFormat> CopyFormatting { get; };
     };
 


Test patch: