diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index 2121578f..c436581a 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -3465,21 +3465,17 @@ pub(crate) fn playbook_blocks(markdown: &str) -> Vec { .collect() } -/// Session ids referenced by `@{session:…}` smart clips anywhere in `markdown`, +/// Session ids referenced by active `@{session:…}` smart clips in `markdown`, /// in first-seen order and deduplicated. Used to keep the referenced worker /// sessions' PTY history warm so the playbook hover preview (spec 0060) can paint /// a live terminal tail the instant the pointer lands. `@{harness:…}` and other /// clip kinds are ignored — only sessions have a terminal to preview. pub(crate) fn playbook_referenced_session_ids(markdown: &str) -> Vec { let mut ids: Vec = Vec::new(); - let mut rest = markdown; - // `@`, `{`, `}` are ASCII so byte-`find` lands on char boundaries. - while let Some(open) = rest.find("@{") { - let after = &rest[open + 2..]; - let Some(close) = after.find('}') else { - break; - }; - let body = &after[..close]; + for range in playbook_smart_clip_ranges(markdown) { + let body_start = byte_pos(markdown, range.start + 2); + let body_end = byte_pos(markdown, range.end.saturating_sub(1)); + let body = &markdown[body_start..body_end]; // Body looks like `session:abc` or `session:abc clip_id=3`; the kind/id // pair is the first whitespace-delimited token (mirrors the clip target // parse used for rendering). @@ -3489,7 +3485,6 @@ pub(crate) fn playbook_referenced_session_ids(markdown: &str) -> Vec { ids.push(id.to_string()); } } - rest = &after[close + 1..]; } ids } @@ -16432,6 +16427,9 @@ enum PlaybookNewline { } fn playbook_newline_action(buffer: &str, cursor: usize) -> PlaybookNewline { + if crate::playbook_markdown::playbook_offset_is_fenced(buffer, cursor) { + return PlaybookNewline::Plain; + } let mut line_start = 0usize; for (idx, ch) in buffer.chars().enumerate() { if idx >= cursor { @@ -16480,6 +16478,9 @@ fn playbook_newline_action(buffer: &str, cursor: usize) -> PlaybookNewline { } fn playbook_list_marker_cursor(buffer: &str, cursor: usize) -> Option { + if crate::playbook_markdown::playbook_offset_is_fenced(buffer, cursor) { + return None; + } let mut line_start = 0usize; for (idx, ch) in buffer.chars().enumerate() { if idx >= cursor { @@ -16504,6 +16505,9 @@ fn playbook_normalize_playbook_cursor(buffer: &str, cursor: usize) -> usize { } fn playbook_smart_clip_query(popup: &PlaybookPopup, trigger_start: usize) -> Option { + if crate::playbook_markdown::playbook_offset_is_fenced(&popup.buffer, trigger_start) { + return None; + } if popup.cursor <= trigger_start { return None; } @@ -16611,21 +16615,12 @@ fn playbook_search_add_clip_label_matches( query: &str, matches: &mut Vec<(usize, usize)>, ) { - let mut char_offset = 0usize; - let mut byte_offset = 0usize; - while byte_offset < buffer.len() { - let rest = &buffer[byte_offset..]; - let Some(at_pos) = rest.find("@{") else { break }; - let before_bytes = &rest[..at_pos]; - let before_chars = before_bytes.chars().count(); - let clip_char_start = char_offset + before_chars; - let after_marker = &rest[at_pos + 2..]; - let Some(end_pos) = after_marker.find('}') else { - break; - }; - let raw_clip = &after_marker[..end_pos]; - let raw_clip_chars = raw_clip.chars().count(); - let clip_char_end = clip_char_start + 2 + raw_clip_chars + 1; + for range in playbook_smart_clip_ranges(buffer) { + let clip_char_start = range.start; + let clip_char_end = range.end; + let body_start = byte_pos(buffer, range.start + 2); + let body_end = byte_pos(buffer, range.end.saturating_sub(1)); + let raw_clip = &buffer[body_start..body_end]; let already_covered = matches .iter() .any(|&(ms, me)| ms < clip_char_end && me > clip_char_start); @@ -16635,9 +16630,6 @@ fn playbook_search_add_clip_label_matches( matches.push((clip_char_start, clip_char_end)); } } - let full_clip_bytes = 2 + end_pos + 1; - byte_offset += at_pos + full_clip_bytes; - char_offset = clip_char_end; } } @@ -16856,27 +16848,53 @@ fn playbook_smart_clip_range_before_or_containing( } fn playbook_smart_clip_ranges(buffer: &str) -> Vec { - let chars: Vec = buffer.chars().collect(); let mut ranges = Vec::new(); - let mut idx = 0usize; - while idx + 1 < chars.len() { - if chars[idx] != '@' || chars[idx + 1] != '{' { - idx += 1; - continue; - } - let mut end = idx + 2; - while end < chars.len() && chars[end] != '}' { - end += 1; - } - if end < chars.len() { - ranges.push(PlaybookSmartClipRange { - start: idx, - end: end + 1, - }); - idx = end + 1; - } else { - idx += 2; + let mut classifier = crate::playbook_markdown::PlaybookLineClassifier::default(); + let mut line_start = 0usize; + for raw in buffer.split('\n') { + let kind = classifier.classify(raw); + if kind.is_markdown() { + let chars: Vec = raw.chars().collect(); + let inline_fences = crate::playbook_markdown::playbook_inline_fences(raw) + .into_iter() + .map(|fence| { + ( + raw[..fence.source.start].chars().count(), + raw[..fence.source.end].chars().count(), + ) + }) + .collect::>(); + let unmatched_inline_start = + crate::playbook_markdown::playbook_unmatched_inline_fence_start(raw) + .map(|start| raw[..start].chars().count()); + let mut idx = 0usize; + while idx + 1 < chars.len() { + if chars[idx] != '@' || chars[idx + 1] != '{' { + idx += 1; + continue; + } + let mut end = idx + 2; + while end < chars.len() && chars[end] != '}' { + end += 1; + } + if end < chars.len() { + if !inline_fences + .iter() + .any(|&(start, fence_end)| idx < fence_end && end + 1 > start) + && unmatched_inline_start.is_none_or(|start| idx < start) + { + ranges.push(PlaybookSmartClipRange { + start: line_start + idx, + end: line_start + end + 1, + }); + } + idx = end + 1; + } else { + idx += 2; + } + } } + line_start += raw.chars().count() + 1; } ranges } @@ -32194,6 +32212,10 @@ mod tests { "clip-fences", ":::clip session:abc123\nbody line inside\n:::\nafter fence line\nZEND", ), + ( + "backtick-fenced-code", + "before\n```rust\n# literal heading\n- literal bullet with @{session:abc123}\n[Run](agentd:action/example)\n```\nafter fence\nZEND", + ), ( "timeline", ":::timeline\n- [x] step one done\n- [ ] step two\n:::\nafter timeline\nZEND", @@ -32662,6 +32684,152 @@ mod tests { assert!(playbook_referenced_session_ids("no clips here").is_empty()); } + #[test] + fn playbook_backtick_fence_keeps_smart_clips_literal() { + let md = "@{session:outside}\n```md\n@{session:literal}\n```\n```@{session:inline}```\n@{harness:codex}"; + assert_eq!( + playbook_referenced_session_ids(md), + vec!["outside".to_string()] + ); + let ranges = playbook_smart_clip_ranges(md); + assert_eq!(ranges.len(), 2, "the fenced clip must not be active"); + let clips = ranges + .into_iter() + .map(|range| { + let start = byte_pos(md, range.start); + let end = byte_pos(md, range.end); + &md[start..end] + }) + .collect::>(); + assert_eq!(clips, ["@{session:outside}", "@{harness:codex}"]); + + let code_list = "```\n- literal item\n```"; + let cursor = code_list.find("\n```").unwrap(); + assert_eq!( + playbook_newline_action(code_list, cursor), + PlaybookNewline::Plain, + "Enter must not continue a Markdown list inside raw code" + ); + assert_eq!( + playbook_list_marker_cursor(code_list, 5), + None, + "the caret must be able to enter a literal list marker" + ); + } + + #[tokio::test] + async fn playbook_backtick_fence_formats_inline_and_keeps_multiline_literal() { + 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```"; + app.playbook_popup = Some(playbook_popup_for_test("s1", md, 0)); + + let lines = crate::ui::render_playbook_markdown_lines_for_test(&app, md); + let painted = lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>(); + assert_eq!(painted, md.lines().collect::>()); + assert!(lines + .iter() + .flat_map(|line| &line.spans) + .all(|span| span.style.bg.is_none())); + + let area = Rect::new(0, 0, 80, 20); + assert!(crate::ui::playbook_session_clip_hits(Some(&app), md, 0, area).is_empty()); + assert!(crate::ui::playbook_attachment_chip_hits(Some(&app), md, 0, area).is_empty()); + assert!(crate::ui::playbook_attachment_instances(md).is_empty()); + assert!(crate::ui::playbook_action_link_hits(Some(&app), md, "s1", 0, area).is_empty()); + + let literal_at = md.find("@{").unwrap(); + app.playbook_popup.as_mut().unwrap().cursor = literal_at; + app.insert_playbook_text("@"); + assert!( + !app.playbook_smart_clip_active(), + "typing @ inside fenced code must remain ordinary source editing" + ); + + let code_list = "```\n- literal item\n```"; + app.playbook_popup = Some(playbook_popup_for_test("s1", code_list, 18)); + app.handle_playbook_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)) + .await; + assert_eq!( + app.playbook_popup.as_ref().unwrap().buffer, + code_list, + "Tab must not apply Markdown list indentation inside raw code" + ); + + let boundary_source = "before ```界🙂 @{session:literal}```"; + let inline = format!("{boundary_source} after"); + app.playbook_popup = Some(playbook_popup_for_test( + "s1", + &inline, + boundary_source.chars().count(), + )); + let lines = crate::ui::render_playbook_markdown_lines_for_test(&app, &inline); + let painted = lines[0] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + assert_eq!(painted, "before 界🙂 @{session:literal} after"); + assert_eq!( + lines[0] + .spans + .iter() + .find(|span| span.content.contains("界🙂")) + .unwrap() + .style + .bg, + Some(app.theme.inactive_highlight_bg) + ); + assert!(crate::ui::playbook_session_clip_hits(Some(&app), &inline, 0, area).is_empty()); + let (_, cursor_col) = crate::ui::playbook_cursor_visual_pos( + Some(&app), + &inline, + boundary_source.chars().count(), + 80, + ); + assert_eq!( + cursor_col, + unicode_width::UnicodeWidthStr::width("before 界🙂 @{session:literal}") + ); + + app.delete_playbook_back(); + assert_eq!( + app.playbook_popup.as_ref().unwrap().buffer, + "before ```界🙂 @{session:literal} after" + ); + 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::(), + "before ```界🙂 @{session:literal} after" + ); + app.insert_playbook_text("```"); + assert_eq!(app.playbook_popup.as_ref().unwrap().buffer, inline); + let restored = crate::ui::render_playbook_markdown_lines_for_test(&app, &inline); + assert_eq!( + restored[0] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(), + "before 界🙂 @{session:literal} after" + ); + server.abort(); + } + #[tokio::test] async fn playbook_referenced_sessions_need_hydration_for_hover_preview() { // A playbook shown in a main-window leaf references a worker session that diff --git a/crates/cli/src/app/editor.rs b/crates/cli/src/app/editor.rs index c9856d01..7c51ce87 100644 --- a/crates/cli/src/app/editor.rs +++ b/crates/cli/src/app/editor.rs @@ -1691,6 +1691,11 @@ impl App { return; }; let lines: Vec = popup.buffer.split('\n').map(str::to_string).collect(); + let mut classifier = crate::playbook_markdown::PlaybookLineClassifier::default(); + let line_kinds = lines + .iter() + .map(|line| classifier.classify(line)) + .collect::>(); // The inclusive band of lines to touch: the selection's lines, or just // the cursor's line. A selection that ends exactly at a line start does @@ -1709,6 +1714,9 @@ impl App { let mut deltas = vec![(0usize, 0usize); lines.len()]; let mut changed = false; for i in start_line..=end_line.min(lines.len().saturating_sub(1)) { + if !line_kinds[i].is_markdown() { + continue; + } let line = &lines[i]; let stripped = line.trim_start(); let is_list = stripped.starts_with("- ") || stripped.starts_with("* "); @@ -2751,7 +2759,9 @@ impl App { let Some(popup) = self.playbook_popup.as_mut() else { return; }; - let trigger_start = if text == "@" { + let trigger_start = if text == "@" + && !crate::playbook_markdown::playbook_offset_is_fenced(&popup.buffer, popup.cursor) + { Some(popup.cursor) } else { None @@ -3013,6 +3023,13 @@ impl App { return; } let (char_start, char_end) = if let Some(range) = + crate::playbook_markdown::playbook_closing_inline_fence_before_cursor( + &popup.buffer, + popup.cursor, + ) + { + (range.start, range.end) + } else if let Some(range) = playbook_smart_clip_range_before_or_containing(&popup.buffer, popup.cursor) { (range.start, range.end) diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 64482a35..3a67c092 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -16,6 +16,7 @@ mod matrix_rain; mod midi; mod mouse_forward; mod plugin_cmd; +mod playbook_markdown; mod project_dashboard; mod pty_render; mod text_util; diff --git a/crates/cli/src/playbook_markdown.rs b/crates/cli/src/playbook_markdown.rs new file mode 100644 index 00000000..e07c7fa7 --- /dev/null +++ b/crates/cli/src/playbook_markdown.rs @@ -0,0 +1,286 @@ +//! 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. + +use std::ops::Range; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PlaybookLineKind { + Markdown, + CodeFence, + Code, +} + +impl PlaybookLineKind { + pub(crate) fn is_markdown(self) -> bool { + matches!(self, Self::Markdown) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PlaybookInlineFence { + /// Byte range of the full triple-backtick source token. + pub(crate) source: Range, + /// Byte range of the visible content between the delimiters. + pub(crate) content: Range, +} + +/// Completed, non-empty, exact triple-backtick spans on one source line. +/// Single and double backticks may occur in the body; runs of four or more do +/// not masquerade as triple delimiters. +pub(crate) fn playbook_inline_fences(line: &str) -> Vec { + let bytes = line.as_bytes(); + let mut out = Vec::new(); + let mut from = 0usize; + while from + 3 <= bytes.len() { + let Some(rel) = line[from..].find("```") else { + break; + }; + let start = from + rel; + let exact_open = (start == 0 || bytes[start - 1] != b'`') + && bytes.get(start + 3).is_none_or(|b| *b != b'`'); + if !exact_open { + from = start + 1; + continue; + } + let content_start = start + 3; + let mut close_from = content_start; + let mut found = None; + while close_from + 3 <= bytes.len() { + let Some(close_rel) = line[close_from..].find("```") else { + break; + }; + let close = close_from + close_rel; + let exact_close = (close == 0 || bytes[close - 1] != b'`') + && bytes.get(close + 3).is_none_or(|b| *b != b'`'); + if exact_close && close > content_start { + found = Some(close); + break; + } + close_from = close + 1; + } + let Some(close) = found else { + break; + }; + out.push(PlaybookInlineFence { + source: start..close + 3, + content: content_start..close, + }); + from = close + 3; + } + out +} + +/// Byte offset of an exact triple-backtick opener with no matching closer on +/// the same line. The tail remains literal and inert until completed. +pub(crate) fn playbook_unmatched_inline_fence_start(line: &str) -> Option { + let bytes = line.as_bytes(); + let completed = playbook_inline_fences(line); + let mut from = 0usize; + while from + 3 <= bytes.len() { + let rel = line[from..].find("```")?; + let start = from + rel; + let exact = (start == 0 || bytes[start - 1] != b'`') + && bytes.get(start + 3).is_none_or(|b| *b != b'`'); + if exact + && !completed + .iter() + .any(|fence| fence.source.start <= start && start < fence.source.end) + { + return Some(start); + } + from = start + 3; + } + None +} + +pub(crate) fn playbook_closing_inline_fence_before_cursor( + markdown: &str, + cursor: usize, +) -> Option> { + let cursor = cursor.min(markdown.chars().count()); + let before = markdown.chars().take(cursor).collect::(); + let line_start = before.rfind('\n').map_or(0, |idx| idx + 1); + let line = &before[line_start..]; + playbook_inline_fences(line) + .into_iter() + .find(|fence| fence.source.end == line.len()) + .map(|fence| { + let prefix_chars = before[..line_start].chars().count(); + let close_start = prefix_chars + line[..fence.source.end - 3].chars().count(); + close_start..close_start + 3 + }) +} + +#[derive(Debug, Default)] +pub(crate) struct PlaybookLineClassifier { + open_backticks: Option, +} + +impl PlaybookLineClassifier { + pub(crate) fn classify(&mut self, raw: &str) -> PlaybookLineKind { + if let Some(open_len) = self.open_backticks { + if closing_backtick_fence(raw, open_len).is_some() { + self.open_backticks = None; + PlaybookLineKind::CodeFence + } else { + PlaybookLineKind::Code + } + } else if opening_backtick_fence(raw).is_some() { + self.open_backticks = backtick_fence(raw).map(|(_, len, _)| len); + PlaybookLineKind::CodeFence + } else { + PlaybookLineKind::Markdown + } + } +} + +/// A CommonMark-style backtick fence may be indented by at most three spaces +/// and contains at least three backticks. Returns indentation, run length, and +/// the remainder after the run. +fn backtick_fence(raw: &str) -> Option<(usize, usize, &str)> { + let spaces = raw.bytes().take_while(|b| *b == b' ').count(); + if spaces > 3 { + return None; + } + let rest = &raw[spaces..]; + let ticks = rest.bytes().take_while(|b| *b == b'`').count(); + (ticks >= 3).then(|| (spaces, ticks, &rest[ticks..])) +} + +fn opening_backtick_fence(raw: &str) -> Option<(usize, usize)> { + let (spaces, ticks, tail) = backtick_fence(raw)?; + // CommonMark forbids backticks in a backtick fence's info string. + (!tail.contains('`')).then_some((spaces, ticks)) +} + +fn closing_backtick_fence(raw: &str, opening_len: usize) -> Option<(usize, usize)> { + let (spaces, ticks, tail) = backtick_fence(raw)?; + (ticks >= opening_len && tail.trim().is_empty()).then_some((spaces, ticks)) +} + +/// 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. +pub(crate) fn playbook_offset_is_fenced(markdown: &str, offset: usize) -> bool { + let offset = offset.min(markdown.chars().count()); + let mut classifier = PlaybookLineClassifier::default(); + let mut line_start = 0usize; + for raw in markdown.split('\n') { + let kind = classifier.classify(raw); + let line_end = line_start + raw.chars().count(); + if offset <= line_end { + let local = offset.saturating_sub(line_start); + let in_inline_fence = playbook_inline_fences(raw).into_iter().any(|fence| { + let start = raw[..fence.source.start].chars().count(); + let end = raw[..fence.source.end].chars().count(); + local >= start && local <= end + }); + let in_unmatched_inline_fence = playbook_unmatched_inline_fence_start(raw) + .map(|start| raw[..start].chars().count()) + .is_some_and(|start| local >= start); + return in_inline_fence || in_unmatched_inline_fence || !kind.is_markdown(); + } + line_start = line_end + 1; + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_multiline_fences_as_literal_inert_source() { + let mut classifier = PlaybookLineClassifier::default(); + let kinds = ["before", "```rust", "# literal", "```", "after"] + .into_iter() + .map(|line| classifier.classify(line)) + .collect::>(); + assert_eq!( + kinds, + [ + PlaybookLineKind::Markdown, + PlaybookLineKind::CodeFence, + PlaybookLineKind::Code, + PlaybookLineKind::CodeFence, + PlaybookLineKind::Markdown, + ] + ); + } + + #[test] + fn closing_fence_must_match_opening_run() { + let mut classifier = PlaybookLineClassifier::default(); + assert_eq!( + ["````lang", "```", "body", "````"] + .into_iter() + .map(|line| classifier.classify(line)) + .collect::>(), + [ + PlaybookLineKind::CodeFence, + PlaybookLineKind::Code, + PlaybookLineKind::Code, + PlaybookLineKind::CodeFence, + ] + ); + } + + #[test] + fn four_space_indent_and_backtick_info_stay_markdown() { + let mut classifier = PlaybookLineClassifier::default(); + assert_eq!(classifier.classify(" ```"), PlaybookLineKind::Markdown); + assert_eq!(classifier.classify("```a`b"), PlaybookLineKind::Markdown); + } + + #[test] + fn offset_detection_includes_completed_and_unclosed_body() { + let markdown = "before\n```\n@{session:example}\n```\nafter\n```\nstill code"; + assert!(!playbook_offset_is_fenced(markdown, 2)); + assert!(playbook_offset_is_fenced( + markdown, + markdown[..markdown.find("@{").unwrap()].chars().count() + )); + assert!(playbook_offset_is_fenced( + markdown, + markdown.chars().count() + )); + } + + #[test] + fn inline_triple_fence_is_completed_source_preserving_and_cjk_safe() { + let line = "before ```界 ` 🙂``` after"; + let fences = playbook_inline_fences(line); + assert_eq!(fences.len(), 1); + assert_eq!(&line[fences[0].content.clone()], "界 ` 🙂"); + + let markdown = format!("{line}\nnext"); + let cursor = "before ```界 ` 🙂```".chars().count(); + let closing = playbook_closing_inline_fence_before_cursor(&markdown, cursor).unwrap(); + assert_eq!( + markdown + .chars() + .skip(closing.start) + .take(closing.len()) + .collect::(), + "```" + ); + assert!(playbook_offset_is_fenced( + &markdown, + "before ```界".chars().count() + )); + } + + #[test] + fn unmatched_and_non_exact_inline_triples_stay_literal() { + assert!(playbook_inline_fences("```unfinished").is_empty()); + assert!(playbook_inline_fences("````wide````").is_empty()); + assert!(playbook_inline_fences("``````").is_empty()); + assert_eq!( + playbook_unmatched_inline_fence_start("before ```raw"), + Some(7) + ); + } +} diff --git a/crates/cli/src/ui.rs b/crates/cli/src/ui.rs index d7ef0adb..be70df8d 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -12,6 +12,10 @@ use crate::app::{ PLAYBOOK_REVEAL_MS, }; use crate::keymap::{KeyAction, Profile}; +use crate::playbook_markdown::{ + playbook_inline_fences, playbook_unmatched_inline_fence_start, PlaybookLineClassifier, + PlaybookLineKind, +}; use crate::text_util::wrap_to_width; use crate::theme::Theme; use construct_protocol::{ @@ -16772,12 +16776,15 @@ 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() { 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); + let (rendered, clips) = + playbook_rendered_line_with_clips(Some(app), raw, width, li, kind); let starts = playbook_wrap_row_starts(&rendered, width); for clip in &clips { let LineClipKind::Attachment { @@ -18913,12 +18920,15 @@ 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() { 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); + let (rendered, _clips) = + playbook_rendered_line_with_clips(app, raw, width, li, kind); let rows = playbook_wrap_row_starts(&rendered, width).len().max(1); if i >= start_line { first.get_or_insert(visual_row_base); @@ -18953,9 +18963,12 @@ 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); let li = playbook_line_instance(&mut dups, raw); - let (rendered, _clips) = playbook_rendered_line_with_clips(app, raw, width, li); + let (rendered, _clips) = + playbook_rendered_line_with_clips(app, raw, width, li, kind); let rows = playbook_wrap_row_starts(&rendered, width).len(); let next_base = visual_row_base.saturating_add(rows); if target_abs_row >= visual_row_base && target_abs_row < next_base { @@ -20450,17 +20463,24 @@ 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(); - for raw in markdown.lines().take(line) { + let mut classifier = PlaybookLineClassifier::default(); + let mut current = ("", 0, PlaybookLineKind::Markdown); + for (idx, raw) in markdown.split('\n').enumerate() { + let kind = classifier.classify(raw); let li = playbook_line_instance(&mut dups, raw); - let text = playbook_rendered_line_text(app, raw, width, li); + if idx == line { + current = (raw, li, kind); + break; + } + let text = playbook_rendered_line_text_in_context(app, raw, width, li, kind); visual_row = visual_row.saturating_add(playbook_wrap_row_starts(&text, width).len()); } - let cur_raw = markdown.lines().nth(line).unwrap_or(""); - let cur_li = playbook_line_instance(&mut dups, cur_raw); - let visual_col = playbook_visual_col_for_line(app, cur_raw, col, width, cur_li); + let (cur_raw, cur_li, cur_kind) = current; + let visual_col = + playbook_visual_col_for_line_in_context(app, cur_raw, col, width, cur_li, cur_kind); let starts = playbook_wrap_row_starts( - &playbook_rendered_line_text(app, cur_raw, width, cur_li), + &playbook_rendered_line_text_in_context(app, cur_raw, width, cur_li, cur_kind), width, ); let (row_in_line, col_in_row) = playbook_wrap_locate(&starts, visual_col, width); @@ -20539,21 +20559,23 @@ pub(crate) fn playbook_visual_to_cursor( // wrapped-row count until the line that owns `target_row` is found. let mut rows_before = 0usize; let mut line_start = 0usize; // char offset of the current line's first char - let mut owner: Option<(usize, Vec, &str, usize, u64)> = None; + 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); let li = playbook_line_instance(&mut dups, raw); - let rendered = playbook_rendered_line_text(app, raw, width, li); + let rendered = playbook_rendered_line_text_in_context(app, raw, width, li, kind); let starts = playbook_wrap_row_starts(&rendered, width); let row_count = starts.len(); if target_row < rows_before + row_count { - owner = Some((line_start, starts, raw, rows_before, li)); + owner = Some((line_start, starts, raw, rows_before, li, kind)); break; } rows_before += row_count; line_start += raw.chars().count() + 1; // + the '\n' } - let Some((line_start, starts, raw, rows_before, owner_li)) = owner else { + let Some((line_start, starts, raw, rows_before, owner_li, owner_kind)) = owner else { // Below all content → end of buffer. return markdown.chars().count(); }; @@ -20564,7 +20586,14 @@ pub(crate) fn playbook_visual_to_cursor( let line_len = raw.chars().count(); let mut best_col = 0usize; for raw_col in 0..=line_len { - let visual_col = playbook_visual_col_for_line(app, raw, raw_col, width, owner_li); + let visual_col = playbook_visual_col_for_line_in_context( + app, + raw, + raw_col, + width, + owner_li, + owner_kind, + ); let (r, c) = playbook_wrap_locate(&starts, visual_col, width); if r < row_in_line || (r == row_in_line && c <= target_col) { best_col = raw_col; @@ -20850,6 +20879,25 @@ fn playbook_rendered_line_text( width: usize, line_instance: u64, ) -> String { + playbook_rendered_line_text_in_context( + app, + raw, + width, + line_instance, + PlaybookLineKind::Markdown, + ) +} + +fn playbook_rendered_line_text_in_context( + app: Option<&App>, + raw: &str, + width: usize, + line_instance: u64, + kind: PlaybookLineKind, +) -> String { + if !kind.is_markdown() { + return playbook_painted_indent_line(raw); + } let trimmed = raw.trim(); let leading = raw.chars().take_while(|ch| ch.is_whitespace()).count(); if trimmed.is_empty() { @@ -21092,10 +21140,13 @@ 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); let li = playbook_line_instance(&mut dups, raw); let trimmed = raw.trim(); - if playbook_heading_content(raw).is_some() + if !kind.is_markdown() + || playbook_heading_content(raw).is_some() || trimmed.starts_with(":::clip") || trimmed == ":::" { @@ -21126,9 +21177,12 @@ 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); let li = playbook_line_instance(&mut dups, raw); - let (rendered, clips) = playbook_rendered_line_with_clips(app, raw, width, li); + let (rendered, clips) = + playbook_rendered_line_with_clips(app, raw, width, li, kind); let starts = playbook_wrap_row_starts(&rendered, width); let rows = starts.len().max(1); if target_row < row_base + rows { @@ -21206,10 +21260,11 @@ enum PlaybookInlineToken<'a> { Code { content: &'a str, src_len: usize, + delimiter_len: usize, }, } -fn find_playbook_inline_code(text: &str) -> Option<(usize, PlaybookInlineToken<'_>)> { +fn find_playbook_single_inline_code(text: &str) -> Option<(usize, PlaybookInlineToken<'_>)> { let bytes = text.as_bytes(); let mut from = 0usize; while let Some(rel) = text[from..].find('`') { @@ -21232,6 +21287,7 @@ fn find_playbook_inline_code(text: &str) -> Option<(usize, PlaybookInlineToken<' PlaybookInlineToken::Code { content: &text[content_start..close], src_len: close + 1 - start, + delimiter_len: 1, }, )); } @@ -21242,6 +21298,25 @@ fn find_playbook_inline_code(text: &str) -> Option<(usize, PlaybookInlineToken<' None } +fn find_playbook_inline_code(text: &str) -> Option<(usize, PlaybookInlineToken<'_>)> { + let single = find_playbook_single_inline_code(text); + let triple = playbook_inline_fences(text).into_iter().next().map(|fence| { + let start = fence.source.start; + ( + start, + PlaybookInlineToken::Code { + content: &text[fence.content], + src_len: fence.source.end - start, + delimiter_len: 3, + }, + ) + }); + match (single, triple) { + (Some(single), Some(triple)) => Some(if single.0 <= triple.0 { single } else { triple }), + (single, triple) => single.or(triple), + } +} + fn next_playbook_inline_token(text: &str) -> Option<(usize, PlaybookInlineToken<'_>)> { let clip = text.find("@{").and_then(|start| { text[start + 2..].find('}').map(|end| { @@ -21263,6 +21338,11 @@ fn next_playbook_inline_token(text: &str) -> Option<(usize, PlaybookInlineToken< next = Some(candidate); } } + if playbook_unmatched_inline_fence_start(text) + .is_some_and(|start| next.as_ref().is_none_or(|candidate| start < candidate.0)) + { + return None; + } next } @@ -21306,7 +21386,9 @@ fn playbook_inline_rendered_text( link_idx += 1; link.end - link.start } - PlaybookInlineToken::Code { content, src_len } => { + PlaybookInlineToken::Code { + content, src_len, .. + } => { out.push_str(content); src_len } @@ -21433,7 +21515,9 @@ fn playbook_inline_with_clips( link_idx += 1; link.end - link.start } - PlaybookInlineToken::Code { content, src_len } => { + PlaybookInlineToken::Code { + content, src_len, .. + } => { out.push_str(content); visual += UnicodeWidthStr::width(content); src_len @@ -21454,7 +21538,14 @@ fn playbook_rendered_line_with_clips( raw: &str, width: usize, line_instance: u64, + kind: PlaybookLineKind, ) -> (String, Vec) { + if !kind.is_markdown() { + return ( + playbook_rendered_line_text_in_context(app, raw, width, line_instance, kind), + Vec::new(), + ); + } let trimmed = raw.trim(); let leading = raw.chars().take_while(|ch| ch.is_whitespace()).count(); let (text, clips) = if trimmed.is_empty() { @@ -21467,7 +21558,7 @@ fn playbook_rendered_line_with_clips( // fence lines paint fixed chip text — neither carries clickable clip // spans, so both report the rendered text with no clips. ( - playbook_rendered_line_text(app, raw, width, line_instance), + playbook_rendered_line_text_in_context(app, raw, width, line_instance, kind), Vec::new(), ) } else if let Some((_, rest)) = playbook_list_item_content(raw) { @@ -21510,12 +21601,15 @@ 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() { 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); + let (rendered, clips) = + playbook_rendered_line_with_clips(app, raw, width, li, kind); let starts = playbook_wrap_row_starts(&rendered, width); for clip in &clips { let LineClipKind::Smart(raw_clip) = &clip.kind else { @@ -21566,12 +21660,15 @@ 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() { 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); + let (rendered, clips) = + playbook_rendered_line_with_clips(app, raw, width, li, kind); let starts = playbook_wrap_row_starts(&rendered, width); for clip in &clips { let LineClipKind::Attachment { @@ -21684,13 +21781,19 @@ 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() { 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(app, raw, width, li); + let rendered = playbook_rendered_line_text_in_context(app, raw, width, li, kind); let starts = playbook_wrap_row_starts(&rendered, width); + if !kind.is_markdown() { + visual_row_base = visual_row_base.saturating_add(starts.len()); + continue; + } for link in scan_agentd_action_links(&rendered) { let visual_start = UnicodeWidthStr::width(&rendered[..link.start]); let visual_width = UnicodeWidthStr::width(&rendered[link.start..link.end]); @@ -21748,6 +21851,27 @@ fn playbook_visual_col_for_line( width: usize, line_instance: u64, ) -> usize { + playbook_visual_col_for_line_in_context( + app, + raw, + raw_col, + width, + line_instance, + PlaybookLineKind::Markdown, + ) +} + +fn playbook_visual_col_for_line_in_context( + app: Option<&App>, + raw: &str, + raw_col: usize, + width: usize, + line_instance: u64, + kind: PlaybookLineKind, +) -> usize { + if !kind.is_markdown() { + return playbook_prefix_display_width(&playbook_painted_indent_line(raw), raw_col); + } let leading = raw.chars().take_while(|ch| ch.is_whitespace()).count(); let col = raw_col.saturating_sub(leading); let trimmed = raw.trim(); @@ -21763,7 +21887,13 @@ fn playbook_visual_col_for_line( // 1:1 to the source chars; measure the source column literally and // clamp it into the painted width so the caret stays on the line (and // end-of-line clicks resolve to the line's last offset). - let rendered = playbook_rendered_line_text(app, raw, 0, line_instance); + let rendered = playbook_rendered_line_text_in_context( + app, + raw, + 0, + line_instance, + kind, + ); playbook_prefix_display_width(raw, raw_col).min(UnicodeWidthStr::width(rendered.as_str())) } else if let Some((_, rest)) = playbook_list_item_content(raw) { // Mirror the proportional indent rendered for nested bullets: the bullet @@ -21817,11 +21947,16 @@ fn playbook_inline_visual_width( visual += UnicodeWidthStr::width(before); raw += before_len; - if let PlaybookInlineToken::Code { content, src_len } = &token { + if let PlaybookInlineToken::Code { + content, + src_len, + delimiter_len, + } = &token + { let src_chars = rest[start_b..start_b + src_len].chars().count(); if raw_col <= raw + src_chars { let content_col = raw_col - .saturating_sub(raw + 1) + .saturating_sub(raw + delimiter_len) .min(content.chars().count()); return visual + playbook_prefix_display_width(content, content_col); } @@ -21884,7 +22019,9 @@ 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); 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 @@ -21892,13 +22029,33 @@ fn render_playbook_markdown_lines<'a>( // an interactive span (never collapsing it, so cursor math and // editing stay untouched) and registers click hits separately via // `playbook_action_link_hits`. - let action_ranges: Vec<(usize, usize)> = if action_links_enabled { + let action_ranges: Vec<(usize, usize)> = if action_links_enabled && kind.is_markdown() { playbook_line_action_link_char_ranges(raw, line_start) } else { Vec::new() }; let li = playbook_line_instance(&mut dups, raw); - if trimmed.is_empty() { + 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. + let painted = playbook_painted_indent_line(raw); + let spans = playbook_text_spans( + &app.theme, + &painted, + line_start, + Style::default().fg(app.theme.text), + selection, + search_matches, + search_selected, + &[], + ) + .into_iter() + .map(|span| Span::styled(span.content.into_owned(), span.style)) + .collect::>(); + out.push(Line::from(spans)); + } else if trimmed.is_empty() { out.push(Line::from("")); } else if let Some(level) = playbook_heading_level(trimmed) { // Slice the heading text from the raw line (leading indent stripped, @@ -22192,12 +22349,16 @@ fn render_playbook_inline_spans<'a>( )); link_idx += 1; } - PlaybookInlineToken::Code { content, .. } => { + PlaybookInlineToken::Code { + content, + delimiter_len, + .. + } => { let code_style = playbook_inline_code_style(&app.theme, base_style); spans.extend(playbook_text_spans( &app.theme, content, - chip_char_start + 1, + chip_char_start + delimiter_len, code_style, selection, search_matches, @@ -23847,6 +24008,19 @@ mod tests { playbook_inline_rendered_text(None, "```rust", None, 80), "```rust" ); + let triple = "before ```界🙂 ` raw``` after"; + assert_eq!( + playbook_inline_rendered_text(None, triple, None, 80), + "before 界🙂 ` raw after" + ); + assert_eq!( + playbook_visual_col_for_line(None, triple, triple.chars().count(), 80, 0), + UnicodeWidthStr::width("before 界🙂 ` raw after") + ); + assert_eq!( + playbook_inline_rendered_text(None, "before ```界🙂", None, 80), + "before ```界🙂" + ); } #[test] diff --git a/crates/daemon/assets/index.html b/crates/daemon/assets/index.html index ebf2b967..74ab3088 100644 --- a/crates/daemon/assets/index.html +++ b/crates/daemon/assets/index.html @@ -3213,6 +3213,10 @@ border-radius: 4px; padding: 0 2px; } + .playbook-inline-code.is-fenced { + background: color-mix(in srgb, var(--accent-alt) 22%, var(--bg-elev)); + } + .playbook-line.is-fenced-source { color: var(--fg); } .playbook-selection-menu { position: absolute; z-index: 8; @@ -9290,21 +9294,39 @@

return ranges; } -// Completed single-backtick spans. Backtick runs are left alone for fenced -// code and literal Markdown; an unmatched opening delimiter stays visible as -// source until the user completes it. +function playbookExactBacktickRun(line, at, len) { + if (line.slice(at, at + len) !== "`".repeat(len)) return false; + return line[at - 1] !== "`" && line[at + len] !== "`"; +} + +// Completed single- and exact triple-backtick spans. Unmatched triple runs +// begin a literal, inert tail until the user types the closing delimiter. function playbookLineCodeRanges(line) { const ranges = []; + let unmatchedTriple = -1; + for (let i = 0; i <= line.length - 3; i++) { + if (!playbookExactBacktickRun(line, i, 3)) continue; + let close = -1; + for (let j = i + 3; j <= line.length - 3; j++) { + if (playbookExactBacktickRun(line, j, 3) && j > i + 3) { close = j; break; } + } + if (close === -1) { unmatchedTriple = i; break; } + ranges.push([i, close + 3, 3]); + i = close + 2; + } for (let i = 0; i < line.length; i++) { + if (unmatchedTriple !== -1 && i >= unmatchedTriple) break; if (line[i] !== "`" || line[i - 1] === "`" || line[i + 1] === "`") continue; + if (ranges.some(([s, e]) => i >= s && i < e)) continue; for (let j = i + 1; j < line.length; j++) { if (line[j] !== "`" || line[j - 1] === "`" || line[j + 1] === "`") continue; - if (j > i + 1) ranges.push([i, j + 1]); + if (j > i + 1) ranges.push([i, j + 1, 1]); i = j; break; } } - return ranges; + ranges.sort((a, b) => a[0] - b[0]); + return { ranges, unmatchedTriple }; } // All construct-dialect inline tokens in one source line, in order: typed @@ -9329,8 +9351,10 @@

} function playbookLineInlineTokens(line) { - const tokens = playbookLineCodeRanges(line).map(([s, e]) => ({ start: s, end: e, kind: "code" })); + const code = playbookLineCodeRanges(line); + const tokens = code.ranges.map(([s, e, delimiter]) => ({ start: s, end: e, kind: "code", delimiter })); for (const [start, end] of playbookLineClipRanges(line)) { + if (code.unmatchedTriple !== -1 && start >= code.unmatchedTriple) continue; if (!tokens.some((t) => start < t.end && end > t.start)) tokens.push({ start, end, kind: "clip" }); } PLAYBOOK_ACTION_LINK_RE.lastIndex = 0; @@ -9338,6 +9362,7 @@

while ((m = PLAYBOOK_ACTION_LINK_RE.exec(line))) { const start = m.index; const end = m.index + m[0].length; + if (code.unmatchedTriple !== -1 && start >= code.unmatchedTriple) continue; if (tokens.some((t) => start < t.end && end > t.start)) continue; tokens.push({ start, end, kind: "action" }); } @@ -9345,6 +9370,7 @@

while ((m = PLAYBOOK_MD_LINK_RE.exec(line))) { const start = m.index; const end = m.index + m[0].length; + if (code.unmatchedTriple !== -1 && start >= code.unmatchedTriple) continue; if (tokens.some((t) => start < t.end && end > t.start)) continue; // Images only (spec 0099): plain `[name](path)` file links stay // literal text — no preview affordance, and chipping hid the path. @@ -9504,15 +9530,16 @@

return span; } -// Atomic presentation for completed inline code. The exact source remains in -// data-raw; Backspace at the right edge replaces this with ` + content so the -// opening delimiter and text become directly editable again. -function playbookInlineCodeEl(raw) { +// Atomic presentation for completed single- or triple-backtick code. The exact +// source remains in data-raw; Backspace at the right edge removes the closing +// delimiter so the opening delimiter and text become directly editable again. +function playbookInlineCodeEl(raw, delimiter = 1) { const span = document.createElement("span"); - span.className = "playbook-inline-code"; + span.className = "playbook-inline-code" + (delimiter === 3 ? " is-fenced" : ""); span.setAttribute("contenteditable", "false"); span.dataset.raw = raw; - span.textContent = raw.slice(1, -1); + span.dataset.delimiter = String(delimiter); + span.textContent = raw.slice(delimiter, -delimiter); return span; } @@ -9851,7 +9878,7 @@

if (t.start > pos) playbookAppendLineText(div, text.slice(pos, t.start), pos === 0); const raw = text.slice(t.start, t.end); const chip = t.kind === "code" - ? playbookInlineCodeEl(raw) + ? playbookInlineCodeEl(raw, t.delimiter || 1) : t.kind === "action" ? playbookActionChipEl(raw) : t.kind === "attachment" @@ -9906,10 +9933,30 @@

} } -function playbookLineDiv(text) { +function playbookMultilineFenceKinds(markdown) { + const lines = markdown.split("\n"); + const kinds = new Array(lines.length).fill("markdown"); + let open = 0; + 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; + } else if (m && !m[3].includes("`")) { + kinds[i] = "fence"; + open = m[2].length; + } + } + return kinds; +} + +function playbookLineDiv(text, fencedSource = false) { const div = document.createElement("div"); - div.className = "playbook-line"; - return playbookFillLine(div, text); + div.className = "playbook-line" + (fencedSource ? " is-fenced-source" : ""); + if (!fencedSource) return playbookFillLine(div, text); + if (text === "") div.appendChild(document.createElement("br")); + else div.appendChild(document.createTextNode(text)); + return div; } // Render the whole document into the contenteditable (one .playbook-line per @@ -9917,7 +9964,9 @@

// template, live adopt), never on every keystroke. function playbookRenderDoc(markdown) { while (playbookInputEl.firstChild) playbookInputEl.removeChild(playbookInputEl.firstChild); - for (const line of markdown.split("\n")) playbookInputEl.appendChild(playbookLineDiv(line)); + const lines = markdown.split("\n"); + const kinds = playbookMultilineFenceKinds(markdown); + lines.forEach((line, i) => playbookInputEl.appendChild(playbookLineDiv(line, kinds[i] !== "markdown"))); playbookApplyLineDecorations(); playbookApplyShimmer(); playbookRenderCursors(); @@ -11191,6 +11240,8 @@

const range = sel.getRangeAt(0); const node = range.startContainer; if (node.nodeType !== 3 || !playbookInputEl.contains(node)) return null; + const offsets = playbookSelectionOffsets(); + if (offsets && playbookOffsetIsFencedSource(playbookSerialize(), offsets.head)) return null; const text = node.data.slice(0, range.startOffset); const at = text.lastIndexOf("@"); if (at === -1) return null; @@ -11201,6 +11252,23 @@

return { node, at, query }; } +function playbookOffsetIsFencedSource(markdown, rawOffset) { + const chars = Array.from(markdown || ""); + const before = chars.slice(0, Math.max(0, rawOffset)).join(""); + const lineIndex = (before.match(/\n/g) || []).length; + const line = (markdown || "").split("\n")[lineIndex] || ""; + const local = Array.from(before.slice(before.lastIndexOf("\n") + 1)).length; + const code = playbookLineCodeRanges(line); + const inCompleted = code.ranges.some(([start, end, delimiter]) => + delimiter === 3 && local >= Array.from(line.slice(0, start)).length && local <= Array.from(line.slice(0, end)).length); + const unmatchedStart = code.unmatchedTriple === -1 + ? null + : Array.from(line.slice(0, code.unmatchedTriple)).length; + return inCompleted + || (unmatchedStart != null && local >= unmatchedStart) + || playbookMultilineFenceKinds(markdown)[lineIndex] !== "markdown"; +} + // Relevance score of a non-empty, lowercased `query` against a candidate. // Mirrors the TUI `playbook_clip_match_score`: higher is better, < 0 means no // match. Drives both the top relevance section and submenu dimming. @@ -11585,6 +11653,28 @@

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. +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")); + if (!mismatch) return false; + const offsets = playbookSelectionOffsets(); + playbookRenderDoc(markdown); + if (offsets && offsets.anchor === offsets.head) { + const sel = window.getSelection(); + const range = playbookRangeForOffset(offsets.head); + sel.removeAllRanges(); + sel.addRange(range); + } + return true; +} + function playbookInlineCodeImmediatelyBeforeCaret() { const sel = window.getSelection(); if (!sel || !sel.isCollapsed || sel.rangeCount === 0) return null; @@ -11604,17 +11694,18 @@

