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 = + "![probe](https://example.invalid/probe.png)\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::>>() + }) + .collect::>>()?; + MarkdownNode::new( + "native-markdown-table", + Table { rows, align: table.align.clone(), offset }, + ) + }, + _ => return None, + }; + Some(custom.text(node.to_string()).markdown(raw.to_owned())) + }) + .block_renderer("native-literal", |node, _, _| div().child(node.as_text().to_owned())) + .block_renderer("native-heading", move |node, _, cx| { + let heading = node.data::().expect("heading parser data"); + let size = owner.upgrade().map_or(px(14.0), |owner| owner.read(cx).font_size); + let heading_size = size + * if heading.depth == 1 { + 1.3 + } else if heading.depth == 2 { + 1.05 + } else { + 1.0 + }; + div() + .debug_selector(|| "native-markdown-heading".into()) + .font_family(cx.theme().font_family.clone()) + .text_size(heading_size) + .font_weight(FontWeight::SEMIBOLD) + .line_height(heading_size * if heading.depth == 1 { 1.4 } else { 1.5 }) + .when(heading.offset > 0, |heading| heading.mt(px(10.0))) + .pb(px(if heading.depth == 1 { 9.0 } else { 7.0 })) + .child( + TextView::html(("native-heading", heading.offset), heading.source.clone()) + .selectable(true) + .scrollable(false), + ) + }) + .block_renderer("native-code", move |node, window, cx| { + super::code::render( + node.data::().expect("code parser data"), + &code_owner, + window, + cx, + ) + }) + .block_renderer("native-markdown-table", move |node, window, cx| { + render_table(node, &table_owner, window, cx) + }) +} + +fn render_table( + node: &MarkdownNode, + owner: &gpui::WeakEntity, + window: &mut Window, + cx: &mut App, +) -> AnyElement { + let table = node.data::().expect("table parser data"); + let Some(owner) = owner.upgrade() else { return div().into_any_element() }; + let result = owner.read(cx); + let size = result.font_size; + let mut body_font = window.text_style().font(); + body_font.family = result.font_family.clone(); + let mut heading_font = body_font.clone(); + heading_font.family = cx.theme().font_family.clone(); + heading_font.weight = FontWeight::MEDIUM; + let fonts = (body_font, heading_font); + let scale = window.scale_factor(); + let state = + window.use_keyed_state(("native-markdown-table-layout", table.offset), cx, |_, _| { + TableLayout::default() + }); + state.update(cx, |layout, cx| { + if layout.source == node.as_markdown() + && layout.fonts.as_ref() == Some(&fonts) + && layout.size == size + && layout.scale == scale + { + return; + } + layout.source = node.as_markdown().to_owned(); + layout.fonts = Some(fonts.clone()); + layout.size = size; + layout.scale = scale; + layout.widths = vec![px(0.0); table.align.len()]; + for (row, cells) in table.rows.iter().enumerate() { + for (column, cell) in cells.iter().enumerate().take(layout.widths.len()) { + let text = field_preview(&cell.text); + let font = if row == 0 { fonts.1.clone() } else { fonts.0.clone() }; + let width = window + .text_system() + .shape_line( + text.clone().into(), + if row == 0 { px(12.0) } else { size }, + &[TextRun { + len: text.len(), + font, + color: cx.theme().foreground, + background_color: None, + underline: None, + strikethrough: None, + }], + None, + ) + .width; + // 布局会按设备像素取整;向上保留内容宽度,避免刚好容纳的中文末字换行。 + let width = px((width.min(px(320.0)).as_f32() * scale).ceil() / scale); + layout.widths[column] = layout.widths[column] + .max(width + px(if column + 1 == table.align.len() { 8.0 } else { 38.0 })); + } + } + }); + let layout = state.read(cx); + let total = layout.widths.iter().copied().fold(px(0.0), |sum, width| sum + width); + div() + .id(("native-markdown-table", table.offset)) + .debug_selector(|| "native-markdown-table".into()) + .max_w_full() + .min_w_0() + .mt(px(10.0)) + .mb(px(16.0)) + .overflow_x_scroll() + .restrict_scroll_to_axis() + .track_scroll(&layout.scroll) + .child(div().w(total).children(table.rows.iter().enumerate().map(|(row, cells)| { + div() + .flex() + .w_full() + .when(row + 1 < table.rows.len(), |row| { + row.border_b_1().border_color(cx.theme().foreground.opacity(0.09)) + }) + .when(row == 0, |row| { + row.font_family(cx.theme().font_family.clone()) + .text_size(px(12.0)) + .font_weight(FontWeight::MEDIUM) + .text_color(cx.theme().muted_foreground) + }) + .children(cells.iter().enumerate().take(layout.widths.len()).map( + |(column, cell)| { + div() + .w(layout.widths[column]) + .flex_shrink_0() + .py(px(6.0)) + .pr(px(if column + 1 == cells.len() { 8.0 } else { 38.0 })) + .when(table.align[column] == AlignKind::Right, |cell| cell.text_right()) + .when(table.align[column] == AlignKind::Center, |cell| { + cell.text_center() + }) + .child( + TextView::html( + ("native-markdown-cell", row * table.align.len() + column), + cell.source.clone(), + ) + .selectable(true) + .scrollable(false), + ) + }, + )) + }))) + .into_any_element() +} diff --git a/nebula_app/src/gpui_shell/terminal/native_results/mod.rs b/nebula_app/src/gpui_shell/terminal/native_results/mod.rs new file mode 100644 index 000000000..2cec511a2 --- /dev/null +++ b/nebula_app/src/gpui_shell/terminal/native_results/mod.rs @@ -0,0 +1,728 @@ +//! 原生结果视图。网格只给出锚点;主题、控件、解析和交互由应用拥有。 +use crate::native_result_model as model; + +use super::view::TerminalView; +use crate::gpui_shell::copy_feedback::CopyFeedback; +use crate::gpui_shell::prelude::*; +use crate::i18n::Message; +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, AppContext as _, ClipboardItem, Context, Entity, FocusHandle, + InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, Pixels, Render, + StatefulInteractiveElement as _, Styled as _, Subscription, Task, Window, div, px, relative, + rems, +}; +use gpui_component::input::{Input, InputEvent, InputState}; +use gpui_component::text::{ + MarkdownExtensions, MarkdownNode, TextView, TextViewState, TextViewStyle, +}; +use nebula_terminal::grid::{Dimensions as _, GridCell as _}; +use nebula_terminal::index::{Column, Point}; +use nebula_terminal::inline_content::{Status, Surface}; +use nebula_terminal::term::TermMode; +use std::collections::HashMap; +use std::sync::{Arc, Weak}; + +mod code; +mod fold; +mod markdown; +mod table; +mod tools; +mod viewport; +pub(super) use viewport::Views; + +impl TerminalView { + pub(super) fn native_result_elements( + &mut self, + window: &mut Window, + cx: &mut Context<'_, Self>, + ) -> Vec { + let Some(session) = &self.session else { return Vec::new() }; + self.native_results.reconcile_retention(cx); + self.native_results.sync(&mut session.term.lock(), self.grid_rows(), cx); + let placements = self.native_results.placements(); + let available_width = self.native_result_width(); + let parent = cx.entity().downgrade(); + placements + .into_iter() + .map(|(surface, row, rows)| { + if fold::is_fold(&surface) { + return fold::element(self, &surface, row, window, cx); + } + let entity = self + .native_results + .entities + .entry(surface.id) + .or_insert_with(|| { + cx.new(|cx| { + NativeResult::new( + &surface, + self.focus_handle.clone(), + parent.clone(), + window, + cx, + ) + }) + }) + .clone(); + entity.update(cx, |view, cx| { + let top = (-row * self.line_height.as_f32()).max(0.0); + if view.row_height != self.line_height + || view.font_size != self.font_size + || view.available_width != available_width + || (view.visible_top - top).abs() > 0.5 + { + cx.notify(); + } + view.row_height = self.line_height; + view.font_size = self.font_size; + view.font_family = self.font.family.clone(); + view.available_width = available_width; + view.viewport_top = 8.0 + row * self.line_height.as_f32(); + view.visible_top = top; + view.visible_height = self.grid_rows() as f32 * self.line_height.as_f32(); + view.sync(cx); + }); + div() + .absolute() + .left(px(12.0)) + .w(self.native_result_width()) + .top(px(8.0) + self.line_height * row) + .min_h(self.line_height * rows) + .child(entity) + .into_any_element() + }) + .collect() + } +} + +pub(super) struct NativeResult { + source: Weak, + mime: Arc, + row_height: Pixels, + font_size: Pixels, + font_family: gpui::SharedString, + measured_height: Pixels, + visible_top: f32, + visible_height: f32, + available_width: Pixels, + viewport_top: f32, + parent: gpui::WeakEntity, + hovered: bool, + toolbar_hovered: bool, + revision: u64, + chunks: usize, + raw: model::RawText, + stderr: model::RawText, + raw_stderr: bool, + status: Status, + raw_mode: bool, + markdown: Entity, + extensions: MarkdownExtensions, + code_blocks: HashMap>, + table: Option>, + table_layout: table::Layout, + table_scroll: gpui::ScrollHandle, + order: Vec, + sort: Option<(usize, bool)>, + filter: Entity, + filter_open: bool, + query_epoch: u64, + parsing: bool, + notice: Option, + git: model::GitProgress, + parent_focus: FocusHandle, + focus: FocusHandle, + feedback: Entity, + task: Option>, + _subscriptions: Vec, +} + +impl NativeResult { + #[cfg(test)] + pub(super) fn height(&self) -> Pixels { + self.measured_height + } + #[cfg(test)] + pub(super) fn body_font_size(&self) -> Pixels { + self.font_size + } + #[cfg(test)] + pub(super) fn displayed_rows(&self) -> Vec> { + self.table.as_ref().map_or_else(Vec::new, |table| { + self.order.iter().map(|&row| table.rows[row].clone()).collect() + }) + } + #[cfg(test)] + pub(super) fn code_identities(&self) -> Vec { + self.code_blocks.values().map(Entity::entity_id).collect() + } + fn new( + source: &Arc, + parent_focus: FocusHandle, + parent: gpui::WeakEntity, + window: &mut Window, + cx: &mut Context<'_, Self>, + ) -> Self { + let language = crate::gpui_shell::config::ui_language(cx); + let filter = cx.new(|cx| { + InputState::new(window, cx).placeholder(language.text(Message::NativeResultFilter)) + }); + let feedback = cx.new(|_| CopyFeedback::new()); + let focus = cx.focus_handle(); + let subscriptions = vec![ + cx.observe(&feedback, |_, _, cx| cx.notify()), + cx.on_focus_in(&focus, window, |_, _, cx| cx.notify()), + cx.on_focus_out(&focus, window, |_, _, _, cx| cx.notify()), + cx.subscribe(&filter, |this, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + this.order_table(cx); + } + }), + ]; + // 扩展实例必须稳定:每帧重建会触发上游重新解析整份 Markdown。 + let extensions = markdown::extensions(cx.entity().downgrade()); + Self { + source: Arc::downgrade(source), + mime: source.mime.clone(), + row_height: px(22.0), + font_size: px(14.0), + font_family: "monospace".into(), + measured_height: px(0.0), + visible_top: 0.0, + visible_height: 600.0, + available_width: px(900.0), + viewport_top: 0.0, + parent, + hovered: false, + toolbar_hovered: false, + revision: 0, + chunks: 0, + raw: model::RawText::default(), + stderr: model::RawText::default(), + raw_stderr: false, + status: Status::Receiving, + raw_mode: false, + markdown: cx.new(|cx| TextViewState::markdown("", cx)), + extensions, + code_blocks: HashMap::new(), + table: None, + table_layout: table::Layout::default(), + table_scroll: gpui::ScrollHandle::new(), + order: Vec::new(), + sort: None, + filter, + filter_open: false, + query_epoch: 0, + parsing: false, + notice: None, + git: model::GitProgress::default(), + parent_focus, + focus, + feedback, + task: None, + _subscriptions: subscriptions, + } + } + + fn sync(&mut self, cx: &mut Context<'_, Self>) { + let Some(source) = self.source.upgrade() else { return }; + if source.revision() == self.revision { + return; + } + let update = source.update_since(self.chunks); + self.revision = update.revision; + self.chunks = update.chunk_count; + self.status = update.status; + if self.status == Status::Evicted { + self.raw = model::RawText::default(); + self.stderr = model::RawText::default(); + self.table = None; + self.table_layout = table::Layout::default(); + self.code_blocks.clear(); + self.order.clear(); + self.task = None; + self.query_epoch += 1; + self.markdown = cx.new(|cx| TextViewState::markdown("", cx)); + self.notice = Some(Message::NativeResultEvicted); + cx.notify(); + return; + } + for chunk in update.chunks { + if chunk.stderr { + if !self.raw_mode && self.stderr.text.is_empty() { + self.raw_stderr = true; + } + self.stderr.append(&chunk.text); + } else { + self.raw.append(&chunk.text); + } + if self.mime.as_ref() == "text/markdown" { + self.markdown.update(cx, |text, cx| text.push_str(&chunk.text, cx)); + } else if self.mime.as_ref() == "text/x-git-progress" { + self.git.feed(&chunk.text, chunk.stderr); + } + } + if matches!(self.mime.as_ref(), "text/x-diff" | "text/x-git-output") { + // 使用长于正文反引号序列的围栏,文件名/日志内容不会改变文档结构。 + let fence = "`".repeat( + self.raw.text.split(|c| c != '`').map(str::len).max().unwrap_or(0).max(2) + 1, + ); + let language = if self.mime.as_ref() == "text/x-diff" { "diff" } else { "text" }; + let source = format!("{fence}{language}\n{}\n{fence}\n", self.raw.text); + self.markdown = cx.new(|cx| TextViewState::markdown(&source, cx)); + } + if self.status != Status::Receiving { + self.git.finish(); + } + if self.status == Status::Limited { + self.notice = Some(Message::NativeResultLimit); + } + if matches!( + self.mime.as_ref(), + "application/json" | "text/x-directory-listing" | "text/x-powershell-directory" + ) && self.status != Status::Receiving + && self.table.is_none() + && !self.parsing + { + self.parsing = true; + let raw = self.raw.text.clone(); + let mime = self.mime.clone(); + self.task = Some(cx.spawn(async move |this, cx| { + let table = cx + .background_spawn(async move { + if mime.as_ref() == "application/json" { + model::Table::parse(&raw) + } else { + model::Table::parse_directory( + &raw, + mime.as_ref() == "text/x-powershell-directory", + ) + } + .map(|table| { + let layout = table::Layout::prepare(&table); + (Arc::new(table), layout) + }) + }) + .await; + let _ = this.update(cx, |this, cx| { + this.parsing = false; + match table { + Ok((table, layout)) => { + this.table = Some(table); + this.table_layout = layout; + this.order_table(cx); + }, + Err(error) => { + this.notice = Some(if error == "limit" { + Message::NativeResultTableLimit + } else { + Message::NativeResultTableInvalid + }); + this.raw_mode = true; + }, + } + cx.notify(); + }); + })); + } + cx.notify(); + } + + fn order_table(&mut self, cx: &mut Context<'_, Self>) { + let Some(table) = self.table.clone() else { return }; + self.query_epoch += 1; + let epoch = self.query_epoch; + let query = self.filter.read(cx).value().to_string(); + let sort = self.sort; + self.task = Some(cx.spawn(async move |this, cx| { + let order = cx.background_spawn(async move { table.order(&query, sort) }).await; + let _ = this.update(cx, |this, cx| { + if this.query_epoch == epoch { + this.order = order; + cx.notify(); + } + }); + })); + } + + fn raw_element(&self, _: &mut Context<'_, Self>) -> AnyElement { + let raw = self.selected_raw(); + let first = (self.visible_top / self.row_height.as_f32()).floor() as usize; + let last = ((self.visible_top + self.visible_height) / self.row_height.as_f32()).ceil() + as usize + + 2; + div() + .debug_selector(|| "native-result-raw-content".into()) + .relative() + .w_full() + .h(self.row_height * raw.lines.len() as f32) + .children((first.saturating_sub(2)..last.min(raw.lines.len())).map(|index| { + div() + .absolute() + .top(self.row_height * index as f32) + .h(self.row_height) + .px_2() + .font_family(self.font_family.clone()) + .whitespace_nowrap() + .child(raw.text[raw.lines[index].clone()].to_owned()) + })) + .into_any_element() + } + + fn selected_raw(&self) -> &model::RawText { + if self.raw_stderr { &self.stderr } else { &self.raw } + } + + fn copy_source(&mut self, cx: &mut Context<'_, Self>) { + if self.status == Status::Evicted { + return; + } + cx.write_to_clipboard(ClipboardItem::new_string(self.selected_raw().text.clone())); + self.feedback.update(cx, |feedback, cx| feedback.mark_copied(cx)); + } + + fn git_element(&self, cx: &Context<'_, Self>) -> AnyElement { + let mut body = div() + .id("native-git-phases") + .w_full() + .max_w(px(834.0)) + .pl(px(18.0)) + .pr(px(16.0)) + .flex() + .flex_col(); + for (index, phase) in self.git.phases.iter().enumerate() { + let complete = phase.percent == Some(100) + || phase.detail.ends_with("done.") + || index + 1 < self.git.phases.len() + || self.status == Status::Finished(0); + let detail = phase + .percent + .and_then(|_| phase.detail.split_once('%')) + .map_or(phase.detail.as_str(), |(_, detail)| detail.trim()); + let item = div() + .debug_selector(|| "native-git-phase".into()) + .flex() + .items_center() + .gap(px(12.0)) + .h(px(29.0)) + .flex_shrink_0() + .min_w_0() + .rounded(px(5.0)) + .hover(|row| row.bg(cx.theme().foreground.opacity(0.035))) + .child( + div().w(px(18.0)).flex_shrink_0().child( + Icon::new(if complete { IconName::Check } else { IconName::ArrowRight }) + .size(px(14.0)) + .text_color(if complete { + cx.theme().success + } else { + cx.theme().primary + }), + ), + ) + .child( + div() + .w(relative(0.28)) + .min_w(px(100.0)) + .flex_shrink_0() + .text_ellipsis() + .text_color(if complete { + cx.theme().muted_foreground + } else { + cx.theme().foreground + }) + .child(phase.name.clone()), + ) + .child( + div() + .w(relative(0.20)) + .max_w(px(180.0)) + .h(px(3.0)) + .rounded_sm() + .bg(cx.theme().foreground.opacity(0.09)) + .when_some(phase.percent, |bar, percent| { + bar.child( + div() + .h_full() + .w(relative(f32::from(percent) / 100.0)) + .rounded_sm() + .bg(cx.theme().primary.opacity(if percent == 100 { + 0.65 + } else { + 1.0 + })), + ) + }), + ) + .child( + div() + .w(px(44.0)) + .text_size(px(12.0)) + .flex_shrink_0() + .text_right() + .text_color(cx.theme().muted_foreground) + .child( + phase + .percent + .map_or_else(|| "—".into(), |percent| format!("{percent}%")), + ), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_size(px(12.0)) + .text_ellipsis() + .text_color(cx.theme().muted_foreground) + .child(detail.to_owned()), + ); + body = body.child(item); + } + for line in self.git.tail.iter().map(String::as_str).chain(self.git.pending_lines()) { + body = body.child( + div() + .h(self.row_height) + .flex_shrink_0() + .text_ellipsis() + .text_color(if line.starts_with("fatal:") || line.starts_with("error:") { + cx.theme().danger + } else { + cx.theme().muted_foreground + }) + .child(line.to_owned()), + ); + } + body.into_any_element() + } +} + +impl Render for NativeResult { + fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement { + let language = crate::gpui_shell::config::ui_language(cx); + let t = |id| language.text(id); + let status = match self.status { + Status::Receiving => Message::NativeResultReceiving, + Status::Finished(0) => Message::NativeResultComplete, + Status::Finished(_) => Message::NativeResultFailed, + Status::Interrupted => Message::NativeResultInterrupted, + Status::Limited => Message::NativeResultLimit, + Status::Evicted => Message::NativeResultEvicted, + }; + let copied = self.feedback.read(cx).is_copied(); + // 鼠标点击留下的焦点不应把工具栏钉住;键盘进入和复制反馈仍可发现操作。 + let tools_visible = self.hovered + || self.toolbar_hovered + || copied + || (self.focus.contains_focused(window, cx) && window.last_input_was_keyboard()); + let show_status = self.mime.as_ref() == "text/x-git-progress" + || !matches!(self.status, Status::Receiving | Status::Finished(0)); + let body = if self.raw_mode { + self.raw_element(cx) + } else { + match self.mime.as_ref() { + "text/markdown" | "text/x-diff" | "text/x-git-output" => { + // 输出中的远程图片/HTML不能因预览而悄悄发起请求或读取本地文件。 + div() + .w_full() + .pl(px(18.0)) + .pr(px(16.0)) + .line_height(self.font_size * 1.65) + .child( + TextView::new(&self.markdown) + .selectable(true) + .scrollable(false) + .markdown_extensions(self.extensions.clone()) + .style(TextViewStyle { + heading_base_font_size: self.font_size, + // 终端 surface 的标题层级保持紧凑,不沿用文档页默认的两倍字号。 + heading_font_size: Some(Arc::new(|level, base| { + base * match level { + 1 => 1.3, + 2 => 1.05, + _ => 1.0, + } + })), + paragraph_gap: rems(10.0 / window.rem_size().as_f32()), + inline_code_radius: px(4.0), + inline_code: gpui::HighlightStyle { + background_color: Some( + cx.theme().foreground.opacity(0.035), + ), + ..Default::default() + }, + highlight_theme: cx.theme().highlight_theme.clone(), + is_dark: cx.theme().is_dark(), + ..Default::default() + }), + ) + .into_any_element() + }, + "application/json" | "text/x-directory-listing" | "text/x-powershell-directory" => { + self.table_element(window, cx) + }, + "text/x-git-progress" => self.git_element(cx), + _ => self.raw_element(cx), + } + }; + let tools = tools_visible.then(|| self.toolbar_element(window, cx)); + let content_count = + 1 + usize::from(self.notice.is_some()) + usize::from(show_status && !self.raw_mode); + let measured = cx.entity().downgrade(); + let mut root = div().on_children_prepainted(move |bounds, _, cx| { + let height = bounds.iter().take(content_count).fold(0.0_f32, |height, bounds| height + bounds.size.height.as_f32()); + let _ = measured.update(cx, |this, cx| { + if (this.measured_height.as_f32() - height).abs() > 0.5 { + this.measured_height = px(height); + let _ = this.parent.update(cx, |_, cx| cx.notify()); + } + }); + }) + .id("native-result") + .debug_selector(|| "native-result".into()) + .occlude() + .relative() + .w_full() + .max_w(px(900.0)) + .min_w_0() + .flex() + .flex_col() + .bg(cx.theme().background) + .text_size(self.font_size) + .font_family(self.font_family.clone()) + .line_height(self.row_height) + .text_color(cx.theme().foreground) + .track_focus(&self.focus) + .on_hover(cx.listener(|this, hovered, _, cx| { + if this.hovered != *hovered { + this.hovered = *hovered; + cx.notify(); + } + })) + .on_scroll_wheel(cx.listener(|this, event: &gpui::ScrollWheelEvent, window, cx| { + // 原生区域会遮挡网格命中;明确交回终端的同一个滚动入口。 + let vertical = match &event.delta { + gpui::ScrollDelta::Pixels(delta) => delta.y.as_f32() != 0.0, + gpui::ScrollDelta::Lines(delta) => delta.y != 0.0, + }; + if vertical { + let _ = this.parent.update(cx, |view, cx| view.on_scroll(event, window, cx)); + } + cx.stop_propagation(); + })) + .key_context("PebrelNativeResult") + .on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, window, cx| { + if super::keymap::is_native_window_shortcut(&event.keystroke) { + return; + } + if this.focus.is_focused(window) && event.keystroke.key == "c" + && (event.keystroke.modifiers.control || event.keystroke.modifiers.platform) + { + this.copy_source(cx); + } + if event.keystroke.key == "escape" { + if this.filter_open { + this.filter_open = false; + this.filter.update(cx, |input, cx| input.set_value("", window, cx)); + this.order_table(cx); + window.focus(&this.focus, cx); + } else { + window.focus(&this.parent_focus, cx); + } + cx.notify(); + } + // 子输入框需要平台继续生成字符/IME 消息;终端自身已按焦点隔离 PTY 输入。 + if event.keystroke.key == "escape" || (this.focus.is_focused(window) && event.keystroke.key != "tab") { + cx.stop_propagation(); + } + })) + .when_some(self.notice, |root, notice| { + root.child(div().px_2().text_color(cx.theme().warning).child(t(notice))) + }) + .child( + div() + .w_full() + .min_w_0() + // 与批准的轻量 lens 保持相同的上下呼吸边距。 + .when( + !self.raw_mode + && matches!( + self.mime.as_ref(), + "text/markdown" + | "application/json" + | "text/x-directory-listing" + | "text/x-powershell-directory" + | "text/x-diff" + | "text/x-git-output" + ), + |body| body.pt(px(4.0)).pb(px(5.0)), + ) + .child(body), + ) + .when(show_status && !self.raw_mode, |root| { + root.child( + div() + .pl(px(18.0)) + .pr(px(16.0)) + .h(self.row_height) + .when(!self.git.phases.is_empty(), |status| status.h(self.row_height + px(11.0)).pt(px(11.0))) + .flex_shrink_0() + .flex() + .items_center() + .text_color( + if matches!(self.status, Status::Finished(code) if code != 0) { + cx.theme().danger + } else { + cx.theme().muted_foreground + }, + ) + .child(match self.status { + Status::Finished(code) if code != 0 => { + format!("{} · {code}", t(status)) + }, + _ => t(status).to_string(), + }), + ) + }) + // 细线固定表达结果归属,悬停只改变语义色。 + .child( + div() + .debug_selector(|| "native-result-line".into()) + .absolute() + .left_0() + .top(px(5.0)) + .bottom(px(5.0)) + .w(px(2.0)) + .rounded(px(1.0)) + .bg(cx.theme().foreground.opacity(0.09)), + ) + .when(self.hovered, |root| root.child( + div().debug_selector(|| "native-result-hover-line".into()) + .absolute().left_0().top(px(5.0)).bottom(px(5.0)).w(px(2.0)).rounded(px(1.0)) + .bg(cx.theme().primary) + )) + // 浮层不占正文行,悬停前后内容位置保持不变。 + .when_some(tools, |root, tools| root.child(tools)); + for button in [MouseButton::Left, MouseButton::Right, MouseButton::Middle] { + root = root + .on_mouse_down(button, |_, _, cx| cx.stop_propagation()) + .on_mouse_up(button, |_, _, cx| cx.stop_propagation()); + } + // 内部可滚动控件先处理滚轮;标题/空白处仍可滚动终端历史,避免整张卡成为死区。 + root.on_mouse_move(|_, _, cx| cx.stop_propagation()) + } +} + +fn field_preview(value: &str) -> String { + // 保留原文供复制,表格只给字体系统发送有界的单行预览。 + let mut end = value.len().min(512); + while !value.is_char_boundary(end) { + end -= 1; + } + let mut text: String = + value[..end].chars().map(|c| if c.is_control() { ' ' } else { c }).collect(); + if end < value.len() { + text.push('…'); + } + text +} diff --git a/nebula_app/src/gpui_shell/terminal/native_results/table.rs b/nebula_app/src/gpui_shell/terminal/native_results/table.rs new file mode 100644 index 000000000..a34905047 --- /dev/null +++ b/nebula_app/src/gpui_shell/terminal/native_results/table.rs @@ -0,0 +1,414 @@ +//! 表格的自然列宽、横向视口与控件;纵向位置仍由终端投影拥有。 +use super::*; +use gpui::{App, FontWeight, SharedString, TextRun}; +use gpui_component::button::ButtonCustomVariant; +use unicode_width::UnicodeWidthStr; + +#[derive(Default)] +pub(super) struct Layout { + samples: Vec, + numeric: Vec, + order: Vec, + widths: Vec, + labels: Vec, + font_size: Pixels, + font_family: SharedString, + ui_family: SharedString, + icon_column: Option, +} + +impl Layout { + pub(super) fn prepare(table: &model::Table) -> Self { + let mut result = Self { + samples: vec![String::new(); table.columns.len()], + numeric: vec![true; table.columns.len()], + order: (0..table.columns.len()).collect(), + ..Default::default() + }; + let mut widths = vec![0; table.columns.len()]; + let mut seen = vec![false; table.columns.len()]; + // 与解析一起在后台挑选有界测量样本;滚动不会重新扫描整张表。 + for row in &table.rows { + for (column, value) in row.iter().enumerate() { + let sample = field_preview(value); + let width = sample.width(); + if width > widths[column] { + result.samples[column] = sample; + widths[column] = width; + } + if !value.trim().is_empty() { + seen[column] = true; + result.numeric[column] &= model::numeric_cell(value).is_some(); + } + } + } + for (numeric, seen) in result.numeric.iter_mut().zip(seen) { + *numeric &= seen; + } + if let Some(files) = &table.files { + result.icon_column = files + .kinds + .iter() + .any(|kind| *kind != model::FileKind::Unknown) + .then_some(files.name_column); + result.order.retain(|column| *column != files.name_column); + result.order.insert(0, files.name_column); + for (column, name) in table.columns.iter().enumerate() { + result.numeric[column] = matches!(name.as_str(), "Size" | "Length" | "Links"); + } + } + result + } + + fn measure( + &mut self, + labels: Vec, + font_size: Pixels, + family: SharedString, + window: &mut Window, + cx: &App, + ) { + let ui_family = cx.theme().font_family.clone(); + if self.labels == labels + && self.font_size == font_size + && self.font_family == family + && self.ui_family == ui_family + && self.widths.len() == labels.len() + { + return; + } + self.widths = labels + .iter() + .enumerate() + .map(|(column, label)| { + let mut heading_font = gpui::font(ui_family.clone()); + heading_font.weight = FontWeight::MEDIUM; + let measure = |text: String, font, size| { + window + .text_system() + .shape_line( + text.clone().into(), + size, + &[TextRun { + len: text.len(), + font, + color: cx.theme().foreground, + background_color: None, + underline: None, + strikethrough: None, + }], + None, + ) + .width + }; + let header = measure(field_preview(label), heading_font, px(12.0)) + px(16.0); + let mut sample = measure( + self.samples[column].clone(), + gpui::font(family.clone()), + font_size * 0.94, + ) + .min(px(280.0)); + if self.icon_column == Some(column) { + sample += px(20.0); + } + if label == "CPUPerc" { + sample = sample.max(px(48.0)) + px(84.0); + } + header.max(sample) + + px(if self.order.last() == Some(&column) { 20.0 } else { 30.0 }) + }) + .collect(); + self.labels = labels; + self.font_size = font_size; + self.font_family = family; + self.ui_family = ui_family; + } +} + +fn column_label(table: &model::Table, column: usize, cx: &App) -> String { + let name = &table.columns[column]; + let message = table.files.as_ref().and_then(|_| match name.as_str() { + "Name" => Some(Message::NativeResultFileName), + "Mode" => Some(Message::NativeResultFileMode), + "Links" => Some(Message::NativeResultFileLinks), + "Owner" => Some(Message::NativeResultFileOwner), + "Group" => Some(Message::NativeResultFileGroup), + "Size" | "Length" => Some(Message::NativeResultFileSize), + "Modified" | "LastWriteTime" => Some(Message::NativeResultFileModified), + _ => None, + }); + message.map_or_else( + || name.clone(), + |id| crate::gpui_shell::config::ui_language(cx).text(id).to_owned(), + ) +} + +impl NativeResult { + pub(super) fn table_element( + &mut self, + window: &mut Window, + cx: &mut Context<'_, Self>, + ) -> AnyElement { + let language = crate::gpui_shell::config::ui_language(cx); + let Some(table) = self.table.clone() else { + return div() + .py_2() + .text_color(cx.theme().muted_foreground) + .child(language.text(Message::NativeResultPreparing)) + .into_any_element(); + }; + let labels = + (0..table.columns.len()).map(|column| column_label(&table, column, cx)).collect(); + self.table_layout.measure(labels, self.font_size, self.font_family.clone(), window, cx); + let layout = &self.table_layout; + let natural: Pixels = layout.widths.iter().copied().fold(px(0.0), |sum, w| sum + w); + let viewport_width = (self.available_width - px(34.0)).max(px(0.0)).min(px(820.0)); + let table_width = natural.max(viewport_width); + let ratio = (table_width.as_f32() / natural.as_f32().max(1.0)).max(1.0); + let body_size = self.font_size * 0.94; + let row_height = body_size * 1.65 + px(17.0); + let header_height = px(43.0); + let line = cx.theme().foreground.opacity(0.09); + let muted = cx.theme().muted_foreground; + let headings = table.files.as_ref().is_none_or(|files| files.headers); + let preamble = table.files.as_ref().map_or(&[][..], |files| files.preamble.as_slice()); + let prefix_height = 28.0 + + if self.filter_open { 44.0 } else { 0.0 } + + if headings { header_height.as_f32() } else { 0.0 } + + preamble.len() as f32 * 22.0 + + 16.0; + let first = + ((self.visible_top - prefix_height).max(0.0) / row_height.as_f32()).floor() as usize; + let last = ((self.visible_top + self.visible_height - prefix_height).max(0.0) + / row_height.as_f32()) + .ceil() as usize + + 2; + let mut header = div() + .flex() + .w(table_width) + .h(header_height) + .bg(cx.theme().foreground.opacity(0.035)) + .rounded(px(6.0)) + .border_b_1() + .border_color(line); + for &column in &layout.order { + let selected = self.sort.filter(|(selected, _)| *selected == column); + let arrow = selected.map_or("", |(_, desc)| if desc { "↓" } else { "↑" }); + let right = layout.numeric[column]; + header = header.child( + Button::new(("native-column", column)) + .debug_selector(move || format!("native-table-column-{column}")) + .small() + .h(header_height) + .w(layout.widths[column] * ratio) + .flex_shrink_0() + .px_0() + .custom( + ButtonCustomVariant::new(cx) + .foreground(muted) + .hover(cx.theme().foreground.opacity(0.035)) + .active(cx.theme().foreground.opacity(0.07)), + ) + .tooltip(layout.labels[column].clone()) + .child( + div() + .flex() + .items_center() + .w_full() + .min_w_0() + .pl(px(10.0)) + .pr(px(if layout.order.last() == Some(&column) { 10.0 } else { 20.0 })) + .gap(px(7.0)) + .font_family(cx.theme().font_family.clone()) + .font_weight(FontWeight::MEDIUM) + .text_size(px(12.0)) + .line_height(px(18.0)) + .when(right, |cell| cell.justify_end()) + .text_color(if selected.is_some() { cx.theme().primary } else { muted }) + .child(field_preview(&layout.labels[column])) + .child(div().w(px(9.0)).child(arrow)), + ) + .on_click(cx.listener(move |this, _, window, cx| { + window.focus(&this.focus, cx); + this.sort = + Some((column, !this.sort.is_some_and(|(c, desc)| c == column && desc))); + this.order_table(cx); + })), + ); + } + let rows = (first.saturating_sub(2)..last.min(self.order.len())) + .map(|index| { + let source_row = self.order[index]; + div() + .debug_selector(move || format!("native-table-row-{index}")) + .absolute() + .top(row_height * index as f32) + .h(row_height) + .w(table_width) + .flex() + .text_size(body_size) + .line_height(body_size * 1.65) + .when(index + 1 < self.order.len(), |row| row.border_b_1().border_color(line)) + .hover(|style| style.bg(cx.theme().foreground.opacity(0.035))) + .children(layout.order.iter().map(|&column| { + let value = &table.rows[source_row][column]; + let kind = table + .files + .as_ref() + .filter(|files| files.name_column == column) + .map_or(model::FileKind::Unknown, |files| files.kinds[source_row]); + let icon = match kind { + model::FileKind::Directory => Some(IconName::FolderClosed), + model::FileKind::File | model::FileKind::Link => Some(IconName::File), + _ => None, + }; + let percent = (table.columns[column] == "CPUPerc" + && value.trim().ends_with('%')) + .then(|| model::numeric_cell(value)) + .flatten(); + div() + .w(layout.widths[column] * ratio) + .flex_shrink_0() + .min_w_0() + .pl(px(10.0)) + .pr(px(if layout.order.last() == Some(&column) { 10.0 } else { 20.0 })) + .py(px(8.0)) + .flex() + .items_center() + .gap(px(6.0)) + .when(layout.numeric[column] && percent.is_none(), |cell| { + cell.justify_end() + }) + .when( + table + .files + .as_ref() + .is_some_and(|files| column != files.name_column), + |cell| cell.text_color(muted), + ) + .when_some(icon, |cell, icon| { + cell.child(Icon::new(icon).size(px(14.0)).text_color(muted)) + }) + .child(div().min_w_0().text_ellipsis().child(field_preview(value))) + .when_some(percent, |cell, value| { + cell.child( + div() + .flex() + .gap(px(3.0)) + .ml(px(6.0)) + .w(px(72.0)) + .flex_shrink_0() + .children((0..10).map(|segment| { + let fill = + (value / 10.0 - f64::from(segment)).clamp(0.0, 1.0); + div() + .w(px(4.0)) + .h(px(4.0)) + .bg(cx.theme().foreground.opacity(0.12)) + .child( + div() + .h_full() + .w(px(4.0 * fill as f32)) + .bg(cx.theme().primary), + ) + })), + ) + }) + })) + }) + .collect::>(); + let total = table.rows.len().to_string(); + let shown = self.order.len().to_string(); + let count = if self.filter.read(cx).value().is_empty() { + language.format(Message::NativeResultRowCount, &[("count", &total)]) + } else { + language.format( + Message::NativeResultFilteredCount, + &[("visible", &shown), ("total", &total)], + ) + }; + div() + .w_full() + .min_w_0() + .pl(px(18.0)) + .pr(px(16.0)) + .flex() + .flex_col() + .children(preamble.iter().map(|line| { + div().h(px(22.0)).text_color(muted).text_size(px(12.0)).child(field_preview(line)) + })) + .child( + div() + .h(px(28.0)) + .font_family(cx.theme().font_family.clone()) + .text_size(px(12.0)) + .line_height(px(18.0)) + .text_color(muted) + .child(count), + ) + .when(self.filter_open, |body| { + body.child( + div() + .debug_selector(|| "native-result-filter-input".into()) + .w(px(340.0)) + .max_w_full() + .mb(px(10.0)) + .border_b_1() + .border_color(line) + .child( + Input::new(&self.filter) + .appearance(false) + .focus_bordered(false) + .cleanable(true) + .h(px(34.0)) + .prefix( + Icon::new(IconName::Search).size(px(14.0)).text_color(muted), + ), + ), + ) + }) + .child( + div() + .relative() + .w(viewport_width) + .max_w_full() + .min_w_0() + .child( + div() + .id("native-table-horizontal") + .w_full() + .min_w_0() + .overflow_x_scroll() + .restrict_scroll_to_axis() + .track_scroll(&self.table_scroll) + .child( + div() + .w(table_width) + .when(headings, |body| body.child(header)) + .child( + div() + .relative() + .w(table_width) + .h(row_height * self.order.len() as f32) + .children(rows), + ), + ), + ) + .when(table_width > viewport_width, |body| { + body.child(gpui_component::scroll::Scrollbar::horizontal( + &self.table_scroll, + )) + }), + ) + .when(self.order.is_empty(), |body| { + body.child( + div() + .py(px(14.0)) + .text_size(px(12.0)) + .text_color(muted) + .child(language.text(Message::NativeResultNoRows)), + ) + }) + .into_any_element() + } +} diff --git a/nebula_app/src/gpui_shell/terminal/native_results/tools.rs b/nebula_app/src/gpui_shell/terminal/native_results/tools.rs new file mode 100644 index 000000000..abba799e4 --- /dev/null +++ b/nebula_app/src/gpui_shell/terminal/native_results/tools.rs @@ -0,0 +1,222 @@ +//! 原型中的轻量模式切换和复制反馈;浮层不参与结果高度。 +use super::*; +use gpui_component::button::ButtonCustomVariant; + +impl NativeResult { + pub(super) fn toolbar_element( + &self, + window: &mut Window, + cx: &mut Context<'_, Self>, + ) -> AnyElement { + let language = crate::gpui_shell::config::ui_language(cx); + let copied = self.feedback.read(cx).is_copied(); + let foreground = cx.theme().foreground; + let muted = cx.theme().muted_foreground; + let target = if self.raw_mode { 0.5 } else { 0.0 }; + let indicator = div() + .id("native-mode-indicator") + .absolute() + .top_0() + .bottom_0() + .left(relative(target)) + .w(px(63.0)) + .rounded(px(4.0)) + .bg(cx.theme().secondary) + .border_1() + .border_color(foreground.opacity(0.12)); + let indicator = crate::gpui_shell::widgets::segmented::animate_segment_indicator( + format!("native-mode-{}", self.source.upgrade().map_or(0, |source| source.id)).into(), + indicator, + target, + window, + cx, + ); + let variant = || { + ButtonCustomVariant::new(cx) + .foreground(muted) + .hover(foreground.opacity(0.04)) + .active(foreground.opacity(0.08)) + }; + let label = |icon, message| { + div() + .flex() + .items_center() + .justify_center() + .gap(px(5.0)) + .font_family(cx.theme().font_family.clone()) + .text_size(px(11.0)) + .line_height(px(16.0)) + .child(Icon::new(icon).size(px(14.0))) + .child(language.text(message)) + }; + let modes = div().p(px(1.0)).rounded(px(6.0)).bg(cx.theme().background.opacity(0.5)).child( + div() + .relative() + .flex() + .w(px(126.0)) + .h(px(28.0)) + .child(indicator) + .child( + Button::new("native-view") + .debug_selector(|| "native-result-view".into()) + .small() + .h(px(28.0)) + .w(px(63.0)) + .px_0() + .rounded(px(4.0)) + .custom(variant()) + .toggled(!self.raw_mode) + .tooltip(language.text(Message::NativeResultView)) + .child(label(IconName::Eye, Message::NativeResultView)) + .on_click(cx.listener(|this, _, window, cx| { + this.raw_mode = false; + window.focus(&this.focus, cx); + cx.notify(); + })), + ) + .child( + Button::new("native-raw") + .debug_selector(|| "native-result-raw".into()) + .small() + .h(px(28.0)) + .w(px(63.0)) + .px_0() + .rounded(px(4.0)) + .custom(variant()) + .toggled(self.raw_mode) + .tooltip(language.text(Message::NativeResultRaw)) + .child(label(IconName::Code, Message::NativeResultRaw)) + .on_click(cx.listener(|this, _, window, cx| { + this.raw_mode = true; + window.focus(&this.focus, cx); + cx.notify(); + })), + ), + ); + let top = if self.visible_top > 0.0 { + self.visible_top + } else { + -self.viewport_top.max(0.0).min(38.0) + }; + let right = if self.viewport_top + top < 40.0 && self.available_width <= px(900.0) { + 36.0 + } else { + 3.0 + }; + let table = matches!( + self.mime.as_ref(), + "application/json" | "text/x-directory-listing" | "text/x-powershell-directory" + ); + div() + .id("native-result-tools") + .debug_selector(|| "native-result-tools".into()) + .absolute() + .top(px(top)) + .right(px(right)) + .flex() + .items_center() + .gap(px(3.0)) + .p(px(3.0)) + .rounded(px(9.0)) + .bg(crate::gpui_shell::theme::settings_panel_bg(cx)) + .border_1() + .border_color(foreground.opacity(0.12)) + .on_hover(cx.listener(|this, hovered, _, cx| { + if this.toolbar_hovered != *hovered { + this.toolbar_hovered = *hovered; + cx.notify(); + } + })) + .when(self.raw_mode && self.mime.as_ref() == "text/x-git-progress", |tools| { + tools + .child( + Button::new("native-stdout") + .label("stdout") + .ghost() + .small() + .selected(!self.raw_stderr) + .on_click(cx.listener(|this, _, _, cx| { + this.raw_stderr = false; + cx.notify(); + })), + ) + .child( + Button::new("native-stderr") + .label("stderr") + .ghost() + .small() + .selected(self.raw_stderr) + .on_click(cx.listener(|this, _, _, cx| { + this.raw_stderr = true; + cx.notify(); + })), + ) + }) + .child(modes) + .child(div().w(px(1.0)).h(px(14.0)).mx(px(3.0)).bg(foreground.opacity(0.09))) + .when(table, |tools| { + tools.child( + Button::new("native-filter") + .debug_selector(|| "native-result-filter-toggle".into()) + .small() + .h(px(30.0)) + .w(px(30.0)) + .px_0() + .rounded(px(5.0)) + .custom(variant()) + .tooltip(language.text(Message::NativeResultFilter)) + .toggled(self.filter_open) + .disabled(self.table.is_none()) + .child(Icon::new(IconName::Search).size(px(14.0))) + .on_click(cx.listener(|this, _, window, cx| { + this.filter_open = !this.filter_open; + this.raw_mode = false; + if this.filter_open { + this.filter.update(cx, |input, cx| input.focus(window, cx)); + } else { + this.filter.update(cx, |input, cx| input.set_value("", window, cx)); + this.order_table(cx); + window.focus(&this.focus, cx); + } + cx.notify(); + })), + ) + }) + .child( + Button::new("native-copy") + .debug_selector(|| "native-result-copy".into()) + .small() + .h(px(30.0)) + .min_w(px(30.0)) + .px(px(8.0)) + .rounded(px(5.0)) + .custom(variant()) + .disabled(self.status == Status::Evicted) + .tooltip(language.text(if copied { + Message::NativeResultCopied + } else { + Message::NativeResultCopy + })) + .child( + div() + .flex() + .items_center() + .gap(px(4.0)) + .font_family(cx.theme().font_family.clone()) + .text_size(px(11.0)) + .text_color(if copied { cx.theme().success } else { muted }) + .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| this.copy_source(cx))), + ) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_mouse_up(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .into_any_element() + } +} diff --git a/nebula_app/src/gpui_shell/terminal/native_results/viewport.rs b/nebula_app/src/gpui_shell/terminal/native_results/viewport.rs new file mode 100644 index 000000000..be764f7a9 --- /dev/null +++ b/nebula_app/src/gpui_shell/terminal/native_results/viewport.rs @@ -0,0 +1,386 @@ +//! 所有结果和普通终端行共用的纵向坐标;占位只影响显示,不改写 VT 网格。 +use super::super::session::EventProxy; +use super::*; +use nebula_terminal::{Term, index::Line}; +use std::cell::RefCell; + +#[derive(Clone)] +struct Slot { + id: u64, + start: i64, + rows: u16, + height: usize, + hide_source: bool, +} + +#[derive(Clone, Default)] +pub(in crate::gpui_shell::terminal) struct Projection { + slots: Vec, + top: f64, + floor: i64, + first: i64, + bottom: i64, + screen: usize, + pub origin: i64, + active: bool, + scroll_generation: u64, +} + +impl Projection { + pub fn covers(&self, row: usize) -> bool { + let source = self.origin + row as i64; + self.active + && self.slots.iter().any(|slot| { + slot.hide_source + && source >= slot.start + && source < slot.start + i64::from(slot.rows) + }) + } + + pub fn source_rows(&self, screen: usize) -> usize { + if !self.active { + return screen; + } + self.source_row(screen as f32).ceil().max(screen as f32).min(u16::MAX as f32) as usize + } + fn visual(&self, source: f64) -> f64 { + let mut extra = 0.0; + for slot in &self.slots { + if source <= slot.start as f64 { + break; + } + let used = (source - slot.start as f64).min(f64::from(slot.rows)); + extra += used * (slot.height as f64 / f64::from(slot.rows) - 1.0); + } + source + extra + } + fn source(&self, visual: f64) -> f64 { + let mut extra = 0.0; + for slot in &self.slots { + let start = slot.start as f64 + extra; + if visual <= start { + break; + } + if visual < start + slot.height as f64 { + return slot.start as f64 + + (visual - start) * f64::from(slot.rows) / slot.height as f64; + } + extra += slot.height as f64 - f64::from(slot.rows); + } + visual - extra + } + pub fn row(&self, source_viewport_row: f32) -> f32 { + if !self.active { + return source_viewport_row; + } + (self.visual(self.origin as f64 + f64::from(source_viewport_row)) - self.top) as f32 + } + pub fn source_row(&self, visual_row: f32) -> f32 { + if !self.active { + return visual_row; + } + (self.source(self.top + f64::from(visual_row)) - self.origin as f64) as f32 + } + pub fn scroll_state(&self) -> Option<(usize, usize)> { + self.active.then(|| { + let end = (self.visual(self.bottom as f64) - self.screen as f64) + .max(self.visual(self.first as f64)); + ( + (end - self.top).max(0.0).round() as usize, + (end - self.visual(self.first as f64)).max(0.0).ceil() as usize, + ) + }) + } + fn publish(&mut self, term: &mut Term) { + let min = self.visual(self.first as f64); + let max = (self.visual(self.bottom as f64) - self.screen as f64).max(min); + self.top = self.top.clamp(min, max); + self.origin = self.source(self.top).floor() as i64; + let line = Line((self.origin - self.floor) as i32); + let offset = (-line.0).max(0) as usize; + let current = term.grid().display_offset(); + if current != offset { + term.scroll_display(nebula_terminal::grid::Scroll::Delta( + offset as i32 - current as i32, + )); + } + term.set_presentation_origin(Some(line)); + self.scroll_generation = term.presentation_scroll_generation(); + } +} + +#[derive(Default)] +pub(in crate::gpui_shell::terminal) struct Views { + pub entities: HashMap>, + pub projection: RefCell, + epoch: Option, + validated_history_end: i64, + sources: Vec<(Weak, u64, i64)>, + expanded_folds: std::collections::HashSet, + fold_anchor: Option, +} + +impl Views { + pub fn fold_expanded(&self, id: u64) -> bool { + self.expanded_folds.contains(&id) + } + + pub fn toggle_fold(&mut self, id: u64) { + let p = self.projection.borrow(); + self.fold_anchor = Some(p.source(p.top)); + if !self.expanded_folds.remove(&id) { + self.expanded_folds.insert(id); + } + } + pub fn reconcile_retention(&mut self, cx: &mut gpui::App) { + for entity in self.entities.values() { + let result = entity.read(cx); + if result.status != Status::Evicted + && result + .source + .upgrade() + .is_some_and(|source| source.update_since(usize::MAX).status == Status::Evicted) + { + // 回收不等离屏内容重新进入视口;这里不做普通内容的解析或布局。 + entity.update(cx, |result, cx| result.sync(cx)); + } + } + let p = self.projection.borrow(); + self.entities.retain(|id, entity| { + entity.read(cx).status != Status::Evicted + || p.slots.iter().any(|slot| { + slot.id == *id && { + let top = p.visual(slot.start as f64) - p.top; + top < p.screen as f64 && top + slot.height as f64 > 0.0 + } + }) + }); + } + #[cfg(test)] + pub fn len(&self) -> usize { + self.entities.len() + } + #[cfg(test)] + pub fn is_empty(&self) -> bool { + self.entities.is_empty() + } + + pub fn sync(&mut self, term: &mut Term, screen: usize, cx: &gpui::App) { + let floor = (term.grid().scrolled_out() + term.history_size()) as i64; + let first = term.grid().scrolled_out() as i64; + let enabled = + !term.mode().intersects(TermMode::ALT_SCREEN | TermMode::MOUSE_MODE | TermMode::VI); + let mut projection = self.projection.borrow_mut(); + if !enabled { + projection.active = false; + term.set_presentation_origin(None); + return; + } + let source_layout_changed = self.epoch != Some(term.reading_epoch()); + if source_layout_changed { + self.epoch = Some(term.reading_epoch()); + self.sources.clear(); + // 只在重排/新增锚点时重建稀疏索引,普通滚轮不扫描历史或重建 Markdown。 + for row in term.topmost_line().0..=term.bottommost_line().0 { + if let Some(marker) = term.grid()[Point::new(Line(row), Column(0))].inline_marker() + && marker.row == 0 + { + self.sources.push(( + Arc::downgrade(&marker.surface), + marker.surface.id, + floor + i64::from(row), + )); + } + } + } + let validated_history_end = self.validated_history_end; + self.sources.retain(|(source, _, start)| { + let Some(source) = source.upgrade() else { + return false; + }; + if source.has_grid_source() && source.update_since(usize::MAX).status == Status::Evicted + { + return false; + } + let line = *start - floor; + *start >= first + && line <= i64::from(term.bottommost_line().0) + && term.grid()[Point::new(Line(line as i32), Column(0))] + .inline_marker() + .is_some_and(|marker| marker.surface.id == source.id) + // 只跳过上次已验证的历史;刚滚入历史的行仍可能在本帧先被覆写。 + // 保存上轮边界,避免“覆写→滚屏”批量 VT 输入保留过期结果。 + && ((if source.has_grid_source() && !source_layout_changed { + (validated_history_end - *start).clamp(0, i64::from(source.rows())) as u16 + } else { 0 })..source.rows()).all(|row| { + let line = Line((line + i64::from(row)) as i32); + line <= term.bottommost_line() + && term.grid()[Point::new(line, Column(0))].inline_marker().is_some_and( + |marker| marker.surface.id == source.id && marker.row == row, + ) + && if source.has_grid_source() { + // 已进历史的行只有 reflow/reset 才改变;滚轮只复核仍可被 VT 覆写的 live 尾。 + !source_layout_changed && floor + i64::from(line.0) < validated_history_end + || source.source_row_matches(row, &term.grid()[line][..]) + } else { + term.grid()[line][..].iter().skip(1).all(|cell| cell.is_empty()) + } + }) + }); + self.validated_history_end = floor; + self.entities.retain(|id, _| self.sources.iter().any(|(_, source_id, _)| source_id == id)); + self.expanded_folds + .retain(|id| self.sources.iter().any(|(_, source_id, _)| source_id == id)); + let anchor = projection.source(projection.top); + let content_anchor = projection.slots.iter().find_map(|slot| { + let start = projection.visual(slot.start as f64); + (start <= projection.top && projection.top < start + slot.height as f64) + .then_some((slot.id, projection.top - start)) + }); + let follow = projection.slots.is_empty() + || projection.visual(projection.bottom as f64) + - projection.screen as f64 + - projection.top + < 0.5; + // resize 仅失效几何,只有真正的滚动命令才切换阅读锚点。 + let external_scroll = term.presentation_scroll_generation() != projection.scroll_generation; + projection.slots = self + .sources + .iter() + .filter_map(|(source, _, start)| { + let source = source.upgrade()?; + let rows = source.rows(); + if super::fold::is_fold(&source) { + if usize::from(rows) <= super::fold::KEEP * 2 { + return None; + } + let expanded = self.expanded_folds.contains(&source.id); + // 展开时在第10行后留一个控制行,原有网格行仍逐行绘制。 + return Some(Slot { + id: source.id, + start: *start + super::fold::KEEP as i64 - i64::from(expanded), + rows: if expanded { 1 } else { rows - (super::fold::KEEP * 2) as u16 }, + height: if expanded { 2 } else { 1 }, + hide_source: !expanded, + }); + } + let height = self + .entities + .get(&source.id) + .map_or(usize::from(rows), |entity| { + let result = entity.read(cx); + (result.measured_height.as_f32() / result.row_height.as_f32().max(1.0)) + .ceil() as usize + }) + .max(if source.has_grid_source() { 1 } else { usize::from(rows) }); + Some(Slot { id: source.id, start: *start, rows, height, hide_source: true }) + }) + .collect(); + projection.floor = floor; + projection.first = first; + // 跟随实际内容末尾,不能把光标后的空白屏幕算成输出并将结果挤出视口。 + let last_content = (0..term.screen_lines()) + .rev() + .find(|row| term.grid()[Line(*row as i32)][..].iter().any(|cell| !cell.is_empty())) + .map_or(0, |row| row + 1); + let live_end = last_content.max(term.grid().cursor.point.line.0.max(0) as usize + 1); + projection.bottom = floor + live_end as i64; + projection.screen = screen; + projection.active = !projection.slots.is_empty(); + if !projection.active { + term.set_presentation_origin(None); + return; + } + projection.top = if let Some(anchor) = self.fold_anchor.take() { + projection.visual(anchor) + } else if external_scroll { + if term.grid().display_offset() == 0 { + projection.visual(projection.bottom as f64) - screen as f64 + } else { + projection.visual((floor - term.grid().display_offset() as i64) as f64) + } + } else if follow { + projection.visual(projection.bottom as f64) - screen as f64 + } else if let Some((id, offset)) = content_anchor + && let Some(slot) = projection.slots.iter().find(|slot| slot.id == id) + { + // 保住内容内的实际偏移,不按整块高度比例缩放阅读位置。 + projection.visual(slot.start as f64) + offset.min(slot.height.saturating_sub(1) as f64) + } else { + projection.visual(anchor) + }; + projection.publish(term); + } + + pub fn placements(&self) -> Vec<(Arc, f32, f32)> { + let p = self.projection.borrow(); + if !p.active { + return Vec::new(); + } + self.sources + .iter() + .filter_map(|(source, _, _)| { + let source = source.upgrade()?; + let slot = p.slots.iter().find(|slot| slot.id == source.id)?; + let fold = super::fold::is_fold(&source); + let top = p.visual(slot.start as f64) - p.top + + if fold && !slot.hide_source { 1.0 } else { 0.0 }; + let height = if fold { 1.0 } else { slot.height as f32 }; + // 未测量的离屏结果继续使用估高,只在实际进入视口时创建和布局。 + (top < p.screen as f64 && top + f64::from(height) > 0.0) + .then(|| (source, top as f32, height)) + }) + .collect() + } + pub fn scroll_to(&self, offset: usize, term: &mut Term) -> bool { + let mut p = self.projection.borrow_mut(); + if !p.active { + return false; + } + p.top = p.visual(p.bottom as f64) - p.screen as f64 - offset as f64; + p.publish(term); + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn native_result_projection_keeps_prompt_and_middle_of_document_addressable() { + let p = Projection { + slots: vec![Slot { id: 1, start: 2, rows: 1, height: 100, hide_source: true }], + active: true, + origin: 2, + top: 52.0, + ..Default::default() + }; + assert_eq!(p.visual(3.0), 102.0); + assert_eq!(p.row(1.0), 50.0); + assert_eq!(p.source(102.0), 3.0); + assert!((p.source_row(0.0) - 0.5).abs() < 0.0001); + for offset in 0..100 { + let source = p.source(2.0 + offset as f64); + assert_eq!(source.floor(), 2.0); + assert!((p.visual(source) - (2.0 + offset as f64)).abs() < 0.00001); + } + } + + #[test] + fn native_result_projection_combines_multiple_results_without_moving_source_lines() { + let p = Projection { + slots: vec![ + Slot { id: 1, start: 1, rows: 1, height: 80, hide_source: true }, + Slot { id: 2, start: 5, rows: 2, height: 20, hide_source: true }, + ], + ..Default::default() + }; + for (source, visual) in + [(0.0, 0.0), (1.0, 1.0), (2.0, 81.0), (5.0, 84.0), (7.0, 104.0), (8.0, 105.0)] + { + assert_eq!(p.visual(source), visual); + assert_eq!(p.source(visual), source); + } + } +} diff --git a/nebula_app/src/gpui_shell/terminal/osc_links.rs b/nebula_app/src/gpui_shell/terminal/osc_links.rs index 361c64deb..b6ea327f3 100644 --- a/nebula_app/src/gpui_shell/terminal/osc_links.rs +++ b/nebula_app/src/gpui_shell/terminal/osc_links.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use gpui::{App, ClipboardItem, Window}; use nebula_terminal::event::EventListener; +use nebula_terminal::grid::Dimensions as _; use nebula_terminal::index::Point; use nebula_terminal::term::cell::Flags; use nebula_terminal::term::{Term, point_to_viewport_from}; @@ -74,20 +75,46 @@ pub(super) fn dashed_cells( config: &UiConfig, rows: usize, cols: usize, +) -> LinkCells { + dashed_cells_where(term, config, rows, cols, |_| true) +} + +pub(super) fn dashed_cells_where( + term: &Term, + config: &UiConfig, + rows: usize, + cols: usize, + include_row: impl Fn(usize) -> bool, ) -> LinkCells { let matches = hint::visible_clickable_matches(term, config); - if matches.is_empty() { + let hyperlinks = config.hints.enabled.iter().any(|hint| { + hint.content.hyperlinks && hint.mouse.as_ref().is_some_and(|mouse| mouse.enabled) + }); + if matches.is_empty() && !hyperlinks || rows == 0 || cols == 0 { return HashMap::new(); } let origin = term.viewport_origin_for(rows); let mut cells = HashMap::new(); - for indexed in term.grid().display_iter() { - if indexed.flags.intersects(Flags::HIDDEN | Flags::LEADING_WIDE_CHAR_SPACER) - || !matches.iter().any(|bounds| bounds.contains(&indexed.point)) + let mut next_match = 0; + // 先跳过被富文本遮住的整行,避免 hover 重绘逐格过滤;链接匹配仍保留完整原文上下文。 + let visible_rows = (0..rows) + .take_while(|&row| origin + row <= term.bottommost_line()) + .filter(|&row| include_row(row)); + for indexed in visible_rows.flat_map(|row| term.grid().display_iter_from(origin + row, 1, cols)) + { + let Some(vp) = point_to_viewport_from(origin, indexed.point) else { continue }; + if indexed.flags.intersects(Flags::HIDDEN | Flags::LEADING_WIDE_CHAR_SPACER) { + continue; + } + // 匹配范围已排序合并,单向推进,避免每个格子再遍历全部文件链接。 + while matches.get(next_match).is_some_and(|bounds| *bounds.end() < indexed.point) { + next_match += 1; + } + if !(hyperlinks && indexed.hyperlink().is_some()) + && !matches.get(next_match).is_some_and(|bounds| bounds.contains(&indexed.point)) { continue; } - let Some(vp) = point_to_viewport_from(origin, indexed.point) else { continue }; if vp.line < rows && vp.column.0 < cols { let (mut fg, mut bg) = (indexed.fg, indexed.bg); let (mut dim, mut bg_dim) = (indexed.flags.contains(Flags::DIM), false); diff --git a/nebula_app/src/gpui_shell/terminal/session.rs b/nebula_app/src/gpui_shell/terminal/session.rs index 2ac6c757b..206710d1f 100644 --- a/nebula_app/src/gpui_shell/terminal/session.rs +++ b/nebula_app/src/gpui_shell/terminal/session.rs @@ -144,6 +144,7 @@ pub(super) fn local_options( log::warn!("Could not prepare shell integration: {error}"); } crate::platform::environment::prepare_local_pty(&mut options); + options.env.insert("PEBREL_NATIVE_RESULTS".into(), "1".into()); // WSL tab 的命令边界(OSC 133;D/A)与 cwd(OSC 7):来宾登录 shell 默认 // 不发,Agent 生命周期、目录树与 Git 视图都会失去来宾侧事实。只对 // `wsl.exe` 启动生效,见 [`crate::shell_detect::wsl_cwd_report_env`]。 diff --git a/nebula_app/src/gpui_shell/terminal/view.rs b/nebula_app/src/gpui_shell/terminal/view.rs index 30ee9100f..3c16ec1a1 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -268,6 +268,7 @@ pub enum SidebarActivity { } pub struct TerminalView { + pub(super) native_results: super::native_results::Views, pub(super) answers: crate::assistant_answer::AnswerInbox, confirmation: super::confirmation::ConfirmationState, pub(super) answer_reader: Option>, @@ -381,7 +382,7 @@ pub struct TerminalView { scrollbar_visibility: nebula_settings::ScrollbarVisibility, origin: Point, cell_width: Pixels, - line_height: Pixels, + pub(super) line_height: Pixels, cols: usize, rows: usize, /// 子进程已被告知的几何。`cols`/`rows` @@ -1242,9 +1243,17 @@ impl TerminalView { _ => None, }; if let Some(scroll) = scroll { - if let Some(session) = &self.session { - session.term.lock().scroll_display(scroll); - } + let (offset, history) = self.scroll_state(); + let target = match scroll { + Scroll::PageUp => offset.saturating_add(self.rows).min(history), + Scroll::PageDown => offset.saturating_sub(self.rows), + Scroll::Top => history, + Scroll::Bottom => 0, + Scroll::Delta(delta) => { + (offset as i64 + i64::from(delta)).clamp(0, history as i64) as usize + }, + }; + self.scroll_to_offset(target, offset); cx.notify(); cx.stop_propagation(); return; @@ -1516,6 +1525,7 @@ impl Render for TerminalView { root = root.child(div().p_4().text_color(gpui::red()).child(error.clone())); } else { root = root.child(TerminalElement::new(cx.entity())); + root = root.children(self.native_result_elements(window, cx)); // SSH 连接卡片(旧壳 `display::ssh_connect` 的 GPUI 形态): // 状态机/文案/常量直接复用,卡片遮罩盖住空 grid。350ms 显示 // 门槛由 `visible()` 决定;动画帧驱动粒子与进度插值。 diff --git a/nebula_app/src/gpui_shell/terminal/view/cursor.rs b/nebula_app/src/gpui_shell/terminal/view/cursor.rs index 61eb80bb4..a78b5d68b 100644 --- a/nebula_app/src/gpui_shell/terminal/view/cursor.rs +++ b/nebula_app/src/gpui_shell/terminal/view/cursor.rs @@ -25,6 +25,7 @@ pub(in crate::gpui_shell::terminal) struct CursorGeometry { pub grid: (usize, usize), pub metrics: (f32, f32, f32), pub projection_shift: i32, + pub row_projection_shift: f32, pub shape: CursorShape, } @@ -108,6 +109,11 @@ impl TerminalView { // repaint coordinates are not a new target or input authorization. return None; }; + let visual_row = self.native_results.projection.borrow().row(cursor.row as f32); + if visual_row < 0.0 || visual_row >= geometry.grid.1 as f32 { + self.cursor_animation.reset(); + return None; + } let expired = self .cursor_animation .hidden_since @@ -118,7 +124,7 @@ impl TerminalView { self.cursor_animation.geometry = Some(geometry); } let position = self.cursor_animation.motion.update( - Position::new(visual_col as f64, cursor.row as f64), + Position::new(visual_col as f64, f64::from(visual_row)), now, enabled, !geometry.screen.1, diff --git a/nebula_app/src/gpui_shell/terminal/view/cursor/tests.rs b/nebula_app/src/gpui_shell/terminal/view/cursor/tests.rs index 01986d007..aea1af4bf 100644 --- a/nebula_app/src/gpui_shell/terminal/view/cursor/tests.rs +++ b/nebula_app/src/gpui_shell/terminal/view/cursor/tests.rs @@ -16,6 +16,7 @@ fn geometry(shape: CursorShape) -> CursorGeometry { grid: (80, 24), metrics: (9.0, 20.0, 1.0), projection_shift: 0, + row_projection_shift: 0.0, shape, } } diff --git a/nebula_app/src/gpui_shell/terminal/view/pointer.rs b/nebula_app/src/gpui_shell/terminal/view/pointer.rs index 18682bb91..c48568f83 100644 --- a/nebula_app/src/gpui_shell/terminal/view/pointer.rs +++ b/nebula_app/src/gpui_shell/terminal/view/pointer.rs @@ -6,11 +6,22 @@ use gpui_component::WindowExt as _; mod tests; impl TerminalView { + pub(in crate::gpui_shell::terminal) fn native_result_width(&self) -> Pixels { + // 结果层不能遮住主滚动条及其真实命中余量。 + (self.cell_width * self.cols as f32 - px(SCROLLBAR_W + SCROLLBAR_SLOP)).max(px(0.0)) + } + pub(in crate::gpui_shell::terminal) fn scrollbar_thumb( &self, display_offset: usize, history: usize, ) -> Option> { + let (display_offset, history) = self + .native_results + .projection + .borrow() + .scroll_state() + .unwrap_or((display_offset, history)); let visible = self.scrollbar_drag.is_some() || match self.scrollbar_visibility { nebula_settings::ScrollbarVisibility::Auto => display_offset > 0, @@ -71,6 +82,9 @@ impl TerminalView { /// 回滚历史行数:拇指高度与拖拽反算的分母来源。 pub(in crate::gpui_shell::terminal) fn history_size(&self) -> usize { + if let Some((_, history)) = self.native_results.projection.borrow().scroll_state() { + return history; + } self.session.as_ref().map(|s| s.term.lock().history_size()).unwrap_or(0) } @@ -113,6 +127,9 @@ impl TerminalView { /// 一次取锁读出滚动条几何要的两个量:当前回滚位置与历史行数。 pub(super) fn scroll_state(&self) -> (usize, usize) { + if let Some(state) = self.native_results.projection.borrow().scroll_state() { + return state; + } self.session .as_ref() .map(|s| { @@ -124,6 +141,11 @@ impl TerminalView { /// 把回滚位置落到 `target`(滚动条拖拽的落点提交)。 pub(super) fn scroll_to_offset(&self, target: usize, current: usize) { + if let Some(session) = &self.session + && self.native_results.scroll_to(target, &mut session.term.lock()) + { + return; + } if target == current { return; } @@ -137,7 +159,10 @@ impl TerminalView { let rel_x = (position.x - self.origin.x).as_f32() / self.cell_width.as_f32().max(1.0); let rel_y = (position.y - self.origin.y).as_f32() / self.line_height.as_f32().max(1.0); let col = (rel_x.floor().max(0.0) as usize).min(self.cols.saturating_sub(1)); - let row = (rel_y.floor().max(0.0) as usize).min(self.rows.saturating_sub(1)); + let projection = self.native_results.projection.borrow(); + let rel_y = projection.source_row(rel_y); + let row = (rel_y.floor().max(0.0) as usize) + .min(projection.source_rows(self.rows).saturating_sub(1)); let side = if rel_x.fract() > 0.5 { Side::Right } else { Side::Left }; let Some(session) = self.session.as_ref() else { return (TermPoint::new(Line(row as i32), Column(col)), side); @@ -158,6 +183,19 @@ impl TerminalView { }) } + pub(in crate::gpui_shell::terminal) fn toggle_output_fold( + &mut self, + id: u64, + cx: &mut Context, + ) { + self.selecting = false; + self.stop_selection_scroll(); + self.pending_link_open = false; + self.link_hover = None; + self.native_results.toggle_fold(id); + cx.notify(); + } + pub(super) fn update_link_hover( &mut self, position: Point, @@ -175,7 +213,7 @@ impl TerminalView { super::super::osc_links::hover_from_hint( &term, hint, - self.rows, + self.native_results.projection.borrow().source_rows(self.rows), self.cols, crate::gpui_shell::config::ui_language(cx), ) @@ -258,6 +296,8 @@ impl TerminalView { return None; } let (cursor_row, cursor_col) = self.suggest_anchor?; + let cursor_row = + self.native_results.projection.borrow().row(cursor_row as f32).max(0.0) as usize; completion_popup_layout( &self.suggest.completion_items, self.suggest.completion_selected, @@ -797,7 +837,9 @@ impl TerminalView { return true; } if let Some(session) = &self.session { - session.term.lock().scroll_display(Scroll::Delta(lines)); + let (offset, history) = self.scroll_state(); + let target = (offset as i64 + i64::from(lines)).clamp(0, history as i64) as usize; + self.scroll_to_offset(target, offset); } // 滚动换了视口到绝对行的映射,选区末端必须按滚动后的网格重算—— // 否则视口滑过去了,选区还钉在原来那几行。两段各自取一次锁: @@ -917,7 +959,7 @@ impl TerminalView { cx.notify(); } - pub(super) fn on_scroll( + pub(in crate::gpui_shell::terminal) fn on_scroll( &mut self, event: &ScrollWheelEvent, window: &mut Window, @@ -1013,7 +1055,16 @@ impl TerminalView { } session.notifier.notify(bytes); } else if !application_only { - term.scroll_display(Scroll::Delta(lines)); + let (offset, history) = self + .native_results + .projection + .borrow() + .scroll_state() + .unwrap_or((term.grid().display_offset(), term.history_size())); + let target = (offset as i64 + i64::from(lines)).clamp(0, history as i64) as usize; + if !self.native_results.scroll_to(target, &mut term) { + term.scroll_display(Scroll::Delta(lines)); + } } cx.notify(); } diff --git a/nebula_app/src/gpui_shell/terminal/view/reading/output_hover.rs b/nebula_app/src/gpui_shell/terminal/view/reading/output_hover.rs index b19ed6399..0dd8aca9c 100644 --- a/nebula_app/src/gpui_shell/terminal/view/reading/output_hover.rs +++ b/nebula_app/src/gpui_shell/terminal/view/reading/output_hover.rs @@ -56,8 +56,9 @@ impl TerminalView { let (viewport_top, _, _) = self.output_viewport(term); let start = target.span.start.line as i64; let end = target.span.end.line as i64 + i64::from(target.span.end.column != 0); - let first = (start - viewport_top).max(0); - let last = (end - viewport_top).min(self.rows as i64); + let vertical = self.native_results.projection.borrow(); + let first = vertical.row((start - viewport_top) as f32).max(0.0); + let last = vertical.row((end - viewport_top) as f32).min(self.rows as f32); if first >= last { return None; } diff --git a/nebula_app/src/gpui_shell/terminal/view/reading/tests.rs b/nebula_app/src/gpui_shell/terminal/view/reading/tests.rs index 88cda6bae..6233f659c 100644 --- a/nebula_app/src/gpui_shell/terminal/view/reading/tests.rs +++ b/nebula_app/src/gpui_shell/terminal/view/reading/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::gpui_shell::terminal::element::snapshot_probe; use gpui::{Entity, Modifiers, TestAppContext, VisualTestContext}; use gpui_component::Root; use nebula_terminal::event_loop::StreamProcessor; @@ -6,13 +7,783 @@ use std::sync::mpsc::Receiver; mod output_hover; +#[gpui::test] +fn ordinary_cat_uses_native_layout_and_keeps_following_prompt(cx: &mut TestAppContext) { + use nebula_terminal::grid::Dimensions as _; + let (view, mut cx, _, proxy) = open(cx); + let source = (0..80) + .map(|i| format!("## 第 {i} 段\r\n\r\n中文与 English 正文。\r\n\r\n")) + .collect::(); + let wire = format!( + "\x1b]1337;SetUserVar=pebrel_shell=cHdzaDox\x07\x1b]1337;SetUserVar=pebrel_command=cHdzaDoxCmNhdCBsb25nLm1k\x07\x1b]133;C\x07{source}\x1b]133;D;0\x07prompt>" + ); + feed(&view, &mut cx, &proxy, wire.as_bytes()); + for _ in 0..5 { + draw(&mut cx); + } + view.read_with(&cx, |view, cx| { + assert_eq!(view.native_results.len(), 1); + let result = view.native_results.entities.values().next().unwrap().read(cx); + assert!(result.height() > px(600.0)); + let term = view.session.as_ref().unwrap().term.lock(); + let p = view.native_results.projection.borrow(); + let rows = p.source_rows(view.grid_rows()); + let snap = nebula_terminal::render::RenderSnapshot::capture_rows( + &term, + &nebula_terminal::render::SnapshotConfig { + rows: rows as u16, + cols: term.columns() as u16, + }, + |row| !p.covers(row), + ); + let cursor = + snap.cursor.as_ref().expect("prompt must be included beyond the replaced source rows"); + assert!( + p.row(cursor.row as f32) >= 0.0 && p.row(cursor.row as f32) < view.grid_rows() as f32 + ); + let painted = snap + .segments + .iter() + .flat_map(|seg| seg.cells.iter().map(|cell| cell.text.as_str())) + .collect::(); + assert!(painted.contains("prompt>"), "{painted:?}"); + assert!(!painted.contains("第"), "native source must not be painted twice"); + }); +} + +#[gpui::test] +fn native_result_compact_hover_tools_and_copy_feedback(cx: &mut TestAppContext) { + use nebula_terminal::inline_content::Packet; + let (view, mut cx, receiver, proxy) = open(cx); + let source = "fatal: missing repository\n"; + let wire = format!( + "{}\r\n{}\r\n{}{}", + Packet::Begin { + wire_id: 90, + rows: 16, + mime: "text/x-git-progress".into(), + title: "git status".into() + } + .encode(), + Packet::Place { wire_id: 90, row: 1 }.encode(), + Packet::AppendStderr { wire_id: 90, text: source.into() }.encode(), + Packet::End { wire_id: 90, code: 128 }.encode() + ); + feed(&view, &mut cx, &proxy, wire.as_bytes()); + draw(&mut cx); + let bounds = cx.debug_bounds("native-result").unwrap(); + assert_eq!(bounds.size.height, view.read_with(&cx, |view, _| view.line_height * 2.0)); + assert!(cx.debug_bounds("native-result-tools").is_none()); + let hover = bounds.origin + point(px(10.0), px(5.0)); + cx.simulate_mouse_move(hover, None, Modifiers::default()); + draw(&mut cx); + assert!(cx.debug_bounds("native-result-tools").is_some()); + assert!(cx.debug_bounds("native-result-hover-line").is_some()); + assert_eq!(cx.debug_bounds("native-result").unwrap(), bounds); + let copy = cx.debug_bounds("native-result-copy").unwrap(); + assert!(copy.size.width > px(0.0) && copy.size.height > px(0.0), "{copy:?}"); + assert!(bounds.contains(©.center()), "button {copy:?} outside {bounds:?}"); + cx.simulate_mouse_move(copy.center(), None, Modifiers::default()); + draw(&mut cx); + assert_eq!( + cx.debug_bounds("native-result-copy"), + Some(copy), + "button moved or disappeared before click" + ); + cx.simulate_click(copy.center(), Modifiers::default()); + cx.run_until_parked(); + cx.simulate_mouse_move(point(px(0.0), px(0.0)), None, Modifiers::default()); + draw(&mut cx); + assert_eq!(clipboard(&mut cx).as_deref(), Some(source)); + assert!(cx.debug_bounds("native-result-tools").is_some()); + cx.executor().advance_clock(std::time::Duration::from_millis(1600)); + draw(&mut cx); + assert!(cx.debug_bounds("native-result-tools").is_none()); + cx.simulate_mouse_move(hover, None, Modifiers::default()); + draw(&mut cx); + let raw_button = cx.debug_bounds("native-result-raw").unwrap().center(); + cx.simulate_click(raw_button, Modifiers::default()); + draw(&mut cx); + assert!( + cx.debug_bounds("native-result-raw-content").unwrap().size.height >= bounds.size.height + ); + let view_button = cx.debug_bounds("native-result-view").unwrap().center(); + cx.simulate_mouse_move(view_button, None, Modifiers::default()); + draw(&mut cx); + cx.simulate_click(view_button, Modifiers::default()); + cx.run_until_parked(); + cx.simulate_mouse_move(point(px(0.0), px(0.0)), None, Modifiers::default()); + draw(&mut cx); + assert!(cx.debug_bounds("native-result-tools").is_none(), "mouse focus must not pin tools"); + cx.simulate_keystrokes("right"); + draw(&mut cx); + assert!(cx.debug_bounds("native-result-tools").is_some(), "keyboard focus reveals tools"); + cx.simulate_keystrokes("escape"); + draw(&mut cx); + assert!(cx.debug_bounds("native-result-tools").is_none()); + assert!(input(&receiver).is_empty()); +} + +#[gpui::test] +fn native_result_mount_preserves_grid_and_isolates_pointer(cx: &mut TestAppContext) { + use nebula_terminal::inline_content::Packet; + let (view, mut cx, receiver, proxy) = open(cx); + let packets = [ + Packet::Begin { + wire_id: 89, + rows: 12, + mime: "text/markdown".into(), + title: "Native Markdown".into(), + }, + Packet::Append { wire_id: 89, text: "# Heading\n\n**Native** result".into() }, + Packet::End { wire_id: 89, code: 0 }, + ]; + let mut wire = packets[0].encode(); + for row in 1..12 { + wire.push_str("\r\n"); + wire.push_str(&Packet::Place { wire_id: 89, row }.encode()); + } + wire.push_str("\r\n"); + wire.extend(packets[1..].iter().map(Packet::encode)); + feed(&view, &mut cx, &proxy, wire.as_bytes()); + draw(&mut cx); + assert_eq!(view.read_with(&cx, |view, _| view.native_results.len()), 1); + let bounds = cx.debug_bounds("native-result").unwrap(); + for button in [MouseButton::Left, MouseButton::Middle, MouseButton::Right] { + cx.simulate_mouse_down(bounds.center(), button, Modifiers::default()); + cx.simulate_mouse_up(bounds.center(), button, Modifiers::default()); + } + draw(&mut cx); + assert!(input(&receiver).is_empty()); + assert!(!view.read_with(&cx, |view, _| view.selecting)); + cx.simulate_resize(gpui::size(px(550.0), px(600.0))); + draw(&mut cx); + assert!(cx.debug_bounds("native-result").is_some()); + feed(&view, &mut cx, &proxy, b"\x1b[1;2HX"); + draw(&mut cx); + assert!(view.read_with(&cx, |view, _| view.native_results.is_empty())); + feed(&view, &mut cx, &proxy, b"\x1bc"); + draw(&mut cx); + assert!(view.read_with(&cx, |view, _| view.native_results.is_empty())); +} + +#[gpui::test] +fn native_result_long_document_uses_terminal_scroll_and_retains_identity(cx: &mut TestAppContext) { + use nebula_terminal::inline_content::Packet; + let (view, mut cx, receiver, proxy) = open(cx); + let body = (0..80).map(|i| format!("paragraph {i}\n\n")).collect::(); + let wire = format!( + "{}\r\n{}{}prompt>", + Packet::Begin { wire_id: 200, rows: 2, mime: "text/markdown".into(), title: "long".into() } + .encode(), + Packet::Append { wire_id: 200, text: body.clone() }.encode(), + Packet::End { wire_id: 200, code: 0 }.encode() + ); + feed(&view, &mut cx, &proxy, wire.as_bytes()); + for _ in 0..4 { + draw(&mut cx); + } + let (id, entity, height) = view.read_with(&cx, |view, cx| { + let (&id, entity) = view.native_results.entities.iter().next().unwrap(); + (id, entity.entity_id(), entity.read(cx).height()) + }); + assert!(height > px(600.0), "document must expand past the viewport: {height:?}"); + let (_, history) = view.read_with(&cx, |view, _| view.scroll_state()); + assert!(history > 80, "expanded output contributes to terminal scrollback"); + view.update(&mut cx, |view, cx| { + view.scroll_to_offset(history, 0); + cx.notify(); + }); + draw(&mut cx); + let before = view.read_with(&cx, |view, _| view.scroll_state().0); + let at = point(px(80.0), px(120.0)); + cx.simulate_mouse_move(at, None, Modifiers::default()); + cx.simulate_event(ScrollWheelEvent { + position: at, + delta: gpui::ScrollDelta::Lines(point(0.0, -3.0)), + modifiers: Modifiers::default(), + touch_phase: gpui::TouchPhase::Moved, + }); + draw(&mut cx); + assert!(view.read_with(&cx, |view, _| view.scroll_state().0) < before); + view.update(&mut cx, |view, cx| { + view.scroll_to_offset(history / 2, before); + cx.notify(); + }); + draw(&mut cx); + let anchor_y = cx.debug_bounds("native-result").unwrap().origin.y; + // 滚动条可能按用户的 Hover 偏好隐藏;先走真实命中区,才能验收拖动。 + let edge = view.read_with(&cx, |view, _| { + view.origin + point(view.cell_width * view.cols as f32 - px(2.0), px(100.0)) + }); + cx.simulate_mouse_move(edge, None, Modifiers::default()); + draw(&mut cx); + let thumb = view.read_with(&cx, |view, _| { + let (offset, history) = view.scroll_state(); + view.scrollbar_thumb(offset, history).unwrap() + }); + assert!(cx.debug_bounds("native-result").unwrap().right() <= thumb.origin.x); + cx.simulate_mouse_down(thumb.center(), MouseButton::Left, Modifiers::default()); + assert!( + view.read_with(&cx, |view, _| view.scrollbar_drag.is_some()), + "main scrollbar remains reachable beside native output" + ); + cx.simulate_mouse_up(thumb.center(), MouseButton::Left, Modifiers::default()); + draw(&mut cx); + cx.simulate_resize(gpui::size(px(700.0), px(600.0))); + for _ in 0..4 { + draw(&mut cx); + } + assert!( + (cx.debug_bounds("native-result").unwrap().origin.y - anchor_y).abs() <= px(1.0), + "resize must preserve the offset inside the same document" + ); + feed(&view, &mut cx, &proxy, b"\x1b[?1049h"); + draw(&mut cx); + view.update(&mut cx, |view, _| { + view.session.as_ref().unwrap().term.lock().scroll_display(Scroll::Delta(1)); + }); + feed(&view, &mut cx, &proxy, b"\x1b[?1049l"); + for _ in 0..3 { + draw(&mut cx); + } + assert!( + (cx.debug_bounds("native-result").unwrap().origin.y - anchor_y).abs() <= px(1.0), + "alternate-screen scrolling must preserve the primary document anchor" + ); + view.update(&mut cx, |view, cx| { + view.scroll_to_offset(0, before); + cx.notify(); + }); + draw(&mut cx); + assert_eq!( + view.read_with(&cx, |view, _| view.native_results.entities[&id].entity_id()), + entity + ); + let prompt = view.read_with(&cx, |view, _| { + let term = view.session.as_ref().unwrap().term.lock(); + let origin = term.viewport_origin_for(view.rows); + let row = (term.grid().cursor.point.line - origin).0; + view.origin + + point( + view.cell_width * 3.5, + view.line_height * (view.native_results.projection.borrow().row(row as f32) + 0.5), + ) + }); + let mapped = view.read_with(&cx, |view, _| view.grid_point(prompt).0); + assert_eq!(mapped.line.0, 1, "prompt hit must resolve to the original VT row"); + cx.update(|window, cx| { + view.update(cx, |view, cx| { + use nebula_terminal::render::{RenderSnapshot, SnapshotConfig}; + let snapshot = RenderSnapshot::capture( + &*view.session.as_ref().unwrap().term.lock(), + &SnapshotConfig { rows: view.rows as u16, cols: view.cols as u16 }, + ); + let cursor = snapshot.cursor.as_ref().unwrap(); + let row = view.native_results.projection.borrow().row(cursor.row as f32); + let geometry = super::super::cursor::CursorGeometry { + screen: (1, false, 0), + grid: (view.cols, view.rows), + metrics: (view.cell_width.as_f32(), view.line_height.as_f32(), 1.0), + projection_shift: 0, + row_projection_shift: row - cursor.row as f32, + shape: cursor.shape, + }; + view.cursor_animation.reset(); + let visual = view + .visual_cursor(Some(cursor), cursor.col as usize, geometry, true, window, cx) + .unwrap(); + assert!( + (visual.row - f64::from(row)).abs() < 0.001, + "2.2.2 smooth cursor must share the native result's vertical projection" + ); + }) + }); + assert!(input(&receiver).is_empty()); + let mut costs = Vec::new(); + for step in 0..40 { + let started = std::time::Instant::now(); + view.update(&mut cx, |view, cx| { + let (current, total) = view.scroll_state(); + view.scroll_to_offset(total * (step % 20) / 20, current); + cx.notify(); + }); + draw(&mut cx); + costs.push(started.elapsed().as_secs_f64() * 1000.0); + } + costs.sort_by(f64::total_cmp); + crate::gpui_shell::try_write_stderr(format_args!( + "native-result synthetic-window scroll: 40 frames, median={:.3} ms, max={:.3} ms\n", + costs[costs.len() / 2], + costs.last().unwrap() + )); +} + +#[gpui::test] +fn ordinary_directory_folds_only_middle_and_keeps_original_links(cx: &mut TestAppContext) { + use base64::Engine as _; + use nebula_terminal::grid::Dimensions as _; + let (view, mut cx, receiver, proxy) = open_with_cache(cx, true); + cx.simulate_resize(gpui::size(px(900.0), px(480.0))); + draw(&mut cx); + view.update(&mut cx, |view, _| { + let mut config = crate::config::UiConfig::default(); + Arc::make_mut(&mut config.hints.enabled[0]).action = + crate::config::ui_config::HintAction::Action( + crate::config::ui_config::HintInternalAction::Copy, + ); + view.hint_config = Arc::new(config); + }); + let count: usize = 31; + let source = (0..count) + .map(|i| { + format!("\x1b[32m\x1b]8;;file:///fold/{i}\x1b\\file-{i:02}\x1b]8;;\x1b\\\x1b[0m\r\n") + }) + .collect::(); + let owner = base64::engine::general_purpose::STANDARD.encode("bash:1"); + let command = base64::engine::general_purpose::STANDARD.encode("bash:1\nls -l"); + let wire = format!( + "\x1b]1337;SetUserVar=pebrel_shell={owner}\x07\x1b]1337;SetUserVar=pebrel_command={command}\x07\x1b]133;C\x07{source}\x1b]133;D;0\x07prompt>" + ); + feed(&view, &mut cx, &proxy, wire.as_bytes()); + for _ in 0..4 { + draw(&mut cx); + } + assert!( + view.read_with(&cx, |view, _| view.native_results.entities.is_empty()), + "directory output must not become a native table" + ); + assert!(cx.debug_bounds("native-result").is_none()); + view.read_with(&cx, |view, _| { + let term = view.session.as_ref().unwrap().term.lock(); + let p = view.native_results.projection.borrow(); + let first = term.grid().cursor.point.line - count; + let origin = term.viewport_origin_for(view.rows); + for i in 0..count { + let row = (first + i - origin).0 as usize; + assert_eq!(p.covers(row), (10..count - 10).contains(&i), "source row {i}"); + let cell = &term.grid()[TermPoint::new(first + i, Column(0))]; + assert_eq!(cell.hyperlink().unwrap().uri(), format!("file:///fold/{i}")); + assert_eq!( + cell.fg, + nebula_terminal::vte::ansi::Color::Named( + nebula_terminal::vte::ansi::NamedColor::Green + ) + ); + } + assert_eq!(p.row((first + (count - 1) - origin).0 as f32), 20.0); + let cells = super::super::super::osc_links::dashed_cells_where( + &term, + &view.hint_config, + p.source_rows(view.rows), + view.cols, + |row| !p.covers(row), + ); + assert!( + cells.contains_key(&(((first + (count - 1) - origin).0 as u16), 0)), + "tail hyperlinks remain decorated" + ); + }); + let position = |view: &Entity, cx: &VisualTestContext, row: usize, col: f32| { + view.read_with(cx, |view, _| { + let term = view.session.as_ref().unwrap().term.lock(); + let line = term.grid().cursor.point.line - count + row; + let source = (line - term.viewport_origin_for(view.rows)).0 as f32; + point( + view.origin.x + view.cell_width * col, + view.origin.y + + view.line_height + * (view.native_results.projection.borrow().row(source) + 0.5), + ) + }) + }; + cx.update(|window, cx| view.update(cx, |view, cx| view.open_reading(false, window, cx))); + for row in [0, 15, 30] { + view.update(&mut cx, |view, cx| { + let term = view.session.as_ref().unwrap().term.lock(); + let line = (term.grid().scrolled_out() + term.history_size()) as i64 + + i64::from(term.grid().cursor.point.line.0) + - count as i64 + + row as i64; + view.reading.panel.as_mut().unwrap().highlight = Some(ReadingSpan { + epoch: term.reading_epoch(), + start: ReadingPoint { line: line as usize, column: 0 }, + end: ReadingPoint { line: line as usize, column: 7 }, + }); + cx.notify(); + }); + draw(&mut cx); + let painted = cx.update(|_, cx| snapshot_probe::last(view.entity_id(), cx)); + if row == 15 { + assert!( + painted.reading_highlights.is_empty(), + "hidden text must not highlight the fold control" + ); + } else { + assert_eq!(painted.reading_highlights.len(), 1, "visible source row {row}"); + let highlight = painted.reading_highlights[0]; + let expected = position(&view, &cx, row, 0.0); + assert_eq!(highlight.origin.x, expected.x); + assert_eq!(highlight.origin.y + highlight.size.height * 0.5, expected.y); + assert_eq!(highlight.size.width, painted.cell_size.width * 7.0); + } + } + cx.update(|window, cx| view.update(cx, |view, cx| view.close_reading(window, cx))); + draw(&mut cx); + let modifiers = if crate::platform::Platform::current() == crate::platform::Platform::MacOS { + Modifiers { platform: true, ..Modifiers::default() } + } else { + Modifiers { control: true, ..Modifiers::default() } + }; + for row in [0, 21, 30] { + let point = position(&view, &cx, row, 0.25); + cx.simulate_mouse_move(point, None, modifiers); + draw(&mut cx); + assert_eq!( + view.read_with(&cx, |view, _| view + .link_hover + .as_ref() + .unwrap() + .hint + .hyperlink() + .unwrap() + .uri() + .to_owned()), + format!("file:///fold/{row}") + ); + cx.simulate_mouse_down(point, MouseButton::Left, modifiers); + cx.simulate_mouse_up(point, MouseButton::Left, Modifiers::default()); + draw(&mut cx); + assert_eq!(clipboard(&mut cx).unwrap(), format!("file:///fold/{row}")); + } + // 跨折叠区拖选仍复制完整原文,隐藏中段不是被删除的数据。 + let start = position(&view, &cx, 0, 0.25); + let end = position(&view, &cx, count - 1, 6.75); + cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); + cx.simulate_mouse_move(end, Some(MouseButton::Left), Modifiers::default()); + cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default()); + draw(&mut cx); + cx.update(|window, cx| { + view.update(cx, |view, cx| assert!(view.copy_selection(true, window, cx))) + }); + assert_eq!( + clipboard(&mut cx).unwrap(), + (0..count).map(|i| format!("file-{i:02}")).collect::>().join("\n") + ); + // 控制行紧接前十行;用真实终端布局定位,避免查询缓存帧不会重放的 debug selector。 + let toggle = position(&view, &cx, 10, 4.0); + let folded = cx.update(|_, cx| snapshot_probe::last(view.entity_id(), cx)); + assert!(!folded.text.contains("file-15")); + cx.simulate_click(toggle, Modifiers::default()); + draw(&mut cx); + let expanded = cx.update(|_, cx| snapshot_probe::last(view.entity_id(), cx)); + assert!(expanded.count > folded.count); + assert!(expanded.text.contains("file-15")); + let middle = position(&view, &cx, 15, 0.25); + cx.simulate_mouse_move(middle, None, modifiers); + draw(&mut cx); + cx.simulate_mouse_down(middle, MouseButton::Left, modifiers); + cx.simulate_mouse_up(middle, MouseButton::Left, Modifiers::default()); + draw(&mut cx); + assert_eq!(clipboard(&mut cx).unwrap(), "file:///fold/15"); + cx.simulate_click(toggle, Modifiers::default()); + draw(&mut cx); + let collapsed = cx.update(|_, cx| snapshot_probe::last(view.entity_id(), cx)); + assert!(collapsed.count > expanded.count); + assert!(!collapsed.text.contains("file-15")); + let prompt = position(&view, &cx, count, 0.25); + view.read_with(&cx, |view, _| { + assert_eq!( + view.grid_point(prompt).0.line, + view.session.as_ref().unwrap().term.lock().grid().cursor.point.line + ) + }); + assert!(input(&receiver).is_empty()); +} + +#[gpui::test] +fn ordinary_short_directory_keeps_every_line_without_controls(cx: &mut TestAppContext) { + use base64::Engine as _; + let (view, mut cx, _, proxy) = open(cx); + let owner = base64::engine::general_purpose::STANDARD.encode("pwsh:1"); + let command = base64::engine::general_purpose::STANDARD.encode("pwsh:1\nls"); + let source = (0..20).map(|i| format!("file-{i:02}\r\n")).collect::(); + let wire = format!( + "\x1b]1337;SetUserVar=pebrel_shell={owner}\x07\x1b]1337;SetUserVar=pebrel_command={command}\x07\x1b]133;C\x07{source}\x1b]133;D;0\x07prompt>" + ); + feed(&view, &mut cx, &proxy, wire.as_bytes()); + for _ in 0..3 { + draw(&mut cx); + } + assert!(cx.debug_bounds("terminal-output-fold-toggle").is_none()); + assert!(cx.debug_bounds("native-result").is_none()); + view.read_with(&cx, |view, _| { + let p = view.native_results.projection.borrow(); + for row in 0..20 { + assert!(!p.covers(row)); + } + }); +} + +#[gpui::test] +fn native_result_revalidates_live_rows_that_scroll_into_history(cx: &mut TestAppContext) { + use base64::Engine as _; + use nebula_terminal::grid::Dimensions as _; + let (view, mut cx, _, proxy) = open(cx); + for column in [1, 2] { + feed(&view, &mut cx, &proxy, b"\x1bc"); + let owner = base64::engine::general_purpose::STANDARD.encode("bash:1"); + let command = base64::engine::general_purpose::STANDARD.encode("bash:1\ncat sample.md"); + let wire = format!( + "\x1b]1337;SetUserVar=pebrel_shell={owner}\x07\x1b]1337;SetUserVar=pebrel_command={command}\x07\x1b]133;C\x07one\r\ntwo\r\nthree\r\n\x1b]133;D;0\x07prompt>" + ); + feed(&view, &mut cx, &proxy, wire.as_bytes()); + for _ in 0..4 { + draw(&mut cx); + } + assert_eq!(view.read_with(&cx, |view, _| view.native_results.len()), 1); + let (screen, epoch) = view.read_with(&cx, |view, _| { + let term = view.session.as_ref().unwrap().term.lock(); + (term.screen_lines(), term.reading_epoch()) + }); + // 一次输入既覆盖中间行又把它滚入历史;col=2 保留 marker,须校验指纹。 + let rewrite = format!("\x1b[2;{column}HNEW\x1b[{screen};1H\r\n\r\n\r\n"); + feed(&view, &mut cx, &proxy, rewrite.as_bytes()); + for _ in 0..3 { + draw(&mut cx); + } + view.read_with(&cx, |view, _| { + let term = view.session.as_ref().unwrap().term.lock(); + assert_eq!(term.reading_epoch(), epoch); + assert!( + view.native_results.is_empty(), + "stale output must not cover rewritten historical text; column {column}" + ); + }); + } +} + +#[gpui::test] +fn native_result_table_expands_all_rows_and_keeps_body_font(cx: &mut TestAppContext) { + use nebula_terminal::inline_content::Packet; + let (view, mut cx, _, proxy) = open(cx); + let source = format!( + "[{}]", + (0..100).map(|i| format!("{{\"index\":{i}}}")).collect::>().join(",") + ); + let wire = format!( + "{}\r\n{}{}", + Packet::Begin { + wire_id: 201, + rows: 2, + mime: "application/json".into(), + title: "table".into() + } + .encode(), + Packet::Append { wire_id: 201, text: source }.encode(), + Packet::End { wire_id: 201, code: 0 }.encode() + ); + feed(&view, &mut cx, &proxy, wire.as_bytes()); + for _ in 0..4 { + draw(&mut cx); + } + view.read_with(&cx, |view, cx| { + let result = view.native_results.entities.values().next().unwrap().read(cx); + assert!(result.height() >= view.line_height * 100.0); + assert_eq!(result.body_font_size(), view.font_size); + }); +} + +#[gpui::test] +fn native_result_table_filter_and_sort_follow_real_controls(cx: &mut TestAppContext) { + use nebula_terminal::inline_content::Packet; + let (view, mut cx, receiver, proxy) = open(cx); + let source = r#"[{"Name":"alpha-long-filename.rs","Size":2},{"Name":"beta.rs","Size":10},{"Name":"gamma.rs","Size":1}]"#; + let wire = format!( + "{}\r\n{}{}", + Packet::Begin { + wire_id: 202, + rows: 2, + mime: "application/json".into(), + title: "table".into() + } + .encode(), + Packet::Append { wire_id: 202, text: source.into() }.encode(), + Packet::End { wire_id: 202, code: 0 }.encode() + ); + feed(&view, &mut cx, &proxy, wire.as_bytes()); + for _ in 0..5 { + draw(&mut cx); + } + let rows = |view: &Entity, cx: &VisualTestContext| { + view.read_with(cx, |view, cx| { + view.native_results.entities.values().next().unwrap().read(cx).displayed_rows() + }) + }; + assert!(cx.debug_bounds("native-result-filter-input").is_none()); + let names = cx.debug_bounds("native-table-column-0").unwrap(); + let sizes = cx.debug_bounds("native-table-column-1").unwrap(); + assert!(names.size.width > sizes.size.width, "columns must fit their actual content"); + cx.simulate_click( + sizes.origin + point(px(5.0), sizes.size.height - px(5.0)), + Modifiers::default(), + ); + draw(&mut cx); + assert_eq!(rows(&view, &cx)[0], ["beta.rs", "10"]); + // Button 保留鼠标与键盘焦点的区分;用真实 Tab 导航进入第二列表头。 + cx.simulate_keystrokes("tab"); + draw(&mut cx); + cx.simulate_keystrokes("tab"); + draw(&mut cx); + cx.simulate_keystrokes("enter"); + cx.simulate_event(gpui::KeyUpEvent { keystroke: gpui::Keystroke::parse("enter").unwrap() }); + draw(&mut cx); + assert_eq!(rows(&view, &cx)[0], ["gamma.rs", "1"]); + let bounds = cx.debug_bounds("native-result").unwrap(); + cx.simulate_mouse_move(bounds.origin + point(px(10.0), px(10.0)), None, Modifiers::default()); + draw(&mut cx); + let filter = cx.debug_bounds("native-result-filter-toggle").unwrap().center(); + cx.simulate_mouse_move(filter, None, Modifiers::default()); + draw(&mut cx); + cx.simulate_click(filter, Modifiers::default()); + draw(&mut cx); + assert!(cx.debug_bounds("native-result-filter-input").is_some()); + cx.simulate_input("beta"); + draw(&mut cx); + assert_eq!(rows(&view, &cx), vec![vec!["beta.rs".to_owned(), "10".to_owned()]]); + cx.simulate_keystrokes("escape"); + draw(&mut cx); + assert!(cx.debug_bounds("native-result-filter-input").is_none()); + assert_eq!(rows(&view, &cx).len(), 3); + assert!(input(&receiver).is_empty()); +} + +#[gpui::test] +fn native_result_markdown_keeps_compact_blocks_and_copies_only_code(cx: &mut TestAppContext) { + use nebula_terminal::inline_content::Packet; + let (view, mut cx, receiver, proxy) = open(cx); + let code = "# a comment\nprintf 'hello'"; + let source = format!( + "# Heading\n\nParagraph with `code`.\n\n```bash\n{code}\n```\n\n| Name | Kind | 操作 |\n| --- | --- | --- |\n| alpha | text | 选择与复制 |\n| bravo | bytes | 排序与筛选 |\n" + ); + let wire = format!( + "{}\r\n{}{}", + Packet::Begin { + wire_id: 203, + rows: 2, + mime: "text/markdown".into(), + title: "document".into() + } + .encode(), + Packet::Append { wire_id: 203, text: source }.encode(), + Packet::End { wire_id: 203, code: 0 }.encode() + ); + feed(&view, &mut cx, &proxy, wire.as_bytes()); + for _ in 0..10 { + draw(&mut cx); + } + let heading = cx.debug_bounds("native-markdown-heading").unwrap(); + let code_block = cx.debug_bounds("native-code-block").unwrap(); + let table = cx.debug_bounds("native-markdown-table").unwrap(); + assert!(heading.top() < code_block.top() && code_block.bottom() <= table.top()); + assert!(code_block.size.height < px(140.0), "a short code block must fit its content"); + let one_line_rows = view + .read_with(&cx, |view, _| px((view.font_size.as_f32() * 1.65 + 12.0).ceil() * 3.0 + 3.0)); + assert!(table.size.height <= one_line_rows, "short CJK cells must fit their measured columns"); + cx.simulate_mouse_move(code_block.center(), None, Modifiers::default()); + draw(&mut cx); + let copy = cx.debug_bounds("native-code-copy").unwrap().center(); + cx.simulate_click(copy, Modifiers::default()); + draw(&mut cx); + assert_eq!( + clipboard(&mut cx).as_deref(), + Some(code), + "no fence, language or line numbers in copied code" + ); + assert_eq!(cx.debug_bounds("native-code-block"), Some(code_block)); + let identities = |view: &Entity, cx: &VisualTestContext| { + view.read_with(cx, |view, cx| { + view.native_results.entities.values().next().unwrap().read(cx).code_identities() + }) + }; + let retained = identities(&view, &cx); + assert_eq!(retained.len(), 1); + feed(&view, &mut cx, &proxy, b"\x1b[?1049h"); + draw(&mut cx); + feed(&view, &mut cx, &proxy, b"\x1b[?1049l"); + for _ in 0..5 { + draw(&mut cx); + } + assert_eq!( + identities(&view, &cx), + retained, + "offscreen code must retain language and parsing state" + ); + cx.simulate_mouse_move(point(px(0.0), px(0.0)), None, Modifiers::default()); + draw(&mut cx); + assert!(input(&receiver).is_empty()); +} + +#[gpui::test] +fn native_result_eviction_releases_offscreen_parsed_state(cx: &mut TestAppContext) { + use nebula_terminal::inline_content::Packet; + let (view, mut cx, _, proxy) = open(cx); + let mut stream = StreamProcessor::default(); + let mut first = None; + for id in 0..34 { + let wire = format!( + "{}\r\n{}{}", + Packet::Begin { + wire_id: id, + rows: 2, + mime: "text/markdown".into(), + title: "retention".into() + } + .encode(), + Packet::Append { wire_id: id, text: format!("# Result {id}\n\ncontent") }.encode(), + Packet::End { wire_id: id, code: 0 }.encode() + ); + view.update(&mut cx, |view, cx| { + stream.feed(&mut view.session.as_ref().unwrap().term.lock(), &proxy, wire.as_bytes()); + cx.notify(); + }); + for _ in 0..3 { + draw(&mut cx); + } + if id == 0 { + first = + view.read_with(&cx, |view, _| view.native_results.entities.keys().next().copied()); + } + } + view.read_with(&cx, |view, _| { + assert!(!view.native_results.entities.contains_key(&first.unwrap())); + assert!(view.native_results.entities.len() <= 32); + }); +} + +struct CachedTerminal(Entity); +impl Render for CachedTerminal { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + // 与工作区相同的缓存边界;不以整窗 refresh 掩盖终端漏发通知。 + div().size_full().child(self.0.clone().cached(gpui::StyleRefinement::default().size_full())) + } +} + fn open( cx: &mut TestAppContext, +) -> (Entity, VisualTestContext, Receiver, session::EventProxy) { + open_with_cache(cx, false) +} + +fn open_with_cache( + cx: &mut TestAppContext, + cached: bool, ) -> (Entity, VisualTestContext, Receiver, session::EventProxy) { cx.update(|cx| { gpui_component::init(cx); crate::gpui_shell::terminal::init(cx); crate::gpui_shell::math_view::register(cx); + snapshot_probe::enable(cx); cx.set_global(Settings::load(nebula_settings::ThemeName::Nord)); }); let mut result = None; @@ -47,7 +818,11 @@ fn open( let focus_handle = view.read(cx).focus_handle.clone(); window.focus(&focus_handle, cx); result = Some((view.clone(), receiver, proxy)); - Root::new(view, window, cx) + if cached { + Root::new(cx.new(|_| CachedTerminal(view)), window, cx) + } else { + Root::new(view, window, cx) + } }); let mut window = window.clone(); window.simulate_resize(gpui::size(px(900.0), px(600.0))); @@ -62,7 +837,6 @@ fn open( fn draw(cx: &mut VisualTestContext) { cx.run_until_parked(); cx.update(|window, cx| { - window.refresh(); window.draw(cx).clear(cx); }); } diff --git a/nebula_app/src/gpui_shell/terminal/view/startup.rs b/nebula_app/src/gpui_shell/terminal/view/startup.rs index 064b4aa50..1fab0fe47 100644 --- a/nebula_app/src/gpui_shell/terminal/view/startup.rs +++ b/nebula_app/src/gpui_shell/terminal/view/startup.rs @@ -264,6 +264,7 @@ impl TerminalView { ]; let mut view = Self { + native_results: Default::default(), pane_id, session, focus_handle, diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index 675ba9629..a6aaf9c71 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -91,6 +91,8 @@ mod migrate; mod mobile_connection; mod motion; mod mux; +mod native_result_model; +mod native_view_cli; mod nebula_history; mod notify; #[cfg(windows)] @@ -283,6 +285,7 @@ fn main() -> Result<(), Box> { Some(Subcommands::Theme(options)) => { std::process::exit(theme_library::package::cli::run(options)) }, + Some(Subcommands::View(options)) => std::process::exit(native_view_cli::run(options)), #[cfg(windows)] Some(Subcommands::NotifyTest) => std::process::exit(crate::notify::notify_test()), Some(Subcommands::SetupAi(options)) => { diff --git a/nebula_app/src/native_result_model.rs b/nebula_app/src/native_result_model.rs new file mode 100644 index 000000000..f86121dae --- /dev/null +++ b/nebula_app/src/native_result_model.rs @@ -0,0 +1,338 @@ +use serde_json::Value; +use std::cmp::Ordering; +use std::ops::Range; + +mod directory; +pub(crate) use directory::{FileKind, FileMetadata}; + +pub(crate) const MAX_TABLE_ROWS: usize = 20_000; +const MAX_TABLE_COLUMNS: usize = 64; +const MAX_TABLE_CELLS: usize = 256 * 1024; + +#[derive(Default)] +pub(crate) struct RawText { + pub text: String, + pub lines: Vec>, +} +impl RawText { + pub fn append(&mut self, chunk: &str) { + let start = self.text.len(); + self.text.push_str(chunk); + let mut line_start = self.lines.pop().map_or(start, |line| line.start); + for (offset, c) in chunk.char_indices() { + if matches!(c, '\r' | '\n') { + let absolute = start + offset; + if c != '\n' || absolute == 0 || self.text.as_bytes()[absolute - 1] != b'\r' { + self.lines.push(line_start..absolute); + } + line_start = absolute + c.len_utf8(); + } + } + self.lines.push(line_start..self.text.len()); + } +} + +pub(crate) struct Table { + pub columns: Vec, + pub rows: Vec>, + pub files: Option, +} + +impl Table { + pub fn parse(text: &str) -> Result { + if text.len() > nebula_terminal::inline_content::MAX_CONTENT_BYTES { + return Err("limit"); + } + let values = bounded_values(text)?; + let mut columns = Vec::new(); + for value in &values { + let object = value.as_object().ok_or("table")?; + for key in object.keys() { + if !columns.contains(key) { + columns.push(key.clone()); + } + if columns.len() > MAX_TABLE_COLUMNS + || columns.len().saturating_mul(values.len()) > MAX_TABLE_CELLS + { + return Err("limit"); + } + } + } + let rows = values + .iter() + .map(|value| { + columns + .iter() + .map(|key| match value.get(key) { + Some(Value::String(text)) => text.clone(), + Some(Value::Null) | None => String::new(), + Some(value) => value.to_string(), + }) + .collect() + }) + .collect(); + Ok(Self { columns, rows, files: None }) + } + + pub fn order(&self, query: &str, sort: Option<(usize, bool)>) -> Vec { + let query = query.to_lowercase(); + let mut order: Vec<_> = self + .rows + .iter() + .enumerate() + .filter(|(_, row)| { + query.is_empty() || row.iter().any(|cell| cell.to_lowercase().contains(&query)) + }) + .map(|(index, _)| index) + .collect(); + if let Some((column, descending)) = sort { + order.sort_by(|&a, &b| { + let left = &self.rows[a][column]; + let right = &self.rows[b][column]; + // 先分类型再比较,避免 2 < 10 < 1a < 2 这样的循环关系。 + let cmp = match (numeric_cell(left), numeric_cell(right)) { + (Some(left), Some(right)) => left.total_cmp(&right), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => left.cmp(right), + }; + if descending { cmp.reverse() } else { cmp } + }); + } + order + } +} + +pub(crate) fn numeric_cell(text: &str) -> Option { + text.trim().trim_end_matches('%').parse::().ok().filter(|number| number.is_finite()) +} + +fn bounded_values(text: &str) -> Result, &'static str> { + // 在反序列化过程中止住行数,而不是先把整个 JSON 数组膨胀成 Value 后才检查。 + struct Rows; + impl<'de> serde::de::Visitor<'de> for Rows { + type Value = Result, &'static str>; + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("an array of table records") + } + fn visit_seq>( + self, + mut seq: A, + ) -> Result { + let mut rows = Vec::new(); + while let Some(value) = seq.next_element::()? { + if rows.len() == MAX_TABLE_ROWS { + return Ok(Err("limit")); + } + rows.push(value); + } + Ok(Ok(rows)) + } + } + if text.trim_start().starts_with('[') { + let mut parser = serde_json::Deserializer::from_str(text); + let rows = + serde::Deserializer::deserialize_seq(&mut parser, Rows).map_err(|_| "table")??; + parser.end().map_err(|_| "table")?; + Ok(rows) + } else { + // 单个 JSON 对象可以跨行;失败后只接受逐行完整对象的 NDJSON。 + if let Ok(value @ Value::Object(_)) = serde_json::from_str::(text) { + return Ok(vec![value]); + } + let mut rows = Vec::new(); + for line in text.lines().filter(|line| !line.trim().is_empty()) { + if rows.len() == MAX_TABLE_ROWS { + return Err("limit"); + } + rows.push(serde_json::from_str(line).map_err(|_| "table")?); + } + Ok(rows) + } +} + +#[derive(Default)] +pub(crate) struct GitProgress { + pub phases: Vec, + pub tail: Vec, + pending: [String; 2], +} + +pub(crate) struct Phase { + pub name: String, + pub percent: Option, + pub detail: String, +} + +impl GitProgress { + pub fn rows(&self) -> usize { + self.phases.len() + self.tail.len() + self.pending_lines().count() + 1 + } + pub fn pending_lines(&self) -> impl Iterator { + self.pending.iter().map(String::as_str).filter(|line| { + let value = line.trim().strip_prefix("remote: ").unwrap_or(line.trim()); + !value.is_empty() + && !GIT_PHASES + .iter() + .any(|phase| value.starts_with(phase) || phase.starts_with(value)) + }) + } + pub fn feed(&mut self, text: &str, stderr: bool) { + let stream = usize::from(stderr); + for c in text.chars() { + if matches!(c, '\r' | '\n') { + let line = std::mem::take(&mut self.pending[stream]); + self.line(line.trim()); + } else if self.pending[stream].len() < 8192 { + self.pending[stream].push(c); + } + } + } + pub fn finish(&mut self) { + for stream in 0..2 { + let line = std::mem::take(&mut self.pending[stream]); + self.line(line.trim()); + } + } + fn line(&mut self, line: &str) { + let line = line.strip_prefix("remote: ").unwrap_or(line); + if line.is_empty() { + return; + } + if let Some(name) = GIT_PHASES + .iter() + .find(|name| line.strip_prefix(**name).is_some_and(|rest| rest.starts_with(':'))) + { + let detail = line[name.len() + 1..].trim().to_owned(); + let percent = detail + .split_once('%') + .and_then(|(value, _)| value.trim().parse::().ok()) + .filter(|value| *value <= 100); + if let Some(phase) = self.phases.iter_mut().find(|phase| phase.name == *name) { + phase.percent = percent; + phase.detail = detail; + } else { + self.phases.push(Phase { name: name.to_string(), percent, detail }); + } + } else { + self.tail.push(line.to_owned()); + if self.tail.len() > 6 { + self.tail.remove(0); + } + } + } +} + +const GIT_PHASES: &[&str] = &[ + "Enumerating objects", + "Counting objects", + "Compressing objects", + "Receiving objects", + "Resolving deltas", + "Updating files", + "Checking out files", + "Writing objects", +]; + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn native_git_short_failure_and_unterminated_phase_height() { + let mut git = GitProgress::default(); + git.feed("fatal: missing repository\n", true); + git.finish(); + assert_eq!(git.rows(), 2); + let mut git = GitProgress::default(); + git.feed( + "Counting objects: 100%\rCompressing objects: 100%\rReceiving objects: 100%", + true, + ); + assert_eq!(git.rows(), 3); + git.finish(); + assert_eq!(git.rows(), 4); + assert_eq!(git.phases.len(), 3); + } + #[test] + fn ndjson_union_numeric_sort_and_unicode_filter() { + let table = Table::parse( + "{\"Name\":\"中文\",\"CPU\":\"2%\"}\n{\"Name\":\"api\",\"CPU\":\"10%\",\"PIDs\":8}", + ) + .unwrap(); + assert_eq!(table.columns, ["Name", "CPU", "PIDs"]); + assert_eq!(table.order("", Some((1, true))), [1, 0]); + assert_eq!(table.order("中文", None), [0]); + assert_eq!(Table::parse(r#"[{"Name":"only"}]"#).unwrap().columns, ["Name"]); + assert!(Table::parse("[1,2]").is_err()); + assert!(Table::parse("{broken}").is_err()); + assert!(Table::parse(r#"{"a":1}{"a":2}"#).is_err()); + } + #[test] + fn git_chunk_boundaries_and_unknown_progress() { + let mut git = GitProgress::default(); + git.feed("remote: Counting objects: 2", true); + git.feed("partial stdout", false); + git.feed("0% (2/10)\rremote: Counting objects: 100% (10/10), done.\nReceiving objects: 5% (1/20)\r",true); + git.feed("fatal: example failure 80%\n", true); + assert_eq!(git.phases.len(), 2); + assert_eq!(git.phases[0].percent, Some(100)); + assert_eq!(git.phases[1].percent, Some(5)); + assert_eq!(git.tail, ["fatal: example failure 80%"]); + } + #[test] + fn mixed_column_has_a_consistent_total_order() { + let table = Table::parse(r#"[{"v":"2"},{"v":"10"},{"v":"1a"},{"v":"NaN"}]"#).unwrap(); + assert_eq!(table.order("", Some((0, false))), [0, 1, 2, 3]); + assert_eq!(table.order("", Some((0, true))), [3, 2, 1, 0]); + } + + #[test] + fn large_tables_stop_before_materializing_unbounded_rows_or_cells() { + let records = format!("[{}]", "{},".repeat(MAX_TABLE_ROWS) + "{}"); + assert!(matches!(Table::parse(&records), Err("limit"))); + let record = + format!("{{{}}}", (0..64).map(|i| format!("\"k{i}\":0")).collect::>().join(",")); + let many = format!("[{}]", vec![record; 4097].join(",")); + assert!(matches!(Table::parse(&many), Err("limit"))); + assert_eq!(Table::parse("{\n \"a\": 2\n}").unwrap().rows, [["2"]]); + } + + #[test] + #[ignore = "manual native-result parse and ordering cost sample"] + fn measure_native_result_large_inputs() { + let listing = (0..10_000) + .map(|i| format!("-rw-r--r-- 1 user staff {i} Oct 20 09:20 file-{i}.txt\n")) + .collect::(); + let json = format!( + "[{}]", + (0..10_000) + .map(|i| format!("{{\"name\":\"file-{i}\",\"size\":{i},\"kind\":\"file\"}}")) + .collect::>() + .join(",") + ); + for (label, source, directory) in [("ls-long", &listing, true), ("json", &json, false)] { + let started = std::time::Instant::now(); + let mut raw = RawText::default(); + raw.append(source); + let raw_ms = started.elapsed().as_secs_f64() * 1000.0; + let started = std::time::Instant::now(); + let table = if directory { + Table::parse_directory(source, false) + } else { + Table::parse(source) + } + .unwrap(); + let parse_ms = started.elapsed().as_secs_f64() * 1000.0; + let started = std::time::Instant::now(); + let order = table.order("file-9", Some((table.columns.len() - 1, true))); + crate::gpui_shell::try_write_stderr(format_args!( + "native-result sample {label}: bytes={} rows={} cells={} raw_ms={raw_ms:.3} parse_ms={parse_ms:.3} filter_sort_ms={:.3} matches={}\n", + source.len(), + table.rows.len(), + table.rows.len() * table.columns.len(), + started.elapsed().as_secs_f64() * 1000.0, + order.len() + )); + } + } +} diff --git a/nebula_app/src/native_result_model/directory.rs b/nebula_app/src/native_result_model/directory.rs new file mode 100644 index 000000000..bf3ae0ee5 --- /dev/null +++ b/nebula_app/src/native_result_model/directory.rs @@ -0,0 +1,297 @@ +//! 从已经显示的目录输出取字段;不重新枚举文件,也不补造权限、大小或类型。 +use super::{MAX_TABLE_ROWS, Table}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FileKind { + Unknown, + File, + Directory, + Link, +} + +pub(crate) struct FileMetadata { + pub name_column: usize, + pub kinds: Vec, + pub headers: bool, + pub preamble: Vec, +} + +impl Table { + pub fn parse_directory(text: &str, powershell: bool) -> Result { + if text.len() > nebula_terminal::inline_content::MAX_CONTENT_BYTES { + return Err("limit"); + } + let lines = text + .lines() + .filter(|line| !line.trim().is_empty()) + .take(MAX_TABLE_ROWS + 1) + .collect::>(); + if lines.len() > MAX_TABLE_ROWS { + return Err("limit"); + } + if powershell { + if let Some(table) = powershell_table(&lines) { + return Ok(table); + } + } + if let Some(table) = long_listing(&lines, powershell) + .or_else(|| powershell.then(|| long_listing(&lines, false)).flatten()) + { + return Ok(table); + } + // ls 的多列空白也可能属于文件名。没有结构证据时保留整行和顺序, + // 不以 split_whitespace 把一个名字猜成多个文件,更不查询当前磁盘补信息。 + Ok(Self { + columns: vec!["Name".into()], + rows: lines.iter().map(|line| vec![(*line).to_owned()]).collect(), + files: Some(FileMetadata { + name_column: 0, + kinds: vec![FileKind::Unknown; lines.len()], + headers: false, + preamble: Vec::new(), + }), + }) + } +} + +fn mode_kind(mode: &str) -> Option { + let bytes = mode.as_bytes(); + if bytes.len() < 10 + || !b"-dlcbps".contains(&bytes[0]) + || !bytes[1..10].iter().all(|c| b"rwxstST-".contains(c)) + || !bytes[10..].iter().all(|c| b"+@.".contains(c)) + { + return None; + } + Some(match bytes[0] { + b'd' => FileKind::Directory, + b'l' => FileKind::Link, + _ => FileKind::File, + }) +} + +fn take_word<'a>(rest: &mut &'a str) -> Option<&'a str> { + *rest = rest.trim_start(); + if rest.is_empty() { + return None; + } + let end = rest.find(char::is_whitespace).unwrap_or(rest.len()); + let word = &rest[..end]; + *rest = &rest[end..]; + Some(word) +} + +fn long_listing(lines: &[&str], powershell: bool) -> Option
{ + let mut rows = Vec::new(); + let mut kinds = Vec::new(); + let mut preamble = Vec::new(); + let mut compact = None; + for line in lines { + if rows.is_empty() + && line.trim().strip_prefix("total ").is_some_and(|n| n.parse::().is_ok()) + { + preamble.push((*line).to_owned()); + continue; + } + let mut rest = *line; + let mode = take_word(&mut rest)?; + let kind = mode_kind(mode)?; + let first = take_word(&mut rest)?; + // Nebula-List 的旧输出只有 mode/size/date/name;没有 owner/link 字段。 + let short = powershell; + if compact.is_some_and(|previous| previous != short) { + return None; + } + compact = Some(short); + let mut row = vec![mode.to_owned()]; + if short { + row.push(first.to_owned()); + } else { + first.parse::().ok()?; + row.extend([first, take_word(&mut rest)?, take_word(&mut rest)?].map(str::to_owned)); + let size = take_word(&mut rest)?; + if !size.chars().next()?.is_ascii_digit() { + return None; + } + row.push(size.to_owned()); + } + let date = [take_word(&mut rest)?, take_word(&mut rest)?, take_word(&mut rest)?]; + if !date[1].bytes().all(|b| b.is_ascii_digit()) + || !date[2].bytes().all(|b| b.is_ascii_digit() || b == b':') + { + return None; + } + row.push(date.join(" ")); + // 时间后的第一个空格(旧 PowerShell wrapper 为两个)是列分隔; + // 其余空格属于打印出的文件名,不能用 trim_start 丢弃。 + let name = rest.strip_prefix(if short { " " } else { " " })?; + if name.is_empty() { + return None; + } + row.push(name.to_owned()); + rows.push(row); + kinds.push(kind); + } + let columns: &[&str] = if compact? { + &["Mode", "Size", "Modified", "Name"] + } else { + &["Mode", "Links", "Owner", "Group", "Size", "Modified", "Name"] + }; + Some(Table { + columns: columns.iter().map(|name| (*name).to_owned()).collect(), + rows, + files: Some(FileMetadata { + name_column: columns.len() - 1, + kinds, + headers: true, + preamble, + }), + }) +} + +fn powershell_table(lines: &[&str]) -> Option
{ + let separator = lines.iter().position(|line| { + line.contains("----") && line.chars().all(|c| c == '-' || c.is_whitespace()) + })?; + let header = lines.get(separator.checked_sub(1)?)?; + let mut starts = Vec::new(); + let mut in_field = false; + for (index, c) in lines[separator].chars().enumerate() { + if c == '-' && !in_field { + starts.push(index); + } + in_field = c == '-'; + } + if starts.len() < 2 || starts.len() > 8 { + return None; + } + let fields = |line: &str| { + let chars: Vec<_> = line.chars().collect(); + starts + .iter() + .enumerate() + .map(|(i, start)| { + let end = starts.get(i + 1).copied().unwrap_or(chars.len()).min(chars.len()); + chars[(*start).min(chars.len())..end].iter().collect::().trim().to_owned() + }) + .collect::>() + }; + let columns = fields(header); + if columns != ["Mode", "LastWriteTime", "Length", "Name"] { + return None; + } + let name_column = columns.iter().position(|name| name == "Name")?; + let mode_column = columns.iter().position(|name| name == "Mode"); + let mut rows = Vec::new(); + let mut kinds = Vec::new(); + for line in &lines[separator + 1..] { + // 日期/大小右对齐,数据可能伸到表头起点左边;从 Name 列反向取大小, + // 首词是 Mode,两者之间的完整片段才是时间,不按横线左端硬切。 + let chars: Vec<_> = line.chars().collect(); + let name_start = starts[name_column]; + if chars.len() < name_start { + return None; + } + let name = chars[name_start..].iter().collect::(); + let prefix = chars[..name_start].iter().collect::(); + let mut rest = prefix.as_str(); + let mode = take_word(&mut rest)?; + if !mode.chars().all(|c| "darlsh-".contains(c)) { + return None; + } + let rest = rest.trim(); + let (modified, length) = match rest.rsplit_once(char::is_whitespace) { + Some((modified, length)) + if !length.is_empty() && length.bytes().all(|c| c.is_ascii_digit()) => + { + (modified.trim(), length) + }, + _ if mode.starts_with('d') => (rest, ""), + _ => return None, + }; + let row = vec![mode.to_owned(), modified.to_owned(), length.to_owned(), name]; + if row[name_column].is_empty() || line.trim_start().starts_with("Directory:") { + return None; + } + let mode = mode_column.map(|column| row[column].as_str()).unwrap_or(""); + let kind = if mode.starts_with('d') { + FileKind::Directory + } else if mode.contains('l') { + FileKind::Link + } else if !mode.is_empty() { + FileKind::File + } else { + FileKind::Unknown + }; + rows.push(row); + kinds.push(kind); + } + Some(Table { + columns, + rows, + files: Some(FileMetadata { + name_column, + kinds, + headers: true, + preamble: lines[..separator - 1].iter().map(|line| (*line).to_owned()).collect(), + }), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn listings_only_contain_reported_fields_and_preserve_names() { + let table = Table::parse_directory("alpha two words\n中文 name\n", false).unwrap(); + assert_eq!(table.columns, ["Name"]); + assert_eq!(table.rows[0], ["alpha two words"]); + assert!(!table.files.unwrap().headers); + let table = Table::parse_directory( + "total 8\ndrwxr-xr-x 2 user staff 4096 Oct 20 09:20 中文 folder\n-rw-r--r-- 1 user staff 12 Oct 19 2026 a b.txt\nlrwxrwxrwx 1 user staff 8 Oct 19 08:00 link -> a b.txt\n", + false, + ).unwrap(); + assert_eq!(table.rows[0].last().unwrap(), "中文 folder"); + assert_eq!( + table.files.as_ref().unwrap().kinds, + [FileKind::Directory, FileKind::File, FileKind::Link] + ); + assert_eq!(table.files.unwrap().preamble, ["total 8"]); + let table = + Table::parse_directory("drwxr-xr-x - Oct 20 09:20 中文目录\n", true).unwrap(); + assert_eq!(table.columns, ["Mode", "Size", "Modified", "Name"]); + assert_eq!(table.rows[0][1], "-"); + let table = Table::parse_directory("-rw-r--r-- 1 u g 1 Oct 20 09:20 a\n", false).unwrap(); + assert_eq!(table.rows[0].last().unwrap(), " a"); + } + + #[test] + fn powershell_table_keeps_actual_columns_and_bounded_input() { + let row = |mode: &str, date: &str, size: &str, name: &str| { + format!("{mode:<7}{date:>26} {size:>14} {name}\n") + }; + let text = format!( + " Directory: C:\\work\n\n{}{}{}{}", + row("Mode", "LastWriteTime", "Length", "Name"), + row("----", "-------------", "------", "----"), + row("d-----", "2026/10/20 16:35", "", "中文目录"), + row("-a----", "2026/10/20 16:35", "12", " file name.txt") + ); + let table = Table::parse_directory(&text, true).unwrap(); + assert_eq!(table.columns, ["Mode", "LastWriteTime", "Length", "Name"]); + assert_eq!(table.rows.len(), 2); + assert_eq!(table.rows[0], ["d-----", "2026/10/20 16:35", "", "中文目录"]); + assert_eq!(table.rows[1], ["-a----", "2026/10/20 16:35", "12", " file name.txt"]); + assert_eq!(table.files.unwrap().preamble, [" Directory: C:\\work"]); + assert!(matches!( + Table::parse_directory(&"x\n".repeat(MAX_TABLE_ROWS + 1), false), + Err("limit") + )); + let real = "Mode LastWriteTime Length Name\n---- ------------- ------ ----\n-a---- 2026/10/20 16:35 8733 directory.rs\n"; + assert_eq!( + Table::parse_directory(real, true).unwrap().rows[0], + ["-a----", "2026/10/20 16:35", "8733", "directory.rs"] + ); + } +} diff --git a/nebula_app/src/native_view_cli.rs b/nebula_app/src/native_view_cli.rs new file mode 100644 index 000000000..0d9b3296b --- /dev/null +++ b/nebula_app/src/native_view_cli.rs @@ -0,0 +1,270 @@ +//! 显式内容入口;没有协商能力或输出被重定向时保留普通文本。 +use std::ffi::OsString; +use std::io::{self, IsTerminal as _, Read, Write}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +use clap::{Args, ValueEnum}; +use nebula_terminal::inline_content::{MAX_CONTENT_BYTES, MAX_TITLE_BYTES, Packet}; + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum Kind { + Markdown, + Table, + Git, +} + +#[derive(Debug, Args)] +pub struct Options { + /// Native renderer: Markdown, JSON/NDJSON table, or Git progress. + #[arg(long = "type", value_enum)] + kind: Kind, + /// UTF-8 input file; omit to read standard input. + #[arg(conflicts_with = "command")] + input: Option, + /// Force native output, for a compatible remote terminal. + #[arg(long, conflicts_with = "plain")] + native: bool, + /// Print original text instead of the native view protocol. + #[arg(long)] + plain: bool, + /// Run Git with separately captured stdout/stderr; e.g. -- git fetch --progress. + #[arg(last = true, num_args = 1..)] + command: Vec, +} + +pub fn run(options: Options) -> i32 { + match execute(options) { + Ok(code) => code, + Err(error) => { + eprintln!("pebrel view: {error}"); + 1 + }, + } +} + +fn execute(options: Options) -> io::Result { + let native = !options.plain + && (options.native + || (io::stdout().is_terminal() + && std::env::var("PEBREL_NATIVE_RESULTS").as_deref() == Ok("1"))); + if !options.command.is_empty() { + return run_git(&options, native); + } + let mut input: Box = match &options.input { + Some(path) => Box::new(std::fs::File::open(path)?), + None => Box::new(io::stdin()), + }; + if !native { + io::copy(&mut input, &mut io::stdout())?; + return Ok(0); + } + let mut bytes = Vec::new(); + input.take((MAX_CONTENT_BYTES + 1) as u64).read_to_end(&mut bytes)?; + if bytes.len() > MAX_CONTENT_BYTES { + return Err(io::Error::other( + "native input exceeds 4 MiB; use --plain or split the dataset", + )); + } + let text = String::from_utf8(bytes).map_err(|_| io::Error::other("input must be UTF-8"))?; + let title = options + .input + .as_ref() + .and_then(|p| p.file_name()) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|| "stdin".into()); + let mut output = Emitter::new(&options, &title)?; + output.append(&text, false)?; + output.finish(0)?; + Ok(0) +} + +struct Emitter { + id: u64, + output: io::Stdout, +} + +fn bounded_title(title: &str) -> String { + let mut result = String::new(); + for c in title.chars().filter(|c| !c.is_control()) { + if result.len() + c.len_utf8() > MAX_TITLE_BYTES { + break; + } + result.push(c); + } + result +} +impl Emitter { + fn new(options: &Options, title: &str) -> io::Result { + let id = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as u64 + ^ u64::from(std::process::id()); + let mime = match options.kind { + Kind::Markdown => "text/markdown", + Kind::Table => "application/json", + Kind::Git => "text/x-git-progress", + }; + let mut emitter = Self { id, output: io::stdout() }; + emitter.output.write_all(b"\r")?; + emitter.send(Packet::Begin { + wire_id: id, + rows: 2, + mime: mime.into(), + title: bounded_title(title), + })?; + emitter.output.write_all(b"\r\n")?; + // 发送端只占一个真实锚点行,富内容高度由视图布局决定;ConPTY 与前端仍看到同一个 CRLF。 + emitter.output.flush()?; + Ok(emitter) + } + fn send(&mut self, packet: Packet) -> io::Result<()> { + self.output.write_all(packet.encode().as_bytes())?; + self.output.flush() + } + fn append(&mut self, mut text: &str, stderr: bool) -> io::Result<()> { + while !text.is_empty() { + let mut end = text.len().min(16 * 1024); + while !text.is_char_boundary(end) { + end -= 1; + } + let text_chunk = text[..end].to_owned(); + self.send(if stderr { + Packet::AppendStderr { wire_id: self.id, text: text_chunk } + } else { + Packet::Append { wire_id: self.id, text: text_chunk } + })?; + text = &text[end..]; + } + Ok(()) + } + fn finish(&mut self, code: i32) -> io::Result<()> { + self.send(Packet::End { wire_id: self.id, code }) + } +} + +fn run_git(options: &Options, native: bool) -> io::Result { + let program = PathBuf::from(&options.command[0]); + if !matches!(options.kind, Kind::Git) + || !program.file_name().is_some_and(|name| { + name.eq_ignore_ascii_case("git") || name.eq_ignore_ascii_case("git.exe") + }) + { + return Err(io::Error::other("command capture requires --type git -- git ")); + } + let mut command = Command::new(&options.command[0]); + command.args(&options.command[1..]); + if !native { + return Ok(command.status()?.code().unwrap_or(1)); + } + // Git 的阶段文本由 locale 决定;固定子进程语言,避免把任意日志数字猜成进度。 + command.env("LC_ALL", "C").env("LANG", "C").stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut child = command.spawn()?; + let title = options.command.iter().map(|v| v.to_string_lossy()).collect::>().join(" "); + let mut emitter = match Emitter::new(options, &title) { + Ok(emitter) => emitter, + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(error); + }, + }; + let (tx, rx) = std::sync::mpsc::sync_channel::>(8); + let stdout = child.stdout.take().unwrap(); + let stderr = child.stderr.take().unwrap(); + let readers = [(Box::new(stdout) as Box), Box::new(stderr)]; + let handles: Vec<_> = readers + .into_iter() + .enumerate() + .map(|(stream, reader)| { + let tx = tx.clone(); + std::thread::spawn(move || { + if let Err(error) = + read_utf8(reader, |text| tx.send(Ok((stream == 1, text))).is_ok()) + { + let _ = tx.send(Err(error)); + } + }) + }) + .collect(); + drop(tx); + let mut failure = None; + for text in rx { + let result = text.and_then(|(stderr, text)| emitter.append(&text, stderr)); + if let Err(error) = result { + failure = Some(error); + let _ = child.kill(); + break; + } + } + // 接收器在异常路径已经释放,读线程不会卡在有界队列上。 + let status = child.wait()?; + for handle in handles { + let _ = handle.join(); + } + let code = status.code().unwrap_or(1); + emitter.finish(if failure.is_some() { 1 } else { code })?; + if let Some(error) = failure { + return Err(error); + } + Ok(code) +} + +fn read_utf8( + mut reader: Box, + mut send: impl FnMut(String) -> bool, +) -> io::Result<()> { + let mut buffer = [0; 8192]; + let mut pending = Vec::new(); + loop { + let n = reader.read(&mut buffer)?; + if n == 0 { + return if pending.is_empty() { + Ok(()) + } else { + Err(io::Error::other("incomplete UTF-8 output")) + }; + } + pending.extend_from_slice(&buffer[..n]); + let valid = match std::str::from_utf8(&pending) { + Ok(_) => pending.len(), + Err(error) if error.error_len().is_none() => error.valid_up_to(), + Err(_) => return Err(io::Error::other("Git output must be UTF-8")), + }; + if valid > 0 { + let text = std::str::from_utf8(&pending[..valid]).unwrap().to_owned(); + if !send(text) { + return Ok(()); + } + pending.drain(..valid); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn title_budget_is_in_bytes_without_splitting_unicode() { + let title = bounded_title(&"🦀".repeat(80)); + assert_eq!(title.len(), 256); + assert_eq!(title.chars().count(), 64); + } + #[test] + fn utf8_reader_preserves_split_multibyte_characters() { + struct Bytes(std::io::Cursor>); + impl Read for Bytes { + fn read(&mut self, b: &mut [u8]) -> io::Result { + self.0.read(&mut b[..1]) + } + } + let mut output = String::new(); + read_utf8(Box::new(Bytes(std::io::Cursor::new("中文🦀".as_bytes().to_vec()))), |text| { + output.push_str(&text); + true + }) + .unwrap(); + assert_eq!(output, "中文🦀"); + } +} diff --git a/scripts/build-installer.ps1 b/scripts/build-installer.ps1 index 344af15d2..438195096 100644 --- a/scripts/build-installer.ps1 +++ b/scripts/build-installer.ps1 @@ -65,6 +65,7 @@ $setupPath = Join-Path $outputRoot "$PackageBrand-v$Version-windows-$Architectur $requiredFiles = @( (Join-Path $targetRoot 'pebrel.exe'), + (Join-Path $targetRoot 'pebrel-cli.exe'), (Join-Path $targetRoot 'pebrel-hook.exe'), (Join-Path $targetRoot 'conpty.dll'), (Join-Path $targetRoot 'OpenConsole.exe'), @@ -111,6 +112,7 @@ if (-not $AllowStale) { 'nebula-completions', 'nebula_gpui', 'nebula_settings', 'nebula_split' ) | ForEach-Object { Join-Path $repo $_ } $sourceScopes = @( + @{ Binary = (Join-Path $targetRoot 'pebrel-cli.exe'); Roots = $memberDirs + @((Join-Path $repo 'Cargo.toml')) }, @{ Binary = $packagedExe; Roots = $memberDirs + @( (Join-Path $repo 'Cargo.toml'), (Join-Path $repo '..\gpui-component-fork\crates')) }, @{ Binary = (Join-Path $targetRoot 'pebrel-hook.exe'); Roots = @( diff --git a/scripts/build-windows-product.ps1 b/scripts/build-windows-product.ps1 index 89367e25b..eb68b8c14 100644 --- a/scripts/build-windows-product.ps1 +++ b/scripts/build-windows-product.ps1 @@ -21,7 +21,7 @@ try { # The acceptance lab is tested by CI but does not supply a packaged binary. $arguments = @( 'build', '--locked', '--timings', - '-p', 'nebula', '--bin', 'pebrel', + '-p', 'nebula', '--bin', 'pebrel', '--bin', 'pebrel-cli', '-p', 'nebula_hook', '--bin', 'pebrel-hook', '--features', 'nebula/gpui-shell' ) diff --git a/scripts/installer.iss b/scripts/installer.iss index 34307bf2b..51a11fe4f 100644 --- a/scripts/installer.iss +++ b/scripts/installer.iss @@ -120,6 +120,7 @@ Name: "autostart"; Description: "{cm:AutoStart}"; GroupDescription: "{cm:Additio #endif [Files] +Source: "{#BuildRoot}\pebrel-cli.exe"; DestDir: "{app}"; DestName: "pebrel.com"; Flags: ignoreversion Source: "{#BuildRoot}\pebrel.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "{#RepoRoot}\README.md"; DestDir: "{app}"; Flags: ignoreversion Source: "{#RepoRoot}\README.zh-CN.md"; DestDir: "{app}"; Flags: ignoreversion diff --git a/scripts/package-release.ps1 b/scripts/package-release.ps1 index d5c7bd29d..deb8ef9cf 100644 --- a/scripts/package-release.ps1 +++ b/scripts/package-release.ps1 @@ -54,6 +54,7 @@ $temporaryZip = Join-Path $outputRoot ".$PackageBrand-v$Version-windows-$Archite $manifest = [ordered]@{ 'pebrel.exe' = Join-Path $targetRoot 'pebrel.exe' + 'pebrel.com' = Join-Path $targetRoot 'pebrel-cli.exe' 'README.md' = Join-Path $repo 'README.md' 'README.zh-CN.md' = Join-Path $repo 'README.zh-CN.md' 'runtime/pebrel-hook.exe' = Join-Path $targetRoot 'pebrel-hook.exe' @@ -136,6 +137,7 @@ function Assert-FreshBinaries { ) $checks = @( @{ Binary = $manifest['pebrel.exe']; Newest = Get-NewestSourceTime $appSources }, + @{ Binary = $manifest['pebrel.com']; Newest = Get-NewestSourceTime $appSources }, @{ Binary = $manifest['runtime/pebrel-hook.exe']; Newest = Get-NewestSourceTime @( (Join-Path $repo 'nebula_hook'), (Join-Path $repo 'Cargo.toml')) } ) diff --git a/scripts/tests/build-windows-product.tests.ps1 b/scripts/tests/build-windows-product.tests.ps1 index a14f52de7..28024674a 100644 --- a/scripts/tests/build-windows-product.tests.ps1 +++ b/scripts/tests/build-windows-product.tests.ps1 @@ -20,7 +20,7 @@ try { if ($arguments[$i] -eq '--bin') { $binaries += $arguments[$i + 1] } } if (($packages -join ',') -ne 'nebula,nebula_hook') { throw 'Unexpected package selection' } - if (($binaries -join ',') -ne 'pebrel,pebrel-hook') { throw 'Unexpected product binaries' } + if (($binaries -join ',') -ne 'pebrel,pebrel-cli,pebrel-hook') { throw 'Unexpected product binaries' } foreach ($required in @('--locked', '--release', '--features', 'nebula/gpui-shell')) { if ($required -notin $arguments) { throw "Missing product build argument: $required" } } diff --git a/scripts/tests/installer-migration.tests.ps1 b/scripts/tests/installer-migration.tests.ps1 index 6187c6c36..b3f29698d 100644 --- a/scripts/tests/installer-migration.tests.ps1 +++ b/scripts/tests/installer-migration.tests.ps1 @@ -42,7 +42,7 @@ try { $payload = Join-Path $root 'syntax-payload' $syntaxOutput = Join-Path $root 'syntax-only-do-not-install' New-Item -ItemType Directory -Path $payload, $syntaxOutput | Out-Null - foreach ($name in @('pebrel.exe', 'pebrel-hook.exe', 'conpty.dll', 'OpenConsole.exe')) { + foreach ($name in @('pebrel.exe', 'pebrel-cli.exe', 'pebrel-hook.exe', 'conpty.dll', 'OpenConsole.exe')) { [System.IO.File]::WriteAllText((Join-Path $payload $name), 'Syntax fixture, not a runtime executable.') } & $InnoCompiler '/Q' '/DAppVersion=0.0.0' '/DNumericVersion=0.0.0.0' ` diff --git a/scripts/tests/installer.tests.ps1 b/scripts/tests/installer.tests.ps1 index 6424d4832..74d5396b8 100644 --- a/scripts/tests/installer.tests.ps1 +++ b/scripts/tests/installer.tests.ps1 @@ -29,6 +29,7 @@ $requiredPatterns = [ordered]@{ 'localized Chinese launch error' = 'chinesesimplified\.LaunchEnvironmentFailed=.*%1' 'idempotent cleanup entry' = 'RunOnceId: "RemovePebrelAiHooks"' 'hook helper payload' = 'pebrel-hook\.exe' + 'console CLI payload' = 'pebrel-cli\.exe.*DestName: "pebrel\.com"' 'ConPTY payload' = 'conpty\.dll' 'ConPTY host payload' = 'OpenConsole\.exe' 'font payload' = 'MapleMonoNormal-NF-CN-Regular\.ttf' diff --git a/scripts/tests/package-release.tests.ps1 b/scripts/tests/package-release.tests.ps1 index cfbcd6b5d..ac9db42f3 100644 --- a/scripts/tests/package-release.tests.ps1 +++ b/scripts/tests/package-release.tests.ps1 @@ -78,6 +78,7 @@ try { 'licenses/LICENSE-MLUA' 'licenses/THIRD-PARTY-NOTICES' 'pebrel.exe' + 'pebrel.com' 'runtime/OpenConsole.exe' 'runtime/conpty.dll' 'runtime/pebrel-hook.exe' @@ -91,7 +92,7 @@ try { } $rootFiles = @($actual | Where-Object { -not $_.Contains('/') }) - if (@(Compare-Object -ReferenceObject @('README.md', 'README.zh-CN.md', 'pebrel.exe') -DifferenceObject $rootFiles).Count -ne 0) { + if (@(Compare-Object -ReferenceObject @('README.md', 'README.zh-CN.md', 'pebrel.com', 'pebrel.exe') -DifferenceObject $rootFiles).Count -ne 0) { throw "ZIP root must contain the English and Chinese READMEs and pebrel.exe" } diff --git a/scripts/tests/test_pebrel_branding.py b/scripts/tests/test_pebrel_branding.py index c38bee330..cf2978c9c 100644 --- a/scripts/tests/test_pebrel_branding.py +++ b/scripts/tests/test_pebrel_branding.py @@ -23,7 +23,7 @@ def test_visible_identity_and_cli_use_pebrel(self): self.assertIn('version = env!("VERSION")', cli) self.assertEqual( [target["name"] for target in self.manifest("nebula_app/Cargo.toml")["bin"]], - ["pebrel"], + ["pebrel", "pebrel-cli"], ) self.assertEqual( [target["name"] for target in self.manifest("nebula_hook/Cargo.toml")["bin"]], diff --git a/scripts/tests/windows-package-architecture.tests.ps1 b/scripts/tests/windows-package-architecture.tests.ps1 index bd44e521a..6c83e0ca1 100644 --- a/scripts/tests/windows-package-architecture.tests.ps1 +++ b/scripts/tests/windows-package-architecture.tests.ps1 @@ -11,10 +11,18 @@ try { [BitConverter]::GetBytes([uint32]0x4550).CopyTo($bytes, 64) $machine = if ($architecture -eq 'arm64') { 0xAA64 } else { 0x8664 } [BitConverter]::GetBytes([uint16]$machine).CopyTo($bytes, 68) - foreach ($name in @('pebrel.exe', 'pebrel-hook.exe', 'conpty.dll', 'OpenConsole.exe')) { + foreach ($name in @('pebrel.exe', 'pebrel-cli.exe', 'pebrel-hook.exe', 'conpty.dll', 'OpenConsole.exe')) { [IO.File]::WriteAllBytes((Join-Path $fixtureRoot $name), $bytes) } Assert-WindowsPackageArchitecture -Root $fixtureRoot -Architecture $architecture + Copy-Item -LiteralPath (Join-Path $fixtureRoot 'pebrel-cli.exe') -Destination (Join-Path $fixtureRoot 'pebrel.com') + Assert-WindowsPackageArchitecture -Root $fixtureRoot -Architecture $architecture -CliExecutable 'pebrel.com' + [IO.File]::WriteAllBytes((Join-Path $fixtureRoot 'pebrel.com'), [byte[]]@(0, 1, 2)) + $rejected = $false + try { Assert-WindowsPackageArchitecture -Root $fixtureRoot -Architecture $architecture -CliExecutable 'pebrel.com' } catch { + $rejected = $_.Exception.Message -match 'Invalid executable header' + } + if (-not $rejected) { throw 'Invalid console launcher was accepted' } $wrong = if ($architecture -eq 'arm64') { 'x64' } else { 'arm64' } $rejected = $false try { Assert-WindowsPackageArchitecture -Root $fixtureRoot -Architecture $wrong } catch { @@ -38,5 +46,10 @@ try { } Write-Output 'windows-package-architecture.tests.ps1: PASS' } finally { - Remove-Item -LiteralPath $fixtureRoot -Recurse -Force + $resolvedFixture = (Resolve-Path -LiteralPath $fixtureRoot).Path + $expectedPrefix = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\pebrel-package-architecture-' + if (-not $resolvedFixture.StartsWith($expectedPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing cleanup outside the architecture fixture: $resolvedFixture" + } + Remove-Item -LiteralPath $resolvedFixture -Recurse -Force } diff --git a/scripts/windows-package-architecture.ps1 b/scripts/windows-package-architecture.ps1 index cb7507219..748a73c32 100644 --- a/scripts/windows-package-architecture.ps1 +++ b/scripts/windows-package-architecture.ps1 @@ -3,11 +3,12 @@ function Assert-WindowsPackageArchitecture { param( [Parameter(Mandatory = $true)][string] $Root, [ValidateSet('x64', 'arm64')][string] $Architecture = 'x64', - [string] $RuntimeDirectory = $Root + [string] $RuntimeDirectory = $Root, + [ValidateSet('pebrel-cli.exe', 'pebrel.com')][string] $CliExecutable = 'pebrel-cli.exe' ) $machine = if ($Architecture -eq 'arm64') { 0xAA64 } else { 0x8664 } - foreach ($name in @('pebrel.exe', 'pebrel-hook.exe', 'conpty.dll', 'OpenConsole.exe')) { - $directory = if ($name -eq 'pebrel.exe') { $Root } else { $RuntimeDirectory } + foreach ($name in @('pebrel.exe', $CliExecutable, 'pebrel-hook.exe', 'conpty.dll', 'OpenConsole.exe')) { + $directory = if ($name -in @('pebrel.exe', $CliExecutable)) { $Root } else { $RuntimeDirectory } $path = Join-Path $directory $name $stream = [IO.File]::OpenRead($path) $reader = [IO.BinaryReader]::new($stream) From 2c054ca2134ee950737826b28907d87ab24b8eb9 Mon Sep 17 00:00:00 2001 From: kuddev Date: Wed, 21 Oct 2026 19:52:12 +0800 Subject: [PATCH 2/5] Refresh shell completions for native view commands --- extra/completions/_pebrel | 31 ++++++++++++++++++-- extra/completions/pebrel.bash | 42 +++++++++++++++++++++++++-- extra/completions/pebrel.fish | 32 ++++++++++++-------- extra/completions/windows/_pebrel | 31 ++++++++++++++++++-- extra/completions/windows/pebrel.bash | 42 +++++++++++++++++++++++++-- extra/completions/windows/pebrel.fish | 36 ++++++++++++++--------- 6 files changed, 180 insertions(+), 34 deletions(-) diff --git a/extra/completions/_pebrel b/extra/completions/_pebrel index f2782120b..25a67ec8f 100644 --- a/extra/completions/_pebrel +++ b/extra/completions/_pebrel @@ -48,7 +48,18 @@ _pebrel() { (( CURRENT += 1 )) curcontext="${curcontext%:*:*}:pebrel-command-$line[2]:" case $line[2] in - (ctl) + (view) +_arguments "${_arguments_options[@]}" : \ +'--type=[Native renderer\: Markdown, JSON/NDJSON table, or Git progress]:KIND:(markdown table git)' \ +'(--plain)--native[Force native output, for a compatible remote terminal]' \ +'--plain[Print original text instead of the native view protocol]' \ +'-h[Print help]' \ +'--help[Print help]' \ +'::input -- UTF-8 input file; omit to read standard input:_files' \ +'*::command -- Run Git with separately captured stdout/stderr; e.g. -- git fetch --progress:_default' \ +&& ret=0 +;; +(ctl) _arguments "${_arguments_options[@]}" : \ '--timeout-ms=[Maximum time to wait for a command response]:TIMEOUT_MS:_default' \ '--pretty[Pretty-print one-shot JSON responses. Streaming subscriptions stay JSON Lines]' \ @@ -1298,7 +1309,11 @@ _arguments "${_arguments_options[@]}" : \ (( CURRENT += 1 )) curcontext="${curcontext%:*:*}:pebrel-help-command-$line[1]:" case $line[1] in - (ctl) + (view) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(ctl) _arguments "${_arguments_options[@]}" : \ ":: :_pebrel__help__ctl_commands" \ "*::: :->ctl" \ @@ -1686,6 +1701,7 @@ esac (( $+functions[_pebrel_commands] )) || _pebrel_commands() { local commands; commands=( +'view:Render Markdown, JSON tables, or Git progress in the current terminal' \ 'ctl:Agent-oriented terminal control\: split panes, run commands, start Codex/Claude, send prompts, wait for state changes, and read verified terminal output' \ 'env:Report this pane'\''s terminal identity, the control-plane path, and every command available to it. Answers even with no runtime reachable, so an agent can always discover what it has instead of guessing' \ 'window:Control terminal windows' \ @@ -2234,6 +2250,7 @@ _pebrel__env_commands() { (( $+functions[_pebrel__help_commands] )) || _pebrel__help_commands() { local commands; commands=( +'view:Render Markdown, JSON tables, or Git progress in the current terminal' \ 'ctl:Agent-oriented terminal control\: split panes, run commands, start Codex/Claude, send prompts, wait for state changes, and read verified terminal output' \ 'env:Report this pane'\''s terminal identity, the control-plane path, and every command available to it. Answers even with no runtime reachable, so an agent can always discover what it has instead of guessing' \ 'window:Control terminal windows' \ @@ -2653,6 +2670,11 @@ _pebrel__help__theme__pack_commands() { local commands; commands=() _describe -t commands 'pebrel help theme pack commands' commands "$@" } +(( $+functions[_pebrel__help__view_commands] )) || +_pebrel__help__view_commands() { + local commands; commands=() + _describe -t commands 'pebrel help view commands' commands "$@" +} (( $+functions[_pebrel__help__window_commands] )) || _pebrel__help__window_commands() { local commands; commands=( @@ -2967,6 +2989,11 @@ _pebrel__theme__pack_commands() { local commands; commands=() _describe -t commands 'pebrel theme pack commands' commands "$@" } +(( $+functions[_pebrel__view_commands] )) || +_pebrel__view_commands() { + local commands; commands=() + _describe -t commands 'pebrel view commands' commands "$@" +} (( $+functions[_pebrel__window_commands] )) || _pebrel__window_commands() { local commands; commands=( diff --git a/extra/completions/pebrel.bash b/extra/completions/pebrel.bash index cb3a7204e..dd6ac90c9 100644 --- a/extra/completions/pebrel.bash +++ b/extra/completions/pebrel.bash @@ -49,6 +49,9 @@ _pebrel() { pebrel,theme) cmd="pebrel__theme" ;; + pebrel,view) + cmd="pebrel__view" + ;; pebrel,window) cmd="pebrel__window" ;; @@ -337,6 +340,9 @@ _pebrel() { pebrel__help,theme) cmd="pebrel__help__theme" ;; + pebrel__help,view) + cmd="pebrel__help__view" + ;; pebrel__help,window) cmd="pebrel__help__window" ;; @@ -665,7 +671,7 @@ _pebrel() { case "${cmd}" in pebrel) - opts="-q -v -e -T -o -h -V --print-events --ref-test --embed --gpui --config-file --socket --daemon --working-directory --shell --hold --command --title --class --option --help --version [DIRECTORY] ctl env window tab pane agent migrate config plugin theme setup-ai help" + opts="-q -v -e -T -o -h -V --print-events --ref-test --embed --gpui --config-file --socket --daemon --working-directory --shell --hold --command --title --class --option --help --version [DIRECTORY] view ctl env window tab pane agent migrate config plugin theme setup-ai help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2584,7 +2590,7 @@ _pebrel() { return 0 ;; pebrel__help) - opts="ctl env window tab pane agent migrate config plugin theme setup-ai help" + opts="view ctl env window tab pane agent migrate config plugin theme setup-ai help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -3549,6 +3555,20 @@ _pebrel() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + pebrel__help__view) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; pebrel__help__window) opts="close" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -4490,6 +4510,24 @@ _pebrel() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + pebrel__view) + opts="-h --type --native --plain --help [INPUT] [COMMAND]..." + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --type) + COMPREPLY=($(compgen -W "markdown table git" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; pebrel__window) opts="-h --help close help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then diff --git a/extra/completions/pebrel.fish b/extra/completions/pebrel.fish index eb152a366..bf6130bf4 100644 --- a/extra/completions/pebrel.fish +++ b/extra/completions/pebrel.fish @@ -42,6 +42,7 @@ complete -c pebrel -n "__fish_pebrel_needs_command" -l daemon -d 'Do not spawn a complete -c pebrel -n "__fish_pebrel_needs_command" -l hold -d 'Remain open after child process exit' complete -c pebrel -n "__fish_pebrel_needs_command" -s h -l help -d 'Print help (see more with \'--help\')' complete -c pebrel -n "__fish_pebrel_needs_command" -s V -l version -d 'Print version' +complete -c pebrel -n "__fish_pebrel_needs_command" -a "view" -d 'Render Markdown, JSON tables, or Git progress in the current terminal' complete -c pebrel -n "__fish_pebrel_needs_command" -a "ctl" -d 'Agent-oriented terminal control: split panes, run commands, start Codex/Claude, send prompts, wait for state changes, and read verified terminal output' complete -c pebrel -n "__fish_pebrel_needs_command" -a "env" -d 'Report this pane\'s terminal identity, the control-plane path, and every command available to it. Answers even with no runtime reachable, so an agent can always discover what it has instead of guessing' complete -c pebrel -n "__fish_pebrel_needs_command" -a "window" -d 'Control terminal windows' @@ -54,6 +55,12 @@ complete -c pebrel -n "__fish_pebrel_needs_command" -a "plugin" -d 'Check or inv complete -c pebrel -n "__fish_pebrel_needs_command" -a "theme" -d 'Export, verify, or import a portable ZIP theme package' complete -c pebrel -n "__fish_pebrel_needs_command" -a "setup-ai" -d 'Install (or --remove) AI hooks plus the Pebrel Runtime Skill for Codex and Claude Code' complete -c pebrel -n "__fish_pebrel_needs_command" -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c pebrel -n "__fish_pebrel_using_subcommand view" -l type -d 'Native renderer: Markdown, JSON/NDJSON table, or Git progress' -r -f -a "markdown\t'' +table\t'' +git\t''" +complete -c pebrel -n "__fish_pebrel_using_subcommand view" -l native -d 'Force native output, for a compatible remote terminal' +complete -c pebrel -n "__fish_pebrel_using_subcommand view" -l plain -d 'Print original text instead of the native view protocol' +complete -c pebrel -n "__fish_pebrel_using_subcommand view" -s h -l help -d 'Print help' complete -c pebrel -n "__fish_pebrel_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-paste agent-read agent-wait subscribe new-window close-window focus new-tab close-tab rename-tab move-tab split close-pane zoom-pane resize-pane prompt paste read procs send-key run exec-pane wait help" -l timeout-ms -d 'Maximum time to wait for a command response' -r complete -c pebrel -n "__fish_pebrel_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-paste agent-read agent-wait subscribe new-window close-window focus new-tab close-tab rename-tab move-tab split close-pane zoom-pane resize-pane prompt paste read procs send-key run exec-pane wait help" -l pretty -d 'Pretty-print one-shot JSON responses. Streaming subscriptions stay JSON Lines' complete -c pebrel -n "__fish_pebrel_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-paste agent-read agent-wait subscribe new-window close-window focus new-tab close-tab rename-tab move-tab split close-pane zoom-pane resize-pane prompt paste read procs send-key run exec-pane wait help" -s h -l help -d 'Print help' @@ -558,18 +565,19 @@ complete -c pebrel -n "__fish_pebrel_using_subcommand setup-ai" -l wsl -d 'Insta complete -c pebrel -n "__fish_pebrel_using_subcommand setup-ai" -l wsl-user -d 'WSL user; defaults to that distribution\'s configured user' -r complete -c pebrel -n "__fish_pebrel_using_subcommand setup-ai" -l remove -d 'Remove Pebrel-managed hooks for every supported agent and preserve other integrations. Without this flag, enable Claude Code and Codex plus agents explicitly enabled in settings' complete -c pebrel -n "__fish_pebrel_using_subcommand setup-ai" -s h -l help -d 'Print help' -complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from ctl env window tab pane agent migrate config plugin theme setup-ai help" -f -a "ctl" -d 'Agent-oriented terminal control: split panes, run commands, start Codex/Claude, send prompts, wait for state changes, and read verified terminal output' -complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from ctl env window tab pane agent migrate config plugin theme setup-ai help" -f -a "env" -d 'Report this pane\'s terminal identity, the control-plane path, and every command available to it. Answers even with no runtime reachable, so an agent can always discover what it has instead of guessing' -complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from ctl env window tab pane agent migrate config plugin theme setup-ai help" -f -a "window" -d 'Control terminal windows' -complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from ctl env window tab pane agent migrate config plugin theme setup-ai help" -f -a "tab" -d 'Control tabs within one terminal window' -complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from ctl env window tab pane agent migrate config plugin theme setup-ai help" -f -a "pane" -d 'Inspect and drive terminal panes: list, read, send, paste, wait, and change layout' -complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from ctl env window tab pane agent migrate config plugin theme setup-ai help" -f -a "agent" -d 'Inspect and drive the AI agents running in panes: list, send, read, wait' -complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from ctl env window tab pane agent migrate config plugin theme setup-ai help" -f -a "migrate" -d 'Migrate the configuration file' -complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from ctl env window tab pane agent migrate config plugin theme setup-ai help" -f -a "config" -d 'Validate or create the Pebrel configuration' -complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from ctl env window tab pane agent migrate config plugin theme setup-ai help" -f -a "plugin" -d 'Check or invoke a local plugin package without starting a GUI or plugin daemon' -complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from ctl env window tab pane agent migrate config plugin theme setup-ai help" -f -a "theme" -d 'Export, verify, or import a portable ZIP theme package' -complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from ctl env window tab pane agent migrate config plugin theme setup-ai help" -f -a "setup-ai" -d 'Install (or --remove) AI hooks plus the Pebrel Runtime Skill for Codex and Claude Code' -complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from ctl env window tab pane agent migrate config plugin theme setup-ai help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from view ctl env window tab pane agent migrate config plugin theme setup-ai help" -f -a "view" -d 'Render Markdown, JSON tables, or Git progress in the current terminal' +complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from view ctl env window tab pane agent migrate config plugin theme setup-ai help" -f -a "ctl" -d 'Agent-oriented terminal control: split panes, run commands, start Codex/Claude, send prompts, wait for state changes, and read verified terminal output' +complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from view ctl env window tab pane agent migrate config plugin theme setup-ai help" -f -a "env" -d 'Report this pane\'s terminal identity, the control-plane path, and every command available to it. Answers even with no runtime reachable, so an agent can always discover what it has instead of guessing' +complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from view ctl env window tab pane agent migrate config plugin theme setup-ai help" -f -a "window" -d 'Control terminal windows' +complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from view ctl env window tab pane agent migrate config plugin theme setup-ai help" -f -a "tab" -d 'Control tabs within one terminal window' +complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from view ctl env window tab pane agent migrate config plugin theme setup-ai help" -f -a "pane" -d 'Inspect and drive terminal panes: list, read, send, paste, wait, and change layout' +complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from view ctl env window tab pane agent migrate config plugin theme setup-ai help" -f -a "agent" -d 'Inspect and drive the AI agents running in panes: list, send, read, wait' +complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from view ctl env window tab pane agent migrate config plugin theme setup-ai help" -f -a "migrate" -d 'Migrate the configuration file' +complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from view ctl env window tab pane agent migrate config plugin theme setup-ai help" -f -a "config" -d 'Validate or create the Pebrel configuration' +complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from view ctl env window tab pane agent migrate config plugin theme setup-ai help" -f -a "plugin" -d 'Check or invoke a local plugin package without starting a GUI or plugin daemon' +complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from view ctl env window tab pane agent migrate config plugin theme setup-ai help" -f -a "theme" -d 'Export, verify, or import a portable ZIP theme package' +complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from view ctl env window tab pane agent migrate config plugin theme setup-ai help" -f -a "setup-ai" -d 'Install (or --remove) AI hooks plus the Pebrel Runtime Skill for Codex and Claude Code' +complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from view ctl env window tab pane agent migrate config plugin theme setup-ai help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c pebrel -n "__fish_pebrel_using_subcommand help; and __fish_seen_subcommand_from ctl" -f -a "describe" -d 'Describe the protocol version, runtime version, and available capabilities' complete -c pebrel -n "__fish_pebrel_using_subcommand help; and __fish_seen_subcommand_from ctl" -f -a "snapshot" -d 'Read the authoritative window, tab, pane, and task-state projection' complete -c pebrel -n "__fish_pebrel_using_subcommand help; and __fish_seen_subcommand_from ctl" -f -a "orchestrate" -d 'Execute one typed multi-step terminal workflow in a single Runtime request' diff --git a/extra/completions/windows/_pebrel b/extra/completions/windows/_pebrel index aebdcc149..1ac78ad37 100644 --- a/extra/completions/windows/_pebrel +++ b/extra/completions/windows/_pebrel @@ -47,7 +47,18 @@ _pebrel() { (( CURRENT += 1 )) curcontext="${curcontext%:*:*}:pebrel-command-$line[2]:" case $line[2] in - (ctl) + (view) +_arguments "${_arguments_options[@]}" : \ +'--type=[Native renderer\: Markdown, JSON/NDJSON table, or Git progress]:KIND:(markdown table git)' \ +'(--plain)--native[Force native output, for a compatible remote terminal]' \ +'--plain[Print original text instead of the native view protocol]' \ +'-h[Print help]' \ +'--help[Print help]' \ +'::input -- UTF-8 input file; omit to read standard input:_files' \ +'*::command -- Run Git with separately captured stdout/stderr; e.g. -- git fetch --progress:_default' \ +&& ret=0 +;; +(ctl) _arguments "${_arguments_options[@]}" : \ '--timeout-ms=[Maximum time to wait for a command response]:TIMEOUT_MS:_default' \ '--pretty[Pretty-print one-shot JSON responses. Streaming subscriptions stay JSON Lines]' \ @@ -1310,7 +1321,11 @@ _arguments "${_arguments_options[@]}" : \ (( CURRENT += 1 )) curcontext="${curcontext%:*:*}:pebrel-help-command-$line[1]:" case $line[1] in - (ctl) + (view) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(ctl) _arguments "${_arguments_options[@]}" : \ ":: :_pebrel__help__ctl_commands" \ "*::: :->ctl" \ @@ -1706,6 +1721,7 @@ esac (( $+functions[_pebrel_commands] )) || _pebrel_commands() { local commands; commands=( +'view:Render Markdown, JSON tables, or Git progress in the current terminal' \ 'ctl:Agent-oriented terminal control\: split panes, run commands, start Codex/Claude, send prompts, wait for state changes, and read verified terminal output' \ 'env:Report this pane'\''s terminal identity, the control-plane path, and every command available to it. Answers even with no runtime reachable, so an agent can always discover what it has instead of guessing' \ 'window:Control terminal windows' \ @@ -2256,6 +2272,7 @@ _pebrel__env_commands() { (( $+functions[_pebrel__help_commands] )) || _pebrel__help_commands() { local commands; commands=( +'view:Render Markdown, JSON tables, or Git progress in the current terminal' \ 'ctl:Agent-oriented terminal control\: split panes, run commands, start Codex/Claude, send prompts, wait for state changes, and read verified terminal output' \ 'env:Report this pane'\''s terminal identity, the control-plane path, and every command available to it. Answers even with no runtime reachable, so an agent can always discover what it has instead of guessing' \ 'window:Control terminal windows' \ @@ -2687,6 +2704,11 @@ _pebrel__help__theme__pack_commands() { local commands; commands=() _describe -t commands 'pebrel help theme pack commands' commands "$@" } +(( $+functions[_pebrel__help__view_commands] )) || +_pebrel__help__view_commands() { + local commands; commands=() + _describe -t commands 'pebrel help view commands' commands "$@" +} (( $+functions[_pebrel__help__window_commands] )) || _pebrel__help__window_commands() { local commands; commands=( @@ -3011,6 +3033,11 @@ _pebrel__theme__pack_commands() { local commands; commands=() _describe -t commands 'pebrel theme pack commands' commands "$@" } +(( $+functions[_pebrel__view_commands] )) || +_pebrel__view_commands() { + local commands; commands=() + _describe -t commands 'pebrel view commands' commands "$@" +} (( $+functions[_pebrel__window_commands] )) || _pebrel__window_commands() { local commands; commands=( diff --git a/extra/completions/windows/pebrel.bash b/extra/completions/windows/pebrel.bash index 861e42957..3c66d252f 100644 --- a/extra/completions/windows/pebrel.bash +++ b/extra/completions/windows/pebrel.bash @@ -55,6 +55,9 @@ _pebrel() { pebrel,theme) cmd="pebrel__theme" ;; + pebrel,view) + cmd="pebrel__view" + ;; pebrel,window) cmd="pebrel__window" ;; @@ -349,6 +352,9 @@ _pebrel() { pebrel__help,theme) cmd="pebrel__help__theme" ;; + pebrel__help,view) + cmd="pebrel__help__view" + ;; pebrel__help,window) cmd="pebrel__help__window" ;; @@ -677,7 +683,7 @@ _pebrel() { case "${cmd}" in pebrel) - opts="-q -v -e -T -o -h -V --print-events --ref-test --embed --gpui --config-file --daemon --working-directory --shell --hold --command --title --class --option --help --version [DIRECTORY] ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" + opts="-q -v -e -T -o -h -V --print-events --ref-test --embed --gpui --config-file --daemon --working-directory --shell --hold --command --title --class --option --help --version [DIRECTORY] view ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2581,7 +2587,7 @@ _pebrel() { return 0 ;; pebrel__help) - opts="ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" + opts="view ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -3574,6 +3580,20 @@ _pebrel() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + pebrel__help__view) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; pebrel__help__window) opts="close" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -4543,6 +4563,24 @@ _pebrel() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + pebrel__view) + opts="-h --type --native --plain --help [INPUT] [COMMAND]..." + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --type) + COMPREPLY=($(compgen -W "markdown table git" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; pebrel__window) opts="-h --help close help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then diff --git a/extra/completions/windows/pebrel.fish b/extra/completions/windows/pebrel.fish index 1022762da..1b377a7a7 100644 --- a/extra/completions/windows/pebrel.fish +++ b/extra/completions/windows/pebrel.fish @@ -41,6 +41,7 @@ complete -c pebrel -n "__fish_pebrel_needs_command" -l daemon -d 'Do not spawn a complete -c pebrel -n "__fish_pebrel_needs_command" -l hold -d 'Remain open after child process exit' complete -c pebrel -n "__fish_pebrel_needs_command" -s h -l help -d 'Print help (see more with \'--help\')' complete -c pebrel -n "__fish_pebrel_needs_command" -s V -l version -d 'Print version' +complete -c pebrel -n "__fish_pebrel_needs_command" -a "view" -d 'Render Markdown, JSON tables, or Git progress in the current terminal' complete -c pebrel -n "__fish_pebrel_needs_command" -a "ctl" -d 'Agent-oriented terminal control: split panes, run commands, start Codex/Claude, send prompts, wait for state changes, and read verified terminal output' complete -c pebrel -n "__fish_pebrel_needs_command" -a "env" -d 'Report this pane\'s terminal identity, the control-plane path, and every command available to it. Answers even with no runtime reachable, so an agent can always discover what it has instead of guessing' complete -c pebrel -n "__fish_pebrel_needs_command" -a "window" -d 'Control terminal windows' @@ -55,6 +56,12 @@ complete -c pebrel -n "__fish_pebrel_needs_command" -a "notify-test" -d 'Test sy complete -c pebrel -n "__fish_pebrel_needs_command" -a "setup-ai" -d 'Install (or --remove) AI hooks plus the Pebrel Runtime Skill for Codex and Claude Code' complete -c pebrel -n "__fish_pebrel_needs_command" -a "ssh" -d 'SSH with Pebrel shell integration bootstrapped on the remote host, so tab icons / spinner / cwd track the program running over the connection (claude, vim, cargo…). All arguments are forwarded to the system `ssh`' complete -c pebrel -n "__fish_pebrel_needs_command" -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c pebrel -n "__fish_pebrel_using_subcommand view" -l type -d 'Native renderer: Markdown, JSON/NDJSON table, or Git progress' -r -f -a "markdown\t'' +table\t'' +git\t''" +complete -c pebrel -n "__fish_pebrel_using_subcommand view" -l native -d 'Force native output, for a compatible remote terminal' +complete -c pebrel -n "__fish_pebrel_using_subcommand view" -l plain -d 'Print original text instead of the native view protocol' +complete -c pebrel -n "__fish_pebrel_using_subcommand view" -s h -l help -d 'Print help' complete -c pebrel -n "__fish_pebrel_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-paste agent-read agent-wait subscribe new-window close-window focus new-tab close-tab rename-tab move-tab split close-pane zoom-pane resize-pane prompt paste read procs send-key run exec-pane wait help" -l timeout-ms -d 'Maximum time to wait for a command response' -r complete -c pebrel -n "__fish_pebrel_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-paste agent-read agent-wait subscribe new-window close-window focus new-tab close-tab rename-tab move-tab split close-pane zoom-pane resize-pane prompt paste read procs send-key run exec-pane wait help" -l pretty -d 'Pretty-print one-shot JSON responses. Streaming subscriptions stay JSON Lines' complete -c pebrel -n "__fish_pebrel_using_subcommand ctl; and not __fish_seen_subcommand_from describe snapshot orchestrate agents agent-start agent-fork agent-get agent-prompt agent-paste agent-read agent-wait subscribe new-window close-window focus new-tab close-tab rename-tab move-tab split close-pane zoom-pane resize-pane prompt paste read procs send-key run exec-pane wait help" -s h -l help -d 'Print help' @@ -561,20 +568,21 @@ complete -c pebrel -n "__fish_pebrel_using_subcommand setup-ai" -l wsl-user -d ' complete -c pebrel -n "__fish_pebrel_using_subcommand setup-ai" -l remove -d 'Remove Pebrel-managed hooks for every supported agent and preserve other integrations. Without this flag, enable Claude Code and Codex plus agents explicitly enabled in settings' complete -c pebrel -n "__fish_pebrel_using_subcommand setup-ai" -s h -l help -d 'Print help' complete -c pebrel -n "__fish_pebrel_using_subcommand ssh" -s h -l help -d 'Print help' -complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" -f -a "ctl" -d 'Agent-oriented terminal control: split panes, run commands, start Codex/Claude, send prompts, wait for state changes, and read verified terminal output' -complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" -f -a "env" -d 'Report this pane\'s terminal identity, the control-plane path, and every command available to it. Answers even with no runtime reachable, so an agent can always discover what it has instead of guessing' -complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" -f -a "window" -d 'Control terminal windows' -complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" -f -a "tab" -d 'Control tabs within one terminal window' -complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" -f -a "pane" -d 'Inspect and drive terminal panes: list, read, send, paste, wait, and change layout' -complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" -f -a "agent" -d 'Inspect and drive the AI agents running in panes: list, send, read, wait' -complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" -f -a "migrate" -d 'Migrate the configuration file' -complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" -f -a "config" -d 'Validate or create the Pebrel configuration' -complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" -f -a "plugin" -d 'Check or invoke a local plugin package without starting a GUI or plugin daemon' -complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" -f -a "theme" -d 'Export, verify, or import a portable ZIP theme package' -complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" -f -a "notify-test" -d 'Test system notification (toast) delivery' -complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" -f -a "setup-ai" -d 'Install (or --remove) AI hooks plus the Pebrel Runtime Skill for Codex and Claude Code' -complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" -f -a "ssh" -d 'SSH with Pebrel shell integration bootstrapped on the remote host, so tab icons / spinner / cwd track the program running over the connection (claude, vim, cargo…). All arguments are forwarded to the system `ssh`' -complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from view ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" -f -a "view" -d 'Render Markdown, JSON tables, or Git progress in the current terminal' +complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from view ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" -f -a "ctl" -d 'Agent-oriented terminal control: split panes, run commands, start Codex/Claude, send prompts, wait for state changes, and read verified terminal output' +complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from view ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" -f -a "env" -d 'Report this pane\'s terminal identity, the control-plane path, and every command available to it. Answers even with no runtime reachable, so an agent can always discover what it has instead of guessing' +complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from view ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" -f -a "window" -d 'Control terminal windows' +complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from view ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" -f -a "tab" -d 'Control tabs within one terminal window' +complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from view ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" -f -a "pane" -d 'Inspect and drive terminal panes: list, read, send, paste, wait, and change layout' +complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from view ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" -f -a "agent" -d 'Inspect and drive the AI agents running in panes: list, send, read, wait' +complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from view ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" -f -a "migrate" -d 'Migrate the configuration file' +complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from view ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" -f -a "config" -d 'Validate or create the Pebrel configuration' +complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from view ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" -f -a "plugin" -d 'Check or invoke a local plugin package without starting a GUI or plugin daemon' +complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from view ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" -f -a "theme" -d 'Export, verify, or import a portable ZIP theme package' +complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from view ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" -f -a "notify-test" -d 'Test system notification (toast) delivery' +complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from view ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" -f -a "setup-ai" -d 'Install (or --remove) AI hooks plus the Pebrel Runtime Skill for Codex and Claude Code' +complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from view ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" -f -a "ssh" -d 'SSH with Pebrel shell integration bootstrapped on the remote host, so tab icons / spinner / cwd track the program running over the connection (claude, vim, cargo…). All arguments are forwarded to the system `ssh`' +complete -c pebrel -n "__fish_pebrel_using_subcommand help; and not __fish_seen_subcommand_from view ctl env window tab pane agent migrate config plugin theme notify-test setup-ai ssh help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c pebrel -n "__fish_pebrel_using_subcommand help; and __fish_seen_subcommand_from ctl" -f -a "describe" -d 'Describe the protocol version, runtime version, and available capabilities' complete -c pebrel -n "__fish_pebrel_using_subcommand help; and __fish_seen_subcommand_from ctl" -f -a "snapshot" -d 'Read the authoritative window, tab, pane, and task-state projection' complete -c pebrel -n "__fish_pebrel_using_subcommand help; and __fish_seen_subcommand_from ctl" -f -a "orchestrate" -d 'Execute one typed multi-step terminal workflow in a single Runtime request' From baaf524068b965d48f0fa19550c43f59334d474a Mon Sep 17 00:00:00 2001 From: kuddev Date: Wed, 21 Oct 2026 21:03:01 +0800 Subject: [PATCH 3/5] Invalidate JSON row hover and preserve natural output height --- ...26-10-21-hover-state-and-natural-height.md | 70 +++++++++++++++++++ .../gpui_shell/terminal/native_results/mod.rs | 4 +- .../terminal/native_results/table.rs | 2 + .../terminal/native_results/viewport.rs | 56 ++++++++++----- .../gpui_shell/terminal/view/reading/tests.rs | 52 ++++++++++++++ 5 files changed, 165 insertions(+), 19 deletions(-) create mode 100644 architecture/notes/nebula_app/gpui_shell/terminal/native_results/2026-10-21-hover-state-and-natural-height.md diff --git a/architecture/notes/nebula_app/gpui_shell/terminal/native_results/2026-10-21-hover-state-and-natural-height.md b/architecture/notes/nebula_app/gpui_shell/terminal/native_results/2026-10-21-hover-state-and-natural-height.md new file mode 100644 index 000000000..05e3856b4 --- /dev/null +++ b/architecture/notes/nebula_app/gpui_shell/terminal/native_results/2026-10-21-hover-state-and-natural-height.md @@ -0,0 +1,70 @@ +# Inline result hover state and natural height + +## Status + +Implemented; the focused regression fails before the row-identity change and +passes afterward. Physical pointer acceptance accompanies the review. + +## Context + +JSON row backgrounds followed the pointer only when another event redrew the +result. The following prompt could also acquire extra vertical space after a +native result, even though the underlying terminal output had not added a line. + +## Evidence + +The pinned GPUI Div implementation creates hover state only for elements with an +element identity. Its Capture listener notifies the current view only when that +state exists. A debug selector does not supply identity. The result's Bubble +mouse-move guard runs after this Capture listener and does not cancel its notify. + +The cached-parent regression crosses real JSON row bounds and observes result +notifications before forcing another draw. It fails without row identity. Tests +that force a draw immediately after every pointer move conceal this failure. + +The shared projection previously rounded measured height up to an integral +terminal row. This adds up to almost one row of presentation-only space before +subsequent terminal content. + +## Decision + +Key each row by its source-row index within the owning result. Sorting and +filtering preserve the row's identity while its displayed position changes. +Keep the existing event guard so native controls retain pointer ownership. + +Retain fractional row height in the existing source-to-visual projection. +Rendering, source mapping, selection and following prompts continue to use one +projection. Native body padding is symmetric; original shell newlines remain +source data and are not removed to conceal projection padding. + +## Rejected alternatives + +- Redrawing the whole terminal for every mouse move: hides missing hover state + and spends work even when the pointer stays within the same row. +- Removing the native result's mouse guard: can allow terminal gestures through + the controls without establishing a row's own hover state. +- Resizing the PTY or deleting blank grid rows: changes terminal content to fix + a presentation-space calculation. + +## Consequences + +Crossing row boundaries now schedules a repaint. CPU totals from the previously +under-redrawing implementation are not a responsiveness target. No parsing or +background discovery is added to hover handling. + +## Validation + +Thirty reading interaction tests, three projection tests and fifteen pointer +tests passed locally. They cover cached hover notification, actual table heights, +fractional inverse mapping, original directory links, selection, scrolling, +filters, sorting and structured diff controls. Native desktop comparison remains +separate from synthetic-window execution. + +## Supersedes + +Corrects hover identity and row quantization in the native result renderer. + +## Revisit when + +The GPUI dependency changes stateless hover invalidation, or the terminal adopts +a different shared coordinate authority. diff --git a/nebula_app/src/gpui_shell/terminal/native_results/mod.rs b/nebula_app/src/gpui_shell/terminal/native_results/mod.rs index 2cec511a2..4a392f7eb 100644 --- a/nebula_app/src/gpui_shell/terminal/native_results/mod.rs +++ b/nebula_app/src/gpui_shell/terminal/native_results/mod.rs @@ -643,7 +643,7 @@ impl Render for NativeResult { div() .w_full() .min_w_0() - // 与批准的轻量 lens 保持相同的上下呼吸边距。 + // 上下留白使用同一尺寸;实际内容高度交给投影,不再补齐到终端整行。 .when( !self.raw_mode && matches!( @@ -655,7 +655,7 @@ impl Render for NativeResult { | "text/x-diff" | "text/x-git-output" ), - |body| body.pt(px(4.0)).pb(px(5.0)), + |body| body.py(px(6.0)), ) .child(body), ) diff --git a/nebula_app/src/gpui_shell/terminal/native_results/table.rs b/nebula_app/src/gpui_shell/terminal/native_results/table.rs index a34905047..84873dec6 100644 --- a/nebula_app/src/gpui_shell/terminal/native_results/table.rs +++ b/nebula_app/src/gpui_shell/terminal/native_results/table.rs @@ -240,6 +240,8 @@ impl NativeResult { .map(|index| { let source_row = self.order[index]; div() + // GPUI 依靠稳定元素状态发出 hover 通知;只有 selector 会让背景等到其他事件才重绘。 + .id(("native-table-row", source_row)) .debug_selector(move || format!("native-table-row-{index}")) .absolute() .top(row_height * index as f32) diff --git a/nebula_app/src/gpui_shell/terminal/native_results/viewport.rs b/nebula_app/src/gpui_shell/terminal/native_results/viewport.rs index be764f7a9..437973096 100644 --- a/nebula_app/src/gpui_shell/terminal/native_results/viewport.rs +++ b/nebula_app/src/gpui_shell/terminal/native_results/viewport.rs @@ -9,7 +9,7 @@ struct Slot { id: u64, start: i64, rows: u16, - height: usize, + height: f64, hide_source: bool, } @@ -50,7 +50,7 @@ impl Projection { break; } let used = (source - slot.start as f64).min(f64::from(slot.rows)); - extra += used * (slot.height as f64 / f64::from(slot.rows) - 1.0); + extra += used * (slot.height / f64::from(slot.rows) - 1.0); } source + extra } @@ -61,11 +61,10 @@ impl Projection { if visual <= start { break; } - if visual < start + slot.height as f64 { - return slot.start as f64 - + (visual - start) * f64::from(slot.rows) / slot.height as f64; + if visual < start + slot.height { + return slot.start as f64 + (visual - start) * f64::from(slot.rows) / slot.height; } - extra += slot.height as f64 - f64::from(slot.rows); + extra += slot.height - f64::from(slot.rows); } visual - extra } @@ -151,7 +150,7 @@ impl Views { || p.slots.iter().any(|slot| { slot.id == *id && { let top = p.visual(slot.start as f64) - p.top; - top < p.screen as f64 && top + slot.height as f64 > 0.0 + top < p.screen as f64 && top + slot.height > 0.0 } }) }); @@ -234,7 +233,7 @@ impl Views { let anchor = projection.source(projection.top); let content_anchor = projection.slots.iter().find_map(|slot| { let start = projection.visual(slot.start as f64); - (start <= projection.top && projection.top < start + slot.height as f64) + (start <= projection.top && projection.top < start + slot.height) .then_some((slot.id, projection.top - start)) }); let follow = projection.slots.is_empty() @@ -260,19 +259,20 @@ impl Views { id: source.id, start: *start + super::fold::KEEP as i64 - i64::from(expanded), rows: if expanded { 1 } else { rows - (super::fold::KEEP * 2) as u16 }, - height: if expanded { 2 } else { 1 }, + height: if expanded { 2.0 } else { 1.0 }, hide_source: !expanded, }); } let height = self .entities .get(&source.id) - .map_or(usize::from(rows), |entity| { + .map_or(f64::from(rows), |entity| { let result = entity.read(cx); - (result.measured_height.as_f32() / result.row_height.as_f32().max(1.0)) - .ceil() as usize + // 富文本不是整行网格;向上补齐会在结果尾部凭空留下接近一行的空白。 + f64::from(result.measured_height.as_f32()) + / f64::from(result.row_height.as_f32().max(1.0)) }) - .max(if source.has_grid_source() { 1 } else { usize::from(rows) }); + .max(if source.has_grid_source() { 1.0 } else { f64::from(rows) }); Some(Slot { id: source.id, start: *start, rows, height, hide_source: true }) }) .collect(); @@ -305,7 +305,7 @@ impl Views { && let Some(slot) = projection.slots.iter().find(|slot| slot.id == id) { // 保住内容内的实际偏移,不按整块高度比例缩放阅读位置。 - projection.visual(slot.start as f64) + offset.min(slot.height.saturating_sub(1) as f64) + projection.visual(slot.start as f64) + offset.min((slot.height - 1.0).max(0.0)) } else { projection.visual(anchor) }; @@ -350,7 +350,7 @@ mod tests { #[test] fn native_result_projection_keeps_prompt_and_middle_of_document_addressable() { let p = Projection { - slots: vec![Slot { id: 1, start: 2, rows: 1, height: 100, hide_source: true }], + slots: vec![Slot { id: 1, start: 2, rows: 1, height: 100.0, hide_source: true }], active: true, origin: 2, top: 52.0, @@ -371,8 +371,8 @@ mod tests { fn native_result_projection_combines_multiple_results_without_moving_source_lines() { let p = Projection { slots: vec![ - Slot { id: 1, start: 1, rows: 1, height: 80, hide_source: true }, - Slot { id: 2, start: 5, rows: 2, height: 20, hide_source: true }, + Slot { id: 1, start: 1, rows: 1, height: 80.0, hide_source: true }, + Slot { id: 2, start: 5, rows: 2, height: 20.0, hide_source: true }, ], ..Default::default() }; @@ -383,4 +383,26 @@ mod tests { assert_eq!(p.source(visual), source); } } + + #[test] + fn native_result_height_keeps_fractional_rows_before_the_next_prompt() { + let line_height = 17.5; + let content_height = 123.25; + let p = Projection { + slots: vec![Slot { + id: 1, + start: 3, + rows: 12, + height: content_height / line_height, + hide_source: true, + }], + ..Default::default() + }; + assert!(((p.visual(15.0) - p.visual(3.0)) * line_height - content_height).abs() < 0.001); + // shell 自己输出的空行仍然保留;只去除富文本占位额外补出来的整行空白。 + assert!(((p.visual(16.0) - p.visual(15.0)) * line_height - line_height).abs() < 0.001); + for source in [3.0, 7.5, 15.0, 16.0] { + assert!((p.source(p.visual(source)) - source).abs() < 0.001); + } + } } diff --git a/nebula_app/src/gpui_shell/terminal/view/reading/tests.rs b/nebula_app/src/gpui_shell/terminal/view/reading/tests.rs index 6233f659c..cb679bc20 100644 --- a/nebula_app/src/gpui_shell/terminal/view/reading/tests.rs +++ b/nebula_app/src/gpui_shell/terminal/view/reading/tests.rs @@ -591,9 +591,61 @@ fn native_result_table_expands_all_rows_and_keeps_body_font(cx: &mut TestAppCont let result = view.native_results.entities.values().next().unwrap().read(cx); assert!(result.height() >= view.line_height * 100.0); assert_eq!(result.body_font_size(), view.font_size); + let (_, _, projected_rows) = view.native_results.placements().into_iter().next().unwrap(); + assert!( + (projected_rows * view.line_height.as_f32() - result.height().as_f32()).abs() < 0.01, + "following terminal content must use the table's measured height without row padding" + ); }); } +#[gpui::test] +fn native_result_row_hover_notifies_inside_a_cached_parent(cx: &mut TestAppContext) { + use nebula_terminal::inline_content::Packet; + use std::{cell::Cell, rc::Rc}; + + let (view, mut cx, _, proxy) = open_with_cache(cx, true); + let wire = format!( + "{}\r\n{}{}", + Packet::Begin { + wire_id: 203, + rows: 2, + mime: "application/json".into(), + title: "hover".into() + } + .encode(), + Packet::Append { wire_id: 203, text: r#"[{"Name":"alpha"},{"Name":"beta"}]"#.into() } + .encode(), + Packet::End { wire_id: 203, code: 0 }.encode(), + ); + feed(&view, &mut cx, &proxy, wire.as_bytes()); + let mut positions = None; + for _ in 0..5 { + draw(&mut cx); + if let (Some(first), Some(second)) = + (cx.debug_bounds("native-table-row-0"), cx.debug_bounds("native-table-row-1")) + { + positions = Some([first.center(), second.center()]); + } + } + let [first, second] = positions.expect("actual table row geometry"); + let result = view + .read_with(&cx, |view, _| view.native_results.entities.values().next().unwrap().clone()); + let notifications = Rc::new(Cell::new(0)); + let observed = notifications.clone(); + let _subscription = + cx.update(|_, cx| cx.observe(&result, move |_, _| observed.set(observed.get() + 1))); + cx.simulate_mouse_move(first, None, Modifiers::default()); + draw(&mut cx); + for position in [second, first, second] { + notifications.set(0); + cx.simulate_mouse_move(position, None, Modifiers::default()); + // 先检查鼠标事件自身产生通知;强制 draw 会让缺失的 hover 失效机制看似正常。 + assert!(notifications.get() > 0, "crossing rows must notify before another forced draw"); + draw(&mut cx); + } +} + #[gpui::test] fn native_result_table_filter_and_sort_follow_real_controls(cx: &mut TestAppContext) { use nebula_terminal::inline_content::Packet; From 0fe0ee2ea4155e5ffb8a31a07345ce59b2acf234 Mon Sep 17 00:00:00 2001 From: kuddev Date: Wed, 21 Oct 2026 21:31:11 +0800 Subject: [PATCH 4/5] Avoid an extra blank line before the PowerShell prompt --- nebula_terminal/src/tty/windows/mod.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/nebula_terminal/src/tty/windows/mod.rs b/nebula_terminal/src/tty/windows/mod.rs index 0f719be52..b084a9799 100644 --- a/nebula_terminal/src/tty/windows/mod.rs +++ b/nebula_terminal/src/tty/windows/mod.rs @@ -508,9 +508,8 @@ $global:NebulaPromptTemplate = { $boundary = if ($outermost) { "$e]133;A$([char]7)$e]2;NEBULA|$cwd|$branch$([char]7)" } else { '' } $leadingNewline = '' try { - # PowerShell cursor Y is zero-based. Like Oh My Posh's cancelNewline, - # do not add a leading spacer for the first prompt or when at top. - if ($global:NebulaPromptCount -gt 1 -and $Host.UI.RawUI.CursorPosition.Y -gt 0) { + # 输出已经换行时不再垫一行;仅补齐无换行输出,避免提示符粘在结果末尾。 + if ($global:NebulaPromptCount -gt 1 -and $Host.UI.RawUI.CursorPosition.X -gt 0) { $leadingNewline = "`n" } } catch { From b93c7ca4353c48dff4f4160457437a585e7e5a1a Mon Sep 17 00:00:00 2001 From: kuddev Date: Wed, 21 Oct 2026 22:04:15 +0800 Subject: [PATCH 5/5] Distinguish delayed PTY output from input-triggered redraws --- .../src/tty/windows/test/bash_input.rs | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/nebula_terminal/src/tty/windows/test/bash_input.rs b/nebula_terminal/src/tty/windows/test/bash_input.rs index e2eceb917..3462c71c0 100644 --- a/nebula_terminal/src/tty/windows/test/bash_input.rs +++ b/nebula_terminal/src/tty/windows/test/bash_input.rs @@ -91,12 +91,12 @@ impl BashSession { ) } - fn redraw_after_failure(&mut self) -> std::io::Result<()> { + fn observe_after_failure(&mut self, redraw: bool) -> std::io::Result<()> { self.output_tail.clear(); self.last_reply = None; - // A single redraw distinguishes stale display from retained Readline - // input without deleting any more characters or retrying the assertion. - self.edit(EditKey::Redraw, 1); + if redraw { + self.edit(EditKey::Redraw, 1); + } let deadline = Instant::now() + Duration::from_secs(2); let mut buffer = [0; 8192]; while Instant::now() < deadline { @@ -111,6 +111,18 @@ impl BashSession { Ok(()) } + fn failure_phase(&mut self, redraw: bool) -> String { + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + self.observe_after_failure(redraw) + })); + let result = match outcome { + Ok(Ok(())) => "captured for 2 seconds".to_owned(), + Ok(Err(error)) => format!("PTY read failed: {error}"), + Err(_) => "diagnostic panicked".to_owned(), + }; + format!("({result}): {}", self.diagnostic_snapshot()) + } + fn wait_for(&mut self, label: &str, expected: impl Fn(&Term) -> bool) { let deadline = Instant::now() + Duration::from_secs(10); let mut buffer = [0; 8192]; @@ -129,21 +141,13 @@ impl BashSession { let before_deadline = Instant::now() < deadline; if !before_deadline { let original = self.diagnostic_snapshot(); - // Even a diagnostic I/O failure must retain the original - // assertion and its screen/input/output evidence. - let redraw = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - self.redraw_after_failure() - })); - let redraw_result = match redraw { - Ok(Ok(())) => "captured for 2 seconds".to_owned(), - Ok(Err(error)) => format!("PTY read failed: {error}"), - Err(_) => "diagnostic panicked".to_owned(), - }; + // 先只读再发 Ctrl-L,区分处理过慢与新输入才冲出尾部输出;原超时仍判失败。 + let passive = self.failure_phase(false); + let redraw = self.failure_phase(true); assert!( before_deadline, - "{} / {label}: original failure: {original}; Ctrl-L redraw ({redraw_result}): {}", + "{} / {label}: original failure: {original}; no new input {passive}; Ctrl-L redraw {redraw}", self.locale, - self.diagnostic_snapshot(), ); } thread::sleep(Duration::from_millis(5));