use maud::html;
use crate::backends::gemini::decode_transcript_bytes;
use crate::filesystem::Filesystem;
use crate::types::TranscriptRole;
use super::dom;
use super::templates;
use super::APP;
const HISTORY_FILE: &str = ".lh_history.json";
pub(crate) async fn load_into_pending() {
let fs = super::shared_opfs();
let bytes = match fs.read(HISTORY_FILE).await {
Ok(b) if !b.is_empty() => b,
_ => return,
};
let bytes = super::encryption::open(&bytes).await.unwrap_or(bytes);
match decode_transcript_bytes(&bytes) {
Ok(entries) if !entries.is_empty() => {
paint_entries(&entries);
dom::scroll_to_bottom_soon("transcript");
}
Ok(_) => {
}
Err(err) => {
web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(&format!(
"history decode: {err}"
)));
}
}
APP.with(|cell| cell.borrow_mut().pending_history = Some(bytes));
}
pub(crate) async fn save_from_agent() {
let bytes = APP.with(|cell| {
cell.borrow()
.agent
.as_ref()
.and_then(|a| a.history_bytes().ok().flatten())
});
let Some(bytes) = bytes else { return };
let fs = super::shared_opfs();
let data = super::encryption::seal(&bytes).await.unwrap_or(bytes);
if let Err(err) = fs.write_atomic(HISTORY_FILE, &data).await {
web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(&format!(
"history save: {err}"
)));
}
}
pub(crate) fn take_pending() -> Option<Vec<u8>> {
APP.with(|cell| cell.borrow_mut().pending_history.take())
}
pub(crate) fn paint_entries(entries: &[crate::types::TranscriptEntry]) {
for entry in entries {
for tc in &entry.tool_calls {
let seg_id = APP.with(|cell| cell.borrow_mut().alloc_id());
let call = crate::types::ToolCall {
name: tc.name.clone(),
id: None,
args: tc.args.clone(),
canonical_path: None,
};
let mut block = templates::tool_call_block(seg_id, &call).into_string();
if tc.result.is_some() || tc.error.is_some() {
let result = crate::types::ToolResult {
name: tc.name.clone(),
id: None,
result: tc.result.clone(),
error: tc.error.clone(),
};
let result_html = templates::tool_call_result(&result).into_string();
block = inject_result(&block, seg_id, &result_html);
if let Some(card) =
templates::inline_result_card(&tc.name, &tc.args, &result, None)
{
block = inject_card(&block, seg_id, &card.into_string());
}
}
dom::append_html("transcript", &block);
}
let is_nudge = matches!(entry.role, TranscriptRole::User)
&& super::chat::is_internal_nudge(&entry.text);
if !entry.text.is_empty() && !is_nudge {
let turn_id = APP.with(|cell| cell.borrow_mut().alloc_id());
let role = entry.role.as_str();
let body = match entry.role {
TranscriptRole::User => html! { (entry.text) },
TranscriptRole::Assistant => templates::rendered_markdown(&entry.text),
};
let html_str = templates::turn(turn_id, role, body, false).into_string();
dom::append_html("transcript", &html_str);
}
}
}
fn inject_result(block: &str, seg_id: u32, result_html: &str) -> String {
inject_slot(block, &format!("tool-{seg_id}-result"), result_html)
}
fn inject_card(block: &str, seg_id: u32, card_html: &str) -> String {
inject_slot(block, &format!("tool-{seg_id}-card"), card_html)
}
fn inject_slot(block: &str, slot_id: &str, html: &str) -> String {
let slot = format!("id=\"{slot_id}\"");
let empty = format!("{slot}></div>");
let filled = format!("{slot}>{html}</div>");
block.replace(&empty, &filled)
}
pub(crate) async fn clear_persisted() {
let fs = super::shared_opfs();
if let Err(err) = fs.write_atomic(HISTORY_FILE, &[]).await {
web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(&format!(
"history clear: {err}"
)));
}
}
#[cfg(test)]
mod tests {
use super::inject_result;
use crate::types::ToolResult;
fn empty_block(seg_id: u32) -> String {
format!("<details id=\"tool-{seg_id}\"><div id=\"tool-{seg_id}-result\"></div></details>")
}
#[test]
fn injects_result_into_the_matching_slot() {
let block = empty_block(7);
let out = inject_result(&block, 7, "<pre>ok</pre>");
assert_eq!(
out,
"<details id=\"tool-7\"><div id=\"tool-7-result\"><pre>ok</pre></div></details>"
);
}
#[test]
fn leaves_block_untouched_when_slot_absent() {
let block = "<details id=\"tool-1\"><div>no slot here</div></details>";
assert_eq!(inject_result(block, 1, "<pre>x</pre>"), block);
let b = empty_block(2);
assert_eq!(inject_result(&b, 9, "<pre>x</pre>"), b);
}
#[test]
fn only_the_targeted_seg_id_is_filled() {
let block = format!("{}{}", empty_block(3), empty_block(4));
let out = inject_result(&block, 3, "RESULT");
assert!(out.contains("<div id=\"tool-3-result\">RESULT</div>"));
assert!(out.contains("<div id=\"tool-4-result\"></div>"));
}
#[test]
fn malicious_tool_result_is_escaped_end_to_end() {
let evil = serde_json::json!({
"note": "<img src=x onerror=alert(1)>",
"more": "</pre><script>steal()</script>"
});
let result = ToolResult {
name: "view_file".to_string(),
id: None,
result: Some(evil),
error: None,
};
let result_html = super::templates::tool_call_result(&result).into_string();
assert!(result_html.contains("<img"), "img tag not escaped: {result_html}");
assert!(
result_html.contains("<script>"),
"script tag not escaped: {result_html}"
);
assert!(!result_html.contains("<script>"), "live <script> leaked: {result_html}");
assert!(
!result_html.contains("<img src=x"),
"live <img> leaked: {result_html}"
);
let block = empty_block(5);
let spliced = inject_result(&block, 5, &result_html);
assert!(!spliced.contains("<script>"), "splice leaked live <script>");
assert!(spliced.contains("<script>"), "splice lost escaping");
}
#[test]
fn malicious_tool_error_is_escaped() {
let result = ToolResult {
name: "create_file".to_string(),
id: None,
result: None,
error: Some("<svg onload=alert(1)>boom".to_string()),
};
let html = super::templates::tool_call_result(&result).into_string();
assert!(html.contains("<svg"), "svg not escaped: {html}");
assert!(!html.contains("<svg onload"), "live <svg> leaked: {html}");
}
fn ok_result(name: &str, value: serde_json::Value) -> ToolResult {
ToolResult {
name: name.to_string(),
id: None,
result: Some(value),
error: None,
}
}
#[test]
fn tool_call_block_emits_result_and_card_slots() {
let call = crate::types::ToolCall {
name: "view_file".to_string(),
args: serde_json::json!({"path": "a.txt"}),
id: None,
canonical_path: None,
};
let block = super::templates::tool_call_block(9, &call).into_string();
assert!(block.contains("id=\"tool-9-result\"></div>"), "result slot missing: {block}");
assert!(block.contains("id=\"tool-9-card\"></div>"), "card slot missing: {block}");
}
#[test]
fn inject_card_fills_the_card_slot() {
let block = "<details id=\"tool-3\"></details><div id=\"tool-3-card\"></div>";
let out = super::inject_card(block, 3, "<div class=\"inline-card\">x</div>");
assert!(out.contains("id=\"tool-3-card\"><div class=\"inline-card\">x</div></div>"));
}
#[test]
fn view_file_card_caps_lines_and_links_open() {
let content = (1..=50).map(|i| format!("line {i}\n")).collect::<String>();
let result = ok_result(
"view_file",
serde_json::json!({"path": "src/main.rs", "content": content}),
);
let card = super::templates::inline_result_card(
"view_file",
&serde_json::json!({"path": "src/main.rs"}),
&result,
None,
)
.expect("view_file success should card")
.into_string();
assert!(card.contains("src/main.rs"));
assert!(card.contains("data-action=\"opfs-open\""));
assert!(card.contains("data-arg=\"src/main.rs\""));
assert!(card.contains("line 40"), "40th line shown: {card}");
assert!(!card.contains("line 41"), "41st line must be cut: {card}");
assert!(card.contains("+10 more lines"), "trailer missing: {card}");
}
#[test]
fn create_file_card_renders_args_content() {
let result = ok_result(
"create_file",
serde_json::json!({"ok": true, "path": "notes.txt", "bytes": 6}),
);
let card = super::templates::inline_result_card(
"create_file",
&serde_json::json!({"path": "notes.txt", "content": "hello\n"}),
&result,
None,
)
.expect("create_file success should card")
.into_string();
assert!(card.contains("hello"));
assert!(card.contains("data-arg=\"notes.txt\""));
}
#[test]
fn errored_tool_gets_no_card() {
let result = ToolResult {
name: "view_file".to_string(),
id: None,
result: None,
error: Some("no such file".to_string()),
};
assert!(super::templates::inline_result_card(
"view_file",
&serde_json::json!({"path": "a.txt"}),
&result,
None
)
.is_none());
}
#[test]
fn malicious_file_content_is_escaped_in_card() {
let result = ok_result(
"view_file",
serde_json::json!({
"path": "evil.html",
"content": "</pre><script>steal()</script>"
}),
);
let card = super::templates::inline_result_card(
"view_file",
&serde_json::json!({"path": "evil.html"}),
&result,
None,
)
.unwrap()
.into_string();
assert!(card.contains("<script>"), "script not escaped: {card}");
assert!(!card.contains("<script>"), "live <script> leaked: {card}");
}
#[test]
fn list_directory_card_rows_reuse_panel_actions() {
let result = ok_result(
"list_directory",
serde_json::json!({
"path": "src",
"count": 2,
"entries": [
{"name": "app", "kind": "directory"},
{"name": "lib.rs", "kind": "file", "size": 10},
]
}),
);
let card = super::templates::inline_result_card(
"list_directory",
&serde_json::json!({"path": "src"}),
&result,
None,
)
.expect("list_directory success should card")
.into_string();
assert!(card.contains("data-action=\"opfs-nav\" data-arg=\"src/app\""));
assert!(card.contains("data-action=\"opfs-open\" data-arg=\"src/lib.rs\""));
}
#[test]
fn display_card_marks_success_only() {
let ok = ok_result("render_html", serde_json::json!({"status": "rendered on display"}));
let card = super::templates::inline_result_card(
"render_html",
&serde_json::json!({"source": "<h1>hi</h1>"}),
&ok,
Some("data:image/png;base64,AAAA"),
)
.expect("render success should card")
.into_string();
assert!(card.contains("rendered to display"));
assert!(card.contains("data-action=\"toggle-display\""));
assert!(card.contains("data:image/png;base64,AAAA"), "thumb missing: {card}");
let replay = super::templates::inline_result_card(
"render_html",
&serde_json::json!({"source": "<h1>hi</h1>"}),
&ok,
None,
)
.unwrap()
.into_string();
assert!(!replay.contains("img"), "replay must not fabricate a thumb: {replay}");
let failed = ok_result(
"run_cartridge",
serde_json::json!({"error": "compilation failed", "detail": "boom"}),
);
assert!(super::templates::inline_result_card(
"run_cartridge",
&serde_json::json!({"source": "fn x() {}"}),
&failed,
None
)
.is_none());
}
}