From dea86e2f0482feed3b9c5bd621546a76b847f802 Mon Sep 17 00:00:00 2001 From: Edwin Date: Tue, 18 Aug 2026 22:55:46 -0700 Subject: [PATCH] feat(playbook): render fenced code as coherent blocks --- crates/cli/src/app.rs | 58 ++++++++++++++++++++ crates/cli/src/ui.rs | 82 +++++++++++++++++++++++++++++ crates/daemon/assets/index.html | 8 ++- crates/e2e/tests/playbook_view.rs | 53 ++++++++++++++++++- docs/playbook.md | 14 ++--- specs/0204-playbook-code-editing.md | 11 ++-- 6 files changed, 214 insertions(+), 12 deletions(-) diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index 0fcbf99c..798701cf 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -32868,6 +32868,64 @@ mod tests { ); } + #[tokio::test] + async fn playbook_fenced_code_frame_paints_one_continuous_full_width_block() { + use ratatui::buffer::Buffer; + use ratatui::widgets::{Paragraph, Widget, Wrap}; + + let (app, _dir, server) = empty_app().await; + let markdown = "before\n```rust\n界🙂 alpha beta gamma\n```\nafter"; + let area = Rect::new(0, 0, 18, 6); + let mut buffer = Buffer::empty(area); + + crate::ui::render_playbook_fenced_code_background_for_test( + &mut buffer, + &app, + markdown, + area, + 0, + ); + Paragraph::new(crate::ui::render_playbook_markdown_lines_for_test( + &app, markdown, + )) + .wrap(Wrap { trim: false }) + .render(area, &mut buffer); + + let code_background = app.theme.inactive_highlight_bg; + for row in 1..=4 { + for col in 0..area.width { + assert_eq!( + buffer.cell((col, row)).expect("code-block cell").bg, + code_background, + "every cell on fenced visual row {row} must belong to the same block" + ); + } + } + for row in [0, 5] { + assert!( + (0..area.width).all(|col| buffer + .cell((col, row)) + .expect("adjacent markdown cell") + .bg + != code_background), + "ordinary Markdown row {row} must remain outside the code block" + ); + } + assert_eq!( + (0..4) + .map(|col| buffer.cell((col, 1)).unwrap().symbol()) + .collect::(), + "rust", + "the opener's hidden delimiter must not add block padding or glyphs" + ); + assert!( + (0..area.width).all(|col| buffer.cell((col, 4)).unwrap().symbol() == " "), + "the hidden closer keeps a blank source row inside the block" + ); + + server.abort(); + } + #[tokio::test] async fn playbook_backtick_fence_formats_multiline_and_preserves_source_rows() { let (mut app, _dir, server) = empty_app().await; diff --git a/crates/cli/src/ui.rs b/crates/cli/src/ui.rs index c60954f5..908cc67e 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -17956,6 +17956,13 @@ fn render_playbook_popup_at( let mut popup_buffer = Buffer::empty(rect); Clear.render(rect, &mut popup_buffer); block.render(rect, &mut popup_buffer); + render_playbook_fenced_code_background( + &mut popup_buffer, + app, + &popup.buffer, + inner, + scroll_offset, + ); para.render(inner, &mut popup_buffer); render_playbook_scroll_indicator_to_buffer( &mut popup_buffer, @@ -17970,6 +17977,13 @@ fn render_playbook_popup_at( } else { f.render_widget(Clear, paint_rect); f.render_widget(block, paint_rect); + render_playbook_fenced_code_background( + f.buffer_mut(), + app, + &popup.buffer, + inner, + scroll_offset, + ); f.render_widget(para, inner); render_playbook_scroll_indicator( f, @@ -22261,6 +22275,63 @@ fn render_playbook_markdown_lines<'a>( out } +/// Paint completed multiline fences as one continuous, full-width code +/// surface behind the source-stable text rows. +/// +/// This is deliberately a buffer-layer treatment instead of padding the +/// rendered [`Line`]s: adding glyphs would change wrapping and cursor geometry. +/// The source lines, hidden delimiters, and every collaboration offset keep the +/// exact model used by the text renderer; only the otherwise-empty cells on +/// each wrapped fence row receive the code background. +fn render_playbook_fenced_code_background( + buffer: &mut Buffer, + app: &App, + markdown: &str, + area: Rect, + scroll_offset: usize, +) { + if area.is_empty() { + return; + } + + let width = area.width as usize; + let mut visual_row = 0usize; + let mut dups = std::collections::HashMap::new(); + let style = Style::default().bg(app.theme.inactive_highlight_bg); + + for (raw, kind) in markdown.split('\n').zip(playbook_line_kinds(markdown)) { + let line_instance = playbook_line_instance(&mut dups, raw); + let rendered = + playbook_rendered_line_text_in_context(Some(app), raw, width, line_instance, kind); + let row_count = playbook_wrap_row_starts(&rendered, width).len(); + + if kind.is_formatted_code() { + for row in visual_row..visual_row.saturating_add(row_count) { + let Some(viewport_row) = row.checked_sub(scroll_offset) else { + continue; + }; + if viewport_row >= area.height as usize { + break; + } + buffer.set_style( + Rect::new( + area.x, + area.y.saturating_add(viewport_row as u16), + area.width, + 1, + ), + style, + ); + } + } + + visual_row = visual_row.saturating_add(row_count); + if visual_row >= scroll_offset.saturating_add(area.height as usize) { + break; + } + } +} + /// Absolute buffer char ranges (matching the selection/search coordinate /// space) of every `[label](agentd:action/…)` construct on `raw`, whose /// first char sits at buffer char offset `line_start`. @@ -22283,6 +22354,17 @@ pub(crate) fn render_playbook_markdown_lines_for_test<'a>( render_playbook_markdown_lines(app, markdown, 80, None, None, None) } +#[cfg(test)] +pub(crate) fn render_playbook_fenced_code_background_for_test( + buffer: &mut Buffer, + app: &App, + markdown: &str, + area: Rect, + scroll_offset: usize, +) { + render_playbook_fenced_code_background(buffer, app, markdown, area, scroll_offset); +} + /// Widget-surface renderer entry point for tests outside this module (the /// app-level tests exercising live chip status and playbook projections). #[cfg(test)] diff --git a/crates/daemon/assets/index.html b/crates/daemon/assets/index.html index 7dd70dc7..114ea956 100644 --- a/crates/daemon/assets/index.html +++ b/crates/daemon/assets/index.html @@ -3220,7 +3220,13 @@ .playbook-line.is-fenced-code { color: var(--fg); background: color-mix(in srgb, var(--accent-alt) 22%, var(--bg-elev)); - border-radius: 3px; + border-radius: 0; + } + .playbook-line.is-fenced-code[data-fence-kind="fence-open"] { + border-radius: 5px 5px 0 0; + } + .playbook-line.is-fenced-code[data-fence-kind="fence-close"] { + border-radius: 0 0 5px 5px; } /* Keeps the delimiter in the DOM/source-offset model without consuming a glyph or collapsing its source row. */ diff --git a/crates/e2e/tests/playbook_view.rs b/crates/e2e/tests/playbook_view.rs index 30580145..2f96648f 100644 --- a/crates/e2e/tests/playbook_view.rs +++ b/crates/e2e/tests/playbook_view.rs @@ -212,8 +212,44 @@ async fn web_playbook_view_full_parity() { const multilineHasClip = !!playbookInputEl.querySelector(".is-fenced-code .playbook-clip"); const closing = playbookInputEl.querySelector(".playbook-fence-delimiter.is-closing"); const closingRawLength = playbookNodeRawLength(closing); + const openingLine = multilineEls[0]; + const bodyLine = multilineEls[1]; + const closingLine = multilineEls[2]; + const openingRect = openingLine.getBoundingClientRect(); + const bodyRect = bodyLine.getBoundingClientRect(); + const closingRect = closingLine.getBoundingClientRect(); + const openingStyle = getComputedStyle(openingLine); const bodyStyle = getComputedStyle(multilineEls[1]); + const closingStyle = getComputedStyle(closingLine); const editorStyle = getComputedStyle(playbookInputEl); + const editorRect = playbookInputEl.getBoundingClientRect(); + // Capture computed values before the Backspace step removes these + // nodes. CSSStyleDeclaration is live and becomes empty after the + // line leaves the document. + const openingBackground = openingStyle.backgroundColor; + const bodyBackground = bodyStyle.backgroundColor; + const closingBackground = closingStyle.backgroundColor; + const bodyHighlighted = bodyBackground !== editorStyle.backgroundColor; + const continuousBlock = + openingBackground === bodyBackground && + bodyBackground === closingBackground && + Math.abs(openingRect.bottom - bodyRect.top) < 0.5 && + Math.abs(bodyRect.bottom - closingRect.top) < 0.5 && + Math.abs(openingRect.left - bodyRect.left) < 0.5 && + Math.abs(bodyRect.left - closingRect.left) < 0.5 && + Math.abs(openingRect.right - bodyRect.right) < 0.5 && + Math.abs(bodyRect.right - closingRect.right) < 0.5; + const fillsEditorWidth = + Math.abs(openingRect.left - (editorRect.left + parseFloat(editorStyle.paddingLeft))) < 0.5 && + Math.abs(openingRect.right - (editorRect.right - parseFloat(editorStyle.paddingRight))) < 0.5; + const blockCorners = { + openingTop: openingStyle.getPropertyValue("border-top-left-radius"), + openingBottom: openingStyle.getPropertyValue("border-bottom-left-radius"), + bodyTop: bodyStyle.getPropertyValue("border-top-left-radius"), + bodyBottom: bodyStyle.getPropertyValue("border-bottom-left-radius"), + closingTop: closingStyle.getPropertyValue("border-top-left-radius"), + closingBottom: closingStyle.getPropertyValue("border-bottom-left-radius"), + }; const sel = window.getSelection(); const literalRange = document.createRange(); literalRange.setStart(multilineEls[1].firstChild, 1); literalRange.collapse(true); @@ -244,7 +280,7 @@ async fn web_playbook_view_full_parity() { return { sourceBefore, visibleBefore, rawLength, multiline, multilineKinds, multilineHasClip, multilineOpensClipMenu, bodySourceOffset, sourceBoundary, closingRawLength, - bodyHighlighted: bodyStyle.backgroundColor !== editorStyle.backgroundColor, + bodyHighlighted, continuousBlock, fillsEditorWidth, blockCorners, sourceAfterMultilineBackspace, multilineFormattedAfterBackspace, revealedOpening, sourceAfterMultilineRetype, multilineFormattedAfterRetype, sourceAfterBackspace, lineAfterBackspace, formattedAfterBackspace, @@ -282,6 +318,20 @@ async fn web_playbook_view_full_parity() { ); assert_eq!(fenced_code["multilineHasClip"], false, "{fenced_code:?}"); assert_eq!(fenced_code["bodyHighlighted"], true, "{fenced_code:?}"); + assert_eq!(fenced_code["continuousBlock"], true, "{fenced_code:?}"); + assert_eq!(fenced_code["fillsEditorWidth"], true, "{fenced_code:?}"); + assert_eq!( + fenced_code["blockCorners"], + serde_json::json!({ + "openingTop": "5px", + "openingBottom": "0px", + "bodyTop": "0px", + "bodyBottom": "0px", + "closingTop": "0px", + "closingBottom": "5px", + }), + "{fenced_code:?}" + ); assert_eq!( fenced_code["multilineOpensClipMenu"], false, "{fenced_code:?}" @@ -1392,6 +1442,7 @@ async fn web_playbook_view_full_parity() { state.sessions = list; state.currentId = sid; await switchCurrentViewMode("playbook"); + playbookTestSet(playbookSerialize() + "\n```rust\nfn main() { println!(\"界🙂\"); }\n```\n"); return true; })() "###, diff --git a/docs/playbook.md b/docs/playbook.md index 5e487ec1..fa9844e3 100644 --- a/docs/playbook.md +++ b/docs/playbook.md @@ -59,12 +59,14 @@ Emacs-style cursor-forward (click the Find button to search), and Completed triple-backtick spans and multiline fences render as highlighted code with delimiter glyphs hidden while preserving the exact Markdown source. -Multiline opening and closing lines keep their editor rows, so delimiter-only -lines appear as highlighted blank rows. Backspace at a closing boundary removes -the complete closing run and reveals the literal source; retyping it restores -the formatting. Incomplete fences remain visible literal source. Markdown -syntax, smart clips, attachments, and action links stay non-interactive inside -both complete and incomplete fences. +Completed multiline fences form one continuous full-width code block from the +opening row through the closing row, including wrapped body rows. The opening +and closing lines keep their editor rows, so delimiter-only lines appear as +blank rows within that block. Backspace at a closing boundary removes the +complete closing run and reveals the literal source; retyping it restores the +formatting. Incomplete fences remain visible literal source. Markdown syntax, +smart clips, attachments, and action links stay non-interactive inside both +complete and incomplete fences. ## Smart clips diff --git a/specs/0204-playbook-code-editing.md b/specs/0204-playbook-code-editing.md index de9d7d7f..18a4d344 100644 --- a/specs/0204-playbook-code-editing.md +++ b/specs/0204-playbook-code-editing.md @@ -9,8 +9,11 @@ Scope: Source-preserving inline and multiline backtick-code editing in Playbook A completed single-backtick span, exact triple-backtick span on one source line, or multiline backtick fence renders in the Playbook editor as -highlighted code text without visible delimiter glyphs. The stored document -remains the exact source Markdown, including every delimiter run and newline. +highlighted code text without visible delimiter glyphs. A completed multiline +fence is presented as one continuous code-block surface spanning the full +editor width from its opening row through its closing row, including wrapped +body rows. The stored document remains the exact source Markdown, including +every delimiter run and newline. Multiline fences preserve a one-source-line/one-editor-line model. Opening and closing delimiter lines keep their rows even when hiding the backtick run; @@ -55,8 +58,8 @@ synchronization. - A completed one-line triple-backtick span is atomic in the web editor and source-addressable in the TUI, matching the existing single-backtick model. - Multiline fenced regions preserve every source line, hide delimiter glyphs - only when complete, and suppress interactive Markdown extensions in both - clients. + only when complete, suppress interactive Markdown extensions, and paint one + continuous full-width block surface in both clients. - A delimiter-only opening or closing line appears as a highlighted blank row. Multiple source offsets within hidden delimiter glyphs necessarily share one visual caret position; their collaboration offsets remain distinct.