From e72ed74cac6aa8a1aef1954a68c3a5fe8e07cc17 Mon Sep 17 00:00:00 2001 From: Edwin Date: Tue, 18 Aug 2026 21:57:51 -0700 Subject: [PATCH] feat(playbook): format multiline code fences --- crates/cli/src/app.rs | 72 +++++++++++++- crates/cli/src/app/editor.rs | 7 ++ crates/cli/src/playbook_markdown.rs | 113 +++++++++++++++++++++- crates/cli/src/ui.rs | 139 ++++++++++++++++++++-------- crates/daemon/assets/index.html | 95 ++++++++++++++----- crates/e2e/tests/playbook_view.rs | 72 ++++++++++++-- docs/playbook.md | 14 +-- specs/0204-playbook-code-editing.md | 61 +++++++----- 8 files changed, 465 insertions(+), 108 deletions(-) diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index c1270d00..0fcbf99c 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -32365,7 +32365,7 @@ mod tests { ), ( "backtick-fenced-code", - "before\n```rust\n# literal heading\n- literal bullet with @{session:abc123}\n[Run](agentd:action/example)\n```\nafter fence\nZEND", + "before\n```rust\n# literal 界🙂 heading\n- literal bullet with @{session:abc123}\n[Run](agentd:action/example)\n```\nafter fence\nZEND", ), ( "timeline", @@ -32869,9 +32869,9 @@ mod tests { } #[tokio::test] - async fn playbook_backtick_fence_formats_inline_and_keeps_multiline_literal() { + async fn playbook_backtick_fence_formats_multiline_and_preserves_source_rows() { let (mut app, _dir, server) = empty_app().await; - let md = "```rust\n# not a heading\n- not a bullet\n@{session:literal}\n![shot](/tmp/shot.png)\n[Run](agentd:action/example)\n```"; + let md = "```rust\n# 界🙂 not a heading\n- not a bullet\n@{session:literal}\n![shot](/tmp/shot.png)\n[Run](agentd:action/example)\n```"; app.playbook_popup = Some(playbook_popup_for_test("s1", md, 0)); let lines = crate::ui::render_playbook_markdown_lines_for_test(&app, md); @@ -32884,11 +32884,22 @@ mod tests { .collect::() }) .collect::>(); - assert_eq!(painted, md.lines().collect::>()); + assert_eq!( + painted, + [ + "rust", + "# 界🙂 not a heading", + "- not a bullet", + "@{session:literal}", + "![shot](/tmp/shot.png)", + "[Run](agentd:action/example)", + "", + ] + ); assert!(lines .iter() .flat_map(|line| &line.spans) - .all(|span| span.style.bg.is_none())); + .all(|span| span.style.bg == Some(app.theme.inactive_highlight_bg))); let area = Rect::new(0, 0, 80, 20); assert!(crate::ui::playbook_session_clip_hits(Some(&app), md, 0, area).is_empty()); @@ -32914,6 +32925,57 @@ mod tests { "Tab must not apply Markdown list indentation inside raw code" ); + let closing_boundary = md.chars().count(); + app.playbook_popup = Some(playbook_popup_for_test("s1", md, closing_boundary)); + let cjk_start = "```rust\n# ".chars().count(); + assert_eq!( + crate::ui::playbook_cursor_visual_pos(Some(&app), md, cjk_start, 80), + (1, 2) + ); + assert_eq!( + crate::ui::playbook_cursor_visual_pos(Some(&app), md, cjk_start + 1, 80), + (1, 4), + "a CJK source character inside the fence advances by two cells" + ); + assert_eq!( + crate::ui::playbook_cursor_visual_pos(Some(&app), md, closing_boundary, 80), + (6, 0), + "the hidden closer stays on its own source row" + ); + assert_eq!( + crate::ui::playbook_visual_to_cursor(Some(&app), md, 6, 0, 80), + closing_boundary, + "clicking the hidden closing boundary resolves to its source end" + ); + app.delete_playbook_back(); + assert_eq!( + app.playbook_popup.as_ref().unwrap().buffer, + md.strip_suffix("```").unwrap(), + "Backspace at the hidden closing boundary removes the complete run" + ); + let revealed = crate::ui::render_playbook_markdown_lines_for_test( + &app, + &app.playbook_popup.as_ref().unwrap().buffer, + ); + assert_eq!( + revealed[0] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(), + "```rust" + ); + app.insert_playbook_text("```"); + assert_eq!(app.playbook_popup.as_ref().unwrap().buffer, md); + assert_eq!( + crate::ui::render_playbook_markdown_lines_for_test(&app, md)[0] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(), + "rust" + ); + let boundary_source = "before ```界🙂 @{session:literal}```"; let inline = format!("{boundary_source} after"); app.playbook_popup = Some(playbook_popup_for_test( diff --git a/crates/cli/src/app/editor.rs b/crates/cli/src/app/editor.rs index 7c51ce87..08d8d9d9 100644 --- a/crates/cli/src/app/editor.rs +++ b/crates/cli/src/app/editor.rs @@ -3023,6 +3023,13 @@ impl App { return; } let (char_start, char_end) = if let Some(range) = + crate::playbook_markdown::playbook_closing_multiline_fence_before_cursor( + &popup.buffer, + popup.cursor, + ) + { + (range.start, range.end) + } else if let Some(range) = crate::playbook_markdown::playbook_closing_inline_fence_before_cursor( &popup.buffer, popup.cursor, diff --git a/crates/cli/src/playbook_markdown.rs b/crates/cli/src/playbook_markdown.rs index e07c7fa7..be4c0ac3 100644 --- a/crates/cli/src/playbook_markdown.rs +++ b/crates/cli/src/playbook_markdown.rs @@ -1,8 +1,10 @@ //! Source-preserving Markdown context needed by the Playbook editor. //! //! Completed one-line triple-backtick spans are presented like completed -//! inline code. Multiline and incomplete fences remain literal and inert. A -//! shared classifier keeps painting, hit-testing, and editing in agreement. +//! inline code. Completed multiline fences keep one rendered row per source +//! line while hiding only their delimiter glyphs; incomplete fences remain +//! literal and inert. A shared classifier keeps painting, hit-testing, and +//! editing in agreement. use std::ops::Range; @@ -11,12 +13,22 @@ pub(crate) enum PlaybookLineKind { Markdown, CodeFence, Code, + FormattedCodeFence, + FormattedCode, } impl PlaybookLineKind { pub(crate) fn is_markdown(self) -> bool { matches!(self, Self::Markdown) } + + pub(crate) fn is_formatted_code(self) -> bool { + matches!(self, Self::FormattedCodeFence | Self::FormattedCode) + } + + pub(crate) fn is_formatted_fence(self) -> bool { + matches!(self, Self::FormattedCodeFence) + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -161,6 +173,67 @@ fn closing_backtick_fence(raw: &str, opening_len: usize) -> Option<(usize, usize (ticks >= opening_len && tail.trim().is_empty()).then_some((spaces, ticks)) } +/// Presentation kind for every source line. A multiline fence is promoted to +/// the formatted variants only after a valid closer is present; this lets an +/// incomplete fence remain literal while still suppressing Markdown features. +pub(crate) fn playbook_line_kinds(markdown: &str) -> Vec { + let lines = markdown.split('\n').collect::>(); + let mut kinds = vec![PlaybookLineKind::Markdown; lines.len()]; + let mut open: Option<(usize, usize)> = None; + + for (index, raw) in lines.iter().enumerate() { + if let Some((open_index, open_len)) = open { + if closing_backtick_fence(raw, open_len).is_some() { + kinds[open_index] = PlaybookLineKind::FormattedCodeFence; + for kind in &mut kinds[open_index + 1..index] { + *kind = PlaybookLineKind::FormattedCode; + } + kinds[index] = PlaybookLineKind::FormattedCodeFence; + open = None; + } else { + kinds[index] = PlaybookLineKind::Code; + } + } else if let Some((_, ticks)) = opening_backtick_fence(raw) { + kinds[index] = PlaybookLineKind::CodeFence; + open = Some((index, ticks)); + } + } + + kinds +} + +/// Byte range of the backtick run on a multiline fence delimiter line. +pub(crate) fn playbook_fence_delimiter(raw: &str) -> Option> { + let (spaces, ticks, _) = backtick_fence(raw)?; + Some(spaces..spaces + ticks) +} + +/// Closing multiline-fence delimiter immediately before the source cursor. +/// Returned offsets are Unicode character offsets in the full document. +pub(crate) fn playbook_closing_multiline_fence_before_cursor( + markdown: &str, + cursor: usize, +) -> Option> { + let cursor = cursor.min(markdown.chars().count()); + let mut line_start = 0usize; + let mut open_len = None; + for raw in markdown.split('\n') { + if let Some(required) = open_len { + if let Some((spaces, ticks)) = closing_backtick_fence(raw, required) { + let delimiter_end = line_start + spaces + ticks; + if cursor == delimiter_end { + return Some(line_start + spaces..delimiter_end); + } + open_len = None; + } + } else if let Some((_, ticks)) = opening_backtick_fence(raw) { + open_len = Some(ticks); + } + line_start += raw.chars().count() + 1; + } + None +} + /// Whether the source character at `offset` belongs to a fence delimiter or /// fenced body. This includes incomplete fences so extensions remain inert /// while their literal source is visible. @@ -193,7 +266,7 @@ mod tests { use super::*; #[test] - fn classifies_multiline_fences_as_literal_inert_source() { + fn classifies_multiline_fences_as_inert_source() { let mut classifier = PlaybookLineClassifier::default(); let kinds = ["before", "```rust", "# literal", "```", "after"] .into_iter() @@ -211,6 +284,40 @@ mod tests { ); } + #[test] + fn completed_multiline_fence_gets_row_stable_formatted_kinds() { + assert_eq!( + playbook_line_kinds("before\n```rust\n界🙂\n```\nafter"), + [ + PlaybookLineKind::Markdown, + PlaybookLineKind::FormattedCodeFence, + PlaybookLineKind::FormattedCode, + PlaybookLineKind::FormattedCodeFence, + PlaybookLineKind::Markdown, + ] + ); + assert_eq!( + playbook_line_kinds("```rust\nstill open"), + [PlaybookLineKind::CodeFence, PlaybookLineKind::Code] + ); + } + + #[test] + fn multiline_closing_boundary_is_source_offset_and_cjk_safe() { + let markdown = "前\n```rust\n界🙂\n ``` \nafter"; + let cursor = "前\n```rust\n界🙂\n ```".chars().count(); + let closing = playbook_closing_multiline_fence_before_cursor(markdown, cursor).unwrap(); + assert_eq!( + markdown + .chars() + .skip(closing.start) + .take(closing.len()) + .collect::(), + "```" + ); + assert!(playbook_closing_multiline_fence_before_cursor(markdown, cursor - 1).is_none()); + } + #[test] fn closing_fence_must_match_opening_run() { let mut classifier = PlaybookLineClassifier::default(); diff --git a/crates/cli/src/ui.rs b/crates/cli/src/ui.rs index abf9ff3d..c60954f5 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -13,8 +13,8 @@ use crate::app::{ }; use crate::keymap::{KeyAction, Profile}; use crate::playbook_markdown::{ - playbook_inline_fences, playbook_unmatched_inline_fence_start, PlaybookLineClassifier, - PlaybookLineKind, + playbook_fence_delimiter, playbook_inline_fences, playbook_line_kinds, + playbook_unmatched_inline_fence_start, PlaybookLineKind, }; use crate::text_util::wrap_to_width; use crate::theme::Theme; @@ -16776,12 +16776,14 @@ fn render_playbook_attachment_images( let mut blocks: Vec<((u64, usize), String, usize, u16)> = Vec::new(); let mut row_base = 0usize; let mut dups = std::collections::HashMap::new(); - let mut classifier = PlaybookLineClassifier::default(); - for raw in popup.buffer.lines() { + for (raw, kind) in popup + .buffer + .lines() + .zip(playbook_line_kinds(&popup.buffer)) + { if row_base >= viewport_end { break; } - let kind = classifier.classify(raw); let li = playbook_line_instance(&mut dups, raw); let (rendered, clips) = playbook_rendered_line_with_clips(Some(app), raw, width, li, kind); @@ -18920,12 +18922,10 @@ fn playbook_block_visual_rows( let mut first = None; let mut last = None; let mut dups = std::collections::HashMap::new(); - let mut classifier = PlaybookLineClassifier::default(); - for (i, raw) in markdown.lines().enumerate() { + for ((i, raw), kind) in markdown.lines().enumerate().zip(playbook_line_kinds(markdown)) { if i >= end_line { break; } - let kind = classifier.classify(raw); let li = playbook_line_instance(&mut dups, raw); let (rendered, _clips) = playbook_rendered_line_with_clips(app, raw, width, li, kind); @@ -18963,9 +18963,7 @@ fn playbook_shimmer_block_at( let mut visual_row_base = 0usize; let mut source_line = None; let mut dups = std::collections::HashMap::new(); - let mut classifier = PlaybookLineClassifier::default(); - for (i, raw) in markdown.lines().enumerate() { - let kind = classifier.classify(raw); + for ((i, raw), kind) in markdown.lines().enumerate().zip(playbook_line_kinds(markdown)) { let li = playbook_line_instance(&mut dups, raw); let (rendered, _clips) = playbook_rendered_line_with_clips(app, raw, width, li, kind); @@ -20473,10 +20471,12 @@ pub(crate) fn playbook_cursor_visual_pos( // drifts the moment a line wraps mid-word and compounds for lines below. let mut visual_row = 0usize; let mut dups = std::collections::HashMap::new(); - let mut classifier = PlaybookLineClassifier::default(); let mut current = ("", 0, PlaybookLineKind::Markdown); - for (idx, raw) in markdown.split('\n').enumerate() { - let kind = classifier.classify(raw); + for ((idx, raw), kind) in markdown + .split('\n') + .enumerate() + .zip(playbook_line_kinds(markdown)) + { let li = playbook_line_instance(&mut dups, raw); if idx == line { current = (raw, li, kind); @@ -20571,9 +20571,7 @@ pub(crate) fn playbook_visual_to_cursor( let mut line_start = 0usize; // char offset of the current line's first char let mut owner: Option<(usize, Vec, &str, usize, u64, PlaybookLineKind)> = None; let mut dups = std::collections::HashMap::new(); - let mut classifier = PlaybookLineClassifier::default(); - for raw in markdown.split('\n') { - let kind = classifier.classify(raw); + for (raw, kind) in markdown.split('\n').zip(playbook_line_kinds(markdown)) { let li = playbook_line_instance(&mut dups, raw); let rendered = playbook_rendered_line_text_in_context(app, raw, width, li, kind); let starts = playbook_wrap_row_starts(&rendered, width); @@ -20905,6 +20903,9 @@ fn playbook_rendered_line_text_in_context( line_instance: u64, kind: PlaybookLineKind, ) -> String { + if kind.is_formatted_fence() { + return playbook_formatted_fence_text(raw); + } if !kind.is_markdown() { return playbook_painted_indent_line(raw); } @@ -20949,6 +20950,16 @@ fn playbook_rendered_line_text_in_context( } } +/// Visible text for a completed multiline-fence delimiter row. Only the +/// delimiter run disappears; indentation, info strings, and trailing spaces +/// keep their source-row positions and remain editable. +fn playbook_formatted_fence_text(raw: &str) -> String { + let Some(delimiter) = playbook_fence_delimiter(raw) else { + return playbook_painted_indent_line(raw); + }; + format!("{}{}", &raw[..delimiter.start], &raw[delimiter.end..]) +} + /// The first `leading` characters of `raw` as the renderer paints them, with /// tabs standing in as one space each. /// @@ -21150,9 +21161,7 @@ pub(crate) fn playbook_line_instance( pub(crate) fn playbook_attachment_instances(markdown: &str) -> Vec<((u64, usize), String)> { let mut out = Vec::new(); let mut dups = std::collections::HashMap::new(); - let mut classifier = PlaybookLineClassifier::default(); - for raw in markdown.lines() { - let kind = classifier.classify(raw); + for (raw, kind) in markdown.lines().zip(playbook_line_kinds(markdown)) { let li = playbook_line_instance(&mut dups, raw); let trimmed = raw.trim(); if !kind.is_markdown() @@ -21187,9 +21196,7 @@ pub(crate) fn playbook_skip_attachment_rows( let width = width.max(1); let mut row_base = 0usize; let mut dups = std::collections::HashMap::new(); - let mut classifier = PlaybookLineClassifier::default(); - for raw in markdown.lines() { - let kind = classifier.classify(raw); + for (raw, kind) in markdown.lines().zip(playbook_line_kinds(markdown)) { let li = playbook_line_instance(&mut dups, raw); let (rendered, clips) = playbook_rendered_line_with_clips(app, raw, width, li, kind); @@ -21611,12 +21618,10 @@ pub(crate) fn playbook_session_clip_hits( let viewport_end = scroll_offset.saturating_add(area.height as usize); let mut visual_row_base = 0usize; let mut dups = std::collections::HashMap::new(); - let mut classifier = PlaybookLineClassifier::default(); - for raw in markdown.lines() { + for (raw, kind) in markdown.lines().zip(playbook_line_kinds(markdown)) { if visual_row_base >= viewport_end { break; } - let kind = classifier.classify(raw); let li = playbook_line_instance(&mut dups, raw); let (rendered, clips) = playbook_rendered_line_with_clips(app, raw, width, li, kind); @@ -21670,12 +21675,10 @@ pub(crate) fn playbook_attachment_chip_hits( let viewport_end = scroll_offset.saturating_add(area.height as usize); let mut visual_row_base = 0usize; let mut dups = std::collections::HashMap::new(); - let mut classifier = PlaybookLineClassifier::default(); - for raw in markdown.lines() { + for (raw, kind) in markdown.lines().zip(playbook_line_kinds(markdown)) { if visual_row_base >= viewport_end { break; } - let kind = classifier.classify(raw); let li = playbook_line_instance(&mut dups, raw); let (rendered, clips) = playbook_rendered_line_with_clips(app, raw, width, li, kind); @@ -21791,12 +21794,10 @@ pub(crate) fn playbook_action_link_hits( let viewport_end = scroll_offset.saturating_add(area.height as usize); let mut visual_row_base = 0usize; let mut dups = std::collections::HashMap::new(); - let mut classifier = PlaybookLineClassifier::default(); - for raw in markdown.lines() { + for (raw, kind) in markdown.lines().zip(playbook_line_kinds(markdown)) { if visual_row_base >= viewport_end { break; } - let kind = classifier.classify(raw); let li = playbook_line_instance(&mut dups, raw); let rendered = playbook_rendered_line_text_in_context(app, raw, width, li, kind); let starts = playbook_wrap_row_starts(&rendered, width); @@ -21879,6 +21880,22 @@ fn playbook_visual_col_for_line_in_context( line_instance: u64, kind: PlaybookLineKind, ) -> usize { + if kind.is_formatted_fence() { + let Some(delimiter) = playbook_fence_delimiter(raw) else { + return playbook_prefix_display_width(&playbook_painted_indent_line(raw), raw_col); + }; + let delimiter_start = raw[..delimiter.start].chars().count(); + let delimiter_end = raw[..delimiter.end].chars().count(); + if raw_col <= delimiter_start { + return playbook_prefix_display_width(&raw[..delimiter.start], raw_col); + } + let prefix_width = UnicodeWidthStr::width(&raw[..delimiter.start]); + if raw_col <= delimiter_end { + return prefix_width; + } + return prefix_width + + playbook_prefix_display_width(&raw[delimiter.end..], raw_col - delimiter_end); + } if !kind.is_markdown() { return playbook_prefix_display_width(&playbook_painted_indent_line(raw), raw_col); } @@ -22029,9 +22046,7 @@ fn render_playbook_markdown_lines<'a>( let mut out = Vec::new(); let mut line_start = 0usize; let mut dups = std::collections::HashMap::new(); - let mut classifier = PlaybookLineClassifier::default(); - for raw in markdown.lines() { - let kind = classifier.classify(raw); + for (raw, kind) in markdown.lines().zip(playbook_line_kinds(markdown)) { let trimmed = raw.trim(); let leading = raw.chars().take_while(|ch| ch.is_whitespace()).count(); // `[label](agentd:action/…)` char ranges on this line, in absolute @@ -22045,11 +22060,55 @@ fn render_playbook_markdown_lines<'a>( Vec::new() }; let li = playbook_line_instance(&mut dups, raw); - if !kind.is_markdown() { - // Multiline fences remain literal source on this line-oriented - // editor surface. Markdown/clip/link transformations stay inert; - // completed one-line triple-backtick spans are formatted by the - // shared inline-token path below. + if kind.is_formatted_code() { + let code_style = playbook_inline_code_style( + &app.theme, + Style::default().fg(app.theme.text), + ); + let mut spans = Vec::new(); + if kind.is_formatted_fence() { + if let Some(delimiter) = playbook_fence_delimiter(raw) { + if delimiter.start > 0 { + spans.extend(playbook_text_spans( + &app.theme, + &raw[..delimiter.start], + line_start, + code_style, + selection, + search_matches, + search_selected, + &[], + )); + } + if delimiter.end < raw.len() { + spans.extend(playbook_text_spans( + &app.theme, + &raw[delimiter.end..], + line_start + raw[..delimiter.end].chars().count(), + code_style, + selection, + search_matches, + search_selected, + &[], + )); + } + } + } else { + spans.extend(playbook_text_spans( + &app.theme, + raw, + line_start, + code_style, + selection, + search_matches, + search_selected, + &[], + )); + } + out.push(Line::from(spans)); + } else if !kind.is_markdown() { + // Incomplete multiline fences remain literal source while all + // Markdown/clip/link transformations stay inert. let painted = playbook_painted_indent_line(raw); let spans = playbook_text_spans( &app.theme, diff --git a/crates/daemon/assets/index.html b/crates/daemon/assets/index.html index 74ab3088..7dd70dc7 100644 --- a/crates/daemon/assets/index.html +++ b/crates/daemon/assets/index.html @@ -3217,6 +3217,20 @@ background: color-mix(in srgb, var(--accent-alt) 22%, var(--bg-elev)); } .playbook-line.is-fenced-source { color: var(--fg); } + .playbook-line.is-fenced-code { + color: var(--fg); + background: color-mix(in srgb, var(--accent-alt) 22%, var(--bg-elev)); + border-radius: 3px; + } + /* Keeps the delimiter in the DOM/source-offset model without consuming a + glyph or collapsing its source row. */ + .playbook-fence-delimiter { + display: inline-block; + width: 0; + height: 1em; + overflow: hidden; + vertical-align: text-bottom; + } .playbook-selection-menu { position: absolute; z-index: 8; @@ -9936,24 +9950,56 @@

function playbookMultilineFenceKinds(markdown) { const lines = markdown.split("\n"); const kinds = new Array(lines.length).fill("markdown"); - let open = 0; + let open = null; for (let i = 0; i < lines.length; i++) { const m = /^( {0,3})(`{3,})(.*)$/.exec(lines[i]); if (open) { - kinds[i] = m && m[2].length >= open && m[3].trim() === "" ? "fence" : "code"; - if (kinds[i] === "fence") open = 0; + kinds[i] = "literal-code"; + if (m && m[2].length >= open.ticks && m[3].trim() === "") { + kinds[open.line] = "fence-open"; + for (let j = open.line + 1; j < i; j++) kinds[j] = "fence-code"; + kinds[i] = "fence-close"; + open = null; + } } else if (m && !m[3].includes("`")) { - kinds[i] = "fence"; - open = m[2].length; + kinds[i] = "literal-fence"; + open = { line: i, ticks: m[2].length }; } } return kinds; } -function playbookLineDiv(text, fencedSource = false) { +function playbookFenceDelimiterEl(raw, closing) { + const span = document.createElement("span"); + span.className = "playbook-fence-delimiter" + (closing ? " is-closing" : " is-opening"); + span.setAttribute("contenteditable", "false"); + span.dataset.raw = raw; + span.setAttribute("aria-hidden", "true"); + return span; +} + +function playbookFillMultilineFenceDelimiter(div, text, closing) { + const m = /^( {0,3})(`{3,})(.*)$/.exec(text); + if (!m) { + div.appendChild(document.createTextNode(text)); + return div; + } + if (m[1]) div.appendChild(document.createTextNode(m[1])); + div.appendChild(playbookFenceDelimiterEl(m[2], closing)); + if (m[3]) div.appendChild(document.createTextNode(m[3])); + return div; +} + +function playbookLineDiv(text, fenceKind = "markdown") { const div = document.createElement("div"); - div.className = "playbook-line" + (fencedSource ? " is-fenced-source" : ""); - if (!fencedSource) return playbookFillLine(div, text); + div.className = "playbook-line"; + div.dataset.fenceKind = fenceKind; + if (fenceKind === "markdown") return playbookFillLine(div, text); + if (fenceKind === "fence-open" || fenceKind === "fence-close") { + div.classList.add("is-fenced-code", "is-fence-delimiter"); + return playbookFillMultilineFenceDelimiter(div, text, fenceKind === "fence-close"); + } + div.classList.add(fenceKind === "fence-code" ? "is-fenced-code" : "is-fenced-source"); if (text === "") div.appendChild(document.createElement("br")); else div.appendChild(document.createTextNode(text)); return div; @@ -9966,7 +10012,7 @@

while (playbookInputEl.firstChild) playbookInputEl.removeChild(playbookInputEl.firstChild); const lines = markdown.split("\n"); const kinds = playbookMultilineFenceKinds(markdown); - lines.forEach((line, i) => playbookInputEl.appendChild(playbookLineDiv(line, kinds[i] !== "markdown"))); + lines.forEach((line, i) => playbookInputEl.appendChild(playbookLineDiv(line, kinds[i]))); playbookApplyLineDecorations(); playbookApplyShimmer(); playbookRenderCursors(); @@ -9981,7 +10027,7 @@

for (const node of el.childNodes) { if (node.nodeType === 3) out += node.data; else if (node.nodeType === 1) { - if (node.classList && (node.classList.contains("playbook-clip") || node.classList.contains("playbook-inline-code"))) out += node.dataset.raw || ""; + if (node.classList && (node.classList.contains("playbook-clip") || node.classList.contains("playbook-inline-code") || node.classList.contains("playbook-fence-delimiter"))) out += node.dataset.raw || ""; else if (node.tagName === "BR") out += ""; else out += playbookSerializeInline(node); } @@ -10104,7 +10150,7 @@

function playbookNodeRawLength(node) { if (!node) return 0; if (node.nodeType === 3) return playbookCharCount(node.data); - if (node.nodeType === 1 && node.classList && (node.classList.contains("playbook-clip") || node.classList.contains("playbook-inline-code"))) return playbookCharCount(node.dataset.raw || ""); + if (node.nodeType === 1 && node.classList && (node.classList.contains("playbook-clip") || node.classList.contains("playbook-inline-code") || node.classList.contains("playbook-fence-delimiter"))) return playbookCharCount(node.dataset.raw || ""); if (node.nodeType === 1 && node.tagName === "BR") return 0; if (node.nodeType === 1) return playbookCharCount(playbookSerializeInline(node)); return 0; @@ -11637,6 +11683,7 @@

if (!offsets || offsets.anchor !== offsets.head || !sel || sel.rangeCount === 0) return; const line = playbookLineAncestor(sel.getRangeAt(0).startContainer); if (!line) return; + if ((line.dataset.fenceKind || "markdown") !== "markdown") return; let lineStart = 0; for (const candidate of playbookInputEl.childNodes) { if (candidate === line) break; @@ -11653,16 +11700,16 @@

sel.addRange(range); } -// Multiline fences stay literal and inert. Rebuild only when a delimiter edit -// changes which source lines belong to such a fence, then restore the source -// caret offset. Completed one-line triple spans use the inline token above. +// Rebuild only when a delimiter edit changes which source lines belong to a +// multiline fence, then restore the exact source caret offset. Completed +// fences retain one DOM line per source line and hide only delimiter glyphs. function playbookSyncFencedSourcePresentation() { const markdown = playbookSerialize(); const lines = markdown.split("\n"); const kinds = playbookMultilineFenceKinds(markdown); const elements = Array.from(playbookInputEl.children); const mismatch = elements.length !== lines.length || elements.some((el, i) => - el.classList.contains("is-fenced-source") !== (kinds[i] !== "markdown")); + (el.dataset.fenceKind || "markdown") !== kinds[i]); if (!mismatch) return false; const offsets = playbookSelectionOffsets(); playbookRenderDoc(markdown); @@ -11675,7 +11722,7 @@

return true; } -function playbookInlineCodeImmediatelyBeforeCaret() { +function playbookCodeBoundaryImmediatelyBeforeCaret() { const sel = window.getSelection(); if (!sel || !sel.isCollapsed || sel.rangeCount === 0) return null; const range = sel.getRangeAt(0); @@ -11687,25 +11734,31 @@

} else if (node.nodeType === 1) { node = node.childNodes[offset - 1] || null; } - return node && node.nodeType === 1 && node.classList.contains("playbook-inline-code") ? node : null; + if (!node || node.nodeType !== 1) return null; + if (node.classList.contains("playbook-inline-code")) return node; + if (node.classList.contains("playbook-fence-delimiter") && node.classList.contains("is-closing")) return node; + return null; } function playbookRevealInlineCodeForBackspace() { - const code = playbookInlineCodeImmediatelyBeforeCaret(); + const code = playbookCodeBoundaryImmediatelyBeforeCaret(); if (!code) return false; const raw = code.dataset.raw || ""; - const delimiter = Math.max(1, Number(code.dataset.delimiter || 1)); + const replacementText = code.classList.contains("playbook-fence-delimiter") + ? "" + : raw.slice(0, -Math.max(1, Number(code.dataset.delimiter || 1))); const sel = window.getSelection(); const replacement = document.createRange(); replacement.selectNode(code); sel.removeAllRanges(); sel.addRange(replacement); // Keep the replacement in the browser's native contenteditable undo stack. - if (document.execCommand("insertText", false, raw.slice(0, -delimiter))) return true; + const command = code.classList.contains("playbook-fence-delimiter") ? "delete" : "insertText"; + if (document.execCommand(command, false, replacementText)) return true; // Older engines may reject execCommand across a contenteditable=false node. // Preserve the behavior there and publish the edit through the normal path. - const text = document.createTextNode(raw.slice(0, -delimiter)); + const text = document.createTextNode(replacementText); code.replaceWith(text); const range = document.createRange(); range.setStart(text, text.data.length); diff --git a/crates/e2e/tests/playbook_view.rs b/crates/e2e/tests/playbook_view.rs index b424829a..30580145 100644 --- a/crates/e2e/tests/playbook_view.rs +++ b/crates/e2e/tests/playbook_view.rs @@ -189,13 +189,13 @@ async fn web_playbook_view_full_parity() { assert_eq!(inline_code["sourceAfterRetype"], "run `cargo test` now\n", "{inline_code:?}"); assert_eq!(inline_code["formattedAfterRetype"], true, "{inline_code:?}"); - // --- 2b. Exact triple-backtick spans share the inline editing contract; - // multiline and incomplete fences remain literal and inert. ---- + // --- 2b. Exact triple-backtick spans and multiline fences preserve source; + // complete multiline fences keep rows and hide delimiters. ------ let fenced_code: serde_json::Value = page .evaluate( r###" withMockPlaybook({ - "playbook.get": () => ({ playbook: { session_id: "s-fenced", markdown: "run ```cargo 界 test``` now\n```\n@{session:literal}\n```\n```unfinished\n", version: 1, template_id: null }, active_run: null, blocks: [], revisions: [] }), + "playbook.get": () => ({ playbook: { session_id: "s-fenced", markdown: "run ```cargo 界 test``` now\n```\n界🙂 @{session:literal} ![shot](/tmp/x.png) [Run](agentd:action/x)\n```\n```unfinished\n", version: 1, template_id: null }, active_run: null, blocks: [], revisions: [] }), "playbook.list_templates": () => ({ templates: [] }), "playbook.edit": () => ({ applied: true }), "playbook.cursor": () => ({ cursor: null }), @@ -206,25 +206,47 @@ async fn web_playbook_view_full_parity() { const sourceBefore = playbookSerialize(); const visibleBefore = code ? code.textContent : null; const rawLength = code ? playbookNodeRawLength(code) : null; - const multilineEls = Array.from(playbookInputEl.querySelectorAll(".is-fenced-source")); + const multilineEls = Array.from(playbookInputEl.querySelectorAll(".is-fenced-code")); const multiline = multilineEls.map((el) => el.textContent); - const multilineHasClip = !!playbookInputEl.querySelector(".is-fenced-source .playbook-clip"); + const multilineKinds = multilineEls.map((el) => el.dataset.fenceKind); + const multilineHasClip = !!playbookInputEl.querySelector(".is-fenced-code .playbook-clip"); + const closing = playbookInputEl.querySelector(".playbook-fence-delimiter.is-closing"); + const closingRawLength = playbookNodeRawLength(closing); + const bodyStyle = getComputedStyle(multilineEls[1]); + const editorStyle = getComputedStyle(playbookInputEl); const sel = window.getSelection(); const literalRange = document.createRange(); literalRange.setStart(multilineEls[1].firstChild, 1); literalRange.collapse(true); sel.removeAllRanges(); sel.addRange(literalRange); + const bodySourceOffset = playbookSelectionOffsets().head; const multilineOpensClipMenu = !!playbookClipContext(); const range = document.createRange(); - range.setStartAfter(code); range.collapse(true); + range.setStartAfter(closing); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); const sourceBoundary = playbookSelectionOffsets().head; playbookInputEl.dispatchEvent(new KeyboardEvent("keydown", { key: "Backspace", bubbles: true, cancelable: true })); + const sourceAfterMultilineBackspace = playbookSerialize(); + const multilineFormattedAfterBackspace = !!playbookInputEl.querySelector(".is-fenced-code"); + const revealedOpening = playbookInputEl.children[1].textContent; + document.execCommand("insertText", false, "```"); + const sourceAfterMultilineRetype = playbookSerialize(); + const multilineFormattedAfterRetype = !!playbookInputEl.querySelector(".is-fenced-code"); + + const restoredCode = playbookInputEl.querySelector(".playbook-inline-code.is-fenced"); + const inlineRange = document.createRange(); + inlineRange.setStartAfter(restoredCode); inlineRange.collapse(true); + sel.removeAllRanges(); sel.addRange(inlineRange); + playbookInputEl.dispatchEvent(new KeyboardEvent("keydown", { key: "Backspace", bubbles: true, cancelable: true })); const sourceAfterBackspace = playbookSerialize(); const lineAfterBackspace = playbookInputEl.querySelector(".playbook-line").textContent; const formattedAfterBackspace = !!playbookInputEl.querySelector(".playbook-inline-code.is-fenced"); document.execCommand("insertText", false, "```"); return { - sourceBefore, visibleBefore, rawLength, multiline, multilineHasClip, multilineOpensClipMenu, sourceBoundary, + sourceBefore, visibleBefore, rawLength, multiline, multilineKinds, multilineHasClip, + multilineOpensClipMenu, bodySourceOffset, sourceBoundary, closingRawLength, + bodyHighlighted: bodyStyle.backgroundColor !== editorStyle.backgroundColor, + sourceAfterMultilineBackspace, multilineFormattedAfterBackspace, revealedOpening, + sourceAfterMultilineRetype, multilineFormattedAfterRetype, sourceAfterBackspace, lineAfterBackspace, formattedAfterBackspace, sourceAfterRetype: playbookSerialize(), formattedAfterRetype: !!playbookInputEl.querySelector(".playbook-inline-code.is-fenced"), @@ -241,20 +263,50 @@ async fn web_playbook_view_full_parity() { "{fenced_code:?}" ); assert_eq!(fenced_code["rawLength"], 18, "{fenced_code:?}"); - assert_eq!(fenced_code["sourceBoundary"], 22, "{fenced_code:?}"); + assert_eq!(fenced_code["bodySourceOffset"], 32, "{fenced_code:?}"); + assert_eq!(fenced_code["sourceBoundary"], 99, "{fenced_code:?}"); + assert_eq!(fenced_code["closingRawLength"], 3, "{fenced_code:?}"); assert_eq!( fenced_code["multiline"], - serde_json::json!(["```", "@{session:literal}", "```", "```unfinished", ""]), + serde_json::json!([ + "", + "界🙂 @{session:literal} ![shot](/tmp/x.png) [Run](agentd:action/x)", + "" + ]), + "{fenced_code:?}" + ); + assert_eq!( + fenced_code["multilineKinds"], + serde_json::json!(["fence-open", "fence-code", "fence-close"]), "{fenced_code:?}" ); assert_eq!(fenced_code["multilineHasClip"], false, "{fenced_code:?}"); + assert_eq!(fenced_code["bodyHighlighted"], true, "{fenced_code:?}"); assert_eq!( fenced_code["multilineOpensClipMenu"], false, "{fenced_code:?}" ); + assert_eq!( + fenced_code["sourceAfterMultilineBackspace"], + "run ```cargo 界 test``` now\n```\n界🙂 @{session:literal} ![shot](/tmp/x.png) [Run](agentd:action/x)\n\n```unfinished\n", + "{fenced_code:?}" + ); + assert_eq!( + fenced_code["multilineFormattedAfterBackspace"], false, + "{fenced_code:?}" + ); + assert_eq!(fenced_code["revealedOpening"], "```", "{fenced_code:?}"); + assert_eq!( + fenced_code["sourceAfterMultilineRetype"], fenced_code["sourceBefore"], + "{fenced_code:?}" + ); + assert_eq!( + fenced_code["multilineFormattedAfterRetype"], true, + "{fenced_code:?}" + ); assert_eq!( fenced_code["sourceAfterBackspace"], - "run ```cargo 界 test now\n```\n@{session:literal}\n```\n```unfinished\n", + "run ```cargo 界 test now\n```\n界🙂 @{session:literal} ![shot](/tmp/x.png) [Run](agentd:action/x)\n```\n```unfinished\n", "{fenced_code:?}" ); assert_eq!( diff --git a/docs/playbook.md b/docs/playbook.md index cbbc6fbf..5e487ec1 100644 --- a/docs/playbook.md +++ b/docs/playbook.md @@ -57,12 +57,14 @@ IME) with the same capabilities, plus `Ctrl+S` to save, `Ctrl+F` for Emacs-style cursor-forward (click the Find button to search), and `Ctrl+Enter` to run. -Completed triple-backtick spans on one line render as highlighted code with -their delimiters hidden while preserving the exact Markdown source. Backspace -at the rendered right edge removes the closing three backticks and reveals the -source; retyping them restores the formatting. Multiline and incomplete fences -remain visible literal source, with Markdown syntax, smart clips, attachments, -and action links inside them kept non-interactive. +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. ## Smart clips diff --git a/specs/0204-playbook-code-editing.md b/specs/0204-playbook-code-editing.md index fdfa6a7b..de9d7d7f 100644 --- a/specs/0204-playbook-code-editing.md +++ b/specs/0204-playbook-code-editing.md @@ -3,36 +3,46 @@ Status: accepted Date: 2026-08-18 Area: ux -Scope: Source-preserving single- and triple-backtick code editing in Playbook surfaces. +Scope: Source-preserving inline and multiline backtick-code editing in Playbook surfaces. ## Decision -A completed single-backtick span or exact triple-backtick span on one source -line renders in the Playbook editor as highlighted code text without visible -delimiters. The stored document remains the exact source Markdown, including -both delimiter runs. +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. -Each rendered span is an editing boundary. Backspace immediately after it -removes only the complete closing delimiter: one backtick for inline code or -three backticks for triple-backtick code. That dissolves the formatting and +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; +indentation, an opening info string, and trailing spaces remain visible on +those rows. Fence body lines retain their source text and line wrapping while +receiving code highlighting. Cursor, selection, presence, and collaboration +offsets continue to address Unicode character positions in the unmodified +Markdown source. Source positions within a hidden delimiter run map to the +single visual boundary where that run was hidden. + +Each rendered span or block is an editing boundary. Backspace immediately +after its closing delimiter removes the complete closing run: one backtick for +inline code, three backticks for an exact one-line triple span, or the full +matching run on a multiline closing row. That dissolves the formatting and reveals the opening delimiter plus body as ordinary editable source. Retyping -the closing delimiter restores the formatted presentation. +the closing delimiter restores the formatted presentation without rewriting +any other source. -Unmatched delimiters remain literal source. Multiline triple-backtick fences -also remain literal and line-preserving in both editors; content within them is -inert Markdown source, so smart clips, attachments, action links, headings, and -list-aware editing do not activate there. This conservative presentation keeps -the editor's source-line and collaboration-offset invariants intact rather than -collapsing delimiter lines differently across clients. +Unmatched delimiters and incomplete multiline fences remain literal source. +Content inside both complete and incomplete fences is inert Markdown source, +so smart clips, attachments, action links, headings, inline-code spans, and +list-aware editing do not activate there. ## Reason Code should be easy to scan without turning the Playbook into a WYSIWYG -document or losing its Markdown representation. Revealing source with one -Backspace provides an obvious, reversible path into editing while preserving -normal source-level synchronization. Restricting hidden triple delimiters to a -single source line avoids ambiguous cursor rows and remote-cursor placement for -multiline blocks. +document or losing its Markdown representation. Users expect opening and +closing triple backticks on separate lines to create a formatted code block. +Keeping both delimiter rows resolves that expectation without collapsing the +row or source-offset model. Revealing source with one Backspace provides an +obvious, reversible path into editing while preserving normal source-level +synchronization. ## Consequences @@ -44,10 +54,15 @@ multiline blocks. remain Unicode character offsets. - 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 and suppress interactive - Markdown extensions in both clients. +- Multiline fenced regions preserve every source line, hide delimiter glyphs + only when complete, and suppress interactive Markdown extensions 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. ## Non-Goals - Full rich-text editing or hiding other Markdown punctuation. -- Collapsing multiline opening and closing fence lines into a WYSIWYG block. +- Collapsing multiline opening and closing fence rows. +- Language-specific syntax highlighting or interpreting the info string.