const code = playbookInlineCodeImmediatelyBeforeCaret(); if (!code) return false; const raw = code.dataset.raw || ""; + const delimiter = 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, -1))) return true; + if (document.execCommand("insertText", false, raw.slice(0, -delimiter))) 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, -1)); + const text = document.createTextNode(raw.slice(0, -delimiter)); code.replaceWith(text); const range = document.createRange(); range.setStart(text, text.data.length); @@ -11627,7 +11718,7 @@

function playbookOnInput() { playbookNormalizeLines(); - playbookFormatInlineCodeAtCaret(); + if (!playbookSyncFencedSourcePresentation()) playbookFormatInlineCodeAtCaret(); playbookApplyLineDecorations(); playbookRefreshEmptyState(); playbookUpdateDirty(); diff --git a/crates/e2e/tests/playbook_view.rs b/crates/e2e/tests/playbook_view.rs index fa58beb2..b424829a 100644 --- a/crates/e2e/tests/playbook_view.rs +++ b/crates/e2e/tests/playbook_view.rs @@ -189,6 +189,88 @@ 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. ---- + 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.list_templates": () => ({ templates: [] }), + "playbook.edit": () => ({ applied: true }), + "playbook.cursor": () => ({ cursor: null }), + }, async () => { + setSession("s-fenced", "shell"); + await switchCurrentViewMode("playbook"); + const code = playbookInputEl.querySelector(".playbook-inline-code.is-fenced"); + 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 multiline = multilineEls.map((el) => el.textContent); + const multilineHasClip = !!playbookInputEl.querySelector(".is-fenced-source .playbook-clip"); + const sel = window.getSelection(); + const literalRange = document.createRange(); + literalRange.setStart(multilineEls[1].firstChild, 1); literalRange.collapse(true); + sel.removeAllRanges(); sel.addRange(literalRange); + const multilineOpensClipMenu = !!playbookClipContext(); + const range = document.createRange(); + range.setStartAfter(code); range.collapse(true); + sel.removeAllRanges(); sel.addRange(range); + const sourceBoundary = playbookSelectionOffsets().head; + 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, + sourceAfterBackspace, lineAfterBackspace, formattedAfterBackspace, + sourceAfterRetype: playbookSerialize(), + formattedAfterRetype: !!playbookInputEl.querySelector(".playbook-inline-code.is-fenced"), + }; + }) + "###, + ) + .await + .expect("evaluate fenced code editing") + .into_value() + .expect("json"); + assert_eq!( + fenced_code["visibleBefore"], "cargo 界 test", + "{fenced_code:?}" + ); + assert_eq!(fenced_code["rawLength"], 18, "{fenced_code:?}"); + assert_eq!(fenced_code["sourceBoundary"], 22, "{fenced_code:?}"); + assert_eq!( + fenced_code["multiline"], + serde_json::json!(["```", "@{session:literal}", "```", "```unfinished", ""]), + "{fenced_code:?}" + ); + assert_eq!(fenced_code["multilineHasClip"], false, "{fenced_code:?}"); + assert_eq!( + fenced_code["multilineOpensClipMenu"], false, + "{fenced_code:?}" + ); + assert_eq!( + fenced_code["sourceAfterBackspace"], + "run ```cargo 界 test now\n```\n@{session:literal}\n```\n```unfinished\n", + "{fenced_code:?}" + ); + assert_eq!( + fenced_code["lineAfterBackspace"], "run ```cargo 界 test now", + "{fenced_code:?}" + ); + assert_eq!( + fenced_code["formattedAfterBackspace"], false, + "{fenced_code:?}" + ); + assert_eq!( + fenced_code["sourceAfterRetype"], fenced_code["sourceBefore"], + "{fenced_code:?}" + ); + assert_eq!(fenced_code["formattedAfterRetype"], true, "{fenced_code:?}"); + // --- 3. Empty playbook shows templates; clicking one seeds the doc. ------- let templates: serde_json::Value = page .evaluate( diff --git a/docs/playbook.md b/docs/playbook.md index a168d4fd..cbbc6fbf 100644 --- a/docs/playbook.md +++ b/docs/playbook.md @@ -57,6 +57,13 @@ 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. + ## Smart clips Smart clips are typed references stored as plain Markdown: diff --git a/specs/0050-playbook-builtin-template-content.md b/specs/0050-playbook-builtin-template-content.md index 7c3059d8..5badd2b3 100644 --- a/specs/0050-playbook-builtin-template-content.md +++ b/specs/0050-playbook-builtin-template-content.md @@ -17,14 +17,14 @@ The built-in set is Blank (empty), Tasks (a Todo / Progress / Done board), Inves The empty-state placeholder surfaces these templates as one-click starting points, so they are many users' first contact with the playbook. A bare set of headings does not convey that the playbook is an execution surface or that smart clips exist. A small amount of in-document guidance turns each template into onboarding without a separate tutorial. Because playbook execution feeds the document prose to the owning agent, the guidance also orients the agent, while the canonical smart-clip syntax is still injected by the run-context tool rather than relied upon from the template. -The clip constraint exists because playbook rendering scans for clip syntax everywhere, including inside code fences and inline code — there is no "raw" region. A literal example clip with a non-existent target would render as a broken chip in a brand-new playbook. Restricting templates to resolvable clips keeps a freshly applied template clean. +The clip constraint exists because ordinary prose and inline code are scanned for clip syntax. Triple-backtick fenced code is a raw, source-preserving region: clip syntax there stays literal and non-interactive. A literal example clip outside such a fence with a non-existent target would render as a broken chip in a brand-new playbook. Restricting active template clips to resolvable targets keeps a freshly applied template clean. ## Consequences -- Editing a built-in template, or authoring a user template, must keep every embedded clip resolvable. To show non-resolvable syntax (a specific session, a `:::clip` block), describe it in prose instead of embedding it. +- Editing a built-in template, or authoring a user template, must keep every active embedded clip resolvable. Non-resolvable syntax may be shown literally inside triple-backtick fenced code; outside a fence, describe it in prose instead of embedding it. - Template guidance should stay short and clearly read as orientation, so it does not read as a task when the playbook is run. - Renaming or adding a built-in template changes its stable `id`; the empty-state placeholder and any id-based references must be updated together. Template selection copies Markdown into the playbook and is not live-linked, so changing a template does not alter playbookes already created from it. -- Built-in templates should only use Markdown constructs the playbook renderer styles (headings, list items, single-backtick inline code, smart clips, `:::clip` blocks); emphasis and fenced code render as literal characters and should be avoided in template bodies. +- Built-in templates should only use Markdown constructs the playbook renderer styles (headings, list items, inline and fenced code, smart clips, and `:::clip` blocks); emphasis renders as literal characters and should be avoided in template bodies. ## Non-Goals diff --git a/specs/0203-playbook-inline-code-editing.md b/specs/0203-playbook-inline-code-editing.md deleted file mode 100644 index c6691dde..00000000 --- a/specs/0203-playbook-inline-code-editing.md +++ /dev/null @@ -1,40 +0,0 @@ -# 0203-playbook-inline-code-editing - -Status: accepted -Date: 2026-08-18 -Area: ux -Scope: Completed single-backtick inline code in editable Playbook surfaces. - -## Decision - -A completed single-backtick Markdown span renders in the Playbook editor as -highlighted code text without visible delimiters. The stored document remains -the exact source Markdown, including both backticks. - -The rendered span is an editing boundary. Backspace immediately after it -removes only the closing backtick, which dissolves the formatting and reveals -the opening backtick plus the text as ordinary editable source. Retyping the -closing backtick restores the formatted presentation. - -Unmatched opening backticks and multi-backtick runs remain literal source. - -## Reason - -Inline code should be easy to scan without turning the Playbook into a -WYSIWYG document or losing its Markdown representation. Revealing the source -with one Backspace gives the user an obvious, reversible path back into text -editing while preserving normal source-level deletion semantics. - -## Consequences - -- Renderers and cursor geometry must account for hidden delimiters while the - persisted and synchronized offsets continue to address source Markdown. -- Deleting the closing delimiter is a normal source edit and participates in - undo and live Playbook synchronization. -- Fenced code and richer multi-backtick Markdown code spans are separate - affordances and must not be inferred from this single-backtick rule. - -## Non-Goals - -- Full rich-text editing or hiding other Markdown punctuation. -- Defining fenced-code rendering or editing behavior. diff --git a/specs/0204-playbook-code-editing.md b/specs/0204-playbook-code-editing.md new file mode 100644 index 00000000..fdfa6a7b --- /dev/null +++ b/specs/0204-playbook-code-editing.md @@ -0,0 +1,53 @@ +# 0204-playbook-code-editing + +Status: accepted +Date: 2026-08-18 +Area: ux +Scope: Source-preserving single- and triple-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. + +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 +reveals the opening delimiter plus body as ordinary editable source. Retyping +the closing delimiter restores the formatted presentation. + +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. + +## 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. + +## Consequences + +- Renderers and cursor geometry account for hidden delimiters while persisted + and synchronized offsets continue to address source Markdown. +- Deleting a closing delimiter is a normal source edit and participates in + undo and live Playbook synchronization. +- CJK and other wide glyphs use display-cell geometry while source offsets + 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. + +## Non-Goals + +- Full rich-text editing or hiding other Markdown punctuation. +- Collapsing multiline opening and closing fence lines into a WYSIWYG block.