Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions crates/cli/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32987,6 +32987,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::<String>(),
"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;
Expand Down
82 changes: 82 additions & 0 deletions crates/cli/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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`.
Expand All @@ -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)]
Expand Down
8 changes: 7 additions & 1 deletion crates/daemon/assets/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
53 changes: 52 additions & 1 deletion crates/e2e/tests/playbook_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:?}"
Expand Down Expand Up @@ -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;
})()
"###,
Expand Down
14 changes: 8 additions & 6 deletions docs/playbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 7 additions & 4 deletions specs/0204-playbook-code-editing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
Loading