Skip to content

Commit d33289e

Browse files
authored
Playbook editor: support triple-backtick fenced code (#1281)
* feat(tui): support fenced code in playbook editor * fix(playbook): align fenced code editing semantics
1 parent a419c1c commit d33289e

11 files changed

Lines changed: 980 additions & 141 deletions

File tree

crates/cli/src/app.rs

Lines changed: 215 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
34733473
pub(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

1643416429
fn 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

1648216480
fn 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

1650616507
fn 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

1685816850
fn 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![shot](/tmp/shot.png)\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

crates/cli/src/app/editor.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1691,6 +1691,11 @@ impl App {
16911691
return;
16921692
};
16931693
let lines: Vec<String> = popup.buffer.split('\n').map(str::to_string).collect();
1694+
let mut classifier = crate::playbook_markdown::PlaybookLineClassifier::default();
1695+
let line_kinds = lines
1696+
.iter()
1697+
.map(|line| classifier.classify(line))
1698+
.collect::<Vec<_>>();
16941699

16951700
// The inclusive band of lines to touch: the selection's lines, or just
16961701
// the cursor's line. A selection that ends exactly at a line start does
@@ -1709,6 +1714,9 @@ impl App {
17091714
let mut deltas = vec![(0usize, 0usize); lines.len()];
17101715
let mut changed = false;
17111716
for i in start_line..=end_line.min(lines.len().saturating_sub(1)) {
1717+
if !line_kinds[i].is_markdown() {
1718+
continue;
1719+
}
17121720
let line = &lines[i];
17131721
let stripped = line.trim_start();
17141722
let is_list = stripped.starts_with("- ") || stripped.starts_with("* ");
@@ -2751,7 +2759,9 @@ impl App {
27512759
let Some(popup) = self.playbook_popup.as_mut() else {
27522760
return;
27532761
};
2754-
let trigger_start = if text == "@" {
2762+
let trigger_start = if text == "@"
2763+
&& !crate::playbook_markdown::playbook_offset_is_fenced(&popup.buffer, popup.cursor)
2764+
{
27552765
Some(popup.cursor)
27562766
} else {
27572767
None
@@ -3013,6 +3023,13 @@ impl App {
30133023
return;
30143024
}
30153025
let (char_start, char_end) = if let Some(range) =
3026+
crate::playbook_markdown::playbook_closing_inline_fence_before_cursor(
3027+
&popup.buffer,
3028+
popup.cursor,
3029+
)
3030+
{
3031+
(range.start, range.end)
3032+
} else if let Some(range) =
30163033
playbook_smart_clip_range_before_or_containing(&popup.buffer, popup.cursor)
30173034
{
30183035
(range.start, range.end)

crates/cli/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ mod matrix_rain;
1616
mod midi;
1717
mod mouse_forward;
1818
mod plugin_cmd;
19+
mod playbook_markdown;
1920
mod project_dashboard;
2021
mod pty_render;
2122
mod text_util;

0 commit comments

Comments
 (0)