@@ -3465,21 +3465,17 @@ pub(crate) fn playbook_blocks(markdown: &str) -> Vec<PlaybookBlock> {
34653465 .collect()
34663466}
34673467
3468- /// Session ids referenced by `@{session:…}` smart clips anywhere in `markdown`,
3468+ /// Session ids referenced by active `@{session:…}` smart clips in `markdown`,
34693469/// in first-seen order and deduplicated. Used to keep the referenced worker
34703470/// sessions' PTY history warm so the playbook hover preview (spec 0060) can paint
34713471/// a live terminal tail the instant the pointer lands. `@{harness:…}` and other
34723472/// clip kinds are ignored — only sessions have a terminal to preview.
34733473pub(crate) fn playbook_referenced_session_ids(markdown: &str) -> Vec<String> {
34743474 let mut ids: Vec<String> = Vec::new();
3475- let mut rest = markdown;
3476- // `@`, `{`, `}` are ASCII so byte-`find` lands on char boundaries.
3477- while let Some(open) = rest.find("@{") {
3478- let after = &rest[open + 2..];
3479- let Some(close) = after.find('}') else {
3480- break;
3481- };
3482- let body = &after[..close];
3475+ for range in playbook_smart_clip_ranges(markdown) {
3476+ let body_start = byte_pos(markdown, range.start + 2);
3477+ let body_end = byte_pos(markdown, range.end.saturating_sub(1));
3478+ let body = &markdown[body_start..body_end];
34833479 // Body looks like `session:abc` or `session:abc clip_id=3`; the kind/id
34843480 // pair is the first whitespace-delimited token (mirrors the clip target
34853481 // parse used for rendering).
@@ -3489,7 +3485,6 @@ pub(crate) fn playbook_referenced_session_ids(markdown: &str) -> Vec<String> {
34893485 ids.push(id.to_string());
34903486 }
34913487 }
3492- rest = &after[close + 1..];
34933488 }
34943489 ids
34953490}
@@ -16432,6 +16427,9 @@ enum PlaybookNewline {
1643216427}
1643316428
1643416429fn playbook_newline_action(buffer: &str, cursor: usize) -> PlaybookNewline {
16430+ if crate::playbook_markdown::playbook_offset_is_fenced(buffer, cursor) {
16431+ return PlaybookNewline::Plain;
16432+ }
1643516433 let mut line_start = 0usize;
1643616434 for (idx, ch) in buffer.chars().enumerate() {
1643716435 if idx >= cursor {
@@ -16480,6 +16478,9 @@ fn playbook_newline_action(buffer: &str, cursor: usize) -> PlaybookNewline {
1648016478}
1648116479
1648216480fn playbook_list_marker_cursor(buffer: &str, cursor: usize) -> Option<usize> {
16481+ if crate::playbook_markdown::playbook_offset_is_fenced(buffer, cursor) {
16482+ return None;
16483+ }
1648316484 let mut line_start = 0usize;
1648416485 for (idx, ch) in buffer.chars().enumerate() {
1648516486 if idx >= cursor {
@@ -16504,6 +16505,9 @@ fn playbook_normalize_playbook_cursor(buffer: &str, cursor: usize) -> usize {
1650416505}
1650516506
1650616507fn playbook_smart_clip_query(popup: &PlaybookPopup, trigger_start: usize) -> Option<String> {
16508+ if crate::playbook_markdown::playbook_offset_is_fenced(&popup.buffer, trigger_start) {
16509+ return None;
16510+ }
1650716511 if popup.cursor <= trigger_start {
1650816512 return None;
1650916513 }
@@ -16611,21 +16615,12 @@ fn playbook_search_add_clip_label_matches(
1661116615 query: &str,
1661216616 matches: &mut Vec<(usize, usize)>,
1661316617) {
16614- let mut char_offset = 0usize;
16615- let mut byte_offset = 0usize;
16616- while byte_offset < buffer.len() {
16617- let rest = &buffer[byte_offset..];
16618- let Some(at_pos) = rest.find("@{") else { break };
16619- let before_bytes = &rest[..at_pos];
16620- let before_chars = before_bytes.chars().count();
16621- let clip_char_start = char_offset + before_chars;
16622- let after_marker = &rest[at_pos + 2..];
16623- let Some(end_pos) = after_marker.find('}') else {
16624- break;
16625- };
16626- let raw_clip = &after_marker[..end_pos];
16627- let raw_clip_chars = raw_clip.chars().count();
16628- let clip_char_end = clip_char_start + 2 + raw_clip_chars + 1;
16618+ for range in playbook_smart_clip_ranges(buffer) {
16619+ let clip_char_start = range.start;
16620+ let clip_char_end = range.end;
16621+ let body_start = byte_pos(buffer, range.start + 2);
16622+ let body_end = byte_pos(buffer, range.end.saturating_sub(1));
16623+ let raw_clip = &buffer[body_start..body_end];
1662916624 let already_covered = matches
1663016625 .iter()
1663116626 .any(|&(ms, me)| ms < clip_char_end && me > clip_char_start);
@@ -16635,9 +16630,6 @@ fn playbook_search_add_clip_label_matches(
1663516630 matches.push((clip_char_start, clip_char_end));
1663616631 }
1663716632 }
16638- let full_clip_bytes = 2 + end_pos + 1;
16639- byte_offset += at_pos + full_clip_bytes;
16640- char_offset = clip_char_end;
1664116633 }
1664216634}
1664316635
@@ -16856,27 +16848,53 @@ fn playbook_smart_clip_range_before_or_containing(
1685616848}
1685716849
1685816850fn playbook_smart_clip_ranges(buffer: &str) -> Vec<PlaybookSmartClipRange> {
16859- let chars: Vec<char> = buffer.chars().collect();
1686016851 let mut ranges = Vec::new();
16861- let mut idx = 0usize;
16862- while idx + 1 < chars.len() {
16863- if chars[idx] != '@' || chars[idx + 1] != '{' {
16864- idx += 1;
16865- continue;
16866- }
16867- let mut end = idx + 2;
16868- while end < chars.len() && chars[end] != '}' {
16869- end += 1;
16870- }
16871- if end < chars.len() {
16872- ranges.push(PlaybookSmartClipRange {
16873- start: idx,
16874- end: end + 1,
16875- });
16876- idx = end + 1;
16877- } else {
16878- idx += 2;
16852+ let mut classifier = crate::playbook_markdown::PlaybookLineClassifier::default();
16853+ let mut line_start = 0usize;
16854+ for raw in buffer.split('\n') {
16855+ let kind = classifier.classify(raw);
16856+ if kind.is_markdown() {
16857+ let chars: Vec<char> = raw.chars().collect();
16858+ let inline_fences = crate::playbook_markdown::playbook_inline_fences(raw)
16859+ .into_iter()
16860+ .map(|fence| {
16861+ (
16862+ raw[..fence.source.start].chars().count(),
16863+ raw[..fence.source.end].chars().count(),
16864+ )
16865+ })
16866+ .collect::<Vec<_>>();
16867+ let unmatched_inline_start =
16868+ crate::playbook_markdown::playbook_unmatched_inline_fence_start(raw)
16869+ .map(|start| raw[..start].chars().count());
16870+ let mut idx = 0usize;
16871+ while idx + 1 < chars.len() {
16872+ if chars[idx] != '@' || chars[idx + 1] != '{' {
16873+ idx += 1;
16874+ continue;
16875+ }
16876+ let mut end = idx + 2;
16877+ while end < chars.len() && chars[end] != '}' {
16878+ end += 1;
16879+ }
16880+ if end < chars.len() {
16881+ if !inline_fences
16882+ .iter()
16883+ .any(|&(start, fence_end)| idx < fence_end && end + 1 > start)
16884+ && unmatched_inline_start.is_none_or(|start| idx < start)
16885+ {
16886+ ranges.push(PlaybookSmartClipRange {
16887+ start: line_start + idx,
16888+ end: line_start + end + 1,
16889+ });
16890+ }
16891+ idx = end + 1;
16892+ } else {
16893+ idx += 2;
16894+ }
16895+ }
1687916896 }
16897+ line_start += raw.chars().count() + 1;
1688016898 }
1688116899 ranges
1688216900}
@@ -32194,6 +32212,10 @@ mod tests {
3219432212 "clip-fences",
3219532213 ":::clip session:abc123\nbody line inside\n:::\nafter fence line\nZEND",
3219632214 ),
32215+ (
32216+ "backtick-fenced-code",
32217+ "before\n```rust\n# literal heading\n- literal bullet with @{session:abc123}\n[Run](agentd:action/example)\n```\nafter fence\nZEND",
32218+ ),
3219732219 (
3219832220 "timeline",
3219932221 ":::timeline\n- [x] step one done\n- [ ] step two\n:::\nafter timeline\nZEND",
@@ -32662,6 +32684,152 @@ mod tests {
3266232684 assert!(playbook_referenced_session_ids("no clips here").is_empty());
3266332685 }
3266432686
32687+ #[test]
32688+ fn playbook_backtick_fence_keeps_smart_clips_literal() {
32689+ let md = "@{session:outside}\n```md\n@{session:literal}\n```\n```@{session:inline}```\n@{harness:codex}";
32690+ assert_eq!(
32691+ playbook_referenced_session_ids(md),
32692+ vec!["outside".to_string()]
32693+ );
32694+ let ranges = playbook_smart_clip_ranges(md);
32695+ assert_eq!(ranges.len(), 2, "the fenced clip must not be active");
32696+ let clips = ranges
32697+ .into_iter()
32698+ .map(|range| {
32699+ let start = byte_pos(md, range.start);
32700+ let end = byte_pos(md, range.end);
32701+ &md[start..end]
32702+ })
32703+ .collect::<Vec<_>>();
32704+ assert_eq!(clips, ["@{session:outside}", "@{harness:codex}"]);
32705+
32706+ let code_list = "```\n- literal item\n```";
32707+ let cursor = code_list.find("\n```").unwrap();
32708+ assert_eq!(
32709+ playbook_newline_action(code_list, cursor),
32710+ PlaybookNewline::Plain,
32711+ "Enter must not continue a Markdown list inside raw code"
32712+ );
32713+ assert_eq!(
32714+ playbook_list_marker_cursor(code_list, 5),
32715+ None,
32716+ "the caret must be able to enter a literal list marker"
32717+ );
32718+ }
32719+
32720+ #[tokio::test]
32721+ async fn playbook_backtick_fence_formats_inline_and_keeps_multiline_literal() {
32722+ let (mut app, _dir, server) = empty_app().await;
32723+ let md = "```rust\n# not a heading\n- not a bullet\n@{session:literal}\n\n[Run](agentd:action/example)\n```";
32724+ app.playbook_popup = Some(playbook_popup_for_test("s1", md, 0));
32725+
32726+ let lines = crate::ui::render_playbook_markdown_lines_for_test(&app, md);
32727+ let painted = lines
32728+ .iter()
32729+ .map(|line| {
32730+ line.spans
32731+ .iter()
32732+ .map(|span| span.content.as_ref())
32733+ .collect::<String>()
32734+ })
32735+ .collect::<Vec<_>>();
32736+ assert_eq!(painted, md.lines().collect::<Vec<_>>());
32737+ assert!(lines
32738+ .iter()
32739+ .flat_map(|line| &line.spans)
32740+ .all(|span| span.style.bg.is_none()));
32741+
32742+ let area = Rect::new(0, 0, 80, 20);
32743+ assert!(crate::ui::playbook_session_clip_hits(Some(&app), md, 0, area).is_empty());
32744+ assert!(crate::ui::playbook_attachment_chip_hits(Some(&app), md, 0, area).is_empty());
32745+ assert!(crate::ui::playbook_attachment_instances(md).is_empty());
32746+ assert!(crate::ui::playbook_action_link_hits(Some(&app), md, "s1", 0, area).is_empty());
32747+
32748+ let literal_at = md.find("@{").unwrap();
32749+ app.playbook_popup.as_mut().unwrap().cursor = literal_at;
32750+ app.insert_playbook_text("@");
32751+ assert!(
32752+ !app.playbook_smart_clip_active(),
32753+ "typing @ inside fenced code must remain ordinary source editing"
32754+ );
32755+
32756+ let code_list = "```\n- literal item\n```";
32757+ app.playbook_popup = Some(playbook_popup_for_test("s1", code_list, 18));
32758+ app.handle_playbook_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
32759+ .await;
32760+ assert_eq!(
32761+ app.playbook_popup.as_ref().unwrap().buffer,
32762+ code_list,
32763+ "Tab must not apply Markdown list indentation inside raw code"
32764+ );
32765+
32766+ let boundary_source = "before ```界🙂 @{session:literal}```";
32767+ let inline = format!("{boundary_source} after");
32768+ app.playbook_popup = Some(playbook_popup_for_test(
32769+ "s1",
32770+ &inline,
32771+ boundary_source.chars().count(),
32772+ ));
32773+ let lines = crate::ui::render_playbook_markdown_lines_for_test(&app, &inline);
32774+ let painted = lines[0]
32775+ .spans
32776+ .iter()
32777+ .map(|span| span.content.as_ref())
32778+ .collect::<String>();
32779+ assert_eq!(painted, "before 界🙂 @{session:literal} after");
32780+ assert_eq!(
32781+ lines[0]
32782+ .spans
32783+ .iter()
32784+ .find(|span| span.content.contains("界🙂"))
32785+ .unwrap()
32786+ .style
32787+ .bg,
32788+ Some(app.theme.inactive_highlight_bg)
32789+ );
32790+ assert!(crate::ui::playbook_session_clip_hits(Some(&app), &inline, 0, area).is_empty());
32791+ let (_, cursor_col) = crate::ui::playbook_cursor_visual_pos(
32792+ Some(&app),
32793+ &inline,
32794+ boundary_source.chars().count(),
32795+ 80,
32796+ );
32797+ assert_eq!(
32798+ cursor_col,
32799+ unicode_width::UnicodeWidthStr::width("before 界🙂 @{session:literal}")
32800+ );
32801+
32802+ app.delete_playbook_back();
32803+ assert_eq!(
32804+ app.playbook_popup.as_ref().unwrap().buffer,
32805+ "before ```界🙂 @{session:literal} after"
32806+ );
32807+ let revealed = crate::ui::render_playbook_markdown_lines_for_test(
32808+ &app,
32809+ &app.playbook_popup.as_ref().unwrap().buffer,
32810+ );
32811+ assert_eq!(
32812+ revealed[0]
32813+ .spans
32814+ .iter()
32815+ .map(|span| span.content.as_ref())
32816+ .collect::<String>(),
32817+ "before ```界🙂 @{session:literal} after"
32818+ );
32819+ app.insert_playbook_text("```");
32820+ assert_eq!(app.playbook_popup.as_ref().unwrap().buffer, inline);
32821+ let restored = crate::ui::render_playbook_markdown_lines_for_test(&app, &inline);
32822+ assert_eq!(
32823+ restored[0]
32824+ .spans
32825+ .iter()
32826+ .map(|span| span.content.as_ref())
32827+ .collect::<String>(),
32828+ "before 界🙂 @{session:literal} after"
32829+ );
32830+ server.abort();
32831+ }
32832+
3266532833 #[tokio::test]
3266632834 async fn playbook_referenced_sessions_need_hydration_for_hover_preview() {
3266732835 // A playbook shown in a main-window leaf references a worker session that
0 commit comments