Repository: microsoft/terminal (language: cpp)

Issue:
Add support for the VT Paging APIs (`NP`, `PP`, `PPA`)
<!-- 
🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨

I ACKNOWLEDGE THE FOLLOWING BEFORE PROCEEDING:
1. If I delete this entire template and go my own path, the core team may close my issue without further explanation or engagement.
2. If I list multiple bugs/concerns in this one issue, the core team may close my issue without further explanation or engagement.
3. If I write an issue that has many duplicates, the core team may close my issue without further explanation or engagement (and without necessarily spending time to find the exact duplicate ID number).
4. If I leave the title incomplete when filing the issue, the core team may close my issue without further explanation or engagement.
5. If I file something completely blank in the body, the core team may close my issue without further explanation or engagement.

All good? Then proceed!
-->

# Description of the new feature/enhancement

This request is for multi-layer support in a terminal. This would be used for CLI-based wizards, menus, tools (think htop), etc. without disturbing the root command line.

This is not panes or tabs, which open additional terminal processes. Each layer in the terminal would be part of the same command line process. It would be like using layers in a photo/drawing editor, but with CLI.

Example:

1.	PowerShell starts in the default layer (layer 0). This is just the normal terminal.

![image](https://user-images.githubusercontent.com/40303902/187776461-1d10474f-4906-4059-83d1-57febe917838.png)

2.	A multi-layer aware wizard/menu/tool could then request a new layer from the runspace.
   a.    This would be layer X (i.e. layer 1 if run from the root/default layer).
   b.    The wizard/menu/tool would run in layer 1.
   c.    Layer 1 would be a separate thread in the same process where the console/terminal is running. Like a ThreadJob…or something along those lines.
   d.	While the wizard is running the user only sees Layer 1, while Layer 0 lurks inactive/deactivated in the background.

![image](https://user-images.githubusercontent.com/40303902/187777716-af0e16c8-0d94-4026-8348-99c433216b0c.png)


3.	The wizard completes, Layer 1 is closed, returning any objects needed back to layer 0 in the process.
4.	User continues in Layer 0 (default), right where they left off. The default console is undisturbed by the wizard.




<!-- 
A clear and concise description of what the problem is that the new feature would solve.
Describe why and how a user would use this new functionality (if applicable).
-->

# Proposed technical implementation details (optional)

I have no idea how this would work. I only know it would be awesome 😊
 
I assume that PowerShell would need to know about layers to use them. I would assume changes to both PowerShell and terminal are needed. Or maybe not, if PowerShell can do all of this virtually.
 
If each layer is just a text buffer that's swapped in and out of the process that displays terminal/console to the user then it could be cross-platform, cross-terminal/console. Maybe… I don't know for sure. Just a thought.



<!-- 
A clear and concise description of what you want to happen.
-->



Discussion / hints:
You know, this is pretty close to what we were discussing in the neighborhood of https://github.com/microsoft/terminal/issues/10810#issuecomment-1163127364. Does that thread (and maybe the few comments before that, maybe start here: https://github.com/microsoft/terminal/issues/10810#issuecomment-897905318) sound like what you were looking for/?
@zadjii-msft I think this particular request is simpler than that (if I've understood it correctly), because the layers are completely independent of each other. You could already achieve something like this in the old console using the screen buffer APIs (see [`CreateConsoleScreenBuffer`](https://docs.microsoft.com/en-us/windows/console/createconsolescreenbuffer#remarks)).

The idea I had, which I think I've mentioned before, was for us to replicate that functionality in Windows Terminal using the VT paging APIs, which are similar in nature (see [`NP`](https://www.vt100.net/docs/vt510-rm/NP.html), [`PP`](https://www.vt100.net/docs/vt510-rm/PP.html), [`PPA`](https://www.vt100.net/docs/vt510-rm/PPA.html), etc.). You couldn't "create" new buffers as such, but the terminal would have a predefined number of pages that would be available for use, and you would just move to a new page whenever you needed a new layer/buffer.

Technically we could probably even allow for an infinite number of pages, creating them on demand whenever an app tried to move to a page that hadn't previously been used.

This wouldn't solve #10810, but I think it might be good enough for the OPs multi-layer idea, and it has the advantage of not needing to invent proprietary escape sequences.
Huh. I don't think I've ever actually seen those sequences in use. They sure do seem like they've got some overlap here. Do pages "layer" on top of one another? Or are they just... additional buffers? Are there any other emulators which support these out there we can use for reference? I shockingly don't see it on invisible-island
> Do pages "layer" on top of one another? Or are they just... additional buffers?

They're just additional buffers as far as I understand them. That's why I thought they could probably serve as a reasonable representation of the console buffers.

But if the idea behind this issue was to layer the buffers in a way that the "lower" layers would be visible through the unwritten portions of the "upper" layers, then VT pages would not be suitable. But note point 2d in the description above:

> the user only sees Layer 1, while Layer 0 lurks inactive/deactivated in the background.

That's what made me think that only one layer would need to be visible at a time. I may be misreading that though.

> Are there any other emulators which support these out there we can use for reference?

The ones that I'm aware of are VTStar, Reflection Desktop, MLTerm, RLogin, and PowerTerm. I haven't done much testing on them, though - I just have notes indicating that they all have some level of paging support.
This issue has been automatically marked as stale because it has been marked as requiring author feedback but has not had any activity for **4 days**. It will be closed if no further activity occurs **within 3 days of this comment**.
I'm gonna convert this to "Support VT paging API's", and generally keep the "layers" thing over in #10810. Thanks for the discussion!

Fix patch:
diff --git a/.github/actions/spelling/expect/expect.txt b/.github/actions/spelling/expect/expect.txt
index 5c0c14b0df9..1a189277aef 100644
--- a/.github/actions/spelling/expect/expect.txt
+++ b/.github/actions/spelling/expect/expect.txt
@@ -406,6 +406,7 @@ DECNKM
 DECNRCM
 DECOM
 decommit
+DECPCCM
 DECPCTERM
 DECPS
 DECRARA
@@ -414,6 +415,7 @@ DECREQTPARM
 DECRLM
 DECRPM
 DECRQCRA
+DECRQDE
 DECRQM
 DECRQPSR
 DECRQSS
@@ -2123,6 +2125,7 @@ XIn
 XManifest
 XMath
 xorg
+XPan
 XResource
 xsi
 xstyler
@@ -2142,6 +2145,7 @@ YCast
 YCENTER
 YCount
 YLimit
+YPan
 YSubstantial
 YVIRTUALSCREEN
 YWalk
diff --git a/src/cascadia/TerminalCore/Terminal.hpp b/src/cascadia/TerminalCore/Terminal.hpp
index 75b822b1386..bf1120f421a 100644
--- a/src/cascadia/TerminalCore/Terminal.hpp
+++ b/src/cascadia/TerminalCore/Terminal.hpp
@@ -131,8 +131,7 @@ class Microsoft::Terminal::Core::Terminal final :
     // These methods are defined in TerminalApi.cpp
     void ReturnResponse(const std::wstring_view response) override;
     Microsoft::Console::VirtualTerminal::StateMachine& GetStateMachine() noexcept override;
-    TextBuffer& GetTextBuffer() noexcept override;
-    til::rect GetViewport() const noexcept override;
+    BufferState GetBufferAndViewport() noexcept override;
     void SetViewportPosition(const til::point position) noexcept override;
     void SetTextAttributes(const TextAttribute& attrs) noexcept override;
     void SetSystemMode(const Mode mode, const bool enabled) noexcept override;
diff --git a/src/cascadia/TerminalCore/TerminalApi.cpp b/src/cascadia/TerminalCore/TerminalApi.cpp
index 42f1c903c33..ad284ceb3ff 100644
--- a/src/cascadia/TerminalCore/TerminalApi.cpp
+++ b/src/cascadia/TerminalCore/TerminalApi.cpp
@@ -33,14 +33,9 @@ Microsoft::Console::VirtualTerminal::StateMachine& Terminal::GetStateMachine() n
     return *_stateMachine;
 }
 
-TextBuffer& Terminal::GetTextBuffer() noexcept
+ITerminalApi::BufferState Terminal::GetBufferAndViewport() noexcept
 {
-    return _activeBuffer();
-}
-
-til::rect Terminal::GetViewport() const noexcept
-{
-    return til::rect{ _GetMutableViewport().ToInclusive() };
+    return { _activeBuffer(), til::rect{ _GetMutableViewport().ToInclusive() }, !_inAltBuffer() };
 }
 
 void Terminal::SetViewportPosition(const til::point position) noexcept
diff --git a/src/cascadia/UnitTests_TerminalCore/SelectionTest.cpp b/src/cascadia/UnitTests_TerminalCore/SelectionTest.cpp
index 195eb17fcc3..1e55fb1f6e9 100644
--- a/src/cascadia/UnitTests_TerminalCore/SelectionTest.cpp
+++ b/src/cascadia/UnitTests_TerminalCore/SelectionTest.cpp
@@ -44,6 +44,11 @@ namespace TerminalCoreUnitTests
             VERIFY_ARE_EQUAL(selection, expected);
         }
 
+        TextBuffer& GetTextBuffer(Terminal& term)
+        {
+            return term.GetBufferAndViewport().buffer;
+        }
+
         TEST_METHOD(SelectUnit)
         {
             Terminal term{ Terminal::TestDummyMarker{} };
@@ -394,7 +399,7 @@ namespace TerminalCoreUnitTests
             const auto burrito = L"\xD83C\xDF2F";
 
             // Insert wide glyph at position (4,10)
-            term.GetTextBuffer().GetCursor().SetPosition({ 4, 10 });
+            GetTextBuffer(term).GetCursor().SetPosition({ 4, 10 });
             term.Write(burrito);
 
             // Simulate click at (x,y) = (5,10)
@@ -417,7 +422,7 @@ namespace TerminalCoreUnitTests
             const auto burrito = L"\xD83C\xDF2F";
 
             // Insert wide glyph at position (4,10)
-            term.GetTextBuffer().GetCursor().SetPosition({ 4, 10 });
+            GetTextBuffer(term).GetCursor().SetPosition({ 4, 10 });
             term.Write(burrito);
 
             // Simulate click at (x,y) = (5,10)
@@ -440,11 +445,11 @@ namespace TerminalCoreUnitTests
             const auto burrito = L"\xD83C\xDF2F";
 
             // Insert wide glyph at position (4,10)
-            term.GetTextBuffer().GetCursor().SetPosition({ 4, 10 });
+            GetTextBuffer(term).GetCursor().SetPosition({ 4, 10 });
             term.Write(burrito);
 
             // Insert wide glyph at position (7,11)
-            term.GetTextBuffer().GetCursor().SetPosition({ 7, 11 });
+            GetTextBuffer(term).GetCursor().SetPosition({ 7, 11 });
             term.Write(burrito);
 
             // Simulate ALT + click at (x,y) = (5,8)
@@ -496,7 +501,7 @@ namespace TerminalCoreUnitTests
 
             // Insert text at position (4,10)
             const std::wstring_view text = L"doubleClickMe";
-            term.GetTextBuffer().GetCursor().SetPosition({ 4, 10 });
+            GetTextBuffer(term).GetCursor().SetPosition({ 4, 10 });
             term.Write(text);
 
             // Simulate double click at (x,y) = (5,10)
@@ -540,7 +545,7 @@ namespace TerminalCoreUnitTests
 
             // Insert text at position (4,10)
             const std::wstring_view text = L"C:\\Terminal>";
-            term.GetTextBuffer().GetCursor().SetPosition({ 4, 10 });
+            GetTextBuffer(term).GetCursor().SetPosition({ 4, 10 });
             term.Write(text);
 
             // Simulate click at (x,y) = (15,10)
@@ -568,7 +573,7 @@ namespace TerminalCoreUnitTests
 
             // Insert text at position (4,10)
             const std::wstring_view text = L"doubleClickMe dragThroughHere";
-            term.GetTextBuffer().GetCursor().SetPosition({ 4, 10 });
+            GetTextBuffer(term).GetCursor().SetPosition({ 4, 10 });
             term.Write(text);
 
             // Simulate double click at (x,y) = (5,10)
@@ -597,7 +602,7 @@ namespace TerminalCoreUnitTests
 
             // Insert text at position (21,10)
             const std::wstring_view text = L"doubleClickMe dragThroughHere";
-            term.GetTextBuffer().GetCursor().SetPosition({ 4, 10 });
+            GetTextBuffer(term).GetCursor().SetPosition({ 4, 10 });
             term.Write(text);
 
             // Simulate double click at (x,y) = (21,10)
@@ -685,7 +690,7 @@ namespace TerminalCoreUnitTests
 
             // Insert text at position (4,10)
             const std::wstring_view text = L"doubleClickMe dragThroughHere";
-            term.GetTextBuffer().GetCursor().SetPosition({ 4, 10 });
+            GetTextBuffer(term).GetCursor().SetPosition({ 4, 10 });
             term.Write(text);
 
             // Step 1: Create a selection on "doubleClickMe"
diff --git a/src/cascadia/UnitTests_TerminalCore/TerminalApiTest.cpp b/src/cascadia/UnitTests_TerminalCore/TerminalApiTest.cpp
index 9f002db5098..157bad1805d 100644
--- a/src/cascadia/UnitTests_TerminalCore/TerminalApiTest.cpp
+++ b/src/cascadia/UnitTests_TerminalCore/TerminalApiTest.cpp
@@ -152,7 +152,8 @@ void TerminalApiTest::CursorVisibility()
     VERIFY_IS_TRUE(term._mainBuffer->GetCursor().IsOn());
     VERIFY_IS_TRUE(term._mainBuffer->GetCursor().IsBlinkingAllowed());
 
-    term.GetTextBuffer().GetCursor().SetIsVisible(false);
+    auto& textBuffer = term.GetBufferAndViewport().buffer;
+    textBuffer.GetCursor().SetIsVisible(false);
     VERIFY_IS_FALSE(term._mainBuffer->GetCursor().IsVisible());
     VERIFY_IS_TRUE(term._mainBuffer->GetCursor().IsOn());
     VERIFY_IS_TRUE(term._mainBuffer->GetCursor().IsBlinkingAllowed());
diff --git a/src/host/outputStream.cpp b/src/host/outputStream.cpp
index fb20d74b75b..151a472f69d 100644
--- a/src/host/outputStream.cpp
+++ b/src/host/outputStream.cpp
@@ -52,25 +52,16 @@ StateMachine& ConhostInternalGetSet::GetStateMachine()
 }
 
 // Routine Description:
-// - Retrieves the text buffer for the active output buffer.
+// - Retrieves the text buffer and virtual viewport for the active output
+//   buffer. Also returns a flag indicating whether it's the main buffer.
 // Arguments:
 // - <none>
 // Return Value:
-// - a reference to the TextBuffer instance.
-TextBuffer& ConhostInternalGetSet::GetTextBuffer()
+// - a tuple with the buffer reference, viewport, and main buffer flag.
+ITerminalApi::BufferState ConhostInternalGetSet::GetBufferAndViewport()
 {
-    return _io.GetActiveOutputBuffer().GetTextBuffer();
-}
-
-// Routine Description:
-// - Retrieves the virtual viewport of the active output buffer.
-// Arguments:
-// - <none>
-// Return Value:
-// - the exclusive coordinates of the viewport.
-til::rect ConhostInternalGetSet::GetViewport() const
-{
-    return _io.GetActiveOutputBuffer().GetVirtualViewport().ToExclusive();
+    auto& info = _io.GetActiveOutputBuffer();
+    return { info.GetTextBuffer(), info.GetVirtualViewport().ToExclusive(), info.Next == nullptr };
 }
 
 // Routine Description:
diff --git a/src/host/outputStream.hpp b/src/host/outputStream.hpp
index 01d8abaf17f..36283087ac2 100644
--- a/src/host/outputStream.hpp
+++ b/src/host/outputStream.hpp
@@ -32,8 +32,7 @@ class ConhostInternalGetSet final : public Microsoft::Console::VirtualTerminal::
     void ReturnResponse(const std::wstring_view response) override;
 
     Microsoft::Console::VirtualTerminal::StateMachine& GetStateMachine() override;
-    TextBuffer& GetTextBuffer() override;
-    til::rect GetViewport() const override;
+    BufferState GetBufferAndViewport() override;
     void SetViewportPosition(const til::point position) override;
 
     void SetTextAttributes(const TextAttribute& attrs) override;
diff --git a/src/terminal/adapter/DispatchTypes.hpp b/src/terminal/adapter/DispatchTypes.hpp
index 52852bed6a2..a0a74d8c83a 100644
--- a/src/terminal/adapter/DispatchTypes.hpp
+++ b/src/terminal/adapter/DispatchTypes.hpp
@@ -531,6 +531,7 @@ namespace Microsoft::Console::VirtualTerminal::DispatchTypes
         ATT610_StartCursorBlink = DECPrivateMode(12),
         DECTCEM_TextCursorEnableMode = DECPrivateMode(25),
         XTERM_EnableDECCOLMSupport = DECPrivateMode(40),
+        DECPCCM_PageCursorCouplingMode = DECPrivateMode(64),
         DECNKM_NumericKeypadMode = DECPrivateMode(66),
         DECBKM_BackarrowKeyMode = DECPrivateMode(67),
         DECLRMM_LeftRightMarginMode = DECPrivateMode(69),
diff --git a/src/terminal/adapter/ITermDispatch.hpp b/src/terminal/adapter/ITermDispatch.hpp
index eed892f5a94..e3976996433 100644
--- a/src/terminal/adapter/ITermDispatch.hpp
+++ b/src/terminal/adapter/ITermDispatch.hpp
@@ -49,6 +49,12 @@ class Microsoft::Console::VirtualTerminal::ITermDispatch
     virtual bool DeleteCharacter(const VTInt count) = 0; // DCH
     virtual bool ScrollUp(const VTInt distance) = 0; // SU
     virtual bool ScrollDown(const VTInt distance) = 0; // SD
+    virtual bool NextPage(const VTInt pageCount) = 0; // NP
+    virtual bool PrecedingPage(const VTInt pageCount) = 0; // PP
+    virtual bool PagePositionAbsolute(const VTInt page) = 0; // PPA
+    virtual bool PagePositionRelative(const VTInt pageCount) = 0; // PPR
+    virtual bool PagePositionBack(const VTInt pageCount) = 0; // PPB
+    virtual bool RequestDisplayedExtent() = 0; // DECRQDE
     virtual bool InsertLine(const VTInt distance) = 0; // IL
     virtual bool DeleteLine(const VTInt distance) = 0; // DL
     virtual bool InsertColumn(const VTInt distance) = 0; // DECIC
diff --git a/src/terminal/adapter/ITerminalApi.hpp b/src/terminal/adapter/ITerminalApi.hpp
index 4381c4ecbdd..2c3aec044ed 100644
--- a/src/terminal/adapter/ITerminalApi.hpp
+++ b/src/terminal/adapter/ITerminalApi.hpp
@@ -39,9 +39,15 @@ namespace Microsoft::Console::VirtualTerminal
 
         virtual void ReturnResponse(const std::wstring_view response) = 0;
 
+        struct BufferState
+        {
+            TextBuffer& buffer;
+            til::rect viewport;
+            bool isMainBuffer;
+        };
+
         virtual StateMachine& GetStateMachine() = 0;
-        virtual TextBuffer& GetTextBuffer() = 0;
-        virtual til::rect GetViewport() const = 0;
+        virtual BufferState GetBufferAndViewport() = 0;
         virtual void SetViewportPosition(const til::point position) = 0;
 
         virtual bool IsVtInputEnabled() const = 0;
diff --git a/src/terminal/adapter/InteractDispatch.cpp b/src/terminal/adapter/InteractDispatch.cpp
index cb06c985c91..a102368ec5b 100644
--- a/src/terminal/adapter/InteractDispatch.cpp
+++ b/src/terminal/adapter/InteractDispatch.cpp
@@ -108,7 +108,7 @@ bool InteractDispatch::WindowManipulation(const DispatchTypes::WindowManipulatio
         _api.ShowWindow(false);
         return true;
     case DispatchTypes::WindowManipulationType::RefreshWindow:
-        _api.GetTextBuffer().TriggerRedrawAll();
+        _api.GetBufferAndViewport().buffer.TriggerRedrawAll();
         return true;
     case DispatchTypes::WindowManipulationType::ResizeWindowInCharacters:
         // TODO:GH#1765 We should introduce a better `ResizeConpty` function to
@@ -135,7 +135,7 @@ bool InteractDispatch::WindowManipulation(const DispatchTypes::WindowManipulatio
 bool InteractDispatch::MoveCursor(const VTInt row, const VTInt col)
 {
     // First retrieve some information about the buffer
-    const auto viewport = _api.GetViewport();
+    const auto viewport = _api.GetBufferAndViewport().viewport;
 
     // In VT, the origin is 1,1. For our array, it's 0,0. So subtract 1.
     // Apply boundary tests to ensure the cursor isn't outside the viewport rectangle.
diff --git a/src/terminal/adapter/PageManager.cpp b/src/terminal/adapter/PageManager.cpp
new file mode 100644
index 00000000000..5408b249738
--- /dev/null
+++ b/src/terminal/adapter/PageManager.cpp
@@ -0,0 +1,254 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+#include "precomp.h"
+
+#include "PageManager.hpp"
+#include "../../renderer/base/renderer.hpp"
+
+using namespace Microsoft::Console::VirtualTerminal;
+
+Page::Page(TextBuffer& buffer, const til::rect& viewport, const til::CoordType number) noexcept :
+    _buffer{ buffer },
+    _viewport{ viewport },
+    _number(number)
+{
+}
+
+TextBuffer& Page::Buffer() const noexcept
+{
+    return _buffer;
+}
+
+til::rect Page::Viewport() const noexcept
+{
+    return _viewport;
+}
+
+til::CoordType Page::Number() const noexcept
+{
+    return _number;
+}
+
+Cursor& Page::Cursor() const noexcept
+{
+    return _buffer.GetCursor();
+}
+
+const TextAttribute& Page::Attributes() const noexcept
+{
+    return _buffer.GetCurrentAttributes();
+}
+
+void Page::SetAttributes(const TextAttribute& attr, ITerminalApi* api) const
+{
+    _buffer.SetCurrentAttributes(attr);
+    // If the api parameter was specified, we need to pass the new attributes
+    // through to the api. This occurs when there's a potential for the colors
+    // to be changed, which may require some legacy remapping in conhost.
+    if (api)
+    {
+        api->SetTextAttributes(attr);
+    }
+}
+
+til::CoordType Page::Top() const noexcept
+{
+    // If we ever support vertical window panning, the page top won't
+    // necessarily align with the viewport top, so it's best we always
+    // treat them as distinct properties.
+    return _viewport.top;
+}
+
+til::CoordType Page::Bottom() const noexcept
+{
+    // Similarly, the page bottom won't always match the viewport bottom.
+    return _viewport.bottom;
+}
+
+til::CoordType Page::Width() const noexcept
+{
+    // The page width could also one day be different from the buffer width,
+    // so again it's best treated as a distinct property.
+    return _buffer.GetSize().Width();
+}
+
+til::CoordType Page::Height() const noexcept
+{
+    return Bottom() - Top();
+}
+
+til::CoordType Page::BufferHeight() const noexcept
+{
+    return _buffer.GetSize().Height();
+}
+
+til::CoordType Page::XPanOffset() const noexcept
+{
+    return _viewport.left;
+}
+
+til::CoordType Page::YPanOffset() const noexcept
+{
+    return 0; // Vertical panning is not yet supported
+}
+
+PageManager::PageManager(ITerminalApi& api, Renderer& renderer) noexcept :
+    _api{ api },
+    _renderer{ renderer }
+{
+}
+
+void PageManager::Reset()
+{
+    _activePageNumber = 1;
+    _visiblePageNumber = 1;
+    _buffers = {};
+}
+
+Page PageManager::Get(const til::CoordType pageNumber) const
+{
+    const auto requestedPageNumber = std::min(std::max(pageNumber, 1), MAX_PAGES);
+    auto [visibleBuffer, visibleViewport, isMainBuffer] = _api.GetBufferAndViewport();
+
+    // If we're not in the main buffer (either because an app has enabled the
+    // alternate buffer mode, or switched the conhost screen buffer), then VT
+    // paging doesn't apply, so we disregard the requested page number and just
+    // use the visible buffer (with a fixed page number of 1).
+    if (!isMainBuffer)
+    {
+        return { visibleBuffer, visibleViewport, 1 };
+    }
+
+    // If the requested page number happens to be the visible page, then we
+    // can also just use the visible buffer as is.
+    if (requestedPageNumber == _visiblePageNumber)
+    {
+        return { visibleBuffer, visibleViewport, _visiblePageNumber };
+    }
+
+    // Otherwise we're working with a background buffer, so we need to
+    // retrieve that from the buffer array, and resize it to match the
+    // active page size.
+    const auto pageSize = visibleViewport.size();
+    auto& pageBuffer = _getBuffer(requestedPageNumber, pageSize);
+    return { pageBuffer, til::rect{ pageSize }, requestedPageNumber };
+}
+
+Page PageManager::ActivePage() const
+{
+    return Get(_activePageNumber);
+}
+
+Page PageManager::VisiblePage() const
+{
+    return Get(_visiblePageNumber);
+}
+
+void PageManager::MoveTo(const til::CoordType pageNumber, const bool makeVisible)
+{
+    auto [visibleBuffer, visibleViewport, isMainBuffer] = _api.GetBufferAndViewport();
+    if (!isMainBuffer)
+    {
+        return;
+    }
+
+    const auto pageSize = visibleViewport.size();
+    const auto visibleTop = visibleViewport.top;
+    const auto wasVisible = _activePageNumber == _visiblePageNumber;
+    const auto newPageNumber = std::min(std::max(pageNumber, 1), MAX_PAGES);
+    auto redrawRequired = false;
+
+    // If we're changing the visible page, what we do is swap out the current
+    // visible page into its backing buffer, and swap in the new page from the
+    // backing buffer to the main buffer. That way the rest of the system only
+    // ever has to deal with the main buffer.
+    if (makeVisible && _visiblePageNumber != newPageNumber)
+    {
+        const auto& newBuffer = _getBuffer(newPageNumber, pageSize);
+        auto& saveBuffer = _getBuffer(_visiblePageNumber, pageSize);
+        for (auto i = 0; i < pageSize.height; i++)
+        {
+            saveBuffer.GetMutableRowByOffset(i).CopyFrom(visibleBuffer.GetRowByOffset(visibleTop + i));
+        }
+        for (auto i = 0; i < pageSize.height; i++)
+        {
+            visibleBuffer.GetMutableRowByOffset(visibleTop + i).CopyFrom(newBuffer.GetRowByOffset(i));
+        }
+        _visiblePageNumber = newPageNumber;
+        redrawRequired = true;
+    }
+
+    // If the active page was previously visible, and is now still visible,
+    // there is no need to update any buffer properties, because we'll have
+    // been using the main buffer in both cases.
+    const auto isVisible = newPageNumber == _visiblePageNumber;
+    if (!wasVisible || !isVisible)
+    {
+        // Otherwise we need to copy the properties from the old buffer to the
+        // new, so we retain the current attributes and cursor position. This
+        // is only needed if they are actually different.
+        auto& oldBuffer = wasVisible ? visibleBuffer : _getBuffer(_activePageNumber, pageSize);
+        auto& newBuffer = isVisible ? visibleBuffer : _getBuffer(newPageNumber, pageSize);
+        if (&oldBuffer != &newBuffer)
+        {
+            // When copying the cursor position, we need to adjust the y
+            // coordinate to account for scrollback in the visible buffer.
+            const auto oldTop = wasVisible ? visibleTop : 0;
+            const auto newTop = isVisible ? visibleTop : 0;
+            auto position = oldBuffer.GetCursor().GetPosition();
+            position.y = position.y - oldTop + newTop;
+            newBuffer.SetCurrentAttributes(oldBuffer.GetCurrentAttributes());
+            newBuffer.CopyProperties(oldBuffer);
+            newBuffer.GetCursor().SetPosition(position);
+        }
+        // If we moved from the visible buffer to a background buffer we need
+        // to hide the cursor in the visible buffer. This is because the page
+        // number is like a third dimension in the cursor coordinate system.
+        // If the cursor isn't on the visible page, it's the same as if its
+        // x/y coordinates are outside the visible viewport.
+        if (wasVisible && !isVisible)
+        {
+            visibleBuffer.GetCursor().SetIsVisible(false);
+        }
+    }
+
+    _activePageNumber = newPageNumber;
+    if (redrawRequired)
+    {
+        _renderer.TriggerRedrawAll();
+    }
+}
+
+void PageManager::MoveRelative(const til::CoordType pageCount, const bool makeVisible)
+{
+    MoveTo(_activePageNumber + pageCount, makeVisible);
+}
+
+void PageManager::MakeActivePageVisible()
+{
+    if (_activePageNumber != _visiblePageNumber)
+    {
+        MoveTo(_activePageNumber, true);
+    }
+}
+
+TextBuffer& PageManager::_getBuffer(const til::CoordType pageNumber, const til::size pageSize) const
+{
+    auto& buffer = til::at(_buffers, pageNumber - 1);
+    if (buffer == nullptr)
+    {
+        // Page buffers are created on demand, and are sized to match the active
+        // page dimensions without any scrollback rows.
+        buffer = std::make_unique<TextBuffer>(pageSize, TextAttribute{}, 0, false, _renderer);
+    }
+    else if (buffer->GetSize().Dimensions() != pageSize)
+    {
+        // If a buffer already exists for the page, and the page dimensions have
+        // changed while it was inactive, it will need to be resized.
+        // TODO: We don't currently reflow the existing content in this case, but
+        // that may be something we want to reconsider.
+        buffer->ResizeTraditional(pageSize);
+    }
+    return *buffer;
+}
diff --git a/src/terminal/adapter/PageManager.hpp b/src/terminal/adapter/PageManager.hpp
new file mode 100644
index 00000000000..ab913902609
--- /dev/null
+++ b/src/terminal/adapter/PageManager.hpp
@@ -0,0 +1,67 @@
+/*++
+Copyright (c) Microsoft Corporation
+Licensed under the MIT license.
+
+Module Name:
+- PageManager.hpp
+
+Abstract:
+- This manages the text buffers required by the VT paging operations.
+--*/
+
+#pragma once
+
+#include "ITerminalApi.hpp"
+#include "til.h"
+
+namespace Microsoft::Console::VirtualTerminal
+{
+    class Page
+    {
+    public:
+        Page(TextBuffer& buffer, const til::rect& viewport, const til::CoordType number) noexcept;
+        TextBuffer& Buffer() const noexcept;
+        til::rect Viewport() const noexcept;
+        til::CoordType Number() const noexcept;
+        Cursor& Cursor() const noexcept;
+        const TextAttribute& Attributes() const noexcept;
+        void SetAttributes(const TextAttribute& attr, ITerminalApi* api = nullptr) const;
+        til::CoordType Top() const noexcept;
+        til::CoordType Bottom() const noexcept;
+        til::CoordType Width() const noexcept;
+        til::CoordType Height() const noexcept;
+        til::CoordType BufferHeight() const noexcept;
+        til::CoordType XPanOffset() const noexcept;
+        til::CoordType YPanOffset() const noexcept;
+
+    private:
+        TextBuffer& _buffer;
+        til::rect _viewport;
+        til::CoordType _number;
+    };
+
+    class PageManager
+    {
+        using Renderer = Microsoft::Console::Render::Renderer;
+
+    public:
+        PageManager(ITerminalApi& api, Renderer& renderer) noexcept;
+        void Reset();
+        Page Get(const til::CoordType pageNumber) const;
+        Page ActivePage() const;
+        Page VisiblePage() const;
+        void MoveTo(const til::CoordType pageNumber, const bool makeVisible);
+        void MoveRelative(const til::CoordType pageCount, const bool makeVisible);
+        void MakeActivePageVisible();
+
+    private:
+        TextBuffer& _getBuffer(const til::CoordType pageNumber, const til::size pageSize) const;
+
+        ITerminalApi& _api;
+        Renderer& _renderer;
+        til::CoordType _activePageNumber = 1;
+        til::CoordType _visiblePageNumber = 1;
+        static constexpr til::CoordType MAX_PAGES = 6;
+        mutable std::array<std::unique_ptr<TextBuffer>, MAX_PAGES> _buffers;
+    };
+}
diff --git a/src/terminal/adapter/adaptDispatch.cpp b/src/terminal/adapter/adaptDispatch.cpp
index 02c22db8eec..4135cac88d6 100644
--- a/src/terminal/adapter/adaptDispatch.cpp
+++ b/src/terminal/adapter/adaptDispatch.cpp
@@ -22,7 +22,8 @@ AdaptDispatch::AdaptDispatch(ITerminalApi& api, Renderer& renderer, RenderSettin
     _renderSettings{ renderSettings },
     _terminalInput{ terminalInput },
     _usingAltBuffer(false),
-    _termOutput()
+    _termOutput(),
+    _pages{ api, renderer }
 {
 }
 
@@ -72,15 +73,15 @@ void AdaptDispatch::PrintString(const std::wstring_view string)
 
 void AdaptDispatch::_WriteToBuffer(const std::wstring_view string)
 {
-    auto& textBuffer = _api.GetTextBuffer();
-    auto& cursor = textBuffer.GetCursor();
+    const auto page = _pages.ActivePage();
+    auto& textBuffer = page.Buffer();
+    auto& cursor = page.Cursor();
     auto cursorPosition = cursor.GetPosition();
     const auto wrapAtEOL = _api.GetSystemMode(ITerminalApi::Mode::AutoWrap);
-    const auto& attributes = textBuffer.GetCurrentAttributes();
+    const auto& attributes = page.Attributes();
 
-    const auto viewport = _api.GetViewport();
-    const auto [topMargin, bottomMargin] = _GetVerticalMargins(viewport, true);
-    const auto [leftMargin, rightMargin] = _GetHorizontalMargins(textBuffer.GetSize().Width());
+    const auto [topMargin, bottomMargin] = _GetVerticalMargins(page, true);
+    const auto [leftMargin, rightMargin] = _GetHorizontalMargins(page.Width());
 
     auto lineWidth = textBuffer.GetLineWidth(cursorPosition.y);
     if (cursorPosition.x <= rightMargin && cursorPosition.y >= topMargin && cursorPosition.y <= bottomMargin)
@@ -106,7 +107,7 @@ void AdaptDispatch::_WriteToBuffer(const std::wstring_view string)
             // different position from where the EOL was marked.
             if (delayedCursorPosition == cursorPosition)
             {
-                _DoLineFeed(textBuffer, true, true);
+                _DoLineFeed(page, true, true);
                 cursorPosition = cursor.GetPosition();
                 // We need to recalculate the width when moving to a new line.
                 lineWidth = textBuffer.GetLineWidth(cursorPosition.y);
@@ -266,19 +267,19 @@ bool AdaptDispatch::CursorPrevLine(const VTInt distance)
 // Routine Description:
 // - Returns the coordinates of the vertical scroll margins.
 // Arguments:
-// - viewport - The viewport rect (exclusive).
-// - absolute - Should coordinates be absolute or relative to the viewport.
+// - page - The page that the margins will apply to.
+// - absolute - Should coordinates be absolute or relative to the page top.
 // Return Value:
 // - A std::pair containing the top and bottom coordinates (inclusive).
-std::pair<int, int> AdaptDispatch::_GetVerticalMargins(const til::rect& viewport, const bool absolute) noexcept
+std::pair<int, int> AdaptDispatch::_GetVerticalMargins(const Page& page, const bool absolute) noexcept
 {
     // If the top is out of range, reset the margins completely.
-    const auto bottommostRow = viewport.bottom - viewport.top - 1;
+    const auto bottommostRow = page.Height() - 1;
     if (_scrollMargins.top >= bottommostRow)
     {
         _scrollMargins.top = _scrollMargins.bottom = 0;
     }
-    // If margins aren't set, use the full extent of the viewport.
+    // If margins aren't set, use the full extent of the page.
     const auto marginsSet = _scrollMargins.top < _scrollMargins.bottom;
     auto topMargin = marginsSet ? _scrollMargins.top : 0;
     auto bottomMargin = marginsSet ? _scrollMargins.bottom : bottommostRow;
@@ -286,8 +287,8 @@ std::pair<int, int> AdaptDispatch::_GetVerticalMargins(const til::rect& viewport
     bottomMargin = std::min(bottomMargin, bottommostRow);
     if (absolute)
     {
-        topMargin += viewport.top;
-        bottomMargin += viewport.top;
+        topMargin += page.Top();
+        bottomMargin += page.Top();
     }
     return { topMargin, bottomMargin };
 }
@@ -295,13 +296,13 @@ std::pair<int, int> AdaptDispatch::_GetVerticalMargins(const til::rect& viewport
 // Routine Description:
 // - Returns the coordinates of the horizontal scroll margins.
 // Arguments:
-// - bufferWidth - The width of the buffer
+// - pageWidth - The width of the page
 // Return Value:
 // - A std::pair containing the left and right coordinates (inclusive).
-std::pair<int, int> AdaptDispatch::_GetHorizontalMargins(const til::CoordType bufferWidth) noexcept
+std::pair<int, int> AdaptDispatch::_GetHorizontalMargins(const til::CoordType pageWidth) noexcept
 {
     // If the left is out of range, reset the margins completely.
-    const auto rightmostColumn = bufferWidth - 1;
+    const auto rightmostColumn = pageWidth - 1;
     if (_scrollMargins.left >= rightmostColumn)
     {
         _scrollMargins.left = _scrollMargins.right = 0;
@@ -326,13 +327,12 @@ std::pair<int, int> AdaptDispatch::_GetHorizontalMargins(const til::CoordType bu
 bool AdaptDispatch::_CursorMovePosition(const Offset rowOffset, const Offset colOffset, const bool clampInMargins)
 {
     // First retrieve some information about the buffer
-    const auto viewport = _api.GetViewport();
-    auto& textBuffer = _api.GetTextBuffer();
-    auto& cursor = textBuffer.GetCursor();
-    const auto bufferWidth = textBuffer.GetSize().Width();
+    const auto page = _pages.ActivePage();
+    auto& cursor = page.Cursor();
+    const auto pageWidth = page.Width();
     const auto cursorPosition = cursor.GetPosition();
-    const auto [topMargin, bottomMargin] = _GetVerticalMargins(viewport, true);
-    const auto [leftMargin, rightMargin] = _GetHorizontalMargins(bufferWidth);
+    const auto [topMargin, bottomMargin] = _GetVerticalMargins(page, true);
+    const auto [leftMargin, rightMargin] = _GetHorizontalMargins(pageWidth);
 
     // For relative movement, the given offsets will be relative to
     // the current cursor position.
@@ -340,10 +340,10 @@ bool AdaptDispatch::_CursorMovePosition(const Offset rowOffset, const Offset col
     auto col = cursorPosition.x;
 
     // But if the row is absolute, it will be relative to the top of the
-    // viewport, or the top margin, depending on the origin mode.
+    // page, or the top margin, depending on the origin mode.
     if (rowOffset.IsAbsolute)
     {
-        row = _modes.test(Mode::Origin) ? topMargin : viewport.top;
+        row = _modes.test(Mode::Origin) ? topMargin : page.Top();
     }
 
     // And if the column is absolute, it'll be relative to column 0,
@@ -355,10 +355,10 @@ bool AdaptDispatch::_CursorMovePosition(const Offset rowOffset, const Offset col
     }
 
     // Adjust the base position by the given offsets and clamp the results.
-    // The row is constrained within the viewport's vertical boundaries,
+    // The row is constrained within the page's vertical boundaries,
     // while the column is constrained by the buffer width.
-    row = std::clamp(row + rowOffset.Value, viewport.top, viewport.bottom - 1);
-    col = std::clamp(col + colOffset.Value, 0, bufferWidth - 1);
+    row = std::clamp(row + rowOffset.Value, page.Top(), page.Bottom() - 1);
+    col = std::clamp(col + colOffset.Value, 0, pageWidth - 1);
 
     // If the operation needs to be clamped inside the margins, or the origin
     // mode is relative (which always requires margin clamping), then the row
@@ -398,7 +398,7 @@ bool AdaptDispatch::_CursorMovePosition(const Offset rowOffset, const Offset col
     }
 
     // Finally, attempt to set the adjusted cursor position back into the console.
-    cursor.SetPosition(textBuffer.ClampPositionWithinLine({ col, row }));
+    cursor.SetPosition(page.Buffer().ClampPositionWithinLine({ col, row }));
     _ApplyCursorMovementFlags(cursor);
 
     return true;
@@ -490,29 +490,28 @@ bool AdaptDispatch::CursorPosition(const VTInt line, const VTInt column)
 bool AdaptDispatch::CursorSaveState()
 {
     // First retrieve some information about the buffer
-    const auto viewport = _api.GetViewport();
-    const auto& textBuffer = _api.GetTextBuffer();
-    const auto& attributes = textBuffer.GetCurrentAttributes();
+    const auto page = _pages.ActivePage();
 
     // The cursor is given to us by the API as relative to the whole buffer.
-    // But in VT speak, the cursor row should be relative to the current viewport top.
-    auto cursorPosition = textBuffer.GetCursor().GetPosition();
-    cursorPosition.y -= viewport.top;
+    // But in VT speak, the cursor row should be relative to the current page top.
+    auto cursorPosition = page.Cursor().GetPosition();
+    cursorPosition.y -= page.Top();
 
     // Although if origin mode is set, the cursor is relative to the margin origin.
     if (_modes.test(Mode::Origin))
     {
-        cursorPosition.x -= _GetHorizontalMargins(textBuffer.GetSize().Width()).first;
-        cursorPosition.y -= _GetVerticalMargins(viewport, false).first;
+        cursorPosition.x -= _GetHorizontalMargins(page.Width()).first;
+        cursorPosition.y -= _GetVerticalMargins(page, false).first;
     }
 
     // VT is also 1 based, not 0 based, so correct by 1.
     auto& savedCursorState = _savedCursorState.at(_usingAltBuffer);
     savedCursorState.Column = cursorPosition.x + 1;
     savedCursorState.Row = cursorPosition.y + 1;
-    savedCursorState.IsDelayedEOLWrap = textBuffer.GetCursor().IsDelayedEOLWrap();
+    savedCursorState.Page = page.Number();
+    savedCursorState.IsDelayedEOLWrap = page.Cursor().IsDelayedEOLWrap();
     savedCursorState.IsOriginModeRelative = _modes.test(Mode::Origin);
-    savedCursorState.Attributes = attributes;
+    savedCursorState.Attributes = page.Attributes();
     savedCursorState.TermOutput = _termOutput;
 
     return true;
@@ -533,17 +532,21 @@ bool AdaptDispatch::CursorRestoreState()
     // Restore the origin mode first, since the cursor coordinates may be relative.
     _modes.set(Mode::Origin, savedCursorState.IsOriginModeRelative);
 
+    // Restore the page number.
+    PagePositionAbsolute(savedCursorState.Page);
+
     // We can then restore the position with a standard CUP operation.
     CursorPosition(savedCursorState.Row, savedCursorState.Column);
 
     // If the delayed wrap flag was set when the cursor was saved, we need to restore that now.
+    const auto page = _pages.ActivePage();
     if (savedCursorState.IsDelayedEOLWrap)
     {
-        _api.GetTextBuffer().GetCursor().DelayEOLWrap();
+        page.Cursor().DelayEOLWrap();
     }
 
     // Restore text attributes.
-    _api.SetTextAttributes(savedCursorState.Attributes);
+    page.SetAttributes(savedCursorState.Attributes, &_api);
 
     // Restore designated character sets.
     _termOutput.RestoreFrom(savedCursorState.TermOutput);
@@ -556,10 +559,10 @@ bool AdaptDispatch::CursorRestoreState()
 //   the Erase Color mode is set, we use the default attributes, but when reset,
 //   we use the active color attributes with the character attributes cleared.
 // Arguments:
-// - textBuffer - Target buffer that is being erased.
+// - page - Target page that is being erased.
 // Return Value:
 // - The erase TextAttribute value.
-TextAttribute AdaptDispatch::_GetEraseAttributes(const TextBuffer& textBuffer) const noexcept
+TextAttribute AdaptDispatch::_GetEraseAttributes(const Page& page) const noexcept
 {
     if (_modes.test(Mode::EraseColor))
     {
@@ -567,7 +570,7 @@ TextAttribute AdaptDispatch::_GetEraseAttributes(const TextBuffer& textBuffer) c
     }
     else
     {
-        auto eraseAttributes = textBuffer.GetCurrentAttributes();
+        auto eraseAttributes = page.Attributes();
         eraseAttributes.SetStandardErase();
         return eraseAttributes;
     }
@@ -576,13 +579,14 @@ TextAttribute AdaptDispatch::_GetEraseAttributes(const TextBuffer& textBuffer) c
 // Routine Description:
 // - Scrolls an area of the buffer in a vertical direction.
 // Arguments:
-// - textBuffer - Target buffer to be scrolled.
-// - fillRect - Area of the buffer that will be affected.
+// - page - Target page to be scrolled.
+// - fillRect - Area of the page that will be affected.
 // - delta - Distance to move (positive is down, negative is up).
 // Return Value:
 // - <none>
-void AdaptDispatch::_ScrollRectVertically(TextBuffer& textBuffer, const til::rect& scrollRect, const VTInt delta)
+void AdaptDispatch::_ScrollRectVertically(const Page& page, const til::rect& scrollRect, const VTInt delta)
 {
+    auto& textBuffer = page.Buffer();
     const auto absoluteDelta = std::min(std::abs(delta), scrollRect.height());
     if (absoluteDelta < scrollRect.height())
     {
@@ -590,7 +594,7 @@ void AdaptDispatch::_ScrollRectVertically(TextBuffer& textBuffer, const til::rec
         const auto width = scrollRect.width();
         const auto height = scrollRect.height() - absoluteDelta;
         const auto actualDelta = delta > 0 ? absoluteDelta : -absoluteDelta;
-        if (width == textBuffer.GetSize().Width())
+        if (width == page.Width())
         {
             // If the scrollRect is the full width of the buffer, we can scroll
             // more efficiently by rotating the row storage.
@@ -621,8 +625,8 @@ void AdaptDispatch::_ScrollRectVertically(TextBuffer& textBuffer, const til::rec
     auto eraseRect = scrollRect;
     eraseRect.top = delta > 0 ? scrollRect.top : (scrollRect.bottom - absoluteDelta);
     eraseRect.bottom = eraseRect.top + absoluteDelta;
-    const auto eraseAttributes = _GetEraseAttributes(textBuffer);
-    _FillRect(textBuffer, eraseRect, whitespace, eraseAttributes);
+    const auto eraseAttributes = _GetEraseAttributes(page);
+    _FillRect(page, eraseRect, whitespace, eraseAttributes);
 
     // Also reset the line rendition for the erased rows.
     textBuffer.ResetLineRenditionRange(eraseRect.top, eraseRect.bottom);
@@ -631,13 +635,14 @@ void AdaptDispatch::_ScrollRectVertically(TextBuffer& textBuffer, const til::rec
 // Routine Description:
 // - Scrolls an area of the buffer in a horizontal direction.
 // Arguments:
-// - textBuffer - Target buffer to be scrolled.
-// - fillRect - Area of the buffer that will be affected.
+// - page - Target page to be scrolled.
+// - fillRect - Area of the page that will be affected.
 // - delta - Distance to move (positive is right, negative is left).
 // Return Value:
 // - <none>
-void AdaptDispatch::_ScrollRectHorizontally(TextBuffer& textBuffer, const til::rect& scrollRect, const VTInt delta)
+void AdaptDispatch::_ScrollRectHorizontally(const Page& page, const til::rect& scrollRect, const VTInt delta)
 {
+    auto& textBuffer = page.Buffer();
     const auto absoluteDelta = std::min(std::abs(delta), scrollRect.width());
     if (absoluteDelta < scrollRect.width())
     {
@@ -669,8 +674,8 @@ void AdaptDispatch::_ScrollRectHorizontally(TextBuffer& textBuffer, const til::r
     auto eraseRect = scrollRect;
     eraseRect.left = delta > 0 ? scrollRect.left : (scrollRect.right - absoluteDelta);
     eraseRect.right = eraseRect.left + absoluteDelta;
-    const auto eraseAttributes = _GetEraseAttributes(textBuffer);
-    _FillRect(textBuffer, eraseRect, whitespace, eraseAttributes);
+    const auto eraseAttributes = _GetEraseAttributes(page);
+    _FillRect(page, eraseRect, whitespace, eraseAttributes);
 }
 
 // Routine Description:
@@ -682,20 +687,19 @@ void AdaptDispatch::_ScrollRectHorizontally(TextBuffer& textBuffer, const til::r
 // - <none>
 void AdaptDispatch::_InsertDeleteCharacterHelper(const VTInt delta)
 {
-    const auto viewport = _api.GetViewport();
-    auto& textBuffer = _api.GetTextBuffer();
-    const auto row = textBuffer.GetCursor().GetPosition().y;
-    const auto col = textBuffer.GetCursor().GetPosition().x;
-    const auto lineWidth = textBuffer.GetLineWidth(row);
-    const auto [topMargin, bottomMargin] = _GetVerticalMargins(viewport, true);
+    const auto page = _pages.ActivePage();
+    const auto row = page.Cursor().GetPosition().y;
+    const auto col = page.Cursor().GetPosition().x;
+    const auto lineWidth = page.Buffer().GetLineWidth(row);
+    const auto [topMargin, bottomMargin] = _GetVerticalMargins(page, true);
     const auto [leftMargin, rightMargin] = (row >= topMargin && row <= bottomMargin) ?
                                                _GetHorizontalMargins(lineWidth) :
                                                std::make_pair(0, lineWidth - 1);
     if (col >= leftMargin && col <= rightMargin)
     {
-        _ScrollRectHorizontally(textBuffer, { col, row, rightMargin + 1, row + 1 }, delta);
+        _ScrollRectHorizontally(page, { col, row, rightMargin + 1, row + 1 }, delta);
         // The ICH and DCH controls are expected to reset the delayed wrap flag.
-        textBuffer.GetCursor().ResetDelayEOLWrap();
+        page.Cursor().ResetDelayEOLWrap();
     }
 }
 
@@ -728,15 +732,15 @@ bool AdaptDispatch::DeleteCharacter(const VTInt count)
 // Routine Description:
 // - Fills an area of the buffer with a given character and attributes.
 // Arguments:
-// - textBuffer - Target buffer to be filled.
-// - fillRect - Area of the buffer that will be affected.
+// - page - Target page to be filled.
+// - fillRect - Area of the page that will be affected.
 // - fillChar - Character to be written to the buffer.
 // - fillAttrs - Attributes to be written to the buffer.
 // Return Value:
 // - <none>
-void AdaptDispatch::_FillRect(TextBuffer& textBuffer, const til::rect& fillRect, const std::wstring_view& fillChar, const TextAttribute& fillAttrs) const
+void AdaptDispatch::_FillRect(const Page& page, const til::rect& fillRect, const std::wstring_view& fillChar, const TextAttribute& fillAttrs) const
 {
-    textBuffer.FillRect(fillRect, fillChar, fillAttrs);
+    page.Buffer().FillRect(fillRect, fillChar, fillAttrs);
     _api.NotifyAccessibilityChange(fillRect);
 }
 
@@ -751,28 +755,28 @@ void AdaptDispatch::_FillRect(TextBuffer& textBuffer, const til::rect& fillRect,
 // - True.
 bool AdaptDispatch::EraseCharacters(const VTInt numChars)
 {
-    auto& textBuffer = _api.GetTextBuffer();
-    const auto row = textBuffer.GetCursor().GetPosition().y;
-    const auto startCol = textBuffer.GetCursor().GetPosition().x;
-    const auto endCol = std::min<VTInt>(startCol + numChars, textBuffer.GetLineWidth(row));
+    const auto page = _pages.ActivePage();
+    const auto row = page.Cursor().GetPosition().y;
+    const auto startCol = page.Cursor().GetPosition().x;
+    const auto endCol = std::min<VTInt>(startCol + numChars, page.Buffer().GetLineWidth(row));
 
     // The ECH control is expected to reset the delayed wrap flag.
-    textBuffer.GetCursor().ResetDelayEOLWrap();
+    page.Cursor().ResetDelayEOLWrap();
 
-    const auto eraseAttributes = _GetEraseAttributes(textBuffer);
-    _FillRect(textBuffer, { startCol, row, endCol, row + 1 }, whitespace, eraseAttributes);
+    const auto eraseAttributes = _GetEraseAttributes(page);
+    _FillRect(page, { startCol, row, endCol, row + 1 }, whitespace, eraseAttributes);
 
     return true;
 }
 
 // Routine Description:
-// - ED - Erases a portion of the current viewable area (viewport) of the console.
+// - ED - Erases a portion of the current page of the console.
 // Arguments:
 // - eraseType - Determines whether to erase:
 //      From beginning (top-left corner) to the cursor
 //      From cursor to end (bottom-right corner)
-//      The entire viewport area
-//      The scrollback (outside the viewport area)
+//      The entire page
+//      The scrollback (outside the page area)
 // Return Value:
 // - True if handled successfully. False otherwise.
 bool AdaptDispatch::EraseInDisplay(const DispatchTypes::EraseType eraseType)
@@ -783,7 +787,7 @@ bool AdaptDispatch::EraseInDisplay(const DispatchTypes::EraseType eraseType)
     // Scrollback clears erase everything in the "scrollback" of a *nix terminal
     //      Everything that's scrolled off the screen so far.
     // Or if it's an Erase All, then we also need to handle that specially
-    //      by moving the current contents of the viewport into the scrollback.
+    //      by moving the current contents of the page into the scrollback.
     if (eraseType == DispatchTypes::EraseType::Scrollback)
     {
         return _EraseScrollback();
@@ -793,18 +797,18 @@ bool AdaptDispatch::EraseInDisplay(const DispatchTypes::EraseType eraseType)
         return _EraseAll();
     }
 
-    const auto viewport = _api.GetViewport();
-    auto& textBuffer = _api.GetTextBuffer();
-    const auto bufferWidth = textBuffer.GetSize().Width();
-    const auto row = textBuffer.GetCursor().GetPosition().y;
-    const auto col = textBuffer.GetCursor().GetPosition().x;
+    const auto page = _pages.ActivePage();
+    auto& textBuffer = page.Buffer();
+    const auto pageWidth = page.Width();
+    const auto row = page.Cursor().GetPosition().y;
+    const auto col = page.Cursor().GetPosition().x;
 
     // The ED control is expected to reset the delayed wrap flag.
     // The special case variants above ("erase all" and "erase scrollback")
     // take care of that themselves when they set the cursor position.
-    textBuffer.GetCursor().ResetDelayEOLWrap();
+    page.Cursor().ResetDelayEOLWrap();
 
-    const auto eraseAttributes = _GetEraseAttributes(textBuffer);
+    const auto eraseAttributes = _GetEraseAttributes(page);
 
     // When erasing the display, every line that is erased in full should be
     // reset to single width. When erasing to the end, this could include
@@ -814,15 +818,15 @@ bool AdaptDispatch::EraseInDisplay(const DispatchTypes::EraseType eraseType)
     // the line is double width).
     if (eraseType == DispatchTypes::EraseType::FromBeginning)
     {
-        textBuffer.ResetLineRenditionRange(viewport.top, row);
-        _FillRect(textBuffer, { 0, viewport.top, bufferWidth, row }, whitespace, eraseAttributes);
-        _FillRect(textBuffer, { 0, row, col + 1, row + 1 }, whitespace, eraseAttributes);
+        textBuffer.ResetLineRenditionRange(page.Top(), row);
+        _FillRect(page, { 0, page.Top(), pageWidth, row }, whitespace, eraseAttributes);
+        _FillRect(page, { 0, row, col + 1, row + 1 }, whitespace, eraseAttributes);
     }
     if (eraseType == DispatchTypes::EraseType::ToEnd)
     {
-        textBuffer.ResetLineRenditionRange(col > 0 ? row + 1 : row, viewport.bottom);
-        _FillRect(textBuffer, { col, row, bufferWidth, row + 1 }, whitespace, eraseAttributes);
-        _FillRect(textBuffer, { 0, row + 1, bufferWidth, viewport.bottom }, whitespace, eraseAttributes);
+        textBuffer.ResetLineRenditionRange(col > 0 ? row + 1 : row, page.Bottom());
+        _FillRect(page, { col, row, pageWidth, row + 1 }, whitespace, eraseAttributes);
+        _FillRect(page, { 0, row + 1, pageWidth, page.Bottom() }, whitespace, eraseAttributes);
     }
 
     return true;
@@ -836,24 +840,25 @@ bool AdaptDispatch::EraseInDisplay(const DispatchTypes::EraseType eraseType)
 // - True if handled successfully. False otherwise.
 bool AdaptDispatch::EraseInLine(const DispatchTypes::EraseType eraseType)
 {
-    auto& textBuffer = _api.GetTextBuffer();
-    const auto row = textBuffer.GetCursor().GetPosition().y;
-    const auto col = textBuffer.GetCursor().GetPosition().x;
+    const auto page = _pages.ActivePage();
+    const auto& textBuffer = page.Buffer();
+    const auto row = page.Cursor().GetPosition().y;
+    const auto col = page.Cursor().GetPosition().x;
 
     // The EL control is expected to reset the delayed wrap flag.
-    textBuffer.GetCursor().ResetDelayEOLWrap();
+    page.Cursor().ResetDelayEOLWrap();
 
-    const auto eraseAttributes = _GetEraseAttributes(textBuffer);
+    const auto eraseAttributes = _GetEraseAttributes(page);
     switch (eraseType)
     {
     case DispatchTypes::EraseType::FromBeginning:
-        _FillRect(textBuffer, { 0, row, col + 1, row + 1 }, whitespace, eraseAttributes);
+        _FillRect(page, { 0, row, col + 1, row + 1 }, whitespace, eraseAttributes);
         return true;
     case DispatchTypes::EraseType::ToEnd:
-        _FillRect(textBuffer, { col, row, textBuffer.GetLineWidth(row), row + 1 }, whitespace, eraseAttributes);
+        _FillRect(page, { col, row, textBuffer.GetLineWidth(row), row + 1 }, whitespace, eraseAttributes);
         return true;
     case DispatchTypes::EraseType::All:
-        _FillRect(textBuffer, { 0, row, textBuffer.GetLineWidth(row), row + 1 }, whitespace, eraseAttributes);
+        _FillRect(page, { 0, row, textBuffer.GetLineWidth(row), row + 1 }, whitespace, eraseAttributes);
         return true;
     default:
         return false;
@@ -863,17 +868,17 @@ bool AdaptDispatch::EraseInLine(const DispatchTypes::EraseType eraseType)
 // Routine Description:
 // - Selectively erases unprotected cells in an area of the buffer.
 // Arguments:
-// - textBuffer - Target buffer to be erased.
-// - eraseRect - Area of the buffer that will be affected.
+// - page - Target page to be erased.
+// - eraseRect - Area of the page that will be affected.
 // Return Value:
 // - <none>
-void AdaptDispatch::_SelectiveEraseRect(TextBuffer& textBuffer, const til::rect& eraseRect)
+void AdaptDispatch::_SelectiveEraseRect(const Page& page, const til::rect& eraseRect)
 {
     if (eraseRect)
     {
         for (auto row = eraseRect.top; row < eraseRect.bottom; row++)
         {
-            auto& rowBuffer = textBuffer.GetMutableRowByOffset(row);
+            auto& rowBuffer = page.Buffer().GetMutableRowByOffset(row);
             for (auto col = eraseRect.left; col < eraseRect.right; col++)
             {
                 // Only unprotected cells are affected.
@@ -881,7 +886,7 @@ void AdaptDispatch::_SelectiveEraseRect(TextBuffer& textBuffer, const til::rect&
                 {
                     // The text is cleared but the attributes are left as is.
                     rowBuffer.ClearCell(col);
-                    textBuffer.TriggerRedraw(Viewport::FromCoord({ col, row }));
+                    page.Buffer().TriggerRedraw(Viewport::FromCoord({ col, row }));
                 }
             }
         }
@@ -890,37 +895,36 @@ void AdaptDispatch::_SelectiveEraseRect(TextBuffer& textBuffer, const til::rect&
 }
 
 // Routine Description:
-// - DECSED - Selectively erases unprotected cells in a portion of the viewport.
+// - DECSED - Selectively erases unprotected cells in a portion of the page.
 // Arguments:
 // - eraseType - Determines whether to erase:
 //      From beginning (top-left corner) to the cursor
 //      From cursor to end (bottom-right corner)
-//      The entire viewport area
+//      The entire page area
 // Return Value:
 // - True if handled successfully. False otherwise.
 bool AdaptDispatch::SelectiveEraseInDisplay(const DispatchTypes::EraseType eraseType)
 {
-    const auto viewport = _api.GetViewport();
-    auto& textBuffer = _api.GetTextBuffer();
-    const auto bufferWidth = textBuffer.GetSize().Width();
-    const auto row = textBuffer.GetCursor().GetPosition().y;
-    const auto col = textBuffer.GetCursor().GetPosition().x;
+    const auto page = _pages.ActivePage();
+    const auto pageWidth = page.Width();
+    const auto row = page.Cursor().GetPosition().y;
+    const auto col = page.Cursor().GetPosition().x;
 
     // The DECSED control is expected to reset the delayed wrap flag.
-    textBuffer.GetCursor().ResetDelayEOLWrap();
+    page.Cursor().ResetDelayEOLWrap();
 
     switch (eraseType)
     {
     case DispatchTypes::EraseType::FromBeginning:
-        _SelectiveEraseRect(textBuffer, { 0, viewport.top, bufferWidth, row });
-        _SelectiveEraseRect(textBuffer, { 0, row, col + 1, row + 1 });
+        _SelectiveEraseRect(page, { 0, page.Top(), pageWidth, row });
+        _SelectiveEraseRect(page, { 0, row, col + 1, row + 1 });
         return true;
     case DispatchTypes::EraseType::ToEnd:
-        _SelectiveEraseRect(textBuffer, { col, row, bufferWidth, row + 1 });
-        _SelectiveEraseRect(textBuffer, { 0, row + 1, bufferWidth, viewport.bottom });
+        _SelectiveEraseRect(page, { col, row, pageWidth, row + 1 });
+        _SelectiveEraseRect(page, { 0, row + 1, pageWidth, page.Bottom() });
         return true;
     case DispatchTypes::EraseType::All:
-        _SelectiveEraseRect(textBuffer, { 0, viewport.top, bufferWidth, viewport.bottom });
+        _SelectiveEraseRect(page, { 0, page.Top(), pageWidth, page.Bottom() });
         return true;
     default:
         return false;
@@ -938,23 +942,24 @@ bool AdaptDispatch::SelectiveEraseInDisplay(const DispatchTypes::EraseType erase
 // - True if handled successfully. False otherwise.
 bool AdaptDispatch::SelectiveEraseInLine(const DispatchTypes::EraseType eraseType)
 {
-    auto& textBuffer = _api.GetTextBuffer();
-    const auto row = textBuffer.GetCursor().GetPosition().y;
-    const auto col = textBuffer.GetCursor().GetPosition().x;
+    const auto page = _pages.ActivePage();
+    const auto& textBuffer = page.Buffer();
+    const auto row = page.Cursor().GetPosition().y;
+    const auto col = page.Cursor().GetPosition().x;
 
     // The DECSEL control is expected to reset the delayed wrap flag.
-    textBuffer.GetCursor().ResetDelayEOLWrap();
+    page.Cursor().ResetDelayEOLWrap();
 
     switch (eraseType)
     {
     case DispatchTypes::EraseType::FromBeginning:
-        _SelectiveEraseRect(textBuffer, { 0, row, col + 1, row + 1 });
+        _SelectiveEraseRect(page, { 0, row, col + 1, row + 1 });
         return true;
     case DispatchTypes::EraseType::ToEnd:
-        _SelectiveEraseRect(textBuffer, { col, row, textBuffer.GetLineWidth(row), row + 1 });
+        _SelectiveEraseRect(page, { col, row, textBuffer.GetLineWidth(row), row + 1 });
         return true;
     case DispatchTypes::EraseType::All:
-        _SelectiveEraseRect(textBuffer, { 0, row, textBuffer.GetLineWidth(row), row + 1 });
+        _SelectiveEraseRect(page, { 0, row, textBuffer.GetLineWidth(row), row + 1 });
         return true;
     default:
         return false;
@@ -964,18 +969,18 @@ bool AdaptDispatch::SelectiveEraseInLine(const DispatchTypes::EraseType eraseTyp
 // Routine Description:
 // - Changes the attributes of each cell in a rectangular area of the buffer.
 // Arguments:
-// - textBuffer - Target buffer to be changed.
-// - changeRect - A rectangular area of the buffer that will be affected.
+// - page - Target page to be changed.
+// - changeRect - A rectangular area of the page that will be affected.
 // - changeOps - Changes that will be applied to each of the attributes.
 // Return Value:
 // - <none>
-void AdaptDispatch::_ChangeRectAttributes(TextBuffer& textBuffer, const til::rect& changeRect, const ChangeOps& changeOps)
+void AdaptDispatch::_ChangeRectAttributes(const Page& page, const til::rect& changeRect, const ChangeOps& changeOps)
 {
     if (changeRect)
     {
         for (auto row = changeRect.top; row < changeRect.bottom; row++)
         {
-            auto& rowBuffer = textBuffer.GetMutableRowByOffset(row);
+            auto& rowBuffer = page.Buffer().GetMutableRowByOffset(row);
             for (auto col = changeRect.left; col < changeRect.right; col++)
             {
                 auto attr = rowBuffer.GetAttrByColumn(col);
@@ -998,7 +1003,7 @@ void AdaptDispatch::_ChangeRectAttributes(TextBuffer& textBuffer, const til::rec
                 rowBuffer.ReplaceAttributes(col, col + 1, attr);
             }
         }
-        textBuffer.TriggerRedraw(Viewport::FromExclusive(changeRect));
+        page.Buffer().TriggerRedraw(Viewport::FromExclusive(changeRect));
         _api.NotifyAccessibilityChange(changeRect);
     }
 }
@@ -1014,16 +1019,15 @@ void AdaptDispatch::_ChangeRectAttributes(TextBuffer& textBuffer, const til::rec
 // - <none>
 void AdaptDispatch::_ChangeRectOrStreamAttributes(const til::rect& changeArea, const ChangeOps& changeOps)
 {
-    auto& textBuffer = _api.GetTextBuffer();
-    const auto bufferSize = textBuffer.GetSize().Dimensions();
-    const auto changeRect = _CalculateRectArea(changeArea.top, changeArea.left, changeArea.bottom, changeArea.right, bufferSize);
+    const auto page = _pages.ActivePage();
+    const auto changeRect = _CalculateRectArea(page, changeArea.top, changeArea.left, changeArea.bottom, changeArea.right);
     const auto lineCount = changeRect.height();
 
     // If the change extent is rectangular, we can apply the change with a
     // single call. The same is true for a stream extent that is only one line.
     if (_modes.test(Mode::RectangularChangeExtent) || lineCount == 1)
     {
-        _ChangeRectAttributes(textBuffer, changeRect, changeOps);
+        _ChangeRectAttributes(page, changeRect, changeOps);
     }
     // If the stream extent is more than one line we require three passes. The
     // top line is altered from the left offset up to the end of the line. The
@@ -1032,10 +1036,10 @@ void AdaptDispatch::_ChangeRectOrStreamAttributes(const til::rect& changeArea, c
     // must be greater than the left, otherwise the operation is ignored.
     else if (lineCount > 1 && changeRect.right > changeRect.left)
     {
-        const auto bufferWidth = bufferSize.width;
-        _ChangeRectAttributes(textBuffer, { changeRect.origin(), til::size{ bufferWidth - changeRect.left, 1 } }, changeOps);
-        _ChangeRectAttributes(textBuffer, { { 0, changeRect.top + 1 }, til::size{ bufferWidth, lineCount - 2 } }, changeOps);
-        _ChangeRectAttributes(textBuffer, { { 0, changeRect.bottom - 1 }, til::size{ changeRect.right, 1 } }, changeOps);
+        const auto pageWidth = page.Width();
+        _ChangeRectAttributes(page, { changeRect.origin(), til::size{ pageWidth - changeRect.left, 1 } }, changeOps);
+        _ChangeRectAttributes(page, { { 0, changeRect.top + 1 }, til::size{ pageWidth, lineCount - 2 } }, changeOps);
+        _ChangeRectAttributes(page, { { 0, changeRect.bottom - 1 }, til::size{ changeRect.right, 1 } }, changeOps);
     }
 }
 
@@ -1043,25 +1047,26 @@ void AdaptDispatch::_ChangeRectOrStreamAttributes(const til::rect& changeArea, c
 // - Helper method to calculate the applicable buffer coordinates for use with
 //   the various rectangular area operations.
 // Arguments:
+// - page - The target page.
 // - top - The first row of the area.
 // - left - The first column of the area.
 // - bottom - The last row of the area (inclusive).
 // - right - The last column of the area (inclusive).
-// - bufferSize - The size of the target buffer.
 // Return value:
 // - An exclusive rect with the absolute buffer coordinates.
-til::rect AdaptDispatch::_CalculateRectArea(const VTInt top, const VTInt left, const VTInt bottom, const VTInt right, const til::size bufferSize)
+til::rect AdaptDispatch::_CalculateRectArea(const Page& page, const VTInt top, const VTInt left, const VTInt bottom, const VTInt right)
 {
-    const auto viewport = _api.GetViewport();
+    const auto pageWidth = page.Width();
+    const auto pageHeight = page.Height();
 
     // We start by calculating the margin offsets and maximum dimensions.
-    // If the origin mode isn't set, we use the viewport extent.
-    const auto [topMargin, bottomMargin] = _GetVerticalMargins(viewport, false);
-    const auto [leftMargin, rightMargin] = _GetHorizontalMargins(bufferSize.width);
+    // If the origin mode isn't set, we use the page extent.
+    const auto [topMargin, bottomMargin] = _GetVerticalMargins(page, false);
+    const auto [leftMargin, rightMargin] = _GetHorizontalMargins(pageWidth);
     const auto yOffset = _modes.test(Mode::Origin) ? topMargin : 0;
-    const auto yMaximum = _modes.test(Mode::Origin) ? bottomMargin + 1 : viewport.height();
+    const auto yMaximum = _modes.test(Mode::Origin) ? bottomMargin + 1 : pageHeight;
     const auto xOffset = _modes.test(Mode::Origin) ? leftMargin : 0;
-    const auto xMaximum = _modes.test(Mode::Origin) ? rightMargin + 1 : bufferSize.width;
+    const auto xMaximum = _modes.test(Mode::Origin) ? rightMargin + 1 : pageWidth;
 
     auto fillRect = til::inclusive_rect{};
     fillRect.left = left + xOffset;
@@ -1077,9 +1082,9 @@ til::rect AdaptDispatch::_CalculateRectArea(const VTInt top, const VTInt left, c
     fillRect.top = std::min(fillRect.top, yMaximum) - 1;
     fillRect.bottom = std::min(fillRect.bottom, yMaximum) - 1;
 
-    // To get absolute coordinates we offset with the viewport top.
-    fillRect.top += viewport.top;
-    fillRect.bottom += viewport.top;
+    // To get absolute coordinates we offset with the page top.
+    fillRect.top += page.Top();
+    fillRect.bottom += page.Top();
 
     return til::rect{ fillRect };
 }
@@ -1198,25 +1203,22 @@ bool AdaptDispatch::ReverseAttributesRectangularArea(const VTInt top, const VTIn
 // - left - The first column of the source area.
 // - bottom - The last row of the source area (inclusive).
 // - right - The last column of the source area (inclusive).
-// - page - The source page number (unused for now).
+// - page - The source page number.
 // - dstTop - The first row of the destination.
 // - dstLeft - The first column of the destination.
-// - dstPage - The destination page number (unused for now).
+// - dstPage - The destination page number.
 // Return Value:
 // - True.
-bool AdaptDispatch::CopyRectangularArea(const VTInt top, const VTInt left, const VTInt bottom, const VTInt right, const VTInt /*page*/, const VTInt dstTop, const VTInt dstLeft, const VTInt /*dstPage*/)
+bool AdaptDispatch::CopyRectangularArea(const VTInt top, const VTInt left, const VTInt bottom, const VTInt right, const VTInt page, const VTInt dstTop, const VTInt dstLeft, const VTInt dstPage)
 {
-    // GH#13892 We don't yet support the paging extension, so for now we ignore
-    // the page parameters. This is the same as if the maximum page count was 1.
-
-    auto& textBuffer = _api.GetTextBuffer();
-    const auto bufferSize = textBuffer.GetSize().Dimensions();
-    const auto srcRect = _CalculateRectArea(top, left, bottom, right, bufferSize);
+    const auto src = _pages.Get(page);
+    const auto dst = _pages.Get(dstPage);
+    const auto srcRect = _CalculateRectArea(src, top, left, bottom, right);
     const auto dstBottom = dstTop + srcRect.height() - 1;
     const auto dstRight = dstLeft + srcRect.width() - 1;
-    const auto dstRect = _CalculateRectArea(dstTop, dstLeft, dstBottom, dstRight, bufferSize);
+    const auto dstRect = _CalculateRectArea(dst, dstTop, dstLeft, dstBottom, dstRight);
 
-    if (dstRect && dstRect.origin() != srcRect.origin())
+    if (dstRect && (dstRect.origin() != srcRect.origin() || src.Number() != dst.Number()))
     {
         // If the source is bigger than the available space at the destination
         // it needs to be clipped, so we only care about the destination size.
@@ -1228,18 +1230,18 @@ bool AdaptDispatch::CopyRectangularArea(const VTInt top, const VTInt left, const
         // Note that we read two cells from the source before we start writing
         // to the target, so a two-cell DBCS character can't accidentally delete
         // itself when moving one cell horizontally.
-        auto next = OutputCell(*textBuffer.GetCellDataAt(srcPos));
+        auto next = OutputCell(*src.Buffer().GetCellDataAt(srcPos));
         do
         {
             const auto current = next;
             const auto currentSrcPos = srcPos;
             srcView.WalkInBounds(srcPos, walkDirection);
-            next = OutputCell(*textBuffer.GetCellDataAt(srcPos));
+            next = OutputCell(*src.Buffer().GetCellDataAt(srcPos));
             // If the source position is offscreen (which can occur on double
             // width lines), then we shouldn't copy anything to the destination.
-            if (currentSrcPos.x < textBuffer.GetLineWidth(currentSrcPos.y))
+            if (currentSrcPos.x < src.Buffer().GetLineWidth(currentSrcPos.y))
             {
-                textBuffer.WriteLine(OutputCellIterator({ &current, 1 }), dstPos);
+                dst.Buffer().WriteLine(OutputCellIterator({ &current, 1 }), dstPos);
             }
         } while (dstView.WalkInBounds(dstPos, walkDirection));
         _api.NotifyAccessibilityChange(dstRect);
@@ -1261,8 +1263,8 @@ bool AdaptDispatch::CopyRectangularArea(const VTInt top, const VTInt left, const
 // - True.
 bool AdaptDispatch::FillRectangularArea(const VTParameter ch, const VTInt top, const VTInt left, const VTInt bottom, const VTInt right)
 {
-    auto& textBuffer = _api.GetTextBuffer();
-    const auto fillRect = _CalculateRectArea(top, left, bottom, right, textBuffer.GetSize().Dimensions());
+    const auto page = _pages.ActivePage();
+    const auto fillRect = _CalculateRectArea(page, top, left, bottom, right);
 
     // The standard only allows for characters in the range of the GL and GR
     // character set tables, but we also support additional Unicode characters
@@ -1274,8 +1276,8 @@ bool AdaptDispatch::FillRectangularArea(const VTParameter ch, const VTInt top, c
     if (glChar || grChar || unicodeChar)
     {
         const auto fillChar = _termOutput.TranslateKey(gsl::narrow_cast<wchar_t>(charValue));
-        const auto& fillAttributes = textBuffer.GetCurrentAttributes();
-        _FillRect(textBuffer, fillRect, { &fillChar, 1 }, fillAttributes);
+        const auto& fillAttributes = page.Attributes();
+        _FillRect(page, fillRect, { &fillChar, 1 }, fillAttributes);
     }
 
     return true;
@@ -1293,10 +1295,10 @@ bool AdaptDispatch::FillRectangularArea(const VTParameter ch, const VTInt top, c
 // - True.
 bool AdaptDispatch::EraseRectangularArea(const VTInt top, const VTInt left, const VTInt bottom, const VTInt right)
 {
-    auto& textBuffer = _api.GetTextBuffer();
-    const auto eraseRect = _CalculateRectArea(top, left, bottom, right, textBuffer.GetSize().Dimensions());
-    const auto eraseAttributes = _GetEraseAttributes(textBuffer);
-    _FillRect(textBuffer, eraseRect, whitespace, eraseAttributes);
+    const auto page = _pages.ActivePage();
+    const auto eraseRect = _CalculateRectArea(page, top, left, bottom, right);
+    const auto eraseAttributes = _GetEraseAttributes(page);
+    _FillRect(page, eraseRect, whitespace, eraseAttributes);
     return true;
 }
 
@@ -1312,9 +1314,9 @@ bool AdaptDispatch::EraseRectangularArea(const VTInt top, const VTInt left, cons
 // - True.
 bool AdaptDispatch::SelectiveEraseRectangularArea(const VTInt top, const VTInt left, const VTInt bottom, const VTInt right)
 {
-    auto& textBuffer = _api.GetTextBuffer();
-    const auto eraseRect = _CalculateRectArea(top, left, bottom, right, textBuffer.GetSize().Dimensions());
-    _SelectiveEraseRect(textBuffer, eraseRect);
+    const auto page = _pages.ActivePage();
+    const auto eraseRect = _CalculateRectArea(page, top, left, bottom, right);
+    _SelectiveEraseRect(page, eraseRect);
     return true;
 }
 
@@ -1346,7 +1348,7 @@ bool AdaptDispatch::SelectAttributeChangeExtent(const DispatchTypes::ChangeExten
 //   the buffer memory.
 // Arguments:
 // - id - a numeric label used to identify the request.
-// - page - The page number (unused for now).
+// - page - The page number.
 // - top - The first row of the area.
 // - left - The first column of the area.
 // - bottom - The last row of the area (inclusive).
@@ -1359,7 +1361,9 @@ bool AdaptDispatch::RequestChecksumRectangularArea(const VTInt id, const VTInt p
     // If this feature is not enabled, we'll just report a zero checksum.
     if constexpr (Feature_VtChecksumReport::IsEnabled())
     {
-        if (page == 1)
+        // If the page number is 0, then we're meant to return a checksum of all
+        // of the pages, but we have no need for that, so we'll just return 0.
+        if (page != 0)
         {
             // As part of the checksum, we need to include the color indices of each
             // cell, and in the case of default colors, those indices come from the
@@ -1370,8 +1374,8 @@ bool AdaptDispatch::RequestChecksumRectangularArea(const VTInt id, const VTInt p
             defaultFgIndex = defaultFgIndex < 16 ? defaultFgIndex : 7;
             defaultBgIndex = defaultBgIndex < 16 ? defaultBgIndex : 0;
 
-            const auto& textBuffer = _api.GetTextBuffer();
-            const auto eraseRect = _CalculateRectArea(top, left, bottom, right, textBuffer.GetSize().Dimensions());
+            const auto target = _pages.Get(page);
+            const auto eraseRect = _CalculateRectArea(target, top, left, bottom, right);
             for (auto row = eraseRect.top; row < eraseRect.bottom; row++)
             {
                 for (auto col = eraseRect.left; col < eraseRect.right; col++)
@@ -1381,7 +1385,7 @@ bool AdaptDispatch::RequestChecksumRectangularArea(const VTInt id, const VTInt p
                     // predate Unicode, though, so we'd need a custom mapping table
                     // to lookup the correct checksums. Considering this is only for
                     // testing at the moment, that doesn't seem worth the effort.
-                    const auto cell = textBuffer.GetCellDataAt({ col, row });
+                    const auto cell = target.Buffer().GetCellDataAt({ col, row });
                     for (auto ch : cell->Chars())
                     {
                         // That said, I've made a special allowance for U+2426,
@@ -1431,14 +1435,14 @@ bool AdaptDispatch::SetLineRendition(const LineRendition rendition)
     // The line rendition can't be changed if left/right margins are allowed.
     if (!_modes.test(Mode::AllowDECSLRM))
     {
-        auto& textBuffer = _api.GetTextBuffer();
-        const auto eraseAttributes = _GetEraseAttributes(textBuffer);
-        textBuffer.SetCurrentLineRendition(rendition, eraseAttributes);
+        const auto page = _pages.ActivePage();
+        const auto eraseAttributes = _GetEraseAttributes(page);
+        page.Buffer().SetCurrentLineRendition(rendition, eraseAttributes);
         // There is some variation in how this was handled by the different DEC
         // terminals, but the STD 070 reference (on page D-13) makes it clear that
         // the delayed wrap (aka the Last Column Flag) was expected to be reset when
         // line rendition controls were executed.
-        textBuffer.GetCursor().ResetDelayEOLWrap();
+        page.Cursor().ResetDelayEOLWrap();
     }
     return true;
 }
@@ -1630,7 +1634,7 @@ void AdaptDispatch::_DeviceStatusReport(const wchar_t* parameters) const
 }
 
 // Routine Description:
-// - CPR and DECXCPR- Reports the current cursor position within the viewport,
+// - CPR and DECXCPR- Reports the current cursor position within the page,
 //   as well as the current page number if this is an extended report.
 // Arguments:
 // - extendedReport - Set to true if the report should include the page number
@@ -1638,32 +1642,30 @@ void AdaptDispatch::_DeviceStatusReport(const wchar_t* parameters) const
 // - <none>
 void AdaptDispatch::_CursorPositionReport(const bool extendedReport)
 {
-    const auto viewport = _api.GetViewport();
-    const auto& textBuffer = _api.GetTextBuffer();
+    const auto page = _pages.ActivePage();
 
     // First pull the cursor position relative to the entire buffer out of the console.
-    til::point cursorPosition{ textBuffer.GetCursor().GetPosition() };
+    til::point cursorPosition{ page.Cursor().GetPosition() };
 
-    // Now adjust it for its position in respect to the current viewport top.
-    cursorPosition.y -= viewport.top;
+    // Now adjust it for its position in respect to the current page top.
+    cursorPosition.y -= page.Top();
 
-    // NOTE: 1,1 is the top-left corner of the viewport in VT-speak, so add 1.
+    // NOTE: 1,1 is the top-left corner of the page in VT-speak, so add 1.
     cursorPosition.x++;
     cursorPosition.y++;
 
     // If the origin mode is set, the cursor is relative to the margin origin.
     if (_modes.test(Mode::Origin))
     {
-        cursorPosition.x -= _GetHorizontalMargins(textBuffer.GetSize().Width()).first;
-        cursorPosition.y -= _GetVerticalMargins(viewport, false).first;
+        cursorPosition.x -= _GetHorizontalMargins(page.Width()).first;
+        cursorPosition.y -= _GetVerticalMargins(page, false).first;
     }
 
     // Now send it back into the input channel of the console.
     if (extendedReport)
     {
-        // An extended report should also include the page number, but for now
-        // we hard-code it to 1, since we don't yet support paging (GH#13892).
-        const auto pageNumber = 1;
+        // An extended report also includes the page number.
+        const auto pageNumber = page.Number();
         const auto response = wil::str_printf<std::wstring>(L"\x1b[?%d;%d;%dR", cursorPosition.y, cursorPosition.x, pageNumber);
         _api.ReturnResponse(response);
     }
@@ -1711,12 +1713,10 @@ void AdaptDispatch::_MacroChecksumReport(const VTParameter id) const
 // - <none>
 void AdaptDispatch::_ScrollMovement(const VTInt delta)
 {
-    const auto viewport = _api.GetViewport();
-    auto& textBuffer = _api.GetTextBuffer();
-    const auto bufferWidth = textBuffer.GetSize().Width();
-    const auto [topMargin, bottomMargin] = _GetVerticalMargins(viewport, true);
-    const auto [leftMargin, rightMargin] = _GetHorizontalMargins(bufferWidth);
-    _ScrollRectVertically(textBuffer, { leftMargin, topMargin, rightMargin + 1, bottomMargin + 1 }, delta);
+    const auto page = _pages.ActivePage();
+    const auto [topMargin, bottomMargin] = _GetVerticalMargins(page, true);
+    const auto [leftMargin, rightMargin] = _GetHorizontalMargins(page.Width());
+    _ScrollRectVertically(page, { leftMargin, topMargin, rightMargin + 1, bottomMargin + 1 }, delta);
 }
 
 // Routine Description:
@@ -1743,6 +1743,88 @@ bool AdaptDispatch::ScrollDown(const VTInt uiDistance)
     return true;
 }
 
+// Routine Description:
+// - NP - Moves the active position one or more pages ahead, and moves the
+//   cursor to home.
+// Arguments:
+// - pageCount - Number of pages to move
+// Return Value:
+// - True.
+bool AdaptDispatch::NextPage(const VTInt pageCount)
+{
+    PagePositionRelative(pageCount);
+    return CursorPosition(1, 1);
+}
+
+// Routine Description:
+// - PP - Moves the active position one or more pages back, and moves the
+//   cursor to home.
+// Arguments:
+// - pageCount - Number of pages to move
+// Return Value:
+// - True.
+bool AdaptDispatch::PrecedingPage(const VTInt pageCount)
+{
+    PagePositionBack(pageCount);
+    return CursorPosition(1, 1);
+}
+
+// Routine Description:
+// - PPA - Moves the active position to the specified page number, without
+//   altering the cursor coordinates.
+// Arguments:
+// - page - Destination page
+// Return Value:
+// - True.
+bool AdaptDispatch::PagePositionAbsolute(const VTInt page)
+{
+    _pages.MoveTo(page, _modes.test(Mode::PageCursorCoupling));
+    return true;
+}
+
+// Routine Description:
+// - PPR - Moves the active position one or more pages ahead, without altering
+//   the cursor coordinates.
+// Arguments:
+// - pageCount - Number of pages to move
+// Return Value:
+// - True.
+bool AdaptDispatch::PagePositionRelative(const VTInt pageCount)
+{
+    _pages.MoveRelative(pageCount, _modes.test(Mode::PageCursorCoupling));
+    return true;
+}
+
+// Routine Description:
+// - PPB - Moves the active position one or more pages back, without altering
+//   the cursor coordinates.
+// Arguments:
+// - pageCount - Number of pages to move
+// Return Value:
+// - True.
+bool AdaptDispatch::PagePositionBack(const VTInt pageCount)
+{
+    _pages.MoveRelative(-pageCount, _modes.test(Mode::PageCursorCoupling));
+    return true;
+}
+
+// Routine Description:
+// - DECRQDE - Requests the area of page memory that is currently visible.
+// Arguments:
+// - None
+// Return Value:
+// - True.
+bool AdaptDispatch::RequestDisplayedExtent()
+{
+    const auto page = _pages.VisiblePage();
+    const auto width = page.Viewport().width();
+    const auto height = page.Viewport().height();
+    const auto left = page.XPanOffset() + 1;
+    const auto top = page.YPanOffset() + 1;
+    _api.ReturnResponse(fmt::format(FMT_COMPILE(L"\033[{};{};{};{};{}\"w"), height, width, left, top, page.Number()));
+    return true;
+}
+
 // Routine Description:
 // - DECCOLM not only sets the number of columns, but also clears the screen buffer,
 //    resets the page margins and origin mode, and places the cursor at 1,1
@@ -1755,10 +1837,10 @@ void AdaptDispatch::_SetColumnMode(const bool enable)
     // Only proceed if DECCOLM is allowed. Return true, as this is technically a successful handling.
     if (_modes.test(Mode::AllowDECCOLM) && !_api.IsConsolePty())
     {
-        const auto viewport = _api.GetViewport();
-        const auto viewportHeight = viewport.bottom - viewport.top;
-        const auto viewportWidth = (enable ? DispatchTypes::s_sDECCOLMSetColumns : DispatchTypes::s_sDECCOLMResetColumns);
-        _api.ResizeWindow(viewportWidth, viewportHeight);
+        const auto page = _pages.VisiblePage();
+        const auto pageHeight = page.Height();
+        const auto pageWidth = (enable ? DispatchTypes::s_sDECCOLMSetColumns : DispatchTypes::s_sDECCOLMResetColumns);
+        _api.ResizeWindow(pageWidth, pageHeight);
         _modes.set(Mode::Column, enable);
         _modes.reset(Mode::Origin, Mode::AllowDECSLRM);
         CursorPosition(1, 1);
@@ -1781,8 +1863,8 @@ void AdaptDispatch::_SetAlternateScreenBufferMode(const bool enable)
     if (enable)
     {
         CursorSaveState();
-        const auto& textBuffer = _api.GetTextBuffer();
-        _api.UseAlternateScreenBuffer(_GetEraseAttributes(textBuffer));
+        const auto page = _pages.ActivePage();
+        _api.UseAlternateScreenBuffer(_GetEraseAttributes(page));
         _usingAltBuffer = true;
     }
     else
@@ -1860,21 +1942,28 @@ bool AdaptDispatch::_ModeParamsHelper(const DispatchTypes::ModeParams param, con
         // Resetting DECAWM should also reset the delayed wrap flag.
         if (!enable)
         {
-            _api.GetTextBuffer().GetCursor().ResetDelayEOLWrap();
+            _pages.ActivePage().Cursor().ResetDelayEOLWrap();
         }
         return true;
     case DispatchTypes::ModeParams::DECARM_AutoRepeatMode:
         _terminalInput.SetInputMode(TerminalInput::Mode::AutoRepeat, enable);
         return !_PassThroughInputModes();
     case DispatchTypes::ModeParams::ATT610_StartCursorBlink:
-        _api.GetTextBuffer().GetCursor().SetBlinkingAllowed(enable);
+        _pages.ActivePage().Cursor().SetBlinkingAllowed(enable);
         return !_api.IsConsolePty();
     case DispatchTypes::ModeParams::DECTCEM_TextCursorEnableMode:
-        _api.GetTextBuffer().GetCursor().SetIsVisible(enable);
+        _pages.ActivePage().Cursor().SetIsVisible(enable);
         return true;
     case DispatchTypes::ModeParams::XTERM_EnableDECCOLMSupport:
         _modes.set(Mode::AllowDECCOLM, enable);
         return true;
+    case DispatchTypes::ModeParams::DECPCCM_PageCursorCouplingMode:
+        _modes.set(Mode::PageCursorCoupling, enable);
+        if (enable)
+        {
+            _pages.MakeActivePageVisible();
+        }
+        return true;
     case DispatchTypes::ModeParams::DECNKM_NumericKeypadMode:
         _terminalInput.SetInputMode(TerminalInput::Mode::Keypad, enable);
         return !_PassThroughInputModes();
@@ -1887,9 +1976,8 @@ bool AdaptDispatch::_ModeParamsHelper(const DispatchTypes::ModeParams param, con
         if (enable)
         {
             // If we've allowed left/right margins, we can't have line renditions.
-            const auto viewport = _api.GetViewport();
-            auto& textBuffer = _api.GetTextBuffer();
-            textBuffer.ResetLineRenditionRange(viewport.top, viewport.bottom);
+            const auto page = _pages.ActivePage();
+            page.Buffer().ResetLineRenditionRange(page.Top(), page.Bottom());
         }
         return true;
     case DispatchTypes::ModeParams::DECECM_EraseColorMode:
@@ -2011,10 +2099,10 @@ bool AdaptDispatch::RequestMode(const DispatchTypes::ModeParams param)
         enabled = _terminalInput.GetInputMode(TerminalInput::Mode::AutoRepeat);
         break;
     case DispatchTypes::ModeParams::ATT610_StartCursorBlink:
-        enabled = _api.GetTextBuffer().GetCursor().IsBlinkingAllowed();
+        enabled = _pages.ActivePage().Cursor().IsBlinkingAllowed();
         break;
     case DispatchTypes::ModeParams::DECTCEM_TextCursorEnableMode:
-        enabled = _api.GetTextBuffer().GetCursor().IsVisible();
+        enabled = _pages.ActivePage().Cursor().IsVisible();
         break;
     case DispatchTypes::ModeParams::XTERM_EnableDECCOLMSupport:
         // DECCOLM is not supported in conpty mode
@@ -2023,6 +2111,9 @@ bool AdaptDispatch::RequestMode(const DispatchTypes::ModeParams param)
             enabled = _modes.test(Mode::AllowDECCOLM);
         }
         break;
+    case DispatchTypes::ModeParams::DECPCCM_PageCursorCouplingMode:
+        enabled = _modes.test(Mode::PageCursorCoupling);
+        break;
     case DispatchTypes::ModeParams::DECNKM_NumericKeypadMode:
         enabled = _terminalInput.GetInputMode(TerminalInput::Mode::Keypad);
         break;
@@ -2100,20 +2191,17 @@ bool AdaptDispatch::SetKeypadMode(const bool fApplicationMode)
 // - <none>
 void AdaptDispatch::_InsertDeleteLineHelper(const VTInt delta)
 {
-    const auto viewport = _api.GetViewport();
-    auto& textBuffer = _api.GetTextBuffer();
-    const auto bufferWidth = textBuffer.GetSize().Width();
-
-    auto& cursor = textBuffer.GetCursor();
+    const auto page = _pages.ActivePage();
+    auto& cursor = page.Cursor();
     const auto col = cursor.GetPosition().x;
     const auto row = cursor.GetPosition().y;
 
-    const auto [topMargin, bottomMargin] = _GetVerticalMargins(viewport, true);
-    const auto [leftMargin, rightMargin] = _GetHorizontalMargins(bufferWidth);
+    const auto [topMargin, bottomMargin] = _GetVerticalMargins(page, true);
+    const auto [leftMargin, rightMargin] = _GetHorizontalMargins(page.Width());
     if (row >= topMargin && row <= bottomMargin && col >= leftMargin && col <= rightMargin)
     {
         // We emulate inserting and deleting by scrolling the area between the cursor and the bottom margin.
-        _ScrollRectVertically(textBuffer, { leftMargin, row, rightMargin + 1, bottomMargin + 1 }, delta);
+        _ScrollRectVertically(page, { leftMargin, row, rightMargin + 1, bottomMargin + 1 }, delta);
 
         // The IL and DL controls are also expected to move the cursor to the left margin.
         cursor.SetXPosition(leftMargin);
@@ -2161,20 +2249,17 @@ bool AdaptDispatch::DeleteLine(const VTInt distance)
 // - <none>
 void AdaptDispatch::_InsertDeleteColumnHelper(const VTInt delta)
 {
-    const auto viewport = _api.GetViewport();
-    auto& textBuffer = _api.GetTextBuffer();
-    const auto bufferWidth = textBuffer.GetSize().Width();
-
-    const auto& cursor = textBuffer.GetCursor();
+    const auto page = _pages.ActivePage();
+    const auto& cursor = page.Cursor();
     const auto col = cursor.GetPosition().x;
     const auto row = cursor.GetPosition().y;
 
-    const auto [topMargin, bottomMargin] = _GetVerticalMargins(viewport, true);
-    const auto [leftMargin, rightMargin] = _GetHorizontalMargins(bufferWidth);
+    const auto [topMargin, bottomMargin] = _GetVerticalMargins(page, true);
+    const auto [leftMargin, rightMargin] = _GetHorizontalMargins(page.Width());
     if (row >= topMargin && row <= bottomMargin && col >= leftMargin && col <= rightMargin)
     {
         // We emulate inserting and deleting by scrolling the area between the cursor and the right margin.
-        _ScrollRectHorizontally(textBuffer, { col, topMargin, rightMargin + 1, bottomMargin + 1 }, delta);
+        _ScrollRectHorizontally(page, { col, topMargin, rightMargin + 1, bottomMargin + 1 }, delta);
     }
 }
 
@@ -2246,23 +2331,23 @@ void AdaptDispatch::_DoSetTopBottomScrollingMargins(const VTInt topMargin,
     til::CoordType actualTop = topMargin;
     til::CoordType actualBottom = bottomMargin;
 
-    const auto viewport = _api.GetViewport();
-    const auto screenHeight = viewport.bottom - viewport.top;
+    const auto page = _pages.ActivePage();
+    const auto pageHeight = page.Height();
     // The default top margin is line 1
     if (actualTop == 0)
     {
         actualTop = 1;
     }
-    // The default bottom margin is the screen height
+    // The default bottom margin is the page height
     if (actualBottom == 0)
     {
-        actualBottom = screenHeight;
+        actualBottom = pageHeight;
     }
     // The top margin must be less than the bottom margin, and the
-    // bottom margin must be less than or equal to the screen height
-    if (actualTop < actualBottom && actualBottom <= screenHeight)
+    // bottom margin must be less than or equal to the page height
+    if (actualTop < actualBottom && actualBottom <= pageHeight)
     {
-        if (actualTop == 1 && actualBottom == screenHeight)
+        if (actualTop == 1 && actualBottom == pageHeight)
         {
             // Client requests setting margins to the entire screen
             //    - clear them instead of setting them.
@@ -2323,23 +2408,23 @@ void AdaptDispatch::_DoSetLeftRightScrollingMargins(const VTInt leftMargin,
     til::CoordType actualLeft = leftMargin;
     til::CoordType actualRight = rightMargin;
 
-    const auto& textBuffer = _api.GetTextBuffer();
-    const auto bufferWidth = textBuffer.GetSize().Width();
+    const auto page = _pages.ActivePage();
+    const auto pageWidth = page.Width();
     // The default left margin is column 1
     if (actualLeft == 0)
     {
         actualLeft = 1;
     }
-    // The default right margin is the buffer width
+    // The default right margin is the page width
     if (actualRight == 0)
     {
-        actualRight = bufferWidth;
+        actualRight = pageWidth;
     }
     // The left margin must be less than the right margin, and the
     // right margin must be less than or equal to the buffer width
-    if (actualLeft < actualRight && actualRight <= bufferWidth)
+    if (actualLeft < actualRight && actualRight <= pageWidth)
     {
-        if (actualLeft == 1 && actualRight == bufferWidth)
+        if (actualLeft == 1 && actualRight == pageWidth)
         {
             // Client requests setting margins to the entire screen
             //    - clear them instead of setting them.
@@ -2416,20 +2501,20 @@ bool AdaptDispatch::CarriageReturn()
 // Routine Description:
 // - Helper method for executing a line feed, possibly preceded by carriage return.
 // Arguments:
-// - textBuffer - Target buffer on which the line feed is executed.
+// - page - Target page on which the line feed is executed.
 // - withReturn - Set to true if a carriage return should be performed as well.
 // - wrapForced - Set to true is the line feed was the result of the line wrapping.
 // Return Value:
 // - <none>
-void AdaptDispatch::_DoLineFeed(TextBuffer& textBuffer, const bool withReturn, const bool wrapForced)
+void AdaptDispatch::_DoLineFeed(const Page& page, const bool withReturn, const bool wrapForced)
 {
-    const auto viewport = _api.GetViewport();
-    const auto bufferWidth = textBuffer.GetSize().Width();
-    const auto bufferHeight = textBuffer.GetSize().Height();
-    const auto [topMargin, bottomMargin] = _GetVerticalMargins(viewport, true);
-    const auto [leftMargin, rightMargin] = _GetHorizontalMargins(bufferWidth);
+    auto& textBuffer = page.Buffer();
+    const auto pageWidth = page.Width();
+    const auto bufferHeight = page.BufferHeight();
+    const auto [topMargin, bottomMargin] = _GetVerticalMargins(page, true);
+    const auto [leftMargin, rightMargin] = _GetHorizontalMargins(pageWidth);
 
-    auto& cursor = textBuffer.GetCursor();
+    auto& cursor = page.Cursor();
     const auto currentPosition = cursor.GetPosition();
     auto newPosition = currentPosition;
 
@@ -2451,36 +2536,36 @@ void AdaptDispatch::_DoLineFeed(TextBuffer& textBuffer, const bool withReturn, c
     {
         // If we're not at the bottom margin, or outside the horizontal margins,
         // then there's no scrolling, so we make sure we don't move past the
-        // bottom of the viewport.
-        newPosition.y = std::min(currentPosition.y + 1, viewport.bottom - 1);
+        // bottom of the page.
+        newPosition.y = std::min(currentPosition.y + 1, page.Bottom() - 1);
         newPosition = textBuffer.ClampPositionWithinLine(newPosition);
     }
-    else if (topMargin > viewport.top || leftMargin > 0 || rightMargin < bufferWidth - 1)
+    else if (topMargin > page.Top() || leftMargin > 0 || rightMargin < pageWidth - 1)
     {
-        // If the top margin isn't at the top of the viewport, or the
+        // If the top margin isn't at the top of the page, or the
         // horizontal margins are set, then we're just scrolling the margin
         // area and the cursor stays where it is.
-        _ScrollRectVertically(textBuffer, { leftMargin, topMargin, rightMargin + 1, bottomMargin + 1 }, -1);
+        _ScrollRectVertically(page, { leftMargin, topMargin, rightMargin + 1, bottomMargin + 1 }, -1);
     }
-    else if (viewport.bottom < bufferHeight)
+    else if (page.Bottom() < bufferHeight)
     {
-        // If the top margin is at the top of the viewport, then we'll scroll
+        // If the top margin is at the top of the page, then we'll scroll
         // the content up by panning the viewport down, and also move the cursor
         // down a row. But we only do this if the viewport hasn't yet reached
         // the end of the buffer.
-        _api.SetViewportPosition({ viewport.left, viewport.top + 1 });
+        _api.SetViewportPosition({ page.XPanOffset(), page.Top() + 1 });
         newPosition.y++;
 
-        // And if the bottom margin didn't cover the full viewport, we copy the
-        // lower part of the viewport down so it remains static. But for a full
+        // And if the bottom margin didn't cover the full page, we copy the
+        // lower part of the page down so it remains static. But for a full
         // pan we reset the newly revealed row with the erase attributes.
-        if (bottomMargin < viewport.bottom - 1)
+        if (bottomMargin < page.Bottom() - 1)
         {
-            _ScrollRectVertically(textBuffer, { 0, bottomMargin + 1, bufferWidth, viewport.bottom + 1 }, 1);
+            _ScrollRectVertically(page, { 0, bottomMargin + 1, pageWidth, page.Bottom() + 1 }, 1);
         }
         else
         {
-            const auto eraseAttributes = _GetEraseAttributes(textBuffer);
+            const auto eraseAttributes = _GetEraseAttributes(page);
             textBuffer.GetMutableRowByOffset(newPosition.y).Reset(eraseAttributes);
         }
     }
@@ -2489,7 +2574,7 @@ void AdaptDispatch::_DoLineFeed(TextBuffer& textBuffer, const bool withReturn, c
         // If the viewport has reached the end of the buffer, we can't pan down,
         // so we cycle the row coordinates, which effectively scrolls the buffer
         // content up. In this case we don't need to move the cursor down.
-        const auto eraseAttributes = _GetEraseAttributes(textBuffer);
+        const auto eraseAttributes = _GetEraseAttributes(page);
         textBuffer.IncrementCircularBuffer(eraseAttributes);
         _api.NotifyBufferRotation(1);
 
@@ -2499,11 +2584,11 @@ void AdaptDispatch::_DoLineFeed(TextBuffer& textBuffer, const bool withReturn, c
         cursor.SetIsOn(false);
         textBuffer.TriggerScroll({ 0, -1 });
 
-        // And again, if the bottom margin didn't cover the full viewport, we
-        // copy the lower part of the viewport down so it remains static.
-        if (bottomMargin < viewport.bottom - 1)
+        // And again, if the bottom margin didn't cover the full page, we
+        // copy the lower part of the page down so it remains static.
+        if (bottomMargin < page.Bottom() - 1)
         {
-            _ScrollRectVertically(textBuffer, { 0, bottomMargin, bufferWidth, bufferHeight }, 1);
+            _ScrollRectVertically(page, { 0, bottomMargin, pageWidth, bufferHeight }, 1);
         }
     }
 
@@ -2520,17 +2605,17 @@ void AdaptDispatch::_DoLineFeed(TextBuffer& textBuffer, const bool withReturn, c
 // - True if handled successfully. False otherwise.
 bool AdaptDispatch::LineFeed(const DispatchTypes::LineFeedType lineFeedType)
 {
-    auto& textBuffer = _api.GetTextBuffer();
+    const auto page = _pages.ActivePage();
     switch (lineFeedType)
     {
     case DispatchTypes::LineFeedType::DependsOnMode:
-        _DoLineFeed(textBuffer, _api.GetSystemMode(ITerminalApi::Mode::LineFeed), false);
+        _DoLineFeed(page, _api.GetSystemMode(ITerminalApi::Mode::LineFeed), false);
         return true;
     case DispatchTypes::LineFeedType::WithoutReturn:
-        _DoLineFeed(textBuffer, false, false);
+        _DoLineFeed(page, false, false);
         return true;
     case DispatchTypes::LineFeedType::WithReturn:
-        _DoLineFeed(textBuffer, true, false);
+        _DoLineFeed(page, true, false);
         return true;
     default:
         return false;
@@ -2546,23 +2631,22 @@ bool AdaptDispatch::LineFeed(const DispatchTypes::LineFeedType lineFeedType)
 // - True.
 bool AdaptDispatch::ReverseLineFeed()
 {
-    const auto viewport = _api.GetViewport();
-    auto& textBuffer = _api.GetTextBuffer();
-    auto& cursor = textBuffer.GetCursor();
+    const auto page = _pages.ActivePage();
+    const auto& textBuffer = page.Buffer();
+    auto& cursor = page.Cursor();
     const auto cursorPosition = cursor.GetPosition();
-    const auto bufferWidth = textBuffer.GetSize().Width();
-    const auto [leftMargin, rightMargin] = _GetHorizontalMargins(bufferWidth);
-    const auto [topMargin, bottomMargin] = _GetVerticalMargins(viewport, true);
+    const auto [leftMargin, rightMargin] = _GetHorizontalMargins(page.Width());
+    const auto [topMargin, bottomMargin] = _GetVerticalMargins(page, true);
 
     // If the cursor is at the top of the margin area, we shift the buffer
     // contents down, to emulate inserting a line at that point.
     if (cursorPosition.y == topMargin && cursorPosition.x >= leftMargin && cursorPosition.x <= rightMargin)
     {
-        _ScrollRectVertically(textBuffer, { leftMargin, topMargin, rightMargin + 1, bottomMargin + 1 }, 1);
+        _ScrollRectVertically(page, { leftMargin, topMargin, rightMargin + 1, bottomMargin + 1 }, 1);
     }
-    else if (cursorPosition.y > viewport.top)
+    else if (cursorPosition.y > page.Top())
     {
-        // Otherwise we move the cursor up, but not past the top of the viewport.
+        // Otherwise we move the cursor up, but not past the top of the page.
         cursor.SetPosition(textBuffer.ClampPositionWithinLine({ cursorPosition.x, cursorPosition.y - 1 }));
         _ApplyCursorMovementFlags(cursor);
     }
@@ -2578,18 +2662,16 @@ bool AdaptDispatch::ReverseLineFeed()
 // - True.
 bool AdaptDispatch::BackIndex()
 {
-    const auto viewport = _api.GetViewport();
-    auto& textBuffer = _api.GetTextBuffer();
-    auto& cursor = textBuffer.GetCursor();
+    const auto page = _pages.ActivePage();
+    auto& cursor = page.Cursor();
     const auto cursorPosition = cursor.GetPosition();
-    const auto bufferWidth = textBuffer.GetSize().Width();
-    const auto [leftMargin, rightMargin] = _GetHorizontalMargins(bufferWidth);
-    const auto [topMargin, bottomMargin] = _GetVerticalMargins(viewport, true);
+    const auto [leftMargin, rightMargin] = _GetHorizontalMargins(page.Width());
+    const auto [topMargin, bottomMargin] = _GetVerticalMargins(page, true);
 
     // If the cursor is at the left of the margin area, we shift the buffer right.
     if (cursorPosition.x == leftMargin && cursorPosition.y >= topMargin && cursorPosition.y <= bottomMargin)
     {
-        _ScrollRectHorizontally(textBuffer, { leftMargin, topMargin, rightMargin + 1, bottomMargin + 1 }, 1);
+        _ScrollRectHorizontally(page, { leftMargin, topMargin, rightMargin + 1, bottomMargin + 1 }, 1);
     }
     // Otherwise we move the cursor left, but not past the start of the line.
     else if (cursorPosition.x > 0)
@@ -2609,21 +2691,19 @@ bool AdaptDispatch::BackIndex()
 // - True.
 bool AdaptDispatch::ForwardIndex()
 {
-    const auto viewport = _api.GetViewport();
-    auto& textBuffer = _api.GetTextBuffer();
-    auto& cursor = textBuffer.GetCursor();
+    const auto page = _pages.ActivePage();
+    auto& cursor = page.Cursor();
     const auto cursorPosition = cursor.GetPosition();
-    const auto bufferWidth = textBuffer.GetSize().Width();
-    const auto [leftMargin, rightMargin] = _GetHorizontalMargins(bufferWidth);
-    const auto [topMargin, bottomMargin] = _GetVerticalMargins(viewport, true);
+    const auto [leftMargin, rightMargin] = _GetHorizontalMargins(page.Width());
+    const auto [topMargin, bottomMargin] = _GetVerticalMargins(page, true);
 
     // If the cursor is at the right of the margin area, we shift the buffer left.
     if (cursorPosition.x == rightMargin && cursorPosition.y >= topMargin && cursorPosition.y <= bottomMargin)
     {
-        _ScrollRectHorizontally(textBuffer, { leftMargin, topMargin, rightMargin + 1, bottomMargin + 1 }, -1);
+        _ScrollRectHorizontally(page, { leftMargin, topMargin, rightMargin + 1, bottomMargin + 1 }, -1);
     }
     // Otherwise we move the cursor right, but not past the end of the line.
-    else if (cursorPosition.x < textBuffer.GetLineWidth(cursorPosition.y) - 1)
+    else if (cursorPosition.x < page.Buffer().GetLineWidth(cursorPosition.y) - 1)
     {
         cursor.SetXPosition(cursorPosition.x + 1);
         _ApplyCursorMovementFlags(cursor);
@@ -2651,11 +2731,10 @@ bool AdaptDispatch::SetWindowTitle(std::wstring_view title)
 // - True.
 bool AdaptDispatch::HorizontalTabSet()
 {
-    const auto& textBuffer = _api.GetTextBuffer();
-    const auto width = textBuffer.GetSize().Dimensions().width;
-    const auto column = textBuffer.GetCursor().GetPosition().x;
+    const auto page = _pages.ActivePage();
+    const auto column = page.Cursor().GetPosition().x;
 
-    _InitTabStopsForWidth(width);
+    _InitTabStopsForWidth(page.Width());
     _tabStopColumns.at(column) = true;
 
     return true;
@@ -2672,15 +2751,14 @@ bool AdaptDispatch::HorizontalTabSet()
 // - True.
 bool AdaptDispatch::ForwardTab(const VTInt numTabs)
 {
-    auto& textBuffer = _api.GetTextBuffer();
-    auto& cursor = textBuffer.GetCursor();
+    const auto page = _pages.ActivePage();
+    auto& cursor = page.Cursor();
     auto column = cursor.GetPosition().x;
     const auto row = cursor.GetPosition().y;
-    const auto width = textBuffer.GetLineWidth(row);
+    const auto width = page.Buffer().GetLineWidth(row);
     auto tabsPerformed = 0;
 
-    const auto viewport = _api.GetViewport();
-    const auto [topMargin, bottomMargin] = _GetVerticalMargins(viewport, true);
+    const auto [topMargin, bottomMargin] = _GetVerticalMargins(page, true);
     const auto [leftMargin, rightMargin] = _GetHorizontalMargins(width);
     const auto clampToMargin = row >= topMargin && row <= bottomMargin && column <= rightMargin;
     const auto maxColumn = clampToMargin ? rightMargin : width - 1;
@@ -2721,15 +2799,14 @@ bool AdaptDispatch::ForwardTab(const VTInt numTabs)
 // - True.
 bool AdaptDispatch::BackwardsTab(const VTInt numTabs)
 {
-    auto& textBuffer = _api.GetTextBuffer();
-    auto& cursor = textBuffer.GetCursor();
+    const auto page = _pages.ActivePage();
+    auto& cursor = page.Cursor();
     auto column = cursor.GetPosition().x;
     const auto row = cursor.GetPosition().y;
-    const auto width = textBuffer.GetLineWidth(row);
+    const auto width = page.Buffer().GetLineWidth(row);
     auto tabsPerformed = 0;
 
-    const auto viewport = _api.GetViewport();
-    const auto [topMargin, bottomMargin] = _GetVerticalMargins(viewport, true);
+    const auto [topMargin, bottomMargin] = _GetVerticalMargins(page, true);
     const auto [leftMargin, rightMargin] = _GetHorizontalMargins(width);
     const auto clampToMargin = row >= topMargin && row <= bottomMargin && column >= leftMargin;
     const auto minColumn = clampToMargin ? leftMargin : 0;
@@ -2780,11 +2857,10 @@ bool AdaptDispatch::TabClear(const DispatchTypes::TabClearType clearType)
 // - <none>
 void AdaptDispatch::_ClearSingleTabStop()
 {
-    const auto& textBuffer = _api.GetTextBuffer();
-    const auto width = textBuffer.GetSize().Dimensions().width;
-    const auto column = textBuffer.GetCursor().GetPosition().x;
+    const auto page = _pages.ActivePage();
+    const auto column = page.Cursor().GetPosition().x;
 
-    _InitTabStopsForWidth(width);
+    _InitTabStopsForWidth(page.Width());
     _tabStopColumns.at(column) = false;
 }
 
@@ -3021,7 +3097,7 @@ bool AdaptDispatch::AnnounceCodeStructure(const VTInt ansiLevel)
 // True if handled successfully. False otherwise.
 bool AdaptDispatch::SoftReset()
 {
-    _api.GetTextBuffer().GetCursor().SetIsVisible(true); // Cursor enabled.
+    _pages.ActivePage().Cursor().SetIsVisible(true); // Cursor enabled.
 
     // Replace mode; Absolute cursor addressing; Disallow left/right margins.
     _modes.reset(Mode::InsertReplace, Mode::Origin, Mode::AllowDECSLRM);
@@ -3086,6 +3162,9 @@ bool AdaptDispatch::HardReset()
         _usingAltBuffer = false;
     }
 
+    // Reset all page buffers.
+    _pages.Reset();
+
     // Completely reset the TerminalOutput state.
     _termOutput = {};
     if (_initialCodePage.has_value())
@@ -3126,7 +3205,7 @@ bool AdaptDispatch::HardReset()
     _api.SetSystemMode(ITerminalApi::Mode::BracketedPaste, false);
 
     // Restore cursor blinking mode.
-    _api.GetTextBuffer().GetCursor().SetBlinkingAllowed(true);
+    _pages.ActivePage().Cursor().SetBlinkingAllowed(true);
 
     // Delete all current tab stops and reapply
     TabSet(DispatchTypes::TabSetType::SetEvery8Columns);
@@ -3136,7 +3215,7 @@ bool AdaptDispatch::HardReset()
     _fontBuffer = nullptr;
 
     // Reset internal modes to their initial state
-    _modes = {};
+    _modes = { Mode::PageCursorCoupling };
 
     // Clear and release the macro buffer.
     if (_macroBuffer)
@@ -3172,18 +3251,16 @@ bool AdaptDispatch::HardReset()
 // - True.
 bool AdaptDispatch::ScreenAlignmentPattern()
 {
-    const auto viewport = _api.GetViewport();
-    auto& textBuffer = _api.GetTextBuffer();
-    const auto bufferWidth = textBuffer.GetSize().Dimensions().width;
+    const auto page = _pages.ActivePage();
 
     // Fill the screen with the letter E using the default attributes.
-    _FillRect(textBuffer, { 0, viewport.top, bufferWidth, viewport.bottom }, L"E", {});
+    _FillRect(page, { 0, page.Top(), page.Width(), page.Bottom() }, L"E", {});
     // Reset the line rendition for all of these rows.
-    textBuffer.ResetLineRenditionRange(viewport.top, viewport.bottom);
+    page.Buffer().ResetLineRenditionRange(page.Top(), page.Bottom());
     // Reset the meta/extended attributes (but leave the colors unchanged).
-    auto attr = textBuffer.GetCurrentAttributes();
+    auto attr = page.Attributes();
     attr.SetStandardErase();
-    _api.SetTextAttributes(attr);
+    page.SetAttributes(attr);
     // Reset the origin mode to absolute, and disallow left/right margins.
     _modes.reset(Mode::Origin, Mode::AllowDECSLRM);
     // Clear the scrolling margins.
@@ -3198,8 +3275,8 @@ bool AdaptDispatch::ScreenAlignmentPattern()
 //Routine Description:
 //  - Erase Scrollback (^[[3J - ED extension by xterm)
 //    Because conhost doesn't exactly have a scrollback, We have to be tricky here.
-//    We need to move the entire viewport to 0,0, and clear everything outside
-//      (0, 0, viewportWidth, viewportHeight) To give the appearance that
+//    We need to move the entire page to 0,0, and clear everything outside
+//      (0, 0, pageWidth, pageHeight) To give the appearance that
 //      everything above the viewport was cleared.
 //    We don't want to save the text BELOW the viewport, because in *nix, there isn't anything there
 //      (There isn't a scroll-forward, only a scrollback)
@@ -3209,19 +3286,15 @@ bool AdaptDispatch::ScreenAlignmentPattern()
 // - True if handled successfully. False otherwise.
 bool AdaptDispatch::_EraseScrollback()
 {
-    const auto viewport = _api.GetViewport();
-    const auto top = viewport.top;
-    const auto height = viewport.bottom - viewport.top;
-    auto& textBuffer = _api.GetTextBuffer();
-    const auto bufferSize = textBuffer.GetSize().Dimensions();
-    auto& cursor = textBuffer.GetCursor();
+    const auto page = _pages.VisiblePage();
+    auto& cursor = page.Cursor();
     const auto row = cursor.GetPosition().y;
 
-    textBuffer.ClearScrollback(top, height);
+    page.Buffer().ClearScrollback(page.Top(), page.Height());
     // Move the viewport
-    _api.SetViewportPosition({ viewport.left, 0 });
+    _api.SetViewportPosition({ page.XPanOffset(), 0 });
     // Move the cursor to the same relative location.
-    cursor.SetYPosition(row - top);
+    cursor.SetYPosition(row - page.Top());
     cursor.SetHasMoved(true);
 
     // GH#2715 - If this succeeded, but we're in a conpty, return `false` to
@@ -3245,25 +3318,26 @@ bool AdaptDispatch::_EraseScrollback()
 // - True if handled successfully. False otherwise.
 bool AdaptDispatch::_EraseAll()
 {
-    const auto viewport = _api.GetViewport();
-    const auto viewportHeight = viewport.bottom - viewport.top;
-    auto& textBuffer = _api.GetTextBuffer();
-    const auto bufferSize = textBuffer.GetSize();
+    const auto page = _pages.ActivePage();
+    const auto pageWidth = page.Width();
+    const auto pageHeight = page.Height();
+    const auto bufferHeight = page.BufferHeight();
+    auto& textBuffer = page.Buffer();
     const auto inPtyMode = _api.IsConsolePty();
 
-    // Stash away the current position of the cursor within the viewport.
+    // Stash away the current position of the cursor within the page.
     // We'll need to restore the cursor to that same relative position, after
     //      we move the viewport.
-    auto& cursor = textBuffer.GetCursor();
-    const auto row = cursor.GetPosition().y - viewport.top;
+    auto& cursor = page.Cursor();
+    const auto row = cursor.GetPosition().y - page.Top();
 
-    // Calculate new viewport position. Typically we want to move one line below
+    // Calculate new page position. Typically we want to move one line below
     // the last non-space row, but if the last non-space character is the very
     // start of the buffer, then we shouldn't move down at all.
     const auto lastChar = textBuffer.GetLastNonSpaceCharacter();
-    auto newViewportTop = lastChar == til::point{} ? 0 : lastChar.y + 1;
-    auto newViewportBottom = newViewportTop + viewportHeight;
-    const auto delta = newViewportBottom - (bufferSize.Height());
+    auto newPageTop = lastChar == til::point{} ? 0 : lastChar.y + 1;
+    auto newPageBottom = newPageTop + pageHeight;
+    const auto delta = newPageBottom - bufferHeight;
     if (delta > 0)
     {
         for (auto i = 0; i < delta; i++)
@@ -3271,8 +3345,8 @@ bool AdaptDispatch::_EraseAll()
             textBuffer.IncrementCircularBuffer();
         }
         _api.NotifyBufferRotation(delta);
-        newViewportTop -= delta;
-        newViewportBottom -= delta;
+        newPageTop -= delta;
+        newPageBottom -= delta;
         // We don't want to trigger a scroll in pty mode, because we're going to
         // pass through the ED sequence anyway, and this will just result in the
         // buffer being scrolled up by two pages instead of one.
@@ -3281,26 +3355,31 @@ bool AdaptDispatch::_EraseAll()
             textBuffer.TriggerScroll({ 0, -delta });
         }
     }
-    // Move the viewport
-    _api.SetViewportPosition({ viewport.left, newViewportTop });
+    // Move the viewport if necessary.
+    if (newPageTop != page.Top())
+    {
+        _api.SetViewportPosition({ page.XPanOffset(), newPageTop });
+    }
     // Restore the relative cursor position
-    cursor.SetYPosition(row + newViewportTop);
+    cursor.SetYPosition(row + newPageTop);
     cursor.SetHasMoved(true);
 
-    // Erase all the rows in the current viewport.
-    const auto eraseAttributes = _GetEraseAttributes(textBuffer);
-    _FillRect(textBuffer, { 0, newViewportTop, bufferSize.Width(), newViewportBottom }, whitespace, eraseAttributes);
+    // Erase all the rows in the current page.
+    const auto eraseAttributes = _GetEraseAttributes(page);
+    _FillRect(page, { 0, newPageTop, pageWidth, newPageBottom }, whitespace, eraseAttributes);
 
     // Also reset the line rendition for the erased rows.
-    textBuffer.ResetLineRenditionRange(newViewportTop, newViewportBottom);
+    textBuffer.ResetLineRenditionRange(newPageTop, newPageBottom);
 
     // GH#5683 - If this succeeded, but we're in a conpty, return `false` to
     // make the state machine propagate this ED sequence to the connected
     // terminal application. While we're in conpty mode, when the client
     // requests a Erase All operation, we need to manually tell the
     // connected terminal to do the same thing, so that the terminal will
-    // move it's own buffer contents into the scrollback.
-    return !inPtyMode;
+    // move it's own buffer contents into the scrollback. But this only
+    // applies if we're in the active buffer, since this should have no
+    // visible effect for an inactive buffer.
+    return !(inPtyMode && textBuffer.IsActiveBuffer());
 }
 
 //Routine Description:
@@ -3353,7 +3432,7 @@ bool AdaptDispatch::SetCursorStyle(const DispatchTypes::CursorStyle cursorStyle)
         return false;
     }
 
-    auto& cursor = _api.GetTextBuffer().GetCursor();
+    auto& cursor = _pages.ActivePage().Cursor();
     cursor.SetType(actualType);
     cursor.SetBlinkingAllowed(fEnableBlinking);
 
@@ -3515,14 +3594,17 @@ bool AdaptDispatch::WindowManipulation(const DispatchTypes::WindowManipulationTy
         _api.ShowWindow(false);
         return true;
     case DispatchTypes::WindowManipulationType::RefreshWindow:
-        _api.GetTextBuffer().TriggerRedrawAll();
+        _pages.VisiblePage().Buffer().TriggerRedrawAll();
         return true;
     case DispatchTypes::WindowManipulationType::ResizeWindowInCharacters:
         _api.ResizeWindow(parameter2.value_or(0), parameter1.value_or(0));
         return true;
     case DispatchTypes::WindowManipulationType::ReportTextSizeInCharacters:
-        _api.ReturnResponse(fmt::format(FMT_COMPILE(L"\033[8;{};{}t"), _api.GetViewport().height(), _api.GetTextBuffer().GetSize().Width()));
+    {
+        const auto page = _pages.VisiblePage();
+        _api.ReturnResponse(fmt::format(FMT_COMPILE(L"\033[8;{};{}t"), page.Height(), page.Width()));
         return true;
+    }
     default:
         return false;
     }
@@ -3536,12 +3618,12 @@ bool AdaptDispatch::WindowManipulation(const DispatchTypes::WindowManipulationTy
 // - true
 bool AdaptDispatch::AddHyperlink(const std::wstring_view uri, const std::wstring_view params)
 {
-    auto& textBuffer = _api.GetTextBuffer();
-    auto attr = textBuffer.GetCurrentAttributes();
-    const auto id = textBuffer.GetHyperlinkId(uri, params);
+    const auto page = _pages.ActivePage();
+    auto attr = page.Attributes();
+    const auto id = page.Buffer().GetHyperlinkId(uri, params);
     attr.SetHyperlinkId(id);
-    textBuffer.SetCurrentAttributes(attr);
-    textBuffer.AddHyperlinkToMap(uri, id);
+    page.SetAttributes(attr);
+    page.Buffer().AddHyperlinkToMap(uri, id);
     return true;
 }
 
@@ -3551,10 +3633,10 @@ bool AdaptDispatch::AddHyperlink(const std::wstring_view uri, const std::wstring
 // - true
 bool AdaptDispatch::EndHyperlink()
 {
-    auto& textBuffer = _api.GetTextBuffer();
-    auto attr = textBuffer.GetCurrentAttributes();
+    const auto page = _pages.ActivePage();
+    auto attr = page.Attributes();
     attr.SetHyperlinkId(0);
-    textBuffer.SetCurrentAttributes(attr);
+    page.SetAttributes(attr);
     return true;
 }
 
@@ -3657,7 +3739,7 @@ bool AdaptDispatch::DoConEmuAction(const std::wstring_view string)
     // This seems like basically the same as 133;B - the end of the prompt, the start of the commandline.
     else if (subParam == 12)
     {
-        _api.GetTextBuffer().StartCommand();
+        _pages.ActivePage().Buffer().StartCommand();
         return true;
     }
 
@@ -3701,7 +3783,7 @@ bool AdaptDispatch::DoITerm2Action(const std::wstring_view string)
     bool handled = false;
     if (action == L"SetMark")
     {
-        _api.GetTextBuffer().StartPrompt();
+        _pages.ActivePage().Buffer().StartPrompt();
         handled = true;
     }
 
@@ -3747,19 +3829,19 @@ bool AdaptDispatch::DoFinalTermAction(const std::wstring_view string)
         {
         case L'A': // FTCS_PROMPT
         {
-            _api.GetTextBuffer().StartPrompt();
+            _pages.ActivePage().Buffer().StartPrompt();
             handled = true;
             break;
         }
         case L'B': // FTCS_COMMAND_START
         {
-            _api.GetTextBuffer().StartCommand();
+            _pages.ActivePage().Buffer().StartCommand();
             handled = true;
             break;
         }
         case L'C': // FTCS_COMMAND_EXECUTED
         {
-            _api.GetTextBuffer().StartOutput();
+            _pages.ActivePage().Buffer().StartOutput();
             handled = true;
             break;
         }
@@ -3780,7 +3862,7 @@ bool AdaptDispatch::DoFinalTermAction(const std::wstring_view string)
                                                                         UINT_MAX;
             }
 
-            _api.GetTextBuffer().EndCurrentCommand(error);
+            _pages.ActivePage().Buffer().EndCurrentCommand(error);
 
             handled = true;
             break;
@@ -4270,7 +4352,7 @@ void AdaptDispatch::_ReportSGRSetting() const
     fmt::basic_memory_buffer<wchar_t, 64> response;
     response.append(L"\033P1$r0"sv);
 
-    const auto& attr = _api.GetTextBuffer().GetCurrentAttributes();
+    const auto& attr = _pages.ActivePage().Attributes();
     const auto ulStyle = attr.GetUnderlineStyle();
     // For each boolean attribute that is set, we add the appropriate
     // parameter value to the response string.
@@ -4339,8 +4421,8 @@ void AdaptDispatch::_ReportDECSTBMSetting()
     fmt::basic_memory_buffer<wchar_t, 64> response;
     response.append(L"\033P1$r"sv);
 
-    const auto viewport = _api.GetViewport();
-    const auto [marginTop, marginBottom] = _GetVerticalMargins(viewport, false);
+    const auto page = _pages.ActivePage();
+    const auto [marginTop, marginBottom] = _GetVerticalMargins(page, false);
     // VT origin is at 1,1 so we need to add 1 to these margins.
     fmt::format_to(std::back_inserter(response), FMT_COMPILE(L"{};{}"), marginTop + 1, marginBottom + 1);
 
@@ -4363,8 +4445,8 @@ void AdaptDispatch::_ReportDECSLRMSetting()
     fmt::basic_memory_buffer<wchar_t, 64> response;
     response.append(L"\033P1$r"sv);
 
-    const auto bufferWidth = _api.GetTextBuffer().GetSize().Width();
-    const auto [marginLeft, marginRight] = _GetHorizontalMargins(bufferWidth);
+    const auto pageWidth = _pages.ActivePage().Width();
+    const auto [marginLeft, marginRight] = _GetHorizontalMargins(pageWidth);
     // VT origin is at 1,1 so we need to add 1 to these margins.
     fmt::format_to(std::back_inserter(response), FMT_COMPILE(L"{};{}"), marginLeft + 1, marginRight + 1);
 
@@ -4387,7 +4469,7 @@ void AdaptDispatch::_ReportDECSCASetting() const
     fmt::basic_memory_buffer<wchar_t, 64> response;
     response.append(L"\033P1$r"sv);
 
-    const auto& attr = _api.GetTextBuffer().GetCurrentAttributes();
+    const auto& attr = _pages.ActivePage().Attributes();
     response.append(attr.IsProtected() ? L"1"sv : L"0"sv);
 
     // The '"q' indicates this is an DECSCA response, and ST ends the sequence.
@@ -4505,31 +4587,27 @@ ITermDispatch::StringHandler AdaptDispatch::RestorePresentationState(const Dispa
 // - None
 void AdaptDispatch::_ReportCursorInformation()
 {
-    const auto viewport = _api.GetViewport();
-    const auto& textBuffer = _api.GetTextBuffer();
-    const auto& cursor = textBuffer.GetCursor();
-    const auto& attributes = textBuffer.GetCurrentAttributes();
+    const auto page = _pages.ActivePage();
+    const auto& cursor = page.Cursor();
+    const auto& attributes = page.Attributes();
 
     // First pull the cursor position relative to the entire buffer out of the console.
     til::point cursorPosition{ cursor.GetPosition() };
 
-    // Now adjust it for its position in respect to the current viewport top.
-    cursorPosition.y -= viewport.top;
+    // Now adjust it for its position in respect to the current page top.
+    cursorPosition.y -= page.Top();
 
-    // NOTE: 1,1 is the top-left corner of the viewport in VT-speak, so add 1.
+    // NOTE: 1,1 is the top-left corner of the page in VT-speak, so add 1.
     cursorPosition.x++;
     cursorPosition.y++;
 
     // If the origin mode is set, the cursor is relative to the margin origin.
     if (_modes.test(Mode::Origin))
     {
-        cursorPosition.x -= _GetHorizontalMargins(textBuffer.GetSize().Width()).first;
-        cursorPosition.y -= _GetVerticalMargins(viewport, false).first;
+        cursorPosition.x -= _GetHorizontalMargins(page.Width()).first;
+        cursorPosition.y -= _GetVerticalMargins(page, false).first;
     }
 
-    // Paging is not supported yet (GH#13892).
-    const auto pageNumber = 1;
-
     // Only some of the rendition attributes are reported.
     //   Bit    Attribute
     //   1      bold
@@ -4575,7 +4653,7 @@ void AdaptDispatch::_ReportCursorInformation()
         FMT_COMPILE(L"\033P1$u{};{};{};{};{};{};{};{};{};{}{}{}{}\033\\"),
         cursorPosition.y,
         cursorPosition.x,
-        pageNumber,
+        page.Number(),
         renditionAttributes,
         characterAttributes,
         flags,
@@ -4612,7 +4690,6 @@ ITermDispatch::StringHandler AdaptDispatch::_RestoreCursorInformation()
         VTParameter row{};
         VTParameter column{};
     };
-    auto& textBuffer = _api.GetTextBuffer();
     return [&, state = State{}](const auto ch) mutable {
         if (numeric.test(state.field))
         {
@@ -4634,7 +4711,7 @@ ITermDispatch::StringHandler AdaptDispatch::_RestoreCursorInformation()
                 }
                 else if (state.field == Field::Page)
                 {
-                    // Paging is not supported yet (GH#13892).
+                    PagePositionAbsolute(state.value);
                 }
                 else if (state.field == Field::GL && state.value <= 3)
                 {
@@ -4659,19 +4736,21 @@ ITermDispatch::StringHandler AdaptDispatch::_RestoreCursorInformation()
                 state.value = ch;
                 if (state.field == Field::SGR)
                 {
-                    auto attr = textBuffer.GetCurrentAttributes();
+                    const auto page = _pages.ActivePage();
+                    auto attr = page.Attributes();
                     attr.SetIntense(state.value & 1);
                     attr.SetUnderlineStyle(state.value & 2 ? UnderlineStyle::SinglyUnderlined : UnderlineStyle::NoUnderline);
                     attr.SetBlinking(state.value & 4);
                     attr.SetReverseVideo(state.value & 8);
                     attr.SetInvisible(state.value & 16);
-                    textBuffer.SetCurrentAttributes(attr);
+                    page.SetAttributes(attr);
                 }
                 else if (state.field == Field::Attr)
                 {
-                    auto attr = textBuffer.GetCurrentAttributes();
+                    const auto page = _pages.ActivePage();
+                    auto attr = page.Attributes();
                     attr.SetProtected(state.value & 1);
-                    textBuffer.SetCurrentAttributes(attr);
+                    page.SetAttributes(attr);
                 }
                 else if (state.field == Field::Sizes)
                 {
@@ -4697,7 +4776,8 @@ ITermDispatch::StringHandler AdaptDispatch::_RestoreCursorInformation()
                     // above, so we only need to worry about setting it.
                     if (delayedEOLWrap)
                     {
-                        textBuffer.GetCursor().DelayEOLWrap();
+                        const auto page = _pages.ActivePage();
+                        page.Cursor().DelayEOLWrap();
                     }
                 }
             }
@@ -4744,7 +4824,7 @@ void AdaptDispatch::_ReportTabStops()
     // In order to be compatible with the original hardware terminals, we only
     // report tab stops up to the current buffer width, even though there may
     // be positions recorded beyond that limit.
-    const auto width = _api.GetTextBuffer().GetSize().Dimensions().width;
+    const auto width = _pages.ActivePage().Width();
     _InitTabStopsForWidth(width);
 
     using namespace std::string_view_literals;
@@ -4780,7 +4860,7 @@ ITermDispatch::StringHandler AdaptDispatch::_RestoreTabStops()
     // In order to be compatible with the original hardware terminals, we need
     // to be able to set tab stops up to at least 132 columns, even though the
     // current buffer width may be less than that.
-    const auto width = std::max(_api.GetTextBuffer().GetSize().Dimensions().width, 132);
+    const auto width = std::max(_pages.ActivePage().Width(), 132);
     _ClearAllTabStops();
     _InitTabStopsForWidth(width);
 
diff --git a/src/terminal/adapter/adaptDispatch.hpp b/src/terminal/adapter/adaptDispatch.hpp
index 36bb0312145..dbea2fcd887 100644
--- a/src/terminal/adapter/adaptDispatch.hpp
+++ b/src/terminal/adapter/adaptDispatch.hpp
@@ -18,6 +18,7 @@ Author(s):
 #include "ITerminalApi.hpp"
 #include "FontBuffer.hpp"
 #include "MacroBuffer.hpp"
+#include "PageManager.hpp"
 #include "terminalOutput.hpp"
 #include "../input/terminalInput.hpp"
 #include "../../types/inc/sgrStack.hpp"
@@ -81,6 +82,12 @@ namespace Microsoft::Console::VirtualTerminal
         bool RequestTerminalParameters(const DispatchTypes::ReportingPermission permission) override; // DECREQTPARM
         bool ScrollUp(const VTInt distance) override; // SU
         bool ScrollDown(const VTInt distance) override; // SD
+        bool NextPage(const VTInt pageCount) override; // NP
+        bool PrecedingPage(const VTInt pageCount) override; // PP
+        bool PagePositionAbsolute(const VTInt page) override; // PPA
+        bool PagePositionRelative(const VTInt pageCount) override; // PPR
+        bool PagePositionBack(const VTInt pageCount) override; // PPB
+        bool RequestDisplayedExtent() override; // DECRQDE
         bool InsertLine(const VTInt distance) override; // IL
         bool DeleteLine(const VTInt distance) override; // DL
         bool InsertColumn(const VTInt distance) override; // DECIC
@@ -178,7 +185,8 @@ namespace Microsoft::Console::VirtualTerminal
             AllowDECCOLM,
             AllowDECSLRM,
             EraseColor,
-            RectangularChangeExtent
+            RectangularChangeExtent,
+            PageCursorCoupling
         };
         enum class ScrollDirection
         {
@@ -189,6 +197,7 @@ namespace Microsoft::Console::VirtualTerminal
         {
             VTInt Row = 1;
             VTInt Column = 1;
+            VTInt Page = 1;
             bool IsDelayedEOLWrap = false;
             bool IsOriginModeRelative = false;
             TextAttribute Attributes = {};
@@ -214,20 +223,20 @@ namespace Microsoft::Console::VirtualTerminal
         };
 
         void _WriteToBuffer(const std::wstring_view string);
-        std::pair<int, int> _GetVerticalMargins(const til::rect& viewport, const bool absolute) noexcept;
+        std::pair<int, int> _GetVerticalMargins(const Page& page, const bool absolute) noexcept;
         std::pair<int, int> _GetHorizontalMargins(const til::CoordType bufferWidth) noexcept;
         bool _CursorMovePosition(const Offset rowOffset, const Offset colOffset, const bool clampInMargins);
         void _ApplyCursorMovementFlags(Cursor& cursor) noexcept;
-        void _FillRect(TextBuffer& textBuffer, const til::rect& fillRect, const std::wstring_view& fillChar, const TextAttribute& fillAttrs) const;
-        void _SelectiveEraseRect(TextBuffer& textBuffer, const til::rect& eraseRect);
-        void _ChangeRectAttributes(TextBuffer& textBuffer, const til::rect& changeRect, const ChangeOps& changeOps);
+        void _FillRect(const Page& page, const til::rect& fillRect, const std::wstring_view& fillChar, const TextAttribute& fillAttrs) const;
+        void _SelectiveEraseRect(const Page& page, const til::rect& eraseRect);
+        void _ChangeRectAttributes(const Page& page, const til::rect& changeRect, const ChangeOps& changeOps);
         void _ChangeRectOrStreamAttributes(const til::rect& changeArea, const ChangeOps& changeOps);
-        til::rect _CalculateRectArea(const VTInt top, const VTInt left, const VTInt bottom, const VTInt right, const til::size bufferSize);
+        til::rect _CalculateRectArea(const Page& page, const VTInt top, const VTInt left, const VTInt bottom, const VTInt right);
         bool _EraseScrollback();
         bool _EraseAll();
-        TextAttribute _GetEraseAttributes(const TextBuffer& textBuffer) const noexcept;
-        void _ScrollRectVertically(TextBuffer& textBuffer, const til::rect& scrollRect, const VTInt delta);
-        void _ScrollRectHorizontally(TextBuffer& textBuffer, const til::rect& scrollRect, const VTInt delta);
+        TextAttribute _GetEraseAttributes(const Page& page) const noexcept;
+        void _ScrollRectVertically(const Page& page, const til::rect& scrollRect, const VTInt delta);
+        void _ScrollRectHorizontally(const Page& page, const til::rect& scrollRect, const VTInt delta);
         void _InsertDeleteCharacterHelper(const VTInt delta);
         void _InsertDeleteLineHelper(const VTInt delta);
         void _InsertDeleteColumnHelper(const VTInt delta);
@@ -240,7 +249,7 @@ namespace Microsoft::Console::VirtualTerminal
                                              const VTInt rightMargin,
                                              const bool homeCursor = false);
 
-        void _DoLineFeed(TextBuffer& textBuffer, const bool withReturn, const bool wrapForced);
+        void _DoLineFeed(const Page& page, const bool withReturn, const bool wrapForced);
 
         void _DeviceStatusReport(const wchar_t* parameters) const;
         void _CursorPositionReport(const bool extendedReport);
@@ -281,6 +290,7 @@ namespace Microsoft::Console::VirtualTerminal
         RenderSettings& _renderSettings;
         TerminalInput& _terminalInput;
         TerminalOutput _termOutput;
+        PageManager _pages;
         std::unique_ptr<FontBuffer> _fontBuffer;
         std::shared_ptr<MacroBuffer> _macroBuffer;
         std::optional<unsigned int> _initialCodePage;
@@ -295,7 +305,7 @@ namespace Microsoft::Console::VirtualTerminal
 
         til::inclusive_rect _scrollMargins;
 
-        til::enumset<Mode> _modes;
+        til::enumset<Mode> _modes{ Mode::PageCursorCoupling };
 
         SgrStack _sgrStack;
 
diff --git a/src/terminal/adapter/adaptDispatchGraphics.cpp b/src/terminal/adapter/adaptDispatchGraphics.cpp
index ac1b0180eff..f7dab41cd1c 100644
--- a/src/terminal/adapter/adaptDispatchGraphics.cpp
+++ b/src/terminal/adapter/adaptDispatchGraphics.cpp
@@ -422,9 +422,10 @@ void AdaptDispatch::_ApplyGraphicsOptions(const VTParameters options,
 // - True.
 bool AdaptDispatch::SetGraphicsRendition(const VTParameters options)
 {
-    auto attr = _api.GetTextBuffer().GetCurrentAttributes();
+    const auto page = _pages.ActivePage();
+    auto attr = page.Attributes();
     _ApplyGraphicsOptions(options, attr);
-    _api.SetTextAttributes(attr);
+    page.SetAttributes(attr, &_api);
     return true;
 }
 
@@ -438,8 +439,8 @@ bool AdaptDispatch::SetGraphicsRendition(const VTParameters options)
 // - True.
 bool AdaptDispatch::SetCharacterProtectionAttribute(const VTParameters options)
 {
-    auto& textBuffer = _api.GetTextBuffer();
-    auto attr = textBuffer.GetCurrentAttributes();
+    const auto page = _pages.ActivePage();
+    auto attr = page.Attributes();
     for (size_t i = 0; i < options.size(); i++)
     {
         const LogicalAttributeOptions opt = options.at(i);
@@ -456,7 +457,7 @@ bool AdaptDispatch::SetCharacterProtectionAttribute(const VTParameters options)
             break;
         }
     }
-    textBuffer.SetCurrentAttributes(attr);
+    page.SetAttributes(attr);
     return true;
 }
 
@@ -470,7 +471,7 @@ bool AdaptDispatch::SetCharacterProtectionAttribute(const VTParameters options)
 // - True.
 bool AdaptDispatch::PushGraphicsRendition(const VTParameters options)
 {
-    const auto& currentAttributes = _api.GetTextBuffer().GetCurrentAttributes();
+    const auto& currentAttributes = _pages.ActivePage().Attributes();
     _sgrStack.Push(currentAttributes, options);
     return true;
 }
@@ -484,7 +485,8 @@ bool AdaptDispatch::PushGraphicsRendition(const VTParameters options)
 // - True.
 bool AdaptDispatch::PopGraphicsRendition()
 {
-    const auto& currentAttributes = _api.GetTextBuffer().GetCurrentAttributes();
-    _api.SetTextAttributes(_sgrStack.Pop(currentAttributes));
+    const auto page = _pages.ActivePage();
+    const auto& currentAttributes = page.Attributes();
+    page.SetAttributes(_sgrStack.Pop(currentAttributes), &_api);
     return true;
 }
diff --git a/src/terminal/adapter/lib/adapter.vcxproj b/src/terminal/adapter/lib/adapter.vcxproj
index 2780c818dfa..a05c8e5b83e 100644
--- a/src/terminal/adapter/lib/adapter.vcxproj
+++ b/src/terminal/adapter/lib/adapter.vcxproj
@@ -15,6 +15,7 @@
     <ClCompile Include="..\FontBuffer.cpp" />
     <ClCompile Include="..\InteractDispatch.cpp" />
     <ClCompile Include="..\MacroBuffer.cpp" />
+    <ClCompile Include="..\PageManager.cpp" />
     <ClCompile Include="..\adaptDispatchGraphics.cpp" />
     <ClCompile Include="..\terminalOutput.cpp" />
     <ClCompile Include="..\precomp.cpp">
@@ -29,6 +30,7 @@
     <ClInclude Include="..\InteractDispatch.hpp" />
     <ClInclude Include="..\ITerminalApi.hpp" />
     <ClInclude Include="..\MacroBuffer.hpp" />
+    <ClInclude Include="..\PageManager.hpp" />
     <ClInclude Include="..\precomp.h" />
     <ClInclude Include="..\terminalOutput.hpp" />
     <ClInclude Include="..\ITermDispatch.hpp" />
diff --git a/src/terminal/adapter/lib/adapter.vcxproj.filters b/src/terminal/adapter/lib/adapter.vcxproj.filters
index 798522c651f..501f6ce9bdc 100644
--- a/src/terminal/adapter/lib/adapter.vcxproj.filters
+++ b/src/terminal/adapter/lib/adapter.vcxproj.filters
@@ -36,6 +36,9 @@
     <ClCompile Include="..\MacroBuffer.cpp">
       <Filter>Source Files</Filter>
     </ClCompile>
+    <ClCompile Include="..\PageManager.cpp">
+      <Filter>Source Files</Filter>
+    </ClCompile>
   </ItemGroup>
   <ItemGroup>
     <ClInclude Include="..\adaptDispatch.hpp">
@@ -74,6 +77,9 @@
     <ClInclude Include="..\MacroBuffer.hpp">
       <Filter>Header Files</Filter>
     </ClInclude>
+    <ClInclude Include="..\PageManager.hpp">
+      <Filter>Header Files</Filter>
+    </ClInclude>
   </ItemGroup>
   <ItemGroup>
     <Natvis Include="$(SolutionDir)tools\ConsoleTypes.natvis" />
diff --git a/src/terminal/adapter/sources.inc b/src/terminal/adapter/sources.inc
index a7a07a29814..3ffd6bed7c7 100644
--- a/src/terminal/adapter/sources.inc
+++ b/src/terminal/adapter/sources.inc
@@ -34,6 +34,7 @@ SOURCES= \
     ..\FontBuffer.cpp \
     ..\InteractDispatch.cpp \
     ..\MacroBuffer.cpp \
+    ..\PageManager.cpp \
     ..\adaptDispatchGraphics.cpp \
     ..\terminalOutput.cpp \
 
diff --git a/src/terminal/adapter/termDispatch.hpp b/src/terminal/adapter/termDispatch.hpp
index 82bb4f21320..3a1d19a0964 100644
--- a/src/terminal/adapter/termDispatch.hpp
+++ b/src/terminal/adapter/termDispatch.hpp
@@ -42,6 +42,12 @@ class Microsoft::Console::VirtualTerminal::TermDispatch : public Microsoft::Cons
     bool DeleteCharacter(const VTInt /*count*/) override { return false; } // DCH
     bool ScrollUp(const VTInt /*distance*/) override { return false; } // SU
     bool ScrollDown(const VTInt /*distance*/) override { return false; } // SD
+    bool NextPage(const VTInt /*pageCount*/) override { return false; } // NP
+    bool PrecedingPage(const VTInt /*pageCount*/) override { return false; } // PP
+    bool PagePositionAbsolute(const VTInt /*page*/) override { return false; } // PPA
+    bool PagePositionRelative(const VTInt /*pageCount*/) override { return false; } // PPR
+    bool PagePositionBack(const VTInt /*pageCount*/) override { return false; } // PPB
+    bool RequestDisplayedExtent() override { return false; } // DECRQDE
     bool InsertLine(const VTInt /*distance*/) override { return false; } // IL
     bool DeleteLine(const VTInt /*distance*/) override { return false; } // DL
     bool InsertColumn(const VTInt /*distance*/) override { return false; } // DECIC
diff --git a/src/terminal/adapter/ut_adapter/adapterTest.cpp b/src/terminal/adapter/ut_adapter/adapterTest.cpp
index 5cd5fbdc29e..c0ff50e5748 100644
--- a/src/terminal/adapter/ut_adapter/adapterTest.cpp
+++ b/src/terminal/adapter/ut_adapter/adapterTest.cpp
@@ -81,14 +81,10 @@ class TestGetSet final : public ITerminalApi
         return *_stateMachine;
     }
 
-    TextBuffer& GetTextBuffer() override
+    BufferState GetBufferAndViewport() override
     {
-        return *_textBuffer.get();
-    }
-
-    til::rect GetViewport() const override
-    {
-        return { _viewport.left, _viewport.top, _viewport.right, _viewport.bottom };
+        const auto viewport = til::rect{ _viewport.left, _viewport.top, _viewport.right, _viewport.bottom };
+        return { *_textBuffer.get(), viewport, true };
     }
 
     void SetViewportPosition(const til::point /*position*/) override
@@ -1575,14 +1571,23 @@ class AdapterTest
         coordCursorExpected.x++;
         coordCursorExpected.y++;
 
-        // Until we support paging (GH#13892) the reported page number should always be 1.
-        const auto pageExpected = 1;
+        // By default, the initial page number should be 1.
+        auto pageExpected = 1;
 
         VERIFY_IS_TRUE(_pDispatch->DeviceStatusReport(DispatchTypes::StatusType::ExtendedCursorPositionReport, {}));
 
         wchar_t pwszBuffer[50];
         swprintf_s(pwszBuffer, ARRAYSIZE(pwszBuffer), L"\x1b[?%d;%d;%dR", coordCursorExpected.y, coordCursorExpected.x, pageExpected);
         _testGetSet->ValidateInputEvent(pwszBuffer);
+
+        // Now test with the page number set to 3.
+        pageExpected = 3;
+        _pDispatch->PagePositionAbsolute(pageExpected);
+
+        VERIFY_IS_TRUE(_pDispatch->DeviceStatusReport(DispatchTypes::StatusType::ExtendedCursorPositionReport, {}));
+
+        swprintf_s(pwszBuffer, ARRAYSIZE(pwszBuffer), L"\x1b[?%d;%d;%dR", coordCursorExpected.y, coordCursorExpected.x, pageExpected);
+        _testGetSet->ValidateInputEvent(pwszBuffer);
     }
 
     TEST_METHOD(DeviceStatus_MacroSpaceReportTest)
@@ -1746,6 +1751,42 @@ class AdapterTest
         VERIFY_THROWS(_pDispatch->TertiaryDeviceAttributes(), std::exception);
     }
 
+    TEST_METHOD(RequestDisplayedExtentTests)
+    {
+        Log::Comment(L"Starting test...");
+
+        Log::Comment(L"Test 1: Verify DECRQDE response in home position");
+        _testGetSet->PrepData();
+        _testGetSet->_viewport.left = 0;
+        _testGetSet->_viewport.right = 80;
+        _testGetSet->_viewport.top = 0;
+        _testGetSet->_viewport.bottom = 24;
+        VERIFY_IS_TRUE(_pDispatch->RequestDisplayedExtent());
+        _testGetSet->ValidateInputEvent(L"\x1b[24;80;1;1;1\"w");
+
+        Log::Comment(L"Test 2: Verify DECRQDE response when panned horizontally");
+        _testGetSet->_viewport.left += 5;
+        _testGetSet->_viewport.right += 5;
+        VERIFY_IS_TRUE(_pDispatch->RequestDisplayedExtent());
+        _testGetSet->ValidateInputEvent(L"\x1b[24;80;6;1;1\"w");
+
+        Log::Comment(L"Test 3: Verify DECRQDE response on page 3");
+        _pDispatch->PagePositionAbsolute(3);
+        VERIFY_IS_TRUE(_pDispatch->RequestDisplayedExtent());
+        _testGetSet->ValidateInputEvent(L"\x1b[24;80;6;1;3\"w");
+
+        Log::Comment(L"Test 3: Verify DECRQDE response when active page not visible");
+        _pDispatch->ResetMode(DispatchTypes::ModeParams::DECPCCM_PageCursorCouplingMode);
+        _pDispatch->PagePositionAbsolute(1);
+        VERIFY_IS_TRUE(_pDispatch->RequestDisplayedExtent());
+        _testGetSet->ValidateInputEvent(L"\x1b[24;80;6;1;3\"w");
+
+        Log::Comment(L"Test 4: Verify DECRQDE response when page 1 visible again");
+        _pDispatch->SetMode(DispatchTypes::ModeParams::DECPCCM_PageCursorCouplingMode);
+        VERIFY_IS_TRUE(_pDispatch->RequestDisplayedExtent());
+        _testGetSet->ValidateInputEvent(L"\x1b[24;80;6;1;1\"w");
+    }
+
     TEST_METHOD(RequestTerminalParametersTests)
     {
         Log::Comment(L"Starting test...");
@@ -3263,7 +3304,7 @@ class AdapterTest
         setMacroText(63, L"Macro 63");
 
         const auto getBufferOutput = [&]() {
-            const auto& textBuffer = _testGetSet->GetTextBuffer();
+            const auto& textBuffer = _testGetSet->GetBufferAndViewport().buffer;
             const auto cursorPos = textBuffer.GetCursor().GetPosition();
             return textBuffer.GetRowByOffset(cursorPos.y).GetText().substr(0, cursorPos.x);
         };
@@ -3314,7 +3355,8 @@ class AdapterTest
     {
         _testGetSet->PrepData();
         _pDispatch->WindowManipulation(DispatchTypes::WindowManipulationType::ReportTextSizeInCharacters, NULL, NULL);
-        const std::wstring expectedResponse = fmt::format(L"\033[8;{};{}t", _testGetSet->GetViewport().height(), _testGetSet->GetTextBuffer().GetSize().Width());
+        const auto [textBuffer, viewport, _] = _testGetSet->GetBufferAndViewport();
+        const std::wstring expectedResponse = fmt::format(L"\033[8;{};{}t", viewport.height(), textBuffer.GetSize().Width());
         _testGetSet->ValidateInputEvent(expectedResponse.c_str());
     }
 
@@ -3345,6 +3387,89 @@ class AdapterTest
         VERIFY_IS_TRUE(_pDispatch->DoVsCodeAction(LR"(Completions;10;20;30;{ "foo": "what;ever", "bar": 2 })"));
     }
 
+    TEST_METHOD(PageMovementTests)
+    {
+        _testGetSet->PrepData(CursorX::XCENTER, CursorY::YCENTER);
+        auto& pages = _pDispatch->_pages;
+        const auto startPos = pages.ActivePage().Cursor().GetPosition();
+        const auto homePos = til::point{ 0, pages.ActivePage().Top() };
+
+        // Testing PPA (page position absolute)
+        VERIFY_ARE_EQUAL(1, pages.ActivePage().Number(), L"Initial page is 1");
+        _pDispatch->PagePositionAbsolute(3);
+        VERIFY_ARE_EQUAL(3, pages.ActivePage().Number(), L"PPA 3 moves to page 3");
+        _pDispatch->PagePositionAbsolute(VTParameter{});
+        VERIFY_ARE_EQUAL(1, pages.ActivePage().Number(), L"PPA with omitted page moves to 1");
+        _pDispatch->PagePositionAbsolute(9999);
+        VERIFY_ARE_EQUAL(6, pages.ActivePage().Number(), L"PPA is clamped at page 6");
+        VERIFY_ARE_EQUAL(startPos, pages.ActivePage().Cursor().GetPosition(), L"Cursor position never changes");
+
+        _testGetSet->PrepData(CursorX::XCENTER, CursorY::YCENTER);
+        _pDispatch->PagePositionAbsolute(1); // Reset to page 1
+
+        // Testing PPR (page position relative)
+        VERIFY_ARE_EQUAL(1, pages.ActivePage().Number(), L"Initial page is 1");
+        _pDispatch->PagePositionRelative(2);
+        VERIFY_ARE_EQUAL(3, pages.ActivePage().Number(), L"PPR 2 moves forward 2 pages");
+        _pDispatch->PagePositionRelative(VTParameter{});
+        VERIFY_ARE_EQUAL(4, pages.ActivePage().Number(), L"PPR with omitted count moves forward 1");
+        _pDispatch->PagePositionRelative(9999);
+        VERIFY_ARE_EQUAL(6, pages.ActivePage().Number(), L"PPR is clamped at page 6");
+        VERIFY_ARE_EQUAL(startPos, pages.ActivePage().Cursor().GetPosition(), L"Cursor position never changes");
+
+        _testGetSet->PrepData(CursorX::XCENTER, CursorY::YCENTER);
+
+        // Testing PPB (page position back)
+        VERIFY_ARE_EQUAL(6, pages.ActivePage().Number(), L"Initial page is 6");
+        _pDispatch->PagePositionBack(2);
+        VERIFY_ARE_EQUAL(4, pages.ActivePage().Number(), L"PPB 2 moves back 2 pages");
+        _pDispatch->PagePositionBack(VTParameter{});
+        VERIFY_ARE_EQUAL(3, pages.ActivePage().Number(), L"PPB with omitted count moves back 1");
+        _pDispatch->PagePositionBack(9999);
+        VERIFY_ARE_EQUAL(1, pages.ActivePage().Number(), L"PPB is clamped at page 1");
+        VERIFY_ARE_EQUAL(startPos, pages.ActivePage().Cursor().GetPosition(), L"Cursor position never changes");
+
+        _testGetSet->PrepData(CursorX::XCENTER, CursorY::YCENTER);
+
+        // Testing NP (next page)
+        VERIFY_ARE_EQUAL(1, pages.ActivePage().Number(), L"Initial page is 1");
+        _pDispatch->NextPage(2);
+        VERIFY_ARE_EQUAL(3, pages.ActivePage().Number(), L"NP 2 moves forward 2 pages");
+        _pDispatch->NextPage(VTParameter{});
+        VERIFY_ARE_EQUAL(4, pages.ActivePage().Number(), L"NP with omitted count moves forward 1");
+        _pDispatch->NextPage(9999);
+        VERIFY_ARE_EQUAL(6, pages.ActivePage().Number(), L"NP is clamped at page 6");
+        VERIFY_ARE_EQUAL(homePos, pages.ActivePage().Cursor().GetPosition(), L"Cursor position is reset to home");
+
+        _testGetSet->PrepData(CursorX::XCENTER, CursorY::YCENTER);
+
+        // Testing PP (preceding page)
+        VERIFY_ARE_EQUAL(6, pages.ActivePage().Number(), L"Initial page is 6");
+        _pDispatch->PrecedingPage(2);
+        VERIFY_ARE_EQUAL(4, pages.ActivePage().Number(), L"PP 2 moves back 2 pages");
+        _pDispatch->PrecedingPage(VTParameter{});
+        VERIFY_ARE_EQUAL(3, pages.ActivePage().Number(), L"PP with omitted count moves back 1");
+        _pDispatch->PrecedingPage(9999);
+        VERIFY_ARE_EQUAL(1, pages.ActivePage().Number(), L"PP is clamped at page 1");
+        VERIFY_ARE_EQUAL(homePos, pages.ActivePage().Cursor().GetPosition(), L"Cursor position is reset to home");
+
+        // Testing DECPCCM (page cursor coupling mode)
+        _pDispatch->SetMode(DispatchTypes::ModeParams::DECPCCM_PageCursorCouplingMode);
+        _pDispatch->PagePositionAbsolute(2);
+        VERIFY_ARE_EQUAL(2, pages.ActivePage().Number());
+        VERIFY_ARE_EQUAL(2, pages.VisiblePage().Number(), L"Visible page should follow active if DECPCCM set");
+        _pDispatch->ResetMode(DispatchTypes::ModeParams::DECPCCM_PageCursorCouplingMode);
+        _pDispatch->PagePositionAbsolute(4);
+        VERIFY_ARE_EQUAL(4, pages.ActivePage().Number());
+        VERIFY_ARE_EQUAL(2, pages.VisiblePage().Number(), L"Visible page should not change if DECPCCM reset");
+        _pDispatch->SetMode(DispatchTypes::ModeParams::DECPCCM_PageCursorCouplingMode);
+        VERIFY_ARE_EQUAL(4, pages.ActivePage().Number());
+        VERIFY_ARE_EQUAL(4, pages.VisiblePage().Number(), L"Active page should become visible when DECPCCM set");
+
+        // Reset to page 1
+        _pDispatch->PagePositionAbsolute(1);
+    }
+
 private:
     TerminalInput _terminalInput;
     std::unique_ptr<TestGetSet> _testGetSet;
diff --git a/src/terminal/parser/OutputStateMachineEngine.cpp b/src/terminal/parser/OutputStateMachineEngine.cpp
index f4b3fcbf677..7568f154677 100644
--- a/src/terminal/parser/OutputStateMachineEngine.cpp
+++ b/src/terminal/parser/OutputStateMachineEngine.cpp
@@ -556,6 +556,12 @@ bool OutputStateMachineEngine::ActionCsiDispatch(const VTID id, const VTParamete
     case CsiActionCodes::SD_ScrollDown:
         success = _dispatch->ScrollDown(parameters.at(0));
         break;
+    case CsiActionCodes::NP_NextPage:
+        success = _dispatch->NextPage(parameters.at(0));
+        break;
+    case CsiActionCodes::PP_PrecedingPage:
+        success = _dispatch->PrecedingPage(parameters.at(0));
+        break;
     case CsiActionCodes::ANSISYSRC_CursorRestore:
         success = _dispatch->CursorRestoreState();
         break;
@@ -601,6 +607,15 @@ bool OutputStateMachineEngine::ActionCsiDispatch(const VTID id, const VTParamete
         }
         success = true;
         break;
+    case CsiActionCodes::PPA_PagePositionAbsolute:
+        success = _dispatch->PagePositionAbsolute(parameters.at(0));
+        break;
+    case CsiActionCodes::PPR_PagePositionRelative:
+        success = _dispatch->PagePositionRelative(parameters.at(0));
+        break;
+    case CsiActionCodes::PPB_PagePositionBack:
+        success = _dispatch->PagePositionBack(parameters.at(0));
+        break;
     case CsiActionCodes::DECSCUSR_SetCursorStyle:
         success = _dispatch->SetCursorStyle(parameters.at(0));
         break;
@@ -610,6 +625,9 @@ bool OutputStateMachineEngine::ActionCsiDispatch(const VTID id, const VTParamete
     case CsiActionCodes::DECSCA_SetCharacterProtectionAttribute:
         success = _dispatch->SetCharacterProtectionAttribute(parameters);
         break;
+    case CsiActionCodes::DECRQDE_RequestDisplayedExtent:
+        success = _dispatch->RequestDisplayedExtent();
+        break;
     case CsiActionCodes::XT_PushSgr:
     case CsiActionCodes::XT_PushSgrAlias:
         success = _dispatch->PushGraphicsRendition(parameters);
diff --git a/src/terminal/parser/OutputStateMachineEngine.hpp b/src/terminal/parser/OutputStateMachineEngine.hpp
index 64fbf81503e..0970e45dee2 100644
--- a/src/terminal/parser/OutputStateMachineEngine.hpp
+++ b/src/terminal/parser/OutputStateMachineEngine.hpp
@@ -122,6 +122,8 @@ namespace Microsoft::Console::VirtualTerminal
             DCH_DeleteCharacter = VTID("P"),
             SU_ScrollUp = VTID("S"),
             SD_ScrollDown = VTID("T"),
+            NP_NextPage = VTID("U"),
+            PP_PrecedingPage = VTID("V"),
             DECST8C_SetTabEvery8Columns = VTID("?W"),
             ECH_EraseCharacters = VTID("X"),
             CBT_CursorBackTab = VTID("Z"),
@@ -147,9 +149,13 @@ namespace Microsoft::Console::VirtualTerminal
             DTTERM_WindowManipulation = VTID("t"), // NOTE: Overlaps with DECSLPP. Fix when/if implemented.
             ANSISYSRC_CursorRestore = VTID("u"),
             DECREQTPARM_RequestTerminalParameters = VTID("x"),
+            PPA_PagePositionAbsolute = VTID(" P"),
+            PPR_PagePositionRelative = VTID(" Q"),
+            PPB_PagePositionBack = VTID(" R"),
             DECSCUSR_SetCursorStyle = VTID(" q"),
             DECSTR_SoftReset = VTID("!p"),
             DECSCA_SetCharacterProtectionAttribute = VTID("\"q"),
+            DECRQDE_RequestDisplayedExtent = VTID("\"v"),
             XT_PushSgrAlias = VTID("#p"),
             XT_PopSgrAlias = VTID("#q"),
             XT_PushSgr = VTID("#{"),


Test patch: