From 4809686663b84d6e778da50b2a477999ea7131bb Mon Sep 17 00:00:00 2001
From: kuddev
Date: Wed, 21 Oct 2026 18:47:49 +0800
Subject: [PATCH 1/5] Render inline terminal results with one viewport and
preserve folded directory links
---
.../2026-10-20-block-presentation.md | 72 ++
.../2026-10-20-directory-folding.md | 74 ++
.../2026-10-18-native-result-surfaces.md | 130 +++
.../2026-10-19-command-output-lenses.md | 75 ++
.../2026-10-19-unified-result-scroll.md | 104 +++
...20-directory-results-and-bounded-tables.md | 100 +++
nebula_app/AGENTS.md | 1 +
nebula_app/Cargo.toml | 7 +
nebula_app/i18n/en-US.json | 28 +
nebula_app/i18n/zh-CN.json | 28 +
nebula_app/src/cli.rs | 2 +
nebula_app/src/console_main.rs | 31 +
.../src/gpui_shell/terminal/answer_reader.rs | 2 +-
nebula_app/src/gpui_shell/terminal/element.rs | 36 +-
.../gpui_shell/terminal/element/reading.rs | 25 +-
.../terminal/element/snapshot_probe.rs | 9 +
.../src/gpui_shell/terminal/link_underline.rs | 41 +-
.../src/gpui_shell/terminal/math_overlay.rs | 9 +-
nebula_app/src/gpui_shell/terminal/mod.rs | 1 +
.../gpui_shell/terminal/molecule_overlay.rs | 12 +-
.../terminal/native_results/code.rs | 337 ++++++++
.../terminal/native_results/fold.rs | 96 +++
.../terminal/native_results/markdown.rs | 301 +++++++
.../gpui_shell/terminal/native_results/mod.rs | 728 ++++++++++++++++
.../terminal/native_results/table.rs | 414 ++++++++++
.../terminal/native_results/tools.rs | 222 +++++
.../terminal/native_results/viewport.rs | 386 +++++++++
.../src/gpui_shell/terminal/osc_links.rs | 37 +-
nebula_app/src/gpui_shell/terminal/session.rs | 1 +
nebula_app/src/gpui_shell/terminal/view.rs | 18 +-
.../src/gpui_shell/terminal/view/cursor.rs | 8 +-
.../gpui_shell/terminal/view/cursor/tests.rs | 1 +
.../src/gpui_shell/terminal/view/pointer.rs | 61 +-
.../terminal/view/reading/output_hover.rs | 5 +-
.../gpui_shell/terminal/view/reading/tests.rs | 778 +++++++++++++++++-
.../src/gpui_shell/terminal/view/startup.rs | 1 +
nebula_app/src/main.rs | 3 +
nebula_app/src/native_result_model.rs | 338 ++++++++
.../src/native_result_model/directory.rs | 297 +++++++
nebula_app/src/native_view_cli.rs | 270 ++++++
scripts/build-installer.ps1 | 2 +
scripts/build-windows-product.ps1 | 2 +-
scripts/installer.iss | 1 +
scripts/package-release.ps1 | 2 +
scripts/tests/build-windows-product.tests.ps1 | 2 +-
scripts/tests/installer-migration.tests.ps1 | 2 +-
scripts/tests/installer.tests.ps1 | 1 +
scripts/tests/package-release.tests.ps1 | 3 +-
scripts/tests/test_pebrel_branding.py | 2 +-
.../windows-package-architecture.tests.ps1 | 17 +-
scripts/windows-package-architecture.ps1 | 7 +-
51 files changed, 5072 insertions(+), 58 deletions(-)
create mode 100644 architecture/notes/nebula_app/gpui_shell/terminal/native_results/2026-10-20-block-presentation.md
create mode 100644 architecture/notes/nebula_app/gpui_shell/terminal/native_results/2026-10-20-directory-folding.md
create mode 100644 architecture/notes/nebula_terminal/inline_content/2026-10-18-native-result-surfaces.md
create mode 100644 architecture/notes/nebula_terminal/inline_content/2026-10-19-command-output-lenses.md
create mode 100644 architecture/notes/nebula_terminal/inline_content/2026-10-19-unified-result-scroll.md
create mode 100644 architecture/notes/nebula_terminal/inline_content/2026-10-20-directory-results-and-bounded-tables.md
create mode 100644 nebula_app/src/console_main.rs
create mode 100644 nebula_app/src/gpui_shell/terminal/native_results/code.rs
create mode 100644 nebula_app/src/gpui_shell/terminal/native_results/fold.rs
create mode 100644 nebula_app/src/gpui_shell/terminal/native_results/markdown.rs
create mode 100644 nebula_app/src/gpui_shell/terminal/native_results/mod.rs
create mode 100644 nebula_app/src/gpui_shell/terminal/native_results/table.rs
create mode 100644 nebula_app/src/gpui_shell/terminal/native_results/tools.rs
create mode 100644 nebula_app/src/gpui_shell/terminal/native_results/viewport.rs
create mode 100644 nebula_app/src/native_result_model.rs
create mode 100644 nebula_app/src/native_result_model/directory.rs
create mode 100644 nebula_app/src/native_view_cli.rs
diff --git a/architecture/notes/nebula_app/gpui_shell/terminal/native_results/2026-10-20-block-presentation.md b/architecture/notes/nebula_app/gpui_shell/terminal/native_results/2026-10-20-block-presentation.md
new file mode 100644
index 000000000..d142d10b3
--- /dev/null
+++ b/architecture/notes/nebula_app/gpui_shell/terminal/native_results/2026-10-20-block-presentation.md
@@ -0,0 +1,72 @@
+# Native result block presentation
+
+## Status
+
+Implemented locally. Native interaction and parser regressions pass. Desktop
+visual acceptance and release/platform validation remain separate.
+
+## Context
+
+The approved terminal layout uses natural table columns, UI-font headings,
+numbered code blocks and transient controls. Component defaults center button
+labels, stretch Markdown tables and provide neither code line numbers nor the
+required heading font. Replacing text with unselectable labels would lose
+existing reading behavior.
+
+## Evidence
+
+The pinned component supports Markdown block extensions and selectable HTML/text
+views. Its button activation requires both key down and key up; its mouse click
+deliberately does not transfer keyboard focus. GPUI keyed state exists only while
+its element is rendered in consecutive frames. A retained result can leave that
+window without losing its source or user-selected code language.
+
+## Decision
+
+Keep the result as the owner of code entities, language choices and copy feedback.
+Eviction releases them with the source. Parse table samples in the existing owned
+background task; keep scroll handles and font measurements on the UI thread.
+Cache measurements by text/font/width and materialize only visible data rows and
+code line numbers. The terminal continues to own vertical scrolling.
+
+Compile custom headings/tables in their original Markdown context before passing
+inline content to selectable HTML views. Fragments such as `# title` inside a
+table remain text. Blocks requiring full-document references or scientific inline
+rendering retain the existing document renderer. Raw HTML/media remains literal.
+Code reconstruction uses a sufficiently long tilde fence: a language name with a
+backtick cannot invalidate it and turn code text into a fetchable image.
+
+Only keys owned by the result container are stopped. Child inputs retain native
+character/IME processing; the terminal's existing focus check prevents PTY input.
+The filter Escape path clears its query and returns focus to the result.
+
+## Rejected alternatives
+
+- Per-frame extensions or transient code entities: repeat parsing or reset choices.
+- Reparse isolated inline fragments as block Markdown: changes their meaning.
+- Rewrite shared component internals for terminal-only presentation: unnecessarily
+ changes unrelated document and settings views.
+- Stop every key at the parent: Windows then skips character-message translation.
+
+## Consequences
+
+Code and structured tables retain original copy content. Custom presentation has
+bounded state per retained result and additional cold layout work. Synthetic debug
+scroll timing is evidence about that fixture, not a release GPU performance claim.
+
+## Validation
+
+Existing native tests cover column ordering, real filter typing, Tab/Enter release,
+Escape, copy feedback, raw payloads, viewport retention and PTY isolation. Added
+regressions cover unusual code-language fences, inline table semantics and code
+entity retention across an alternate-screen round trip. Native checks passed 20
+tests with one existing manual timing sample ignored; independent i18n checks pass.
+
+## Supersedes
+
+None. Applies the existing native-result retention and single-viewport decisions.
+
+## Revisit when
+
+The component exposes equivalent semantic block styling or measured document
+workloads justify replacing the custom presentation while retaining these behaviors.
diff --git a/architecture/notes/nebula_app/gpui_shell/terminal/native_results/2026-10-20-directory-folding.md b/architecture/notes/nebula_app/gpui_shell/terminal/native_results/2026-10-20-directory-folding.md
new file mode 100644
index 000000000..fa74facef
--- /dev/null
+++ b/architecture/notes/nebula_app/gpui_shell/terminal/native_results/2026-10-20-directory-folding.md
@@ -0,0 +1,74 @@
+# Preserve terminal directory output when folding
+
+## Status
+
+Implemented locally after the maintainer canceled directory tables. Targeted
+native interaction tests pass; packaged and real desktop responsiveness remain
+separate acceptance steps.
+
+## Context
+
+Replacing ordinary directory output with widgets lost the original OSC 8 file
+links, including in the widget's plain-text mode. The maintainer also reported
+slow row hover and requested original terminal presentation with only the middle
+collapsed: keep the first ten and last ten output rows and allow expansion.
+
+## Evidence
+
+Command capture already leaves characters, ANSI attributes and hyperlinks in the
+VT grid. A text-only result cannot reconstruct this metadata. Existing projection
+slots can shorten a source range, but hiding the entire source hides its links.
+Pointer mapping also clamped the inverse-projected source row to the visual
+screen size; this redirects hits on the visible tail of a compressed result.
+
+## Decision
+
+Recognized directory commands use an internal grid-fold presentation. Results of
+at most twenty physical output rows have no slot or controls. Longer results
+replace only their middle range with one control row. The retained full source
+range still owns marker/fingerprint validation and eviction.
+
+Expanded results preserve every original row and reserve one control line after
+the tenth row by projecting that preceding source row into two display rows while
+keeping its original cell painting enabled. Toggling preserves the viewport's
+source anchor, rather than following the newly enlarged output tail.
+
+First/tail rows continue through terminal text, color, selection, OSC 8 and pointer
+rendering. Inverse pointer bounds and hover anchors use the projected source
+range. Link decoration iterates that range, skips hidden rows before producing
+cells and advances through sorted matches once instead of scanning all matches
+for every cell. Copying a selection across the hidden middle includes its source.
+
+## Rejected alternatives
+
+- Show the old native Raw widget: it contains strings, not original VT attributes.
+- Recreate file URIs from filenames: loses the producer's host/path semantics.
+- Change the captured range to only the middle: breaks full-source validation.
+- Insert or delete terminal rows: changes PTY/cursor/history semantics.
+
+## Consequences
+
+Fold state is per retained result and adds no persisted setting or file lookup.
+Source invalidation/eviction restores original terminal output. Row counts refer
+to captured physical terminal output, not inferred filesystem entries. Other
+Markdown, JSON and Git presentations are unchanged.
+
+## Validation
+
+Real GPUI tests cover a 31-row ANSI/OSC 8 result, exactly eleven hidden middle
+rows, first/tail and expanded-middle link actions, complete cross-fold drag-copy,
+collapse, prompt hit mapping and absence of PTY input. A twenty-row boundary case
+has no native card or fold control. Existing pointer, native-result, link underline
+and command-classification checks pass. Desktop hover latency is not inferred
+from synthetic-window timing.
+
+## Supersedes
+
+The default directory-table presentation in
+`architecture/notes/nebula_terminal/inline_content/2026-10-20-directory-results-and-bounded-tables.md`.
+The existing bounded capture and source-validation decisions remain.
+
+## Revisit when
+
+Concrete output formats or reflow behavior require a different source-line boundary,
+while preserving original VT metadata and the single terminal scroll owner.
diff --git a/architecture/notes/nebula_terminal/inline_content/2026-10-18-native-result-surfaces.md b/architecture/notes/nebula_terminal/inline_content/2026-10-18-native-result-surfaces.md
new file mode 100644
index 000000000..51ff125f6
--- /dev/null
+++ b/architecture/notes/nebula_terminal/inline_content/2026-10-18-native-result-surfaces.md
@@ -0,0 +1,130 @@
+# Bounded inline result surfaces
+
+## Status
+
+Height, scrolling and view retention are superseded by
+`2026-10-19-unified-result-scroll.md`; ordinary command capture is extended by
+`2026-10-19-command-output-lenses.md`.
+
+Implemented locally; targeted product and Windows runtime checks passed. This note does not
+claim arbitrary stdout conversion or a cross-platform performance result.
+
+## Context
+
+The product needs native Markdown, JSON tables and Git progress at the location of
+the command result. Ordinary PTY output remains a VT stream; the existing grid is
+display state, not lossless stdout. Its absolute line numbers change during reflow.
+Reusing an image's old absolute anchor for an interactive result can cover later
+output and route pointer input to the wrong terminal cell.
+
+## Evidence
+
+- `inline_content.rs` defines MIME payloads, frame limits and retention.
+- `term/cell.rs` already uses optional shared `CellExtra` for uncommon attributes.
+- `event_loop.rs::StreamProcessor::feed` preserves OSC/VT wire order.
+- `gpui_shell/terminal/native_results` consumes only visible row markers and owns
+ all renderers, filtering, sorting, theme and pointer behavior.
+- `native_view_cli.rs` supplies an explicit entry from stdin/file or a Git child.
+- The pinned component's `TextViewState::push_str` parses streaming appends
+ asynchronously; `set_text` performs a synchronous full replacement.
+
+## Decision
+
+1. Use the private versioned `OSC 777;pebrel-view;1` extension with Begin/Place/Append/End
+ frames. This is not advertised as a universal terminal standard or TSP support.
+ Payload chunks are UTF-8 encoded through Base64. Terminal code owns framing,
+ storage and placement, not Markdown/table/Git interpretation.
+2. The sender writes real CRLFs between Place frames, so ConPTY and the frontend
+ advance together. The first Windows live trial showed that receiver-only line
+ insertion let the next command overwrite an earlier result. Native frames
+ commit pending synchronized VT output before applying placement. Attach a shared content marker only
+ to each reserved row's first cell, through optional `CellExtra`. Ordinary Cell
+ size remains unchanged. Markers are nonempty for reflow and line-length purposes.
+ The registry keeps weak references, so clear/overwrite releases the last marker's
+ source immediately. Alternate-screen views do not render
+ primary-screen results. A partially overwritten visible slot is not overpainted.
+3. Begin declares a maximum height of 2–40 terminal rows; it initially owns one
+ row. Place grows the slot sequentially at real column-zero positions, bounded
+ by that cap. Short Git failures occupy two rows; each visible phase or log
+ line adds one row plus the command status. The CLI and GUI share the Git model,
+ including EOF handling. Static tables use their record count; Markdown uses
+ a bounded source-line estimate, not a claim of exact measured document height.
+ Slots only grow while producing output and never delete existing terminal
+ rows. Content exceeding the slot scrolls internally; width follows the pane.
+4. Bound a frame at 48 KiB, a source at 4 MiB/4096 nonempty chunks, and retained
+ sources at 32/16 MiB per stream. Eviction releases raw chunks and becomes an
+ explicit state on remaining markers. These numbers bound source storage only;
+ UI strings, parser trees, rows, font/GPU state are additional measured costs.
+5. Chunks share references across snapshot boundaries. Source revision controls
+ incremental consumption. UI entities exist only for currently visible results;
+ offscreen layout caches are released. Reopening a result can therefore parse
+ its bounded source again and reset local filter/scroll state.
+6. Table parsing and ordering run in background tasks. Query generations reject
+ obsolete ordering results. Rows are virtualized. Invalid or oversized tables
+ retain source and present an explicit raw view. Markdown extensions remain
+ stable across frames to avoid triggering reparsing on every render.
+7. Native child controls consume pointer events before the ancestor terminal;
+ keyboard input stays in the relevant focus owner, with Escape returning to the
+ terminal. Copy/View/Raw float over the content only while hovered, keyboard
+ focused or showing clipboard success. Mouse-acquired focus alone cannot pin
+ the toolbar. Hover does not change the occupied rows. Clipboard success uses
+ the existing owned feedback lifecycle.
+8. `pebrel view --type markdown|table|git` is opt-in. A new local product pane
+ advertises `PEBREL_NATIVE_RESULTS=1`; other terminals and redirected stdout
+ use text. `--native` explicitly requests a compatible remote endpoint.
+9. `--type git -- git ...` captures stdout/stderr with bounded reader queues.
+ Stream identity is retained in frames, parser line buffers and raw-view selectors;
+ arbitrary reads from different pipes are never treated as one original byte stream.
+ The child uses the C locale for known Git phase parsing. Its exit code supplies
+ completion status; a phase's 100% does not mean the command succeeded. Shell
+ command completion closes a still-receiving surface as interrupted.
+
+## Rejected alternatives
+
+- Guess every arbitrary output as JSON/objects: PTY control traffic, partial data
+ and mixed stderr make that unreliable.
+- Store GUI types or arbitrary action scripts in the terminal core: violates the
+ dependency and trust boundaries.
+- Rebuild the entire VT/history model first: the current slice needs bounded
+ attachments, not a replacement terminal engine.
+- Add metadata to every Cell or every historical line: unnecessary permanent cost.
+- Reuse absolute image coordinates without reflow handling: stale anchors can
+ hide unrelated terminal output.
+- Treat a browser prototype as evidence of native performance or OS file drag:
+ those are separate runtime/platform capabilities.
+
+## Consequences
+
+Traditional output is not automatically transformed. Initial inputs are UTF-8
+Markdown, JSON object arrays/NDJSON and explicit Git capture. Tables currently
+accept at most 20,000 rows/64 columns as native data; larger inputs retain a raw
+view within the source budget. Result source is not persisted across app restarts.
+Reserved rows remain after source eviction with an explicit unavailable notice.
+Native source is not reconstructed by ordinary grid text capture; source copying
+belongs to the result view. No automatic remote image or HTML loading is added.
+
+## Validation
+
+Core split-frame/reflow/reset and incremental placement regressions passed locally,
+including rejection of gaps, duplicates and non-column-zero placement. The GPUI
+regression exercises the real first-row controls: hover, copying, feedback expiry,
+raw-view height, mouse/keyboard focus and PTY isolation. It exposed overlap with
+the terminal search entry; reserving its hit area keeps the toolbar reachable.
+The filtered application suite passed 120 tests with 8 existing ignored tests.
+Windows runtime checks passed on the compact revision: consecutive Markdown and
+JSON results, a real local Git clone (exit 0), a missing-repository failure (exit
+128), and native idle/hover screenshots. The existing Cell-size regression remains
+applicable. Visual acceptance by the user and release performance measurements
+remain separate from these debug-build checks.
+
+## Supersedes
+
+None. Existing bounded command reading still returns guarded display text and
+does not become a raw-byte result API.
+
+## Revisit when
+
+The slice requires exact pixel-measured document height, structured remote file actions,
+unbounded data/spooling, persistence, a standard protocol, or measured costs that
+invalidate the present budgets. Expand from verified workloads, not from format
+names alone.
diff --git a/architecture/notes/nebula_terminal/inline_content/2026-10-19-command-output-lenses.md b/architecture/notes/nebula_terminal/inline_content/2026-10-19-command-output-lenses.md
new file mode 100644
index 000000000..ab8a4cd26
--- /dev/null
+++ b/architecture/notes/nebula_terminal/inline_content/2026-10-19-command-output-lenses.md
@@ -0,0 +1,75 @@
+# Native views for ordinary shell output
+
+## Status
+
+Implemented on the 2.2.2 native-result branch; validation in progress.
+
+## Context
+
+The explicit `pebrel view` protocol rendered native content, but normal
+`cat long.md` still displayed Markdown source. A terminal output feature needs
+to enter through ordinary shell execution as well. The default document
+component also used oversized headings and framed tables in terminal results.
+
+## Evidence
+
+The reference command-lens contract selects commands from shell-reported text
+and preserves plain output for unmatched or interactive commands. Its terminal
+Markdown styles inherit body typography, use heading scales 1.25/1.15/1.05,
+and remove table outer frames. A generic Markdown component defaults to a 2.0
+heading scale and a rounded full table frame; inheriting body size alone does
+not implement this contract.
+
+PowerShell integration already reports accepted, alias-resolved command text
+through `pebrel_command` before OSC 133 C and reports completion via D. The
+terminal processes those reports in wire order. App-side completion parsing
+already distinguishes literal arguments from pipelines and shell expansions.
+
+## Decision
+
+The application classifies literal commands using that existing parser. A
+default-empty EventListener method supplies renderer metadata to the terminal;
+the terminal does not know command names or parse Markdown/JSON/Git formats.
+
+The original command executes once, unchanged. Its output continues through
+the VT parser. At successful completion, a bounded retained source range is
+attached to the existing inline-content registry. The native view replaces its
+visual extent while actual characters, terminal cursor and PTY geometry remain
+intact. Per-row fingerprints prevent a later overwrite from retaining stale
+rich content. Rejected, partial, evicted or interactive output stays plain.
+
+The captured text is terminal display text, including soft-wrap reconstruction;
+it is not advertised as byte-identical stdout. Exact source/stdout consumers
+continue using the explicit view protocol. Unknown shell ownership, failed
+commands and incomplete row boundaries do not create a native replacement.
+
+## Rejected alternatives
+
+- Rewrite `cat` aliases or replace the submitted command: changes shell meaning,
+ pipeline values or history, and can accidentally run a command twice.
+- Hide output before passing it through VT: ConPTY still advances its own grid,
+ leaving the shell's absolute cursor operations in a different coordinate space.
+- Treat every Markdown-looking line as a document: converts unrelated logs and
+ interactive applications with no command boundary.
+
+## Consequences
+
+Automatic results appear when a recognized command completes. Streaming explicit
+views retain their existing behavior. Host render snapshots must include the
+source rows after compressed results, and suppress the replaced grid glyphs.
+The same sparse projection continues to own scrolling and cursor placement.
+
+## Validation
+
+Targeted checks cover chunk boundaries, soft wraps, Unicode, preserved grid and
+cursor, failed/full-screen commands and a long ordinary Markdown command whose
+following prompt must remain visible. Live-window acceptance is still required.
+
+## Supersedes
+
+None. Extends the explicit transport and unified-scroll decisions.
+
+## Revisit when
+
+Additional shell integrations provide authoritative full command text, or
+measured large-output retention/layout costs justify incremental host capture.
diff --git a/architecture/notes/nebula_terminal/inline_content/2026-10-19-unified-result-scroll.md b/architecture/notes/nebula_terminal/inline_content/2026-10-19-unified-result-scroll.md
new file mode 100644
index 000000000..549ba2b51
--- /dev/null
+++ b/architecture/notes/nebula_terminal/inline_content/2026-10-19-unified-result-scroll.md
@@ -0,0 +1,104 @@
+# Native results share the terminal viewport
+
+## Status
+
+Integrated onto Pebrel 2.2.2 (`f0441e6d`, tree identical to `v2.2.2`).
+Core, terminal UI and migration checks passed on this baseline. Earlier 1.9.1
+experimental builds are superseded. Live visual acceptance remains pending.
+
+## Context
+
+The initial native result implementation reserved a bounded set of terminal rows
+and put independently scrollable Markdown, table, raw and Git views inside them.
+User validation found inconsistent font sizes, missing hover boundaries, broken
+scrolling and severe scroll latency. The explicit requirement is complete inline
+output with one terminal scroll path and no per-result vertical scroller.
+
+## Evidence
+
+The retained Tern protocol documentation, `protocol-surfaces.md`, describes a
+single grid anchor, natural-height surface layout, shared input/cursor geometry,
+content-based scroll anchoring and deferred offscreen layout. Extracted
+`surface-lens.css` confirms an absolute hover toolbar and a thin left boundary;
+`surface-content.css` confirms inherited body typography. The probe contains no
+complete termview Rust implementation. These are reference contracts and styles,
+not recovered implementation code copied into Pebrel.
+
+The previous `native_results/mod.rs` destroyed each entity when it left the
+viewport and used separate scrolling containers. Re-entering could recreate
+Markdown parsing and table ordering. Existing ordinary grid coordinates do not
+encode natural document height.
+
+## Decision
+
+The CLI now emits one marker row and one real CRLF. The receiver continues to
+accept existing sequential Place frames, but natural height never requires
+inserting/deleting VT rows or changing PTY dimensions.
+
+The application owns a sparse source-row/visual-row projection. Its scroll
+offset, painting, pointer conversion, decorations and cursor anchors use the
+same projection. The terminal exposes a presentation-only viewport origin so a
+natural-height result can place the visible start inside the live grid; VT
+processing retains source coordinates. Ordinary explicit scrolling clears that
+presentation override, allowing search/navigation to choose a new source anchor.
+
+Actual content layout reports height. Following output pins the content tail;
+reading a result preserves its identity and offset when heights change. Source
+identity retains existing renderer state across scrolling, while erased/expired
+markers release it. JSON/raw visible rows are projected into the outer viewport,
+without an independent scroll offset. Body and code typography derive from the
+terminal; hover decorations do not participate in layout.
+
+## Rejected alternatives
+
+- Hide the scrollbar while retaining the old fixed-height viewport: still clips
+ output and leaves independent scroll ownership.
+- Reserve ever more physical terminal rows: changes source coordinates and
+ cannot represent later wrapping or view changes reliably.
+- Reparse and rebuild each time a result becomes visible: repeats cold work
+ during scrolling.
+- Claim full TSP or recovered Tern layout algorithms: neither is implemented or
+ established by the retained sources.
+
+## Consequences
+
+The projection adds a presentation boundary without changing shell input or
+the terminal's grid storage. Result-source limits remain in force. Layout and
+parsed-state retention have additional cost and must be measured separately.
+Existing multi-row frames keep their source extent; new CLI output uses one
+anchor. A visible component must still disappear when its source is overwritten.
+
+## Validation
+
+On the 2.2.2 baseline, core tests passed 331 with two existing manual samples
+ignored. The terminal UI suite passed 313 with seven existing platform/manual
+conditions ignored, including native Markdown/table height, hover/copy controls,
+wheel and scrollbar routing, prompt hit mapping, resize, alternate-screen return,
+offscreen eviction and the 2.2.2 smooth-cursor projection. Four result-model and
+two CLI tests also passed. Isolated internationalization passed 24 with one
+existing manual sample ignored. Git Bash and PowerShell 7 were selected using
+the tests' existing executable-path overrides; no production changes were made
+to accommodate local PATH differences.
+
+History-relative architecture and changed-file formatting checks passed. The
+Windows build-selection and package-architecture fixtures passed; migration
+checks passed 117 assertions, and installer launch checks passed 22 assertions
+plus their real Run-entry fixture. Full installer syntax compiled but was not
+installed. Release ZIP/installer generation remains untested in this dev build.
+
+The 80-paragraph, 40-scroll synthetic-window sample on this baseline recorded
+a median 9.661 ms and maximum 13.303 ms, including executor drain and explicit
+window redraw. This is not a desktop GPU frame-time or release comparison.
+Live visual acceptance, cross-platform runtime and release performance remain
+separate. Version/help, PE resources and native CLI payloads are rechecked on
+the final preview artifacts; the old 1.9.1 artifacts are not used as evidence.
+
+## Supersedes
+
+The per-result scrolling and height-cap decisions in
+`2026-10-18-native-result-surfaces.md`. Other transport, ownership and limits stay.
+
+## Revisit when
+
+Measured large-document layout or retained-state costs need a more granular
+viewport cache; use stable content identities rather than per-scroll rebuilding.
diff --git a/architecture/notes/nebula_terminal/inline_content/2026-10-20-directory-results-and-bounded-tables.md b/architecture/notes/nebula_terminal/inline_content/2026-10-20-directory-results-and-bounded-tables.md
new file mode 100644
index 000000000..a179c1383
--- /dev/null
+++ b/architecture/notes/nebula_terminal/inline_content/2026-10-20-directory-results-and-bounded-tables.md
@@ -0,0 +1,100 @@
+# Directory output and bounded native tables
+
+## Status
+
+The ordinary directory-table presentation is superseded by
+`architecture/notes/nebula_app/gpui_shell/terminal/native_results/2026-10-20-directory-folding.md`.
+The JSON materialization limits and source-validation decisions remain applicable.
+
+Implemented locally. Parser, ordinary-command and native viewport regressions
+have run; release and cross-platform acceptance are recorded separately.
+
+## Context
+
+Ordinary Markdown/JSON output already enters the native-result registry through
+accepted shell command text. Directory output must use the same viewport and
+preserve only fields the command actually printed. Converting every space into a
+filename boundary or inspecting the current local filesystem would misrepresent
+quoted names, remote directories and historical results.
+
+Large JSON arrays also need a bound during parsing, before the complete array is
+materialized. A row count alone does not bound a sparse union of many columns.
+
+## Evidence
+
+- `command_lenses.rs` owns matching; `inline_content/command.rs` retains display
+ text and source-row fingerprints without executing a command again.
+- `native_result_model/directory.rs` covers POSIX long records, the existing
+ PowerShell directory wrapper and PowerShell's actual table header/ruler.
+- `tty/command.sh` reports full accepted commands. An interactive Bash probe
+ confirmed that duplicate/ignored history can keep the same history index;
+ using `history 1` without checking that index would reuse an unrelated command.
+- The directory viewport regression scrolls 300 real source rows from the last
+ result to the first, checks offscreen row omission and keeps the original prompt.
+
+## Decision
+
+Reuse native tables and their background parsing/filtering. Recognized long
+listings keep reported mode, link count, owner, group, size, timestamp and name;
+the PowerShell wrapper does not gain owner/group fields it never emitted.
+File icons are derived only from reported mode. Ambiguous names-only output
+keeps complete logical lines in original order, with no invented metadata.
+Unknown layouts retain their original text. Copying continues to use the captured
+display source, not reconstructed rows or a second filesystem query.
+
+Bash 4.4+ reports through a prefix to its existing PS0; the parent shell records
+the observed history generation using a zero-length parameter expansion. A
+missing/ignored history entry does not borrow the previous command. Zsh uses its
+accepted preexec argument. Neither path evaluates the report or overrides input
+bindings. PowerShell continues using its existing accepted-line report.
+
+JSON arrays stop while deserializing beyond 20,000 records. The existing 64-column
+limit is joined by a 262,144-cell materialization limit. Original payload limits
+still apply. Exceeded table limits retain full captured source for raw viewing;
+they do not truncate a successful table silently. These bounds describe specific
+data structures, not the total process or GPU memory ceiling.
+
+Ordinary scrolling skips only history that was already validated at the previous
+sync. Rows that were live at that sync must still be checked even if they became
+historical: a batch of VT input can overwrite a row and scroll it away before the
+next draw. The saved history boundary covers both marker loss and changed text
+with an intact marker. Reflow/reset revalidate the complete source. Offscreen table
+rows are omitted; the terminal owns scrolling.
+
+## Rejected alternatives
+
+- Replace `ls`/`Get-ChildItem` execution or query the directory again: changes
+ command semantics and can inspect the wrong host or a later filesystem state.
+- Split arbitrary whitespace into names: destroys valid filenames and ordering.
+- Parse an unrestricted JSON array before checking its row count: transient
+ allocations escape the intended materialization bound.
+- Recheck every historical cell on each wheel event: repeats work on immutable
+ history and makes scroll cost depend on the complete output length.
+
+## Consequences
+
+Unsupported/ignored command reports and unrecognized formats keep literal output.
+Only a verified successful command on the main screen can be replaced. Large
+result parsing and ordering can take longer than one frame, so they remain owned
+background tasks; an old query cannot replace a newer filter/sort result.
+
+## Validation
+
+Model regressions cover Unicode/spaced names, symlinks, missing fields, native
+PowerShell columns, original order, row/cell limits and formatted JSON objects.
+The native regressions check expanded height, body font, viewport row selection,
+PTY isolation and same-batch overwrite/scroll invalidation. Existing copy/hover,
+long Markdown, retention and prompt
+projection checks remain applicable. Timing samples are informational and do not
+assert that a debug synthetic window equals hardware presentation performance.
+
+## Supersedes
+
+Extends `2026-10-19-command-output-lenses.md` and the table materialization bound
+in `2026-10-18-native-result-surfaces.md`; transport and retention ownership stay.
+
+## Revisit when
+
+Real directory formats need additional fields, shell integrations expose accepted
+text without history, or measured workloads invalidate the current source/layout
+costs. Extend from captured output and concrete use cases, not inferred filenames.
diff --git a/nebula_app/AGENTS.md b/nebula_app/AGENTS.md
index adcc54314..af0f65bdd 100644
--- a/nebula_app/AGENTS.md
+++ b/nebula_app/AGENTS.md
@@ -1,6 +1,7 @@
# Nebula application rules
- 本目录是应用编排和适配层;依赖方向保持为 UI/组合 → 应用能力 → 共享领域规则,不在视图里复制设置、持久化、终端状态或协议状态机。
+- 原生结果的块级呈现和目录折叠遵循 [`native_results`](../architecture/notes/nebula_app/gpui_shell/terminal/native_results/);文字、链接和命中共享终端的滚动投影。
- `src/gpui_shell` 负责 GPUI 呈现、命令和订阅。交互改动必须覆盖默认、hover、pressed、键盘焦点、disabled/loading 以及成功/失败中实际需要的状态。
- 首帧窗口尺寸、非阻塞 logo 准备与原生呈现顺序的因果记录见 [`workspace`](../architecture/notes/nebula_app/gpui_shell/workspace/)。
- Linux 静默启动、关窗守卫、桌面身份与 portal 激活令牌见 [`原生生命周期`](../architecture/notes/nebula_app/platform/2026-10-14-linux-desktop-lifecycle.md)。
diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml
index ea8a8de49..36fbe14c8 100644
--- a/nebula_app/Cargo.toml
+++ b/nebula_app/Cargo.toml
@@ -1,5 +1,6 @@
[package]
name = "nebula"
+default-run = "pebrel"
version = "2.2.2"
license = "GPL-3.0-or-later"
description = "Pebrel — a GPU-accelerated terminal emulator"
@@ -13,6 +14,12 @@ rust-version.workspace = true
name = "pebrel"
path = "src/main.rs"
+[[bin]]
+name = "pebrel-cli"
+path = "src/console_main.rs"
+test = false
+bench = false
+
[dependencies.nebula_terminal]
path = "../nebula_terminal"
version = "2.2.2"
diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json
index d1493326b..07d54a9a6 100644
--- a/nebula_app/i18n/en-US.json
+++ b/nebula_app/i18n/en-US.json
@@ -1,4 +1,32 @@
{
+ "native_result": {
+ "fold_expand": "{count} lines omitted · Expand",
+ "fold_collapse": "Collapse the middle {count} lines",
+ "view": "View",
+ "raw": "Raw",
+ "copy": "Copy source",
+ "copied": "Copied",
+ "filter": "Filter rows…",
+ "preparing": "Preparing results…",
+ "row_count": "{count} rows",
+ "filtered_count": "{visible} / {total} rows",
+ "no_rows": "No matching rows. Try another filter.",
+ "receiving": "Receiving output…",
+ "complete": "Completed",
+ "failed": "Command failed",
+ "interrupted": "Output interrupted",
+ "limit": "Output exceeded the native view limit; retained source is incomplete.",
+ "evicted": "This result was released from the retained output budget.",
+ "table_limit": "This result exceeds the display limit. Full source remains available.",
+ "file_name": "Name",
+ "file_mode": "Mode",
+ "file_links": "Links",
+ "file_owner": "Owner",
+ "file_group": "Group",
+ "file_size": "Size",
+ "file_modified": "Modified",
+ "table_invalid": "Expected JSON objects or NDJSON. Showing the original source."
+ },
"providers": {
"saving": "Saving provider…",
"applying": "Applying CLI configuration…",
diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json
index 7736ea709..dfd44fa02 100644
--- a/nebula_app/i18n/zh-CN.json
+++ b/nebula_app/i18n/zh-CN.json
@@ -1,4 +1,32 @@
{
+ "native_result": {
+ "fold_expand": "已省略 {count} 行 · 展开",
+ "fold_collapse": "收起中间 {count} 行",
+ "view": "视图",
+ "raw": "原文",
+ "copy": "复制原文",
+ "copied": "已复制",
+ "filter": "筛选数据行…",
+ "preparing": "正在整理结果…",
+ "row_count": "{count} 行",
+ "filtered_count": "{visible} / {total} 行",
+ "no_rows": "没有匹配的数据,试试其他关键词。",
+ "receiving": "正在接收输出…",
+ "complete": "已完成",
+ "failed": "命令执行失败",
+ "interrupted": "输出已中断",
+ "limit": "输出超过原生视图限额,保留的原文不完整。",
+ "evicted": "此结果已超出历史保留预算并释放。",
+ "table_limit": "此结果超过显示限额,可查看完整原文。",
+ "file_name": "名称",
+ "file_mode": "权限",
+ "file_links": "链接数",
+ "file_owner": "所有者",
+ "file_group": "组",
+ "file_size": "大小",
+ "file_modified": "修改时间",
+ "table_invalid": "需要 JSON 对象或 NDJSON,已显示原文。"
+ },
"providers": {
"saving": "正在保存供应商…",
"applying": "正在应用 CLI 配置…",
diff --git a/nebula_app/src/cli.rs b/nebula_app/src/cli.rs
index 2e6975d45..de514116d 100644
--- a/nebula_app/src/cli.rs
+++ b/nebula_app/src/cli.rs
@@ -290,6 +290,8 @@ impl WindowIdentity {
/// Available CLI subcommands.
#[derive(Subcommand, Debug)]
pub enum Subcommands {
+ /// Render Markdown, JSON tables, or Git progress in the current terminal.
+ View(crate::native_view_cli::Options),
/// Agent-oriented terminal control: split panes, run commands, start Codex/Claude,
/// send prompts, wait for state changes, and read verified terminal output.
Ctl(ControlOptions),
diff --git a/nebula_app/src/console_main.rs b/nebula_app/src/console_main.rs
new file mode 100644
index 000000000..062ee0c7e
--- /dev/null
+++ b/nebula_app/src/console_main.rs
@@ -0,0 +1,31 @@
+//! Windows 包中的 pebrel.com:保留管道、等待和退出码,不把 GUI 子系统当控制台。
+mod native_result_model;
+mod native_view_cli;
+
+use clap::Parser as _;
+
+#[derive(clap::Parser)]
+#[command(bin_name = "pebrel view")]
+struct View {
+ #[command(flatten)]
+ options: native_view_cli::Options,
+}
+
+fn main() {
+ let mut arguments: Vec<_> = std::env::args_os().skip(1).collect();
+ if arguments.first().is_some_and(|argument| argument == "view") {
+ arguments[0] = "pebrel view".into();
+ std::process::exit(native_view_cli::run(View::parse_from(arguments).options));
+ }
+ let result = std::env::current_exe().and_then(|path| {
+ let executable = path.with_file_name(format!("pebrel{}", std::env::consts::EXE_SUFFIX));
+ std::process::Command::new(executable).args(arguments).status()
+ });
+ match result {
+ Ok(status) => std::process::exit(status.code().unwrap_or(1)),
+ Err(error) => {
+ eprintln!("pebrel: {error}");
+ std::process::exit(1);
+ },
+ }
+}
diff --git a/nebula_app/src/gpui_shell/terminal/answer_reader.rs b/nebula_app/src/gpui_shell/terminal/answer_reader.rs
index c08609fcb..5ec950495 100644
--- a/nebula_app/src/gpui_shell/terminal/answer_reader.rs
+++ b/nebula_app/src/gpui_shell/terminal/answer_reader.rs
@@ -261,7 +261,7 @@ impl AnswerReader {
}
}
-fn contains_uncontrolled_media(node: &markdown_ast::Node) -> bool {
+pub(super) fn contains_uncontrolled_media(node: &markdown_ast::Node) -> bool {
let mut pending = vec![node];
while let Some(node) = pending.pop() {
if matches!(
diff --git a/nebula_app/src/gpui_shell/terminal/element.rs b/nebula_app/src/gpui_shell/terminal/element.rs
index 2d79f691b..5f01b6577 100644
--- a/nebula_app/src/gpui_shell/terminal/element.rs
+++ b/nebula_app/src/gpui_shell/terminal/element.rs
@@ -122,12 +122,18 @@ impl TerminalElement {
};
// 分段只反映内容与宽度类,绝不掺入光标状态:per-cell 绘制下反色只是
// 换色,若让闪烁相位改变分段,整行会随闪烁重塑形而跳字。
- let snapshot = RenderSnapshot::capture(
+ let projection = view.native_results.projection.borrow();
+ let source_rows = projection.source_rows(rows);
+ let snapshot = RenderSnapshot::capture_rows(
&term,
- &SnapshotConfig { rows: rows as u16, cols: cols as u16 },
+ &SnapshotConfig { rows: source_rows as u16, cols: cols as u16 },
+ |row| !projection.covers(row),
);
let history = term.history_size();
- let dashed = super::osc_links::dashed_cells(&term, &hint_config, rows, cols);
+ let dashed =
+ super::osc_links::dashed_cells_where(&term, &hint_config, source_rows, cols, |row| {
+ !projection.covers(row)
+ });
let scrollback_floor = term.grid().scrolled_out();
let image_anchor = scrollback_floor.saturating_add(history) as i64;
let viewport_top_abs = image_anchor + i64::from(term.viewport_origin_for(rows).0);
@@ -395,11 +401,13 @@ impl Element for TerminalElement {
.view
.update(cx, |view, cx| view.math.finalize_frame(pending_math_frame, scale_factor, cx));
+ let vertical = self.view.read(cx).native_results.projection.borrow().clone();
+ let row_y = |row: f32| layout.line_height * vertical.row(row);
let cell_rect = |row: usize, start: usize, count: usize| -> Bounds {
Bounds::new(
point(
bounds.origin.x + layout.cell_width * start as f32,
- bounds.origin.y + layout.line_height * row as f32,
+ bounds.origin.y + row_y(row as f32),
),
size(layout.cell_width * count as f32, layout.line_height),
)
@@ -426,6 +434,10 @@ impl Element for TerminalElement {
.cursor
.as_ref()
.map_or(0, |c| target_col as i32 - i32::from(c.col)),
+ row_projection_shift: snap
+ .cursor
+ .as_ref()
+ .map_or(0.0, |c| vertical.row(c.row as f32) - c.row as f32),
shape: snap.cursor.as_ref().map_or(CursorShape::Hidden, |c| c.shape),
};
let visual = self.view.update(cx, |view, cx| {
@@ -433,7 +445,7 @@ impl Element for TerminalElement {
snap.cursor.as_ref(),
target_col,
cursor_geometry,
- snap.display_offset == 0,
+ vertical.scroll_state().map_or(snap.display_offset == 0, |(offset, _)| offset == 0),
window,
cx,
)
@@ -651,7 +663,7 @@ impl Element for TerminalElement {
};
let origin = point(
snap_to_device(bounds.origin.x + layout.cell_width * visual_col as f32, scale),
- snap_to_device(bounds.origin.y + layout.line_height * glyph.row as f32, scale),
+ snap_to_device(bounds.origin.y + row_y(glyph.row as f32), scale),
);
paint_box_glyph(window, &prims, origin, fg);
if let Some(cursor) = &cursor_paint {
@@ -706,7 +718,7 @@ impl Element for TerminalElement {
};
let origin = point(
bounds.origin.x + layout.cell_width * visual_col as f32,
- bounds.origin.y + layout.line_height * seg.row as f32,
+ bounds.origin.y + row_y(seg.row as f32),
);
let count = if ligatures && !seg.wide && !dashed_link {
super::ligatures::span_len(remaining, |next, offset| {
@@ -823,6 +835,7 @@ impl Element for TerminalElement {
(layout.cell_width.as_f32(), layout.line_height.as_f32()),
math_pixels_per_point,
Rgba { a: 1.0, ..selection_fill },
+ &|y| row_y(y / layout.line_height.as_f32()).as_f32(),
window,
cx,
);
@@ -851,8 +864,7 @@ impl Element for TerminalElement {
let viewport_top = bounds.origin.y.as_f32();
let viewport_bottom = viewport_top + bounds.size.height.as_f32();
for inline in inline_images {
- let y = bounds.origin.y
- + layout.line_height * (inline.abs_line as i64 - viewport_top_abs) as f32;
+ let y = bounds.origin.y + row_y((inline.abs_line as i64 - viewport_top_abs) as f32);
let mut width = inline.display_width / device_scale;
let mut height = inline.display_height / device_scale;
let fit = (bounds.size.width.as_f32() / width.max(1.0)).min(1.0);
@@ -922,7 +934,7 @@ impl Element for TerminalElement {
colors.ghost,
point(
bounds.origin.x + layout.cell_width * cursor_visual_col as f32,
- bounds.origin.y + layout.line_height * cursor.row as f32,
+ bounds.origin.y + row_y(cursor.row as f32),
),
layout.cell_width,
layout.line_height,
@@ -959,7 +971,7 @@ impl Element for TerminalElement {
popup_selected,
popup_offset,
popup_hovered,
- cursor.row as usize,
+ vertical.row(cursor.row as f32).max(0.0) as usize,
cursor_visual_col,
layout,
&bounds,
@@ -1042,7 +1054,7 @@ impl Element for TerminalElement {
window,
cx,
&hover.preview,
- hover.anchor_row as usize,
+ vertical.row(hover.anchor_row as f32).max(0.0) as usize,
anchor_col,
layout,
&bounds,
diff --git a/nebula_app/src/gpui_shell/terminal/element/reading.rs b/nebula_app/src/gpui_shell/terminal/element/reading.rs
index ac0c02f5e..29781c8b4 100644
--- a/nebula_app/src/gpui_shell/terminal/element/reading.rs
+++ b/nebula_app/src/gpui_shell/terminal/element/reading.rs
@@ -22,7 +22,12 @@ impl TerminalElement {
return;
}
let color = cx.theme().primary.opacity(0.30);
- for row in 0..layout.rows {
+ let projection = view.native_results.projection.borrow();
+ // 折叠后的可见尾行可能来自屏幕范围以外;隐藏原文也不能把高亮压到控制行上。
+ for row in 0..projection.source_rows(layout.rows) {
+ if projection.covers(row) {
+ continue;
+ }
let absolute = viewport_top + row as i64;
if absolute < 0 {
continue;
@@ -38,16 +43,16 @@ impl TerminalElement {
if start >= end {
continue;
}
- window.paint_quad(fill(
- Bounds::new(
- point(
- bounds.origin.x + layout.cell_width * start as f32,
- bounds.origin.y + layout.line_height * row as f32,
- ),
- size(layout.cell_width * (end - start) as f32, layout.line_height),
+ let highlight = Bounds::new(
+ point(
+ bounds.origin.x + layout.cell_width * start as f32,
+ bounds.origin.y + layout.line_height * projection.row(row as f32),
),
- color,
- ));
+ size(layout.cell_width * (end - start) as f32, layout.line_height),
+ );
+ #[cfg(all(test, feature = "gpui-test-support"))]
+ snapshot_probe::record_reading_highlight(self.view.entity_id(), highlight, cx);
+ window.paint_quad(fill(highlight, color));
}
}
}
diff --git a/nebula_app/src/gpui_shell/terminal/element/snapshot_probe.rs b/nebula_app/src/gpui_shell/terminal/element/snapshot_probe.rs
index e1f7cf902..4ba8ba83f 100644
--- a/nebula_app/src/gpui_shell/terminal/element/snapshot_probe.rs
+++ b/nebula_app/src/gpui_shell/terminal/element/snapshot_probe.rs
@@ -18,6 +18,7 @@ pub(in crate::gpui_shell) struct PaintedSnapshot {
pub selection: Vec<(u16, u16, u16)>,
pub origin: Point,
pub cell_size: Size,
+ pub reading_highlights: Vec>,
}
#[derive(Default)]
@@ -62,6 +63,14 @@ pub(super) fn record(
.collect(),
origin: bounds.origin,
cell_size: size(layout.cell_width, layout.line_height),
+ reading_highlights: Vec::new(),
},
);
}
+
+pub(super) fn record_reading_highlight(entity: EntityId, bounds: Bounds, cx: &App) {
+ let Some(probe) = cx.try_global::() else { return };
+ if let Some(snapshot) = probe.0.borrow_mut().get_mut(&entity) {
+ snapshot.reading_highlights.push(bounds);
+ }
+}
diff --git a/nebula_app/src/gpui_shell/terminal/link_underline.rs b/nebula_app/src/gpui_shell/terminal/link_underline.rs
index 9d73d3c6a..3c0afbe30 100644
--- a/nebula_app/src/gpui_shell/terminal/link_underline.rs
+++ b/nebula_app/src/gpui_shell/terminal/link_underline.rs
@@ -50,7 +50,7 @@ mod tests {
use super::*;
use crate::config::UiConfig;
- use crate::gpui_shell::terminal::osc_links::dashed_cells;
+ use crate::gpui_shell::terminal::osc_links::{dashed_cells, dashed_cells_where};
use nebula_terminal::event::VoidListener;
use nebula_terminal::term::test::TermSize;
use nebula_terminal::term::{Config, Term};
@@ -127,4 +127,43 @@ mod tests {
}
}
}
+
+ #[test]
+ fn hidden_source_rows_preserve_link_context_without_per_cell_filtering() {
+ let mut term = Term::new(Config::default(), &TermSize::new(120, 40), VoidListener);
+ let mut parser: ansi::Processor = ansi::Processor::new();
+ let mut wire = String::new();
+ for row in 0..35 {
+ let suffix = if row == 9 { "x".repeat(300) } else { row.to_string() };
+ wire.push_str(&format!(
+ "\x1b[32m\x1b]8;;file:///fixture/{row}\x1b\\A开始 菜单Z\x1b]8;;\x1b\\\x1b[0m https://example.com/{suffix}\r\n"
+ ));
+ }
+ parser.advance(&mut term, wire.as_bytes());
+ let config = UiConfig::default();
+ let all = dashed_cells(&term, &config, 40, 120);
+ let checks = std::cell::Cell::new(0);
+ let visible = dashed_cells_where(&term, &config, 40, 120, |row| {
+ checks.set(checks.get() + 1);
+ !(10..30).contains(&row)
+ });
+ assert_eq!(checks.get(), 40, "visibility is a row operation, not a cell operation");
+ let expected: BTreeSet<_> =
+ all.keys().filter(|(row, _)| !(10..30).contains(row)).copied().collect();
+ assert_eq!(visible.keys().copied().collect::>(), expected);
+ assert!(visible.contains_key(&(0, 0)) && visible.contains_key(&(34, 0)));
+ assert!(
+ visible.contains_key(&(9, 119)),
+ "a visible URL fragment keeps its hidden continuation context"
+ );
+ for (point, value) in &visible {
+ let original = &all[point];
+ assert_eq!(value.fg, original.fg);
+ assert_eq!(value.bg, original.bg);
+ assert_eq!(
+ (value.bold, value.dim, value.bg_dim),
+ (original.bold, original.dim, original.bg_dim)
+ );
+ }
+ }
}
diff --git a/nebula_app/src/gpui_shell/terminal/math_overlay.rs b/nebula_app/src/gpui_shell/terminal/math_overlay.rs
index f017eac4d..023af85b2 100644
--- a/nebula_app/src/gpui_shell/terminal/math_overlay.rs
+++ b/nebula_app/src/gpui_shell/terminal/math_overlay.rs
@@ -495,6 +495,7 @@ pub fn paint_frame(
cell: (f32, f32),
pixels_per_point: f32,
selection_color: Rgba,
+ map_y: &dyn Fn(f32) -> f32,
window: &mut Window,
cx: &mut App,
) {
@@ -506,14 +507,14 @@ pub fn paint_frame(
// 裁剪跟随 bleed 预算(与旧壳 MathClip 同一矩形),交给 GPUI 的
// content mask 执行像素级裁剪。
let clip = Bounds::new(
- point(origin.x + px(plan.clip_left), origin.y + px(plan.clip_top)),
+ point(origin.x + px(plan.clip_left), origin.y + px(map_y(plan.clip_top))),
size(
px((plan.clip_right - plan.clip_left).max(0.0)),
- px((plan.clip_bottom - plan.clip_top).max(0.0)),
+ px((map_y(plan.clip_bottom) - map_y(plan.clip_top)).max(0.0)),
),
);
let left = origin.x + px(plan.origin_x);
- let baseline = origin.y + px(plan.baseline_y);
+ let baseline = origin.y + px(map_y(plan.baseline_y));
window.with_content_mask(Some(gpui::ContentMask { bounds: clip }), |window| {
math_view::paint_formula_image(
&source,
@@ -561,7 +562,7 @@ pub fn paint_frame(
);
let target = point(
origin.x + px(x),
- origin.y + px(plan.baseline_y + op.baseline_y) - line.ascent,
+ origin.y + px(map_y(plan.baseline_y + op.baseline_y)) - line.ascent,
);
let _ = line.paint(
target,
diff --git a/nebula_app/src/gpui_shell/terminal/mod.rs b/nebula_app/src/gpui_shell/terminal/mod.rs
index 29409fb35..f3d3a3a1b 100644
--- a/nebula_app/src/gpui_shell/terminal/mod.rs
+++ b/nebula_app/src/gpui_shell/terminal/mod.rs
@@ -23,6 +23,7 @@ mod link_underline;
pub mod math_overlay;
mod molecule_overlay;
pub mod mouse_protocol;
+mod native_results;
mod osc_links;
mod reading;
pub mod session;
diff --git a/nebula_app/src/gpui_shell/terminal/molecule_overlay.rs b/nebula_app/src/gpui_shell/terminal/molecule_overlay.rs
index 7ffc979e7..5ce9379c6 100644
--- a/nebula_app/src/gpui_shell/terminal/molecule_overlay.rs
+++ b/nebula_app/src/gpui_shell/terminal/molecule_overlay.rs
@@ -181,7 +181,17 @@ pub(super) fn paint(
continue;
}
let area = Bounds::new(
- point(bounds.origin.x + x, bounds.origin.y + line_height * block.rows.start as f32),
+ point(
+ bounds.origin.x + x,
+ bounds.origin.y
+ + line_height
+ * view
+ .read(cx)
+ .native_results
+ .projection
+ .borrow()
+ .row(block.rows.start as f32),
+ ),
size(available, height),
);
let target_width = available.min(height * 2.0);
diff --git a/nebula_app/src/gpui_shell/terminal/native_results/code.rs b/nebula_app/src/gpui_shell/terminal/native_results/code.rs
new file mode 100644
index 000000000..3a6d1a5f3
--- /dev/null
+++ b/nebula_app/src/gpui_shell/terminal/native_results/code.rs
@@ -0,0 +1,337 @@
+//! 结果中的代码块复用文本选择和语法着色,行号与操作浮层不进入复制内容。
+use super::*;
+use gpui::{App, Focusable as _, SharedString, TextRun};
+use gpui_component::button::ButtonCustomVariant;
+use gpui_component::select::{SearchableVec, SelectEvent};
+
+#[derive(Clone)]
+pub(super) struct Spec {
+ pub source: SharedString,
+ pub language: SharedString,
+ pub offset: usize,
+ pub lines: usize,
+}
+
+pub(super) struct Code {
+ spec: Spec,
+ text: Entity,
+ language: Entity>>,
+ plain: SharedString,
+ feedback: Entity,
+ focus: FocusHandle,
+ hovered: bool,
+ family: SharedString,
+ size: Pixels,
+ width: Pixels,
+ top: Pixels,
+ line_tops: Vec,
+ metrics: Option<(SharedString, Pixels, Pixels)>,
+ _subscriptions: Vec,
+}
+
+fn fenced(source: &str, language: &str) -> String {
+ // 波浪线围栏允许语言名含反引号;重建后正文必须仍是代码,不能重新成为图片/HTML。
+ let fence = "~".repeat(source.split(|c| c != '~').map(str::len).max().unwrap_or(0).max(2) + 1);
+ let language = if language.chars().any(char::is_control) { "" } else { language };
+ format!("{fence}{language}\n{source}\n{fence}\n")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn native_result_code_fences_keep_unusual_languages_and_markup_literal() {
+ let source =
+ "\n~~~\n
\n```";
+ for language in ["x`y", "bash", "text\nmalformed"] {
+ let parsed =
+ ::markdown::to_mdast(&fenced(source, language), &::markdown::ParseOptions::gfm())
+ .unwrap();
+ let children = parsed.children().unwrap();
+ assert_eq!(children.len(), 1);
+ let gpui_component::text::markdown_ast::Node::Code(code) = &children[0] else {
+ panic!("source escaped code fence")
+ };
+ assert_eq!(code.value, source);
+ }
+ }
+}
+
+impl Code {
+ fn new(spec: Spec, window: &mut Window, cx: &mut Context) -> Self {
+ let plain: SharedString =
+ crate::gpui_shell::config::ui_language(cx).text(Message::MarkdownPlainText).into();
+ let mut languages = gpui_component::highlighter::LanguageRegistry::singleton().languages();
+ languages.sort();
+ languages.insert(0, plain.clone());
+ let current = if spec.language.is_empty() { plain.clone() } else { spec.language.clone() };
+ if !languages.contains(¤t) {
+ languages.push(current.clone());
+ }
+ let index = languages.iter().position(|name| name == ¤t).unwrap_or(0);
+ let language = cx.new(|cx| {
+ SelectState::new(SearchableVec::new(languages), Some(IndexPath::new(index)), window, cx)
+ .searchable(true)
+ });
+ let feedback = cx.new(|_| CopyFeedback::new());
+ let focus = cx.focus_handle();
+ let subscriptions = vec![
+ cx.observe(&feedback, |_, _, cx| cx.notify()),
+ cx.observe(&language, |_, _, cx| cx.notify()),
+ cx.on_focus_in(&focus, window, |_, _, cx| cx.notify()),
+ cx.on_focus_out(&focus, window, |_, _, _, cx| cx.notify()),
+ cx.subscribe(&language, |this, _, event, cx| {
+ if let SelectEvent::Confirm(Some(language)) = event {
+ let lang = if *language == this.plain { "" } else { language.as_ref() };
+ let source = fenced(&this.spec.source, lang);
+ this.text.update(cx, |text, cx| text.set_text(&source, cx));
+ cx.notify();
+ }
+ }),
+ ];
+ let text = cx.new(|cx| TextViewState::markdown(&fenced(&spec.source, &spec.language), cx));
+ Self {
+ spec,
+ text,
+ language,
+ plain,
+ feedback,
+ focus,
+ hovered: false,
+ family: "monospace".into(),
+ size: px(14.0),
+ width: px(600.0),
+ top: px(0.0),
+ line_tops: Vec::new(),
+ metrics: None,
+ _subscriptions: subscriptions,
+ }
+ }
+}
+
+pub(super) fn render(
+ spec: &Spec,
+ owner: &gpui::WeakEntity,
+ window: &mut Window,
+ cx: &mut App,
+) -> AnyElement {
+ let Some(owner) = owner.upgrade() else { return div().into_any_element() };
+ let (state, family, size) = owner.update(cx, |result, cx| {
+ // 代码语言选择和解析随结果保留,不能因离开视口一帧就被 keyed state 回收。
+ let state = result
+ .code_blocks
+ .entry(spec.offset)
+ .or_insert_with(|| cx.new(|cx| Code::new(spec.clone(), window, cx)))
+ .clone();
+ (state, result.font_family.clone(), result.font_size)
+ });
+ state.update(cx, |code, cx| {
+ if code.spec.source != spec.source || code.spec.language != spec.language {
+ code.spec = spec.clone();
+ code.metrics = None;
+ let current =
+ if spec.language.is_empty() { code.plain.clone() } else { spec.language.clone() };
+ code.language
+ .update(cx, |language, cx| language.set_selected_value(¤t, window, cx));
+ code.text
+ .update(cx, |text, cx| text.set_text(&fenced(&spec.source, &spec.language), cx));
+ }
+ if code.family != family || code.size != size {
+ code.family = family;
+ code.size = size;
+ cx.notify();
+ }
+ });
+ state.into_any_element()
+}
+
+impl Render for Code {
+ fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ let line_height = self.size * 1.65;
+ let digits = self.spec.lines.max(1).ilog10() + 1;
+ let gutter = px(19.0).max(px(digits as f32 * 7.0)) + px(14.0);
+ let wrap_width = (self.width - gutter - px(32.0)).max(px(1.0));
+ let metrics = (self.family.clone(), self.size, wrap_width);
+ if self.metrics.as_ref() != Some(&metrics) {
+ // 使用与正文相同的字体换行器缓存物理行位置;窗口变宽后行号仍对应源代码行。
+ let source = self.spec.source.clone();
+ if let Ok(lines) = window.text_system().shape_text(
+ source.clone(),
+ self.size,
+ &[TextRun {
+ len: source.len(),
+ font: gpui::font(self.family.clone()),
+ color: cx.theme().foreground,
+ background_color: None,
+ underline: None,
+ strikethrough: None,
+ }],
+ Some(wrap_width),
+ None,
+ ) {
+ let mut top = px(0.0);
+ self.line_tops = lines
+ .iter()
+ .map(|line| {
+ let current = top;
+ top += line.size(line_height).height;
+ current
+ })
+ .collect();
+ self.metrics = Some(metrics);
+ }
+ }
+ let language = crate::gpui_shell::config::ui_language(cx);
+ let copied = self.feedback.read(cx).is_copied();
+ let keyboard = self.focus.contains_focused(window, cx) && window.last_input_was_keyboard();
+ let picker_focused = self.language.read(cx).focus_handle(cx).contains_focused(window, cx);
+ let tools = self.hovered || copied || keyboard || picker_focused;
+ // 终端原型使用轻微下沉的代码底色,不继承独立文档页的抬亮色面。
+ let background =
+ cx.theme().background.darken(if cx.theme().is_dark() { 0.18 } else { 0.035 });
+ let ink = cx.theme().foreground;
+ let mut code_style =
+ gpui::StyleRefinement::default().p_0().rounded(px(0.0)).bg(gpui::transparent_black());
+ code_style.text.font_family = Some(self.family.clone());
+ code_style.text.font_size = Some(self.size.into());
+ code_style.text.line_height = Some(line_height.into());
+ let first = self.line_tops.partition_point(|top| self.top + *top + line_height < px(0.0));
+ let last =
+ self.line_tops.partition_point(|top| self.top + *top < window.viewport_size().height);
+ let measured = cx.entity().downgrade();
+ div()
+ .on_children_prepainted(move |bounds, _, cx| {
+ if let Some(bounds) = bounds.first() {
+ let _ = measured.update(cx, |this, cx| {
+ let width = bounds.size.width + px(32.0);
+ if (this.width - width).abs() > px(0.5)
+ || (this.top - bounds.top()).abs() > px(0.5)
+ {
+ this.width = width;
+ this.top = bounds.top();
+ cx.notify();
+ }
+ });
+ }
+ })
+ .id("native-code-block")
+ .debug_selector(|| "native-code-block".into())
+ .relative()
+ .w_full()
+ .min_w_0()
+ .mt(px(3.0))
+ .mb(px(8.0))
+ .px(px(16.0))
+ .py(px(14.0))
+ .rounded(px(9.0))
+ .bg(background)
+ .border_1()
+ .border_color(cx.theme().foreground.opacity(0.09))
+ .font_family(self.family.clone())
+ .text_size(self.size)
+ .line_height(line_height)
+ .text_color(ink)
+ .track_focus(&self.focus)
+ .on_hover(cx.listener(|this, hovered, _, cx| {
+ if this.hovered != *hovered {
+ this.hovered = *hovered;
+ cx.notify();
+ }
+ }))
+ .child(div().pl(gutter).w_full().min_w_0().child(
+ TextView::new(&self.text).selectable(true).scrollable(false).style(TextViewStyle {
+ code_block: code_style,
+ highlight_theme: cx.theme().highlight_theme.clone(),
+ is_dark: cx.theme().is_dark(),
+ ..Default::default()
+ }),
+ ))
+ .children((first..last).map(|index| {
+ div()
+ .absolute()
+ .left(px(16.0))
+ .top(px(14.0) + self.line_tops[index])
+ .w(gutter - px(14.0))
+ .text_right()
+ .text_size(px(11.0))
+ .line_height(line_height)
+ .text_color(cx.theme().muted_foreground.opacity(0.65))
+ .child((index + 1).to_string())
+ }))
+ .child(
+ div()
+ .absolute()
+ .top(px(5.0))
+ .right(px(5.0))
+ .flex()
+ .items_center()
+ .gap(px(3.0))
+ .p(px(2.0))
+ .rounded(px(6.0))
+ .bg(background)
+ .font_family(cx.theme().font_family.clone())
+ .opacity(if tools { 1.0 } else { 0.0 })
+ .child(
+ div()
+ .debug_selector(|| "native-code-language".into())
+ .w(px(120.0))
+ .h(px(28.0))
+ .child(
+ Select::new(&self.language)
+ .appearance(false)
+ .small()
+ .h(px(28.0))
+ .menu_width(px(210.0))
+ .search_placeholder(
+ language.text(Message::EditorSearchCodeLanguage),
+ ),
+ ),
+ )
+ .child(
+ Button::new("native-code-copy")
+ .debug_selector(|| "native-code-copy".into())
+ .small()
+ .h(px(28.0))
+ .min_w(px(30.0))
+ .px(px(6.0))
+ .custom(
+ ButtonCustomVariant::new(cx)
+ .foreground(cx.theme().muted_foreground)
+ .hover(cx.theme().list_hover)
+ .active(cx.theme().list_hover),
+ )
+ .tooltip(language.text(if copied {
+ Message::NativeResultCopied
+ } else {
+ Message::EditorCopyCode
+ }))
+ .child(
+ div()
+ .flex()
+ .items_center()
+ .gap(px(4.0))
+ .text_size(px(11.0))
+ .child(
+ Icon::new(if copied {
+ IconName::Check
+ } else {
+ IconName::Copy
+ })
+ .size(px(14.0)),
+ )
+ .when(copied, |button| {
+ button.child(language.text(Message::NativeResultCopied))
+ }),
+ )
+ .on_click(cx.listener(|this, _, _, cx| {
+ cx.write_to_clipboard(ClipboardItem::new_string(
+ this.spec.source.to_string(),
+ ));
+ this.feedback.update(cx, |feedback, cx| feedback.mark_copied(cx));
+ cx.stop_propagation();
+ })),
+ ),
+ )
+ }
+}
diff --git a/nebula_app/src/gpui_shell/terminal/native_results/fold.rs b/nebula_app/src/gpui_shell/terminal/native_results/fold.rs
new file mode 100644
index 000000000..3ba2435db
--- /dev/null
+++ b/nebula_app/src/gpui_shell/terminal/native_results/fold.rs
@@ -0,0 +1,96 @@
+//! 目录输出仅折叠中间源行,首尾仍由终端绘制,保留颜色、链接与选择语义。
+use super::*;
+use gpui_component::button::ButtonCustomVariant;
+
+pub(super) const KEEP: usize = 10;
+
+pub(super) fn is_fold(source: &Surface) -> bool {
+ source.has_grid_source() && source.mime.as_ref() == "text/x-terminal-fold"
+}
+
+pub(super) fn element(
+ view: &mut TerminalView,
+ source: &Surface,
+ row: f32,
+ window: &mut Window,
+ cx: &mut Context,
+) -> AnyElement {
+ let id = source.id;
+ let expanded = view.native_results.fold_expanded(id);
+ let count = usize::from(source.rows()).saturating_sub(KEEP * 2).to_string();
+ let label = crate::gpui_shell::config::ui_language(cx).format(
+ if expanded { Message::NativeResultFoldCollapse } else { Message::NativeResultFoldExpand },
+ &[("count", &count)],
+ );
+ let focus = window
+ .use_keyed_state(("terminal-fold-focus", id), cx, |_, cx| cx.focus_handle())
+ .read(cx)
+ .clone();
+ let on_down_focus = focus.clone();
+ div()
+ .id(("terminal-fold", id))
+ .debug_selector(|| "terminal-output-fold".into())
+ .absolute()
+ .left(px(12.0))
+ .top(px(8.0) + view.line_height * row)
+ .w(view.native_result_width())
+ .h(view.line_height)
+ .bg(cx.theme().background)
+ .track_focus(&focus)
+ .child(
+ Button::new(("terminal-fold-toggle", id))
+ .debug_selector(|| "terminal-output-fold-toggle".into())
+ .xsmall()
+ .w_full()
+ .h(view.line_height)
+ .px_0()
+ .aria_label(label.clone())
+ .custom(
+ ButtonCustomVariant::new(cx)
+ .foreground(cx.theme().muted_foreground)
+ .hover(cx.theme().foreground.opacity(0.04))
+ .active(cx.theme().foreground.opacity(0.08)),
+ )
+ .child(
+ div()
+ .w_full()
+ .flex()
+ .items_center()
+ .gap(px(6.0))
+ .px(px(4.0))
+ .font_family(cx.theme().font_family.clone())
+ .text_size(px(12.0))
+ .child(
+ Icon::new(if expanded {
+ IconName::ChevronUp
+ } else {
+ IconName::ChevronDown
+ })
+ .size(px(14.0)),
+ )
+ .child(label),
+ )
+ .on_click(cx.listener(move |view, _, _, cx| {
+ view.toggle_output_fold(id, cx);
+ })),
+ )
+ .on_mouse_down(
+ MouseButton::Left,
+ cx.listener(move |_, _, window, cx| {
+ window.focus(&on_down_focus, cx);
+ cx.stop_propagation();
+ }),
+ )
+ .on_mouse_up(MouseButton::Left, |_, _, cx| cx.stop_propagation())
+ .on_scroll_wheel(cx.listener(|view, event, window, cx| {
+ view.on_scroll(event, window, cx);
+ cx.stop_propagation();
+ }))
+ .on_key_down(cx.listener(|view, event: &gpui::KeyDownEvent, window, cx| {
+ if event.keystroke.key == "escape" {
+ window.focus(&view.focus_handle, cx);
+ cx.stop_propagation();
+ }
+ }))
+ .into_any_element()
+}
diff --git a/nebula_app/src/gpui_shell/terminal/native_results/markdown.rs b/nebula_app/src/gpui_shell/terminal/native_results/markdown.rs
new file mode 100644
index 000000000..bf2671b56
--- /dev/null
+++ b/nebula_app/src/gpui_shell/terminal/native_results/markdown.rs
@@ -0,0 +1,301 @@
+//! 保留 Markdown 语义与选择能力,只替换原型要求不同的块级呈现。
+use super::*;
+use gpui::{App, FontWeight, SharedString, TextRun};
+use gpui_component::text::markdown_ast::{AlignKind, Node};
+
+#[derive(Clone)]
+struct Heading {
+ source: SharedString,
+ depth: u8,
+ offset: usize,
+}
+
+struct Cell {
+ source: SharedString,
+ text: SharedString,
+}
+
+struct Table {
+ rows: Vec>,
+ align: Vec,
+ offset: usize,
+}
+
+#[derive(Default)]
+struct TableLayout {
+ widths: Vec,
+ fonts: Option<(gpui::Font, gpui::Font)>,
+ size: Pixels,
+ scale: f32,
+ source: String,
+ scroll: gpui::ScrollHandle,
+}
+
+fn needs_document_context(node: &Node) -> bool {
+ // 引用和公式继续交给拥有全文定义及科学渲染上下文的现有 TextView。
+ matches!(node, Node::LinkReference(_) | Node::FootnoteReference(_) | Node::InlineMath(_))
+ || node.children().is_some_and(|children| children.iter().any(needs_document_context))
+}
+
+fn table_html_cells(html: &str) -> Vec<&str> {
+ // 只拆固定 markdown-rs 生成的 table 包装;用户文本已经由编译器转义。
+ // 先在原表格上下文解析,避免把单元格的 # / - / --- 再解释成块级结构。
+ let mut cells = Vec::new();
+ let mut rest = html;
+ while let Some(start) = rest.find("' | b' ')) {
+ continue;
+ }
+ let Some((_, body)) = rest.split_once('>') else { break };
+ let close = if kind == b'h' { "" } else { "" };
+ let Some((cell, remaining)) = body.split_once(close) else { break };
+ cells.push(cell);
+ rest = remaining;
+ }
+ cells
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn native_result_inline_content_is_compiled_in_its_original_context() {
+ let heading = ::markdown::to_html("# ---");
+ assert_eq!(heading, "---
");
+ let source = "| value |\n| --- |\n| # title |\n| - item |\n| **strong** and `code` |";
+ let html = ::markdown::to_html_with_options(source, &::markdown::Options::gfm()).unwrap();
+ assert_eq!(
+ table_html_cells(&html),
+ ["value", "# title", "- item", "strong and code"]
+ );
+ }
+}
+
+pub(super) fn extensions(owner: gpui::WeakEntity) -> MarkdownExtensions {
+ let code_owner = owner.clone();
+ let table_owner = owner.clone();
+ MarkdownExtensions::default()
+ .block_parser(|node, context| {
+ let raw = context.node_source(node)?;
+ if super::super::answer_reader::contains_uncontrolled_media(node) {
+ return Some(
+ MarkdownNode::new("native-literal", ())
+ .text(raw.to_owned())
+ .markdown(raw.to_owned()),
+ );
+ }
+ let offset = node.position()?.start.offset + context.offset();
+ let custom = match node {
+ Node::Heading(heading) => {
+ if needs_document_context(node) {
+ return None;
+ }
+ let html =
+ ::markdown::to_html_with_options(raw, &::markdown::Options::gfm()).ok()?;
+ let source = html.split_once('>')?.1.rsplit_once("{source}
").into(),
+ depth: heading.depth,
+ offset,
+ },
+ )
+ },
+ Node::Code(code)
+ if !matches!(code.lang.as_deref(), Some("math" | "latex" | "tex")) =>
+ {
+ MarkdownNode::new(
+ "native-code",
+ super::code::Spec {
+ source: code.value.clone().into(),
+ language: code.lang.clone().unwrap_or_default().into(),
+ offset,
+ lines: code.value.lines().count().max(1),
+ },
+ )
+ },
+ Node::Table(table) => {
+ if needs_document_context(node) {
+ return None;
+ }
+ let html =
+ ::markdown::to_html_with_options(raw, &::markdown::Options::gfm()).ok()?;
+ let mut content = table_html_cells(&html).into_iter();
+ let rows = table
+ .children
+ .iter()
+ .filter_map(|row| row.children())
+ .map(|cells| {
+ cells
+ .iter()
+ .map(|cell| {
+ Some(Cell {
+ source: format!("{}
", content.next()?).into(),
+ text: cell.to_string().into(),
+ })
+ })
+ .collect::