Agent chat: show the agent's answers as Markdown (#1778)
Thanks @Rec0iL.
This commit is contained in:
1 parent
ff7c899840
commit
6496ec86ec
7 files changed
+564
-3
No files matched your search
@@ -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<Span>),
|
||||
Heading {
|
||||
level: u8,
|
||||
spans: Vec<Span>,
|
||||
},
|
||||
Code {
|
||||
lang: String,
|
||||
text: String,
|
||||
},
|
||||
List {
|
||||
ordered: bool,
|
||||
start: u64,
|
||||
items: Vec<Vec<Span>>,
|
||||
},
|
||||
Quote(Vec<Span>),
|
||||
/// Preformatted text (tables).
|
||||
Pre(String),
|
||||
Rule,
|
||||
}
|
||||
|
||||
/// Parse `src` into blocks.
|
||||
pub fn parse(src: &str) -> Vec<Block> {
|
||||
let src = cut(src, MAX_INPUT);
|
||||
let lines: Vec<&str> = src.lines().collect();
|
||||
let mut out: Vec<Block> = 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<String> {
|
||||
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<Span> {
|
||||
let c: Vec<char> = s.chars().collect();
|
||||
let mut out: Vec<Span> = Vec::new();
|
||||
let mut text = String::new();
|
||||
let mut i = 0;
|
||||
fn flush(out: &mut Vec<Span>, 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::<String>().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::<String>().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<usize> {
|
||||
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<usize> {
|
||||
(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<char> = "*_`[]()#->|\\~ \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");
|
||||
}
|
||||
}
|
||||
@@ -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<Vec<Block>> {
|
||||
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<Vec<Block>>)>(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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user