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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
262 changes: 215 additions & 47 deletions crates/cli/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3465,21 +3465,17 @@ pub(crate) fn playbook_blocks(markdown: &str) -> Vec<PlaybookBlock> {
.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<String> {
let mut ids: Vec<String> = 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).
Expand All @@ -3489,7 +3485,6 @@ pub(crate) fn playbook_referenced_session_ids(markdown: &str) -> Vec<String> {
ids.push(id.to_string());
}
}
rest = &after[close + 1..];
}
ids
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -16480,6 +16478,9 @@ fn playbook_newline_action(buffer: &str, cursor: usize) -> PlaybookNewline {
}

fn playbook_list_marker_cursor(buffer: &str, cursor: usize) -> Option<usize> {
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 {
Expand All @@ -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<String> {
if crate::playbook_markdown::playbook_offset_is_fenced(&popup.buffer, trigger_start) {
return None;
}
if popup.cursor <= trigger_start {
return None;
}
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -16856,27 +16848,53 @@ fn playbook_smart_clip_range_before_or_containing(
}

fn playbook_smart_clip_ranges(buffer: &str) -> Vec<PlaybookSmartClipRange> {
let chars: Vec<char> = 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<char> = 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::<Vec<_>>();
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
}
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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::<Vec<_>>();
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::<String>()
})
.collect::<Vec<_>>();
assert_eq!(painted, md.lines().collect::<Vec<_>>());
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::<String>();
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::<String>(),
"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::<String>(),
"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
Expand Down
19 changes: 18 additions & 1 deletion crates/cli/src/app/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1691,6 +1691,11 @@ impl App {
return;
};
let lines: Vec<String> = 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::<Vec<_>>();

// 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
Expand All @@ -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("* ");
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading