Add hooks, @file mentions, session search/fork/export, follow-scroll
- Hooks ([[hooks]]): pre_tool, post_tool, user_prompt, stop; sandboxed, exit 2 blocks - @file mentions with autocomplete; mentioned files/folders are attached to the prompt - Session full-text search in the sidebar, fork into a copy, export as Markdown/JSON - Chat follows the newest message until the user scrolls up; jump-to-latest button - SVG activity spark and thinking icon instead of Unicode/emoji glyphs - File search ranks name matches first and returns relative paths Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ec1b9b4925
commit
013695722a
17 files changed
+911
-41
No files matched your search
@@ -0,0 +1,83 @@
|
||||
//! Session export as Markdown.
|
||||
|
||||
use crate::types::{Message, Part, Role, Session};
|
||||
|
||||
fn fence(s: &str) -> String {
|
||||
// A fence longer than any backtick run inside the content.
|
||||
let mut longest = 0;
|
||||
let mut run = 0;
|
||||
for c in s.chars() {
|
||||
if c == '`' {
|
||||
run += 1;
|
||||
longest = longest.max(run);
|
||||
} else {
|
||||
run = 0;
|
||||
}
|
||||
}
|
||||
"`".repeat((longest + 1).max(3))
|
||||
}
|
||||
|
||||
fn clip(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut end = max;
|
||||
while !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!("{}\n… ({} more bytes)", &s[..end], s.len() - end)
|
||||
}
|
||||
|
||||
fn time(ms: i64) -> String {
|
||||
chrono::DateTime::from_timestamp_millis(ms).map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string()).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn markdown(s: &Session, messages: &[Message]) -> String {
|
||||
let title = if s.title.is_empty() { "Untitled session" } else { s.title.as_str() };
|
||||
let mut out = format!(
|
||||
"# {title}\n\n- Directory: `{}`\n- Model: `{}`\n- Mode: {}\n- Created: {}\n- Exported from CodeCraft\n\n---\n\n",
|
||||
s.cwd,
|
||||
s.model,
|
||||
s.agent,
|
||||
time(s.created_at)
|
||||
);
|
||||
for m in messages {
|
||||
let is_tool_results = m.parts.iter().all(|p| matches!(p, Part::ToolResult { .. }));
|
||||
if !is_tool_results {
|
||||
let who = match m.role {
|
||||
Role::User => "User".to_string(),
|
||||
_ => format!("Assistant{}", m.model.as_deref().map(|x| format!(" ({x})")).unwrap_or_default()),
|
||||
};
|
||||
out.push_str(&format!("## {who} · {}\n\n", time(m.created_at)));
|
||||
}
|
||||
for p in &m.parts {
|
||||
match p {
|
||||
Part::Text { text } => out.push_str(&format!("{}\n\n", text.trim())),
|
||||
Part::Summary { text } => out.push_str(&format!("<details><summary>Summary of the earlier conversation</summary>\n\n{}\n\n</details>\n\n", text.trim())),
|
||||
Part::Reasoning { text, .. } if !text.trim().is_empty() => {
|
||||
out.push_str(&format!("<details><summary>Thinking</summary>\n\n{}\n\n</details>\n\n", text.trim()))
|
||||
}
|
||||
Part::Reasoning { .. } => {}
|
||||
Part::Image { image } => out.push_str(&format!("*[image: {}]*\n\n", image.media_type)),
|
||||
Part::ToolCall { name, input, .. } => {
|
||||
let body = match name.as_str() {
|
||||
"bash" => input["command"].as_str().unwrap_or("").to_string(),
|
||||
_ => serde_json::to_string_pretty(input).unwrap_or_default(),
|
||||
};
|
||||
let body = clip(&body, 4000);
|
||||
let f = fence(&body);
|
||||
out.push_str(&format!("**Tool `{name}`**\n\n{f}{}\n{body}\n{f}\n\n", if name == "bash" { "sh" } else { "json" }));
|
||||
}
|
||||
Part::ToolResult { name, content, is_error, .. } => {
|
||||
let body = clip(content.trim(), 4000);
|
||||
let f = fence(&body);
|
||||
out.push_str(&format!(
|
||||
"<details><summary>{} of <code>{name}</code></summary>\n\n{f}\n{body}\n{f}\n\n</details>\n\n",
|
||||
if *is_error { "Error" } else { "Result" }
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
Reference in new issue
Block a user