From 6496ec86ec0859e78fe1f6a8522afce8606415af Mon Sep 17 00:00:00 2001 From: Rec0iL Date: Fri, 16 Oct 2026 19:26:23 +0200 Subject: [PATCH] Agent chat: show the agent's answers as Markdown (#1778) Thanks @Rec0iL. --- crates/ui-egui/src/agent_chat/markdown.rs | 395 ++++++++++++++++++ .../ui-egui/src/agent_chat/markdown_view.rs | 138 ++++++ crates/ui-egui/src/agent_chat/mod.rs | 2 + crates/ui-egui/src/agent_chat/panel.rs | 7 +- crates/ui-egui/src/agent_chat/tests.rs | 19 + docs/agent-chat.md | 3 + docs/design/in-app-agent-chat.md | 3 +- 7 files changed, 564 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/src/agent_chat/markdown.rs create mode 100644 crates/ui-egui/src/agent_chat/markdown_view.rs diff --git a/crates/ui-egui/src/agent_chat/markdown.rs b/crates/ui-egui/src/agent_chat/markdown.rs new file mode 100644 index 00000000..62710c5f --- /dev/null +++ b/crates/ui-egui/src/agent_chat/markdown.rs @@ -0,0 +1,395 @@ +//! A small Markdown reader for assistant replies: paragraphs, headings, lists, quotes, fenced +//! code, rules, and bold, italic, code and link spans. Tables are kept as preformatted text. +//! +//! It takes untrusted text and never fails: anything it does not understand stays text, and the +//! amount of work and output is bounded. + +/// Longest input parsed; the rest is ignored. +pub const MAX_INPUT: usize = 256 * 1024; +/// Most blocks produced. +pub const MAX_BLOCKS: usize = 4000; +/// Longest link target kept. +const MAX_URL: usize = 2048; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Span { + Text(String), + Bold(String), + Italic(String), + Code(String), + /// A link with an `http` or `https` target; any other target is shown as its label only. + Link { + label: String, + url: String, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Block { + Paragraph(Vec), + Heading { + level: u8, + spans: Vec, + }, + Code { + lang: String, + text: String, + }, + List { + ordered: bool, + start: u64, + items: Vec>, + }, + Quote(Vec), + /// Preformatted text (tables). + Pre(String), + Rule, +} + +/// Parse `src` into blocks. +pub fn parse(src: &str) -> Vec { + let src = cut(src, MAX_INPUT); + let lines: Vec<&str> = src.lines().collect(); + let mut out: Vec = Vec::new(); + let mut i = 0; + let mut para: Vec<&str> = Vec::new(); + macro_rules! flush { + () => { + if !para.is_empty() { + out.push(Block::Paragraph(spans(¶.join(" ")))); + para.clear(); + } + }; + } + while i < lines.len() && out.len() < MAX_BLOCKS { + let line = lines[i]; + let t = line.trim_start(); + if let Some(fence) = fence_of(t) { + flush!(); + let lang = t[fence.len()..].trim().to_string(); + let mut body = Vec::new(); + i += 1; + while i < lines.len() && !lines[i].trim_start().starts_with(&fence) { + body.push(lines[i]); + i += 1; + } + i += 1; + out.push(Block::Code { lang, text: body.join("\n") }); + continue; + } + if t.is_empty() { + flush!(); + i += 1; + continue; + } + if is_rule(t) { + flush!(); + out.push(Block::Rule); + i += 1; + continue; + } + if let Some((level, rest)) = heading(t) { + flush!(); + out.push(Block::Heading { level, spans: spans(rest) }); + i += 1; + continue; + } + if t.starts_with('|') { + flush!(); + let mut rows = Vec::new(); + while i < lines.len() && lines[i].trim_start().starts_with('|') { + rows.push(lines[i].trim_end()); + i += 1; + } + out.push(Block::Pre(rows.join("\n"))); + continue; + } + if let Some(rest) = t.strip_prefix('>') { + flush!(); + let mut q = vec![rest.trim()]; + i += 1; + while i < lines.len() && lines[i].trim_start().starts_with('>') { + q.push(lines[i].trim_start()[1..].trim()); + i += 1; + } + out.push(Block::Quote(spans(&q.join(" ")))); + continue; + } + if let Some((ordered, n, rest)) = list_item(t) { + flush!(); + let mut items = vec![spans(rest)]; + i += 1; + while i < lines.len() { + let l = lines[i].trim_start(); + match list_item(l) { + Some((o, _, r)) if o == ordered => { + items.push(spans(r)); + i += 1; + } + // A wrapped line of the previous item. + None if !l.is_empty() && lines[i].starts_with(' ') && fence_of(l).is_none() && !l.starts_with('|') => { + if let Some(last) = items.last_mut() { + last.push(Span::Text(" ".into())); + last.extend(spans(l)); + } + i += 1; + } + _ => break, + } + if items.len() >= MAX_BLOCKS { + break; + } + } + out.push(Block::List { ordered, start: n, items }); + continue; + } + para.push(t); + i += 1; + } + flush!(); + out +} + +fn cut(s: &str, max: usize) -> &str { + if s.len() <= max { + return s; + } + let mut e = max; + while e > 0 && !s.is_char_boundary(e) { + e -= 1; + } + &s[..e] +} + +fn fence_of(t: &str) -> Option { + let c = t.chars().next().filter(|c| *c == '`' || *c == '~')?; + let n = t.chars().take_while(|x| *x == c).count(); + (n >= 3).then(|| c.to_string().repeat(n)) +} + +fn is_rule(t: &str) -> bool { + let s: String = t.chars().filter(|c| !c.is_whitespace()).collect(); + s.len() >= 3 && (s.chars().all(|c| c == '-') || s.chars().all(|c| c == '*') || s.chars().all(|c| c == '_')) +} + +fn heading(t: &str) -> Option<(u8, &str)> { + let n = t.chars().take_while(|c| *c == '#').count(); + if (1..=6).contains(&n) && t[n..].starts_with(' ') { Some((n as u8, t[n..].trim())) } else { None } +} + +fn list_item(t: &str) -> Option<(bool, u64, &str)> { + for m in ["- ", "* ", "+ "] { + if let Some(r) = t.strip_prefix(m) { + return Some((false, 1, r.trim())); + } + } + let digits = t.chars().take_while(char::is_ascii_digit).count(); + if (1..=9).contains(&digits) { + let rest = &t[digits..]; + if let Some(r) = rest.strip_prefix(". ").or_else(|| rest.strip_prefix(") ")) { + return Some((true, t[..digits].parse().unwrap_or(1), r.trim())); + } + } + None +} + +/// Inline spans of `s`. +pub fn spans(s: &str) -> Vec { + let c: Vec = s.chars().collect(); + let mut out: Vec = Vec::new(); + let mut text = String::new(); + let mut i = 0; + fn flush(out: &mut Vec, text: &mut String) { + if !text.is_empty() { + out.push(Span::Text(std::mem::take(text))); + } + } + while i < c.len() { + let rest = &c[i..]; + // Inline code. + if c[i] == '`' { + let n = rest.iter().take_while(|x| **x == '`').count(); + if let Some(end) = find_run(&c, i + n, '`', n) { + flush(&mut out, &mut text); + out.push(Span::Code(c[i + n..end].iter().collect::().trim().to_string())); + i = end + n; + continue; + } + } + // Bold. + if (rest.starts_with(&['*', '*']) || rest.starts_with(&['_', '_'])) && rest.len() > 4 { + let d = c[i]; + if let Some(end) = find_pair(&c, i + 2, d) + && end > i + 2 + { + flush(&mut out, &mut text); + out.push(Span::Bold(c[i + 2..end].iter().collect())); + i = end + 2; + continue; + } + } + // Italic (not inside words for `_`, so snake_case stays text). + if (c[i] == '*' || (c[i] == '_' && (i == 0 || !c[i - 1].is_alphanumeric()))) && rest.len() > 2 && !rest[1].is_whitespace() { + let d = c[i]; + let closes = |j: &usize| c[*j] == d && !c[*j - 1].is_whitespace() && (d == '*' || c.get(*j + 1).is_none_or(|n| !n.is_alphanumeric())); + if let Some(end) = (i + 1..c.len()).find(closes) + && end > i + 1 + { + flush(&mut out, &mut text); + out.push(Span::Italic(c[i + 1..end].iter().collect())); + i = end + 1; + continue; + } + } + // Link. + if c[i] == '[' + && let Some(close) = c[i + 1..].iter().position(|x| *x == ']').map(|p| p + i + 1) + && c.get(close + 1) == Some(&'(') + && let Some(end) = c[close + 2..].iter().position(|x| *x == ')').map(|p| p + close + 2) + { + let label: String = c[i + 1..close].iter().collect(); + let url: String = c[close + 2..end].iter().collect::().trim().to_string(); + flush(&mut out, &mut text); + let lower = url.to_ascii_lowercase(); + let web = lower.starts_with("https://") || lower.starts_with("http://"); + if web && url.len() <= MAX_URL && !url.chars().any(|ch| ch.is_whitespace() || ch.is_control()) { + out.push(Span::Link { label, url }); + } else { + out.push(Span::Text(label)); + } + i = end + 1; + continue; + } + // A backslash escapes the next marker. + if c[i] == '\\' && c.get(i + 1).is_some_and(|n| "\\`*_[]()#-".contains(*n)) { + text.push(c[i + 1]); + i += 2; + continue; + } + text.push(c[i]); + i += 1; + } + flush(&mut out, &mut text); + out +} + +/// The index of the next run of exactly `n` `ch` at or after `from`. +fn find_run(c: &[char], from: usize, ch: char, n: usize) -> Option { + let mut j = from; + while j < c.len() { + if c[j] == ch { + let run = c[j..].iter().take_while(|x| **x == ch).count(); + if run == n { + return Some(j); + } + j += run; + } else { + j += 1; + } + } + None +} + +fn find_pair(c: &[char], from: usize, d: char) -> Option { + (from..c.len().saturating_sub(1)).find(|&j| c[j] == d && c[j + 1] == d) +} + +/// The plain text of spans, for copying and for exports. +pub fn plain(spans: &[Span]) -> String { + spans + .iter() + .map(|s| match s { + Span::Text(t) | Span::Bold(t) | Span::Italic(t) | Span::Code(t) => t.clone(), + Span::Link { label, url } => format!("{label} ({url})"), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn t(s: &str) -> Span { + Span::Text(s.into()) + } + + #[test] + fn blocks() { + let md = "# Title\n\nSome *emphasis* and **bold** with `code`.\nSecond line.\n\n- one\n- two\n wrapped\n\n1. a\n2. b\n\n> quoted\n\n---\n\n```rust\nlet x = 1;\n```\n\n| a | b |\n|---|---|\n| 1 | 2 |\n"; + let b = parse(md); + assert_eq!(b[0], Block::Heading { level: 1, spans: vec![t("Title")] }); + assert_eq!( + b[1], + Block::Paragraph(vec![ + t("Some "), + Span::Italic("emphasis".into()), + t(" and "), + Span::Bold("bold".into()), + t(" with "), + Span::Code("code".into()), + t(". Second line.") + ]) + ); + assert_eq!(b[2], Block::List { ordered: false, start: 1, items: vec![vec![t("one")], vec![t("two"), t(" "), t("wrapped")]] }); + assert_eq!(b[3], Block::List { ordered: true, start: 1, items: vec![vec![t("a")], vec![t("b")]] }); + assert_eq!(b[4], Block::Quote(vec![t("quoted")])); + assert_eq!(b[5], Block::Rule); + assert_eq!(b[6], Block::Code { lang: "rust".into(), text: "let x = 1;".into() }); + assert!(matches!(&b[7], Block::Pre(s) if s.lines().count() == 3)); + assert_eq!(b.len(), 8); + } + + #[test] + fn snake_case_and_stray_markers_stay_text() { + assert_eq!(spans("use set_parameter and body_topology"), vec![t("use set_parameter and body_topology")]); + assert_eq!(spans("2 * 3 * 4"), vec![t("2 * 3 * 4")]); + assert_eq!(spans("unclosed **bold"), vec![t("unclosed **bold")]); + assert_eq!(spans("a `b"), vec![t("a `b")]); + assert_eq!(spans(r"\*not italic\*"), vec![t("*not italic*")]); + } + + #[test] + fn only_web_links_are_links() { + assert_eq!(spans("[docs](https://example.com/a)"), vec![Span::Link { label: "docs".into(), url: "https://example.com/a".into() }]); + for bad in ["[x](javascript:alert(1))", "[x](file:///etc/passwd)", "[x](ftp://a)", "[x](/relative)", "[x](https://a b)"] { + let s = spans(bad); + assert_eq!(s[0], t("x"), "{bad}"); + assert!(!s.iter().any(|x| matches!(x, Span::Link { .. })), "{bad}"); + } + } + + #[test] + fn code_blocks_keep_everything() { + let b = parse("```\n# not a heading\n- not a list\n```"); + assert_eq!(b, vec![Block::Code { lang: String::new(), text: "# not a heading\n- not a list".into() }]); + // An unterminated fence runs to the end. + let b = parse("```\nstill code"); + assert_eq!(b, vec![Block::Code { lang: String::new(), text: "still code".into() }]); + } + + #[test] + fn hostile_input_is_bounded_and_never_panics() { + let mut seed = 0x1234_5678_u32; + let alphabet: Vec = "*_`[]()#->|\\~ \n\t1.รค๐ŸŒa".chars().collect(); + for _ in 0..300 { + let mut s = String::new(); + for _ in 0..200 { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + s.push(alphabet[(seed >> 16) as usize % alphabet.len()]); + } + let _ = parse(&s); + } + let huge = "- x\n".repeat(MAX_INPUT); + let blocks = parse(&huge); + assert!(blocks.len() <= MAX_BLOCKS); + let nested = "*".repeat(100_000); + let _ = parse(&nested); + let _ = parse(&"[".repeat(100_000)); + let _ = parse(&"`a".repeat(50_000)); + } + + #[test] + fn plain_text_of_spans() { + assert_eq!(plain(&spans("**a** [b](https://c.d) `e`")), "a b (https://c.d) e"); + } +} diff --git a/crates/ui-egui/src/agent_chat/markdown_view.rs b/crates/ui-egui/src/agent_chat/markdown_view.rs new file mode 100644 index 00000000..2d8d6ebf --- /dev/null +++ b/crates/ui-egui/src/agent_chat/markdown_view.rs @@ -0,0 +1,138 @@ +//! An assistant's text as the panel shows it: Markdown (paragraphs, headings, lists, quotes, +//! fenced code, bold, italic, code, links), parsed by [`super::markdown`]. +//! +//! Parsing and laying out a long reply every frame would show in the frame time while it streams, +//! so the parsed blocks are kept per conversation item and parsed again only when its text +//! changes. + +use std::hash::{Hash, Hasher}; +use std::sync::Arc; + +use super::markdown::{self, Block, Span}; +use egui::text::{LayoutJob, TextFormat}; +use egui::{Color32, FontId, Margin, RichText, Stroke, vec2}; + +use crate::i18n::tr; +use crate::theme::Tokens; + +/// The parsed blocks of `src`, kept in the context's temporary memory under `slot` (the item's +/// index) and replaced when the text changes. +fn blocks(ctx: &egui::Context, slot: usize, src: &str) -> Arc> { + let mut h = std::collections::hash_map::DefaultHasher::new(); + src.hash(&mut h); + let key = h.finish(); + let id = egui::Id::new(("agent_chat_markdown", slot)); + if let Some((k, b)) = ctx.data(|d| d.get_temp::<(u64, Arc>)>(id)) + && k == key + { + return b; + } + let parsed = Arc::new(markdown::parse(src)); + ctx.data_mut(|d| d.insert_temp(id, (key, parsed.clone()))); + parsed +} + +fn job(ui: &egui::Ui, spans: &[Span], size: f32, color: Color32) -> LayoutJob { + let t = Tokens::get(); + let mut j = LayoutJob::default(); + j.wrap.max_width = ui.available_width(); + let base = FontId::proportional(size); + for s in spans { + let plain = TextFormat { font_id: base.clone(), color, ..Default::default() }; + match s { + Span::Text(x) => j.append(x, 0.0, plain), + Span::Bold(x) => j.append(x, 0.0, TextFormat { color: t.text, ..plain }), + Span::Italic(x) => j.append(x, 0.0, TextFormat { italics: true, ..plain }), + Span::Code(x) => j.append(x, 0.0, TextFormat { font_id: FontId::monospace(size - 1.0), background: t.field, ..plain }), + Span::Link { label, .. } => j.append(label, 0.0, TextFormat { color: t.accent, underline: Stroke::new(1.0, t.accent), ..plain }), + } + } + j +} + +/// The host of a web address: `example.com` for `https://example.com/a?b`. +fn host(url: &str) -> &str { + let rest = url.split_once("://").map_or(url, |(_, r)| r); + rest.split(['/', '?', '#']).next().unwrap_or(rest) +} + +/// A paragraph's text, then its links as buttons (a laid-out text cannot be clicked in parts). +fn spans(ui: &mut egui::Ui, spans: &[Span], size: f32, color: Color32) { + ui.add(egui::Label::new(job(ui, spans, size, color)).selectable(true).wrap()); + let links: Vec<(&str, &str)> = + spans.iter().filter_map(|s| if let Span::Link { label, url } = s { Some((label.as_str(), url.as_str())) } else { None }).collect(); + if !links.is_empty() { + ui.horizontal_wrapped(|ui| { + for (label, url) in links { + // The text above already shows the label; the button says where it goes. + let to = format!("โ†— {}", host(url)); + ui.hyperlink_to(RichText::new(to).size(size - 1.0), url).on_hover_text(format!("{label}: {url}")); + } + }); + } +} + +/// Draw `src` as Markdown. `slot`: the conversation item it belongs to. +pub fn show(ui: &mut egui::Ui, slot: usize, src: &str) { + let t = Tokens::get(); + let size = 13.0; + let parsed = blocks(ui.ctx(), slot, src); + for block in parsed.iter() { + match block { + Block::Paragraph(s) => spans(ui, s, size, t.text), + Block::Heading { level, spans: s } => { + let size = match level { + 1 => 17.0, + 2 => 15.0, + _ => 14.0, + }; + ui.add_space(4.0); + ui.add(egui::Label::new(RichText::new(markdown::plain(s)).size(size).strong().color(t.text)).selectable(true).wrap()); + } + Block::Code { text, .. } => { + egui::Frame::NONE.fill(t.field).corner_radius(4.0).inner_margin(Margin::same(6)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.add(egui::Label::new(RichText::new(text).monospace().size(size - 1.0).color(t.text)).selectable(true).wrap()); + if ui.small_button(tr("ui.agent_chat_copy")).clicked() { + ui.ctx().copy_text(text.clone()); + } + }); + } + Block::List { ordered, start, items } => { + for (i, item) in items.iter().enumerate() { + ui.horizontal_top(|ui| { + let mark = if *ordered { format!("{}.", start.saturating_add(i as u64)) } else { "โ€ข".to_string() }; + ui.label(RichText::new(mark).size(size).color(t.text_dim)); + ui.vertical(|ui| spans(ui, item, size, t.text)); + }); + } + } + Block::Quote(s) => { + ui.horizontal_top(|ui| { + let (bar, _) = ui.allocate_exact_size(vec2(3.0, 16.0), egui::Sense::hover()); + ui.painter().rect_filled(bar, 1.0, t.border); + ui.vertical(|ui| spans(ui, s, size, t.text_dim)); + }); + } + Block::Pre(text) => { + egui::ScrollArea::horizontal().id_salt(("agent_chat_pre", slot, text.len())).show(ui, |ui| { + ui.add(egui::Label::new(RichText::new(text).monospace().size(size - 2.0).color(t.text)).selectable(true)); + }); + } + Block::Rule => { + ui.separator(); + } + } + ui.add_space(3.0); + } +} + +#[cfg(test)] +mod tests { + #[test] + fn a_link_button_names_the_host() { + assert_eq!(super::host("https://example.com/a?b#c"), "example.com"); + assert_eq!(super::host("http://localhost:8080/x"), "localhost:8080"); + assert_eq!(super::host("https://a.b"), "a.b"); + } +} diff --git a/crates/ui-egui/src/agent_chat/mod.rs b/crates/ui-egui/src/agent_chat/mod.rs index e4be8689..06ba4482 100644 --- a/crates/ui-egui/src/agent_chat/mod.rs +++ b/crates/ui-egui/src/agent_chat/mod.rs @@ -26,6 +26,8 @@ pub mod backend; #[cfg(not(target_arch = "wasm32"))] pub mod discover; pub mod gate; +mod markdown; +mod markdown_view; mod panel; #[cfg(not(target_arch = "wasm32"))] mod process; diff --git a/crates/ui-egui/src/agent_chat/panel.rs b/crates/ui-egui/src/agent_chat/panel.rs index 087a26dc..ad70ac13 100644 --- a/crates/ui-egui/src/agent_chat/panel.rs +++ b/crates/ui-egui/src/agent_chat/panel.rs @@ -639,8 +639,11 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) { }); } Item::Agent { thought, text } => { - let rt = if *thought { RichText::new(text).italics().color(t.text_dim) } else { RichText::new(text).color(t.text) }; - ui.add(egui::Label::new(rt).selectable(true).wrap()); + if *thought { + ui.add(egui::Label::new(RichText::new(text).italics().color(t.text_dim)).selectable(true).wrap()); + } else { + super::markdown_view::show(ui, n, text); + } } Item::Tool { title, kind, status, output, .. } => { tool_card(ui, n, title, kind, *status, output.as_deref()); diff --git a/crates/ui-egui/src/agent_chat/tests.rs b/crates/ui-egui/src/agent_chat/tests.rs index 52eb2a90..fb380f0f 100644 --- a/crates/ui-egui/src/agent_chat/tests.rs +++ b/crates/ui-egui/src/agent_chat/tests.rs @@ -756,3 +756,22 @@ done disconnect(&mut app); let _ = std::fs::remove_dir_all(&dir); } + +/// An agent's text is Markdown: a code block shows with its Copy button and a link is a button +/// of its own, instead of the raw markers. +#[test] +fn an_agents_markdown_is_drawn_as_blocks() { + let mut h = crate::scenario::Harness::new(); + h.call("ui.agent_chat", json!({"open": true})); + h.frames(3); + h.app.agent_chat.items.push(Item::Agent { + thought: false, + text: "# Result\n\n- a **bold** point\n- another\n\n```\nlet x = 1;\n```\n\nSee [the docs](https://example.com/a).".into(), + }); + h.frames(3); + assert!(h.widget("Copy").is_some(), "the code block has its Copy button"); + assert!(h.widget("โ†— example.com").is_some(), "the link is a button that names where it goes"); + // A hostile reply draws too (no panic, bounded work). + h.app.agent_chat.items.push(Item::Agent { thought: false, text: "*".repeat(50_000) + &"[".repeat(5_000) + &"`a".repeat(5_000) }); + h.frames(3); +} diff --git a/docs/agent-chat.md b/docs/agent-chat.md index 5529a94c..5a53284c 100644 --- a/docs/agent-chat.md +++ b/docs/agent-chat.md @@ -149,6 +149,9 @@ The agent reaches the open design only through SolidCraft's own tools (the a single undo step, named after your message. The conversation shows *N changes, one undo step* with **Undo these changes** while nothing else has happened since. - **Tool cards:** each tool call shows as a card with its state; click it to see what it returned. +- **Formatted text:** the agent's answers show as Markdown: headings, lists, quotes, code blocks + (with **Copy**), bold, italic, `code` and links (web links only; each link is a button that opens + it in your browser). What you type, and the agent's thoughts, stay plain text. - **Attach view:** tick it to send a picture of the current view with your message, for agents that take pictures. diff --git a/docs/design/in-app-agent-chat.md b/docs/design/in-app-agent-chat.md index 8db181ba..7ed3b6a3 100644 --- a/docs/design/in-app-agent-chat.md +++ b/docs/design/in-app-agent-chat.md @@ -96,6 +96,7 @@ Code: `crates/ui-egui/src/agent_chat/`. | `settings.rs` | Preferences โ†’ AI and the Add agent dialog. | | `process.rs` | The agent as a child process in a process group of its own; stopping it ends the group. | | `discover.rs` | Which agents are installed, their versions, whether they look signed in. | +| `markdown.rs`, `markdown_view.rs` | The agent's text as Markdown: a bounded reader, and its drawing (parsed once per item, again only when the text changes). | | `gate.rs` | What the agent's tool requests may do: run, wait for Allow/Deny, or never. | | `mod.rs` | The conversation, turns (undo as one step, the per-turn limit), held requests. | | `panel.rs` | The panel: docking, minimising, picker, sign-in, conversation, input. | @@ -208,7 +209,7 @@ returns the whole state, for tests and screenshots. | Area | Work | |---|---| -| Conversation | Markdown rendering, diffs, history per design, resume (`session/load`) | +| Conversation | Diffs, history per design, resume (`session/load`) | | Session controls | Model and mode pickers from `configOptions` / `modes`, slash commands | | Auth | An embedded terminal or "open a terminal here" for terminal logins on each OS; logout | | Discovery | Read the ACP registry; keychain-aware login check on macOS |