diff --git a/assets/icons/arrow-up.svg b/assets/icons/arrow-up.svg new file mode 100644 index 000000000..25a982e1a --- /dev/null +++ b/assets/icons/arrow-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/herdr-acp/src/tui/lines.rs b/crates/herdr-acp/src/tui/lines.rs index 09099a844..22cb55767 100644 --- a/crates/herdr-acp/src/tui/lines.rs +++ b/crates/herdr-acp/src/tui/lines.rs @@ -9,8 +9,12 @@ use ratatui::{ }; use unicode_width::UnicodeWidthChar; +mod prose; + /// Tool output lines shown under a call. -const TOOL_LINES: usize = 6; +const TOOL_LINES: usize = 4; +/// Lines of a tool's title, such as a long command, before it is cut. +const TITLE_LINES: usize = 2; /// Thought lines shown before the rest is folded. const THOUGHT_LINES: usize = 4; @@ -158,7 +162,7 @@ pub(crate) fn entry(entry: &Entry, width: usize) -> Vec> { lines } -/// Agent text with fenced code set apart; the rest is shown as written. +/// Agent text with fenced code set apart and the rest drawn as prose. fn agent_text(lines: &mut Vec>, text: &str, width: usize) { let mut code = false; for raw in text.trim_end().split('\n') { @@ -170,13 +174,11 @@ fn agent_text(lines: &mut Vec>, text: &str, width: usize) { )); continue; } - let style = if code { - Style::default().fg(Color::Green) - } else if raw.starts_with('#') { - Style::default().add_modifier(Modifier::BOLD) - } else { - Style::default() - }; + if !code { + prose::prose(lines, raw, width); + continue; + } + let style = Style::default().fg(Color::Green); for line in wrap(raw, width.saturating_sub(2)) { lines.push(Line::styled(format!(" {line}"), style)); } @@ -190,15 +192,38 @@ fn tool_lines(lines: &mut Vec>, tool: &Tool, width: usize) { ToolStatus::Completed => ("✓", Color::Green), ToolStatus::Failed => ("✗", Color::Red), }; - let title = wrap(&tool.title, width.saturating_sub(2)); + // The call is context for the reply, so it stays quiet: its status mark + // keeps its color, and a long command is cut rather than filling rows. + let dim = Style::default().fg(Color::DarkGray); + let title = tool.title.trim(); + let title = title + .strip_prefix('`') + .and_then(|inner| inner.strip_suffix('`')) + .filter(|inner| !inner.contains('`')) + .unwrap_or(title); + let mut title = wrap(title, width.saturating_sub(2)); + if title.len() > TITLE_LINES { + title.truncate(TITLE_LINES); + if let Some(last) = title.last_mut() { + let room = width.saturating_sub(3); + while last + .chars() + .map(|ch| ch.width().unwrap_or(0)) + .sum::() + > room + { + last.pop(); + } + last.push('…'); + } + } for (index, line) in title.into_iter().enumerate() { let lead = if index == 0 { mark } else { " " }; lines.push(Line::from(vec![ Span::styled(format!("{lead} "), Style::default().fg(color)), - Span::styled(line, Style::default().add_modifier(Modifier::BOLD)), + Span::styled(line, dim), ])); } - let dim = Style::default().fg(Color::DarkGray); for location in tool.locations.iter().take(3) { lines.push(Line::styled(format!(" {location}"), dim)); } diff --git a/crates/herdr-acp/src/tui/lines/prose.rs b/crates/herdr-acp/src/tui/lines/prose.rs new file mode 100644 index 000000000..0579acd00 --- /dev/null +++ b/crates/herdr-acp/src/tui/lines/prose.rs @@ -0,0 +1,225 @@ +//! One line of agent prose as styled spans. Markdown's inline marks +//! (`code`, **strong**, [links](url)) are drawn rather than shown, and a +//! line's block mark (heading, bullet, number, quote) becomes its lead. A +//! mark left unclosed stays as written, so plain text is never eaten. + +use ratatui::{ + style::{Color, Modifier, Style}, + text::{Line, Span}, +}; +use unicode_width::UnicodeWidthChar; + +/// Columns every prose line is indented by, under the entry's lead column. +const INDENT: &str = " "; + +/// `raw`, one line of agent text, wrapped to `width` columns. +pub(super) fn prose(lines: &mut Vec>, raw: &str, width: usize) { + let raw = raw.trim_end_matches('\r').replace('\t', " "); + let block = Block::of(&raw); + let lead_width = text_width(&block.lead); + let room = width.saturating_sub(INDENT.len() + lead_width).max(1); + let mut styled = Vec::new(); + inline(block.body, block.style, &mut styled); + for (index, row) in wrap(&styled, room).into_iter().enumerate() { + let mut spans = vec![Span::raw(INDENT)]; + if index == 0 { + spans.push(Span::styled(block.lead.clone(), block.lead_style)); + } else if lead_width > 0 { + spans.push(Span::raw(" ".repeat(lead_width))); + } + spans.extend(runs(row)); + lines.push(Line::from(spans)); + } +} + +/// What a line's leading block mark makes of it. +struct Block<'a> { + body: &'a str, + lead: String, + lead_style: Style, + style: Style, +} + +impl<'a> Block<'a> { + fn of(raw: &'a str) -> Self { + let muted = Style::default().fg(Color::DarkGray); + let plain = |body| Self { + body, + lead: String::new(), + lead_style: Style::default(), + style: Style::default(), + }; + let trimmed = raw.trim_start(); + let indent = " ".repeat(raw.len() - trimmed.len()); + if let Some(body) = heading(trimmed) { + return Self { + style: Style::default().add_modifier(Modifier::BOLD), + ..plain(body) + }; + } + if let Some(body) = ["- ", "* ", "+ "] + .into_iter() + .find_map(|mark| trimmed.strip_prefix(mark)) + { + return Self { + lead: format!("{indent}• "), + lead_style: muted, + ..plain(body) + }; + } + if let Some((number, body)) = numbered(trimmed) { + return Self { + lead: format!("{indent}{number} "), + lead_style: muted, + ..plain(body) + }; + } + if let Some(body) = trimmed + .strip_prefix("> ") + .or_else(|| (trimmed == ">").then_some("")) + { + return Self { + lead: "│ ".to_owned(), + lead_style: muted, + style: muted.add_modifier(Modifier::ITALIC), + ..plain(body) + }; + } + plain(raw) + } +} + +/// The text of an ATX heading: one to six `#` and a space. +fn heading(line: &str) -> Option<&str> { + let marks = line.chars().take_while(|ch| *ch == '#').count(); + (1..=6) + .contains(&marks) + .then(|| line[marks..].strip_prefix(' ')) + .flatten() +} + +/// An ordered-list item's number with its mark, and the rest. +fn numbered(line: &str) -> Option<(&str, &str)> { + let digits = line.chars().take_while(char::is_ascii_digit).count(); + if digits == 0 || digits > 3 { + return None; + } + let mark = line[digits..].chars().next()?; + if !matches!(mark, '.' | ')') { + return None; + } + let body = line[digits + 1..].strip_prefix(' ')?; + Some((&line[..=digits], body)) +} + +/// Appends `text`'s characters to `out`, each with the style its inline +/// marks give it over `base`. +fn inline(text: &str, base: Style, out: &mut Vec<(char, Style)>) { + let code = base.fg(Color::Cyan); + let mut rest = text; + while let Some(ch) = rest.chars().next() { + if ch == '`' + && let Some(end) = rest[1..].find('`').filter(|end| *end > 0) + { + out.extend(rest[1..=end].chars().map(|ch| (ch, code))); + rest = &rest[end + 2..]; + continue; + } + if let Some(inner) = rest.strip_prefix("**") + && let Some(end) = inner.find("**").filter(|end| *end > 0) + { + inline(&inner[..end], base.add_modifier(Modifier::BOLD), out); + rest = &inner[end + 2..]; + continue; + } + if ch == '[' + && let Some((label, target, tail)) = link(rest) + { + inline(label, base.add_modifier(Modifier::UNDERLINED), out); + // The terminal cannot open it, so the address stays readable. + if !target.is_empty() && target != label { + let quiet = base.fg(Color::DarkGray); + out.extend(format!(" ({target})").chars().map(|ch| (ch, quiet))); + } + rest = tail; + continue; + } + out.push((ch, base)); + rest = &rest[ch.len_utf8()..]; + } +} + +/// A `[label](target)` at the start of `text`: its label, its target, and +/// what follows. +fn link(text: &str) -> Option<(&str, &str, &str)> { + let close = text.find("](")?; + let label = &text[1..close]; + if label.is_empty() || label.contains('[') { + return None; + } + let after = &text[close + 2..]; + let end = after.find(')')?; + Some((label, after[..end].trim(), &after[end + 1..])) +} + +/// Breaks styled characters into rows of at most `width` columns, at +/// spaces where it can and inside words where it must, as [`super::wrap`] +/// does for plain text. Every input yields at least one row. +fn wrap(chars: &[(char, Style)], width: usize) -> Vec> { + let mut rows = Vec::new(); + let mut row: Vec<(char, Style)> = Vec::new(); + let mut used = 0; + for token in chars.chunk_by(|a, b| (a.0 == ' ') == (b.0 == ' ')) { + let token_width: usize = token.iter().map(|(ch, _)| char_width(*ch)).sum(); + let space = token.first().is_some_and(|(ch, _)| *ch == ' '); + if used + token_width > width && used > 0 { + while row.last().is_some_and(|(ch, _)| *ch == ' ') { + row.pop(); + } + rows.push(std::mem::take(&mut row)); + used = 0; + if space { + continue; + } + } + if token_width <= width { + row.extend_from_slice(token); + used += token_width; + continue; + } + for &(ch, style) in token { + let ch_width = char_width(ch); + if used + ch_width > width && used > 0 { + rows.push(std::mem::take(&mut row)); + used = 0; + } + row.push((ch, style)); + used += ch_width; + } + } + rows.push(row); + rows +} + +/// A row's characters as spans, one per run of a single style. +fn runs(row: Vec<(char, Style)>) -> Vec> { + let mut spans: Vec> = Vec::new(); + for (ch, style) in row { + match spans.last_mut() { + Some(span) if span.style == style => span.content.to_mut().push(ch), + _ => spans.push(Span::styled(ch.to_string(), style)), + } + } + spans +} + +fn char_width(ch: char) -> usize { + ch.width().unwrap_or(0) +} + +fn text_width(text: &str) -> usize { + text.chars().map(char_width).sum() +} + +#[cfg(test)] +mod tests; diff --git a/crates/herdr-acp/src/tui/lines/prose/tests.rs b/crates/herdr-acp/src/tui/lines/prose/tests.rs new file mode 100644 index 000000000..1bed31005 --- /dev/null +++ b/crates/herdr-acp/src/tui/lines/prose/tests.rs @@ -0,0 +1,124 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use super::*; + +fn render(raw: &str, width: usize) -> Vec> { + let mut lines = Vec::new(); + prose(&mut lines, raw, width); + lines +} + +fn text(line: &Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() +} + +/// The style of the span holding `needle` on `line`. +fn style_of(line: &Line<'_>, needle: &str) -> Style { + line.spans + .iter() + .find(|span| span.content.contains(needle)) + .map(|span| span.style) + .unwrap_or_else(|| panic!("no span holds {needle:?} in {:?}", text(line))) +} + +#[test] +fn inline_marks_are_drawn_not_shown() { + let lines = render("Run `just ci`, then **push** to [the PR](https://x/1).", 80); + assert_eq!( + text(&lines[0]), + " Run just ci, then push to the PR (https://x/1)." + ); + assert_eq!(style_of(&lines[0], "just ci").fg, Some(Color::Cyan)); + assert!( + style_of(&lines[0], "push") + .add_modifier + .contains(Modifier::BOLD) + ); + assert!( + style_of(&lines[0], "the PR") + .add_modifier + .contains(Modifier::UNDERLINED) + ); +} + +#[test] +fn code_inside_strong_keeps_both() { + let lines = render("**Send is `off`**", 80); + assert_eq!(text(&lines[0]), " Send is off"); + let code = style_of(&lines[0], "off"); + assert_eq!(code.fg, Some(Color::Cyan)); + assert!(code.add_modifier.contains(Modifier::BOLD)); +} + +#[test] +fn unclosed_marks_stay_as_written() { + for raw in [ + "a ` stray tick", + "2 ** 3 is eight", + "``", + "see [notes] later", + "glob *.rs and snake_case_name", + ] { + assert_eq!(text(&render(raw, 80)[0]), format!(" {raw}"), "{raw}"); + } +} + +#[test] +fn block_marks_become_leads() { + assert_eq!(text(&render("## Plan", 40)[0]), " Plan"); + assert!( + style_of(&render("## Plan", 40)[0], "Plan") + .add_modifier + .contains(Modifier::BOLD) + ); + assert_eq!(text(&render("- first", 40)[0]), " • first"); + assert_eq!(text(&render(" * nested", 40)[0]), " • nested"); + assert_eq!(text(&render("12. twelfth", 40)[0]), " 12. twelfth"); + assert_eq!(text(&render("> quoted", 40)[0]), " │ quoted"); + // Not block marks: no space after them. + assert_eq!(text(&render("#hashtag", 40)[0]), " #hashtag"); + assert_eq!(text(&render("-1 degrees", 40)[0]), " -1 degrees"); + assert_eq!(text(&render("3.14 is pi", 40)[0]), " 3.14 is pi"); +} + +#[test] +fn wrapped_items_hang_under_their_text() { + let lines = render("- **Typing** switches it back to send", 20); + let rows: Vec<_> = lines.iter().map(text).collect(); + assert_eq!(rows, [" • Typing switches", " it back to send"]); +} + +#[test] +fn no_row_is_wider_than_asked() { + let raw = "A `very_long_identifier_that_cannot_break` and **more words here** to wrap"; + for width in [6, 12, 30] { + for line in render(raw, width) { + let used: usize = text(&line).chars().map(|ch| ch.width().unwrap_or(0)).sum(); + assert!(used <= width, "{width}: {:?}", text(&line)); + } + } +} + +#[test] +fn tabs_become_spaces() { + assert_eq!(text(&render("a\tb", 40)[0]), " a b"); +} + +#[test] +fn a_link_keeps_its_address_readable() { + let lines = render( + "See [the PR](https://example.com/123) and [https://a.b](https://a.b).", + 80, + ); + assert_eq!( + text(&lines[0]), + " See the PR (https://example.com/123) and https://a.b." + ); + assert_eq!( + style_of(&lines[0], "(https://example.com/123)").fg, + Some(Color::DarkGray) + ); +} diff --git a/crates/herdr-acp/src/tui/lines/tests.rs b/crates/herdr-acp/src/tui/lines/tests.rs index 8ea5695e1..5bb7299de 100644 --- a/crates/herdr-acp/src/tui/lines/tests.rs +++ b/crates/herdr-acp/src/tui/lines/tests.rs @@ -5,6 +5,7 @@ use herdr_chat_protocol::{EntryId, ToolKind}; use super::*; mod images; +mod tools; fn plain(lines: &[Line<'_>]) -> Vec { lines diff --git a/crates/herdr-acp/src/tui/lines/tests/tools.rs b/crates/herdr-acp/src/tui/lines/tests/tools.rs new file mode 100644 index 000000000..df57bbf92 --- /dev/null +++ b/crates/herdr-acp/src/tui/lines/tests/tools.rs @@ -0,0 +1,48 @@ +//! A tool call stays quiet beside the reply: a muted title without its +//! code ticks, cut after a couple of lines. + +use super::*; + +fn call(title: &str) -> Entry { + Entry { + id: EntryId(1), + kind: EntryKind::Tool(Tool { + call_id: "c".to_owned(), + content: Vec::new(), + kind: ToolKind::Execute, + locations: Vec::new(), + status: ToolStatus::Completed, + title: title.to_owned(), + }), + } +} + +#[test] +fn a_command_title_is_muted_and_loses_its_ticks() { + let lines = entry(&call("`git status --short`"), 40); + assert_eq!(plain(&lines)[0], "✓ git status --short"); + assert_eq!(lines[0].spans[0].style.fg, Some(Color::Green)); + assert_eq!(lines[0].spans[1].style.fg, Some(Color::DarkGray)); + assert!( + !lines[0].spans[1] + .style + .add_modifier + .contains(Modifier::BOLD) + ); +} + +#[test] +fn ticks_inside_a_title_are_kept() { + let lines = plain(&entry(&call("`a` then `b`"), 40)); + assert_eq!(lines[0], "✓ `a` then `b`"); +} + +#[test] +fn a_long_command_is_cut_after_two_lines() { + let title = format!("`{}`", "word ".repeat(40)); + let lines = plain(&entry(&call(&title), 30)); + assert_eq!(lines.len(), TITLE_LINES + 1, "{lines:?}"); + let last = &lines[TITLE_LINES - 1]; + assert!(last.ends_with('…'), "{last}"); + assert!(last.chars().count() <= 30, "{last}"); +} diff --git a/crates/herdr-gpui/locales/en-US/chat.ftl b/crates/herdr-gpui/locales/en-US/chat.ftl index 0f1c4f026..ceb3a209b 100644 --- a/crates/herdr-gpui/locales/en-US/chat.ftl +++ b/crates/herdr-gpui/locales/en-US/chat.ftl @@ -6,6 +6,7 @@ chat-layout-terminal = Terminal chat-layout-document = Document chat-layout-cards = Cards chat-layout-imessage = iMessage +chat-layout-ia = iA chat-launcher-plus = On + hover chat-launcher-beside = After + chat-launcher-leading = Before tabs diff --git a/crates/herdr-gpui/locales/en-US/settings-general.ftl b/crates/herdr-gpui/locales/en-US/settings-general.ftl index 6f2275683..635292ae3 100644 --- a/crates/herdr-gpui/locales/en-US/settings-general.ftl +++ b/crates/herdr-gpui/locales/en-US/settings-general.ftl @@ -15,7 +15,7 @@ settings-general-section-updates = Updates settings-general-window-controls = Show window controls settings-general-window-controls-description = Draw minimize, maximize, and close buttons in the menu bar. Disable server-side decorations in your window manager to avoid a double bar. settings-general-chat-layout = Chat layout -settings-general-chat-layout-description = How chat views draw messages and the composer: on a timeline, as bubbles like Claude's app, as Claude Code's terminal screen, as a document, or as one card per turn. The button in a chat's header steps through them too. +settings-general-chat-layout-description = How chat views draw messages and the composer: on a timeline, as bubbles like Claude's app, as Claude Code's terminal screen, as a document, as one card per turn, as Messages bubbles, or in one quiet column like iA Writer. The button in a chat's header steps through them too. settings-general-chat-launcher = Chat launcher settings-general-chat-launcher-description = Where tab strips offer a one-click chat with each installed agent. "+" keeps opening a terminal tab in every placement. settings-general-chat-glide = Smooth chat scrolling diff --git a/crates/herdr-gpui/src/chat.rs b/crates/herdr-gpui/src/chat.rs index 522838718..7428ee5e5 100644 --- a/crates/herdr-gpui/src/chat.rs +++ b/crates/herdr-gpui/src/chat.rs @@ -22,6 +22,7 @@ mod harnesses; mod link; mod list; mod page; +mod pasted; mod prepared; #[cfg(feature = "integration-test")] pub(crate) mod preview; @@ -97,6 +98,8 @@ pub enum Error { NotAttached, #[error("the chat view fell behind the program")] Overflow, + #[error(transparent)] + Pasted(#[from] pasted::Error), #[error("could not read from the chat program")] Read(#[source] herdr_chat_protocol::Error), #[error("This is a read-only transcript; use the agent's terminal to reply")] diff --git a/crates/herdr-gpui/src/chat/chats.rs b/crates/herdr-gpui/src/chat/chats.rs index a881ddc9c..7f34e44d1 100644 --- a/crates/herdr-gpui/src/chat/chats.rs +++ b/crates/herdr-gpui/src/chat/chats.rs @@ -104,6 +104,17 @@ pub(crate) enum ChatInput { Terminal, } +/// The composer's round button. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SendButton { + /// Nothing to send: shown, but does nothing. + Off, + /// Sends the prompt, queued behind a running turn. + Send, + /// Cancels the running turn. + Stop, +} + pub(crate) struct ChatPane { /// Files the next prompt carries, in the order they were added. pub(crate) attachments: Vec, @@ -144,6 +155,9 @@ pub(crate) struct ChatPane { pub(crate) read_only_focus: Option, retry_at: Option, pub(crate) selection: super::selection::Selection, + /// What the composer's round button does; the poll keeps it current as + /// the composer changes, since typing does not redraw the window. + pub(crate) send: SendButton, pub(crate) session: Session, pub(crate) target: Target, /// Expose more of an existing agent's original editor and dialogs inline. @@ -153,11 +167,29 @@ pub(crate) struct ChatPane { pub(crate) text_geometry: super::view::selection::Geometry, /// Files being copied to the chat's SSH host to attach there. pub(crate) uploading: usize, + /// The picture shown large over the chat, while one is. + pub(crate) viewer: Option, /// Interactive message state, scoped to this pane and transcript revision. pub(crate) widgets: super::widgets::State, } impl ChatPane { + /// What the composer's round button does with `text` on its editable + /// line: as in the phone app, it stops a running turn while nothing is + /// written or attached, and sends only once something is. + pub(crate) fn send_button(&self, text: &str) -> SendButton { + let drafted = !text.trim().is_empty() + || self.draft.iter().any(|line| !line.trim().is_empty()) + || !self.attachments.is_empty() + || self.uploading > 0; + let status = &self.session.transcript().status; + match (drafted, status) { + (false, herdr_chat_protocol::Status::Working) => SendButton::Stop, + (true, status) if status.accepts_prompts() => SendButton::Send, + _ => SendButton::Off, + } + } + /// Whether a callback made for `target` at `cursor` still applies: the /// same binding and the same transcript epoch, not a replacement session. pub(crate) fn is_current(&self, target: &Target, cursor: Option) -> bool { @@ -179,7 +211,7 @@ impl ChatPane { } /// Every handle through which the chat holds the keyboard: its composer, - /// its inert mirror focus, and its selectable text. + /// its inert mirror focus, its selectable text, and a picture shown large. pub(crate) fn focus_handles<'a>( &'a self, cx: &'a App, @@ -189,6 +221,7 @@ impl ChatPane { .map(|input| input.read(cx).focus.clone()) .chain(self.read_only_focus.iter().cloned()) .chain(self.text_focus.iter().cloned()) + .chain(self.viewer.iter().map(|viewer| viewer.focus().clone())) } /// Whether the user is reading, with the keyboard on the chat's text. @@ -231,6 +264,7 @@ impl ChatPane { read_only_focus: None, retry_at: None, selection: Default::default(), + send: SendButton::Off, session: Session::default(), target, terminal_size: Default::default(), @@ -238,6 +272,7 @@ impl ChatPane { text_focus: None, text_geometry: Default::default(), uploading: 0, + viewer: None, widgets: Default::default(), } } diff --git a/crates/herdr-gpui/src/chat/page.rs b/crates/herdr-gpui/src/chat/page.rs index 45c3b5d67..8b037e394 100644 --- a/crates/herdr-gpui/src/chat/page.rs +++ b/crates/herdr-gpui/src/chat/page.rs @@ -254,7 +254,8 @@ fn read_page(path: &Path) -> std::io::Result> { /// Writes a page to `index.html` in a folder of its own, named by what it /// holds, and returns the file. Blocks, so it runs off the UI thread. fn write_page(html: &[u8]) -> std::io::Result { - let root = pages_root().ok_or_else(|| std::io::Error::other("no folder for pages"))?; + let root = + private_folder("pages").ok_or_else(|| std::io::Error::other("no folder for pages"))?; let mut hasher = DefaultHasher::new(); html.hash(&mut hasher); let folder = root.join(format!("{:016x}", hasher.finish())); @@ -265,29 +266,29 @@ fn write_page(html: &[u8]) -> std::io::Result { Ok(file) } -/// The per-user folder pages are written in. A local page may not be -/// served from a folder with a hidden name in its path, so on macOS and -/// Windows it is the user's own temporary folder. +/// The per-user folder for chat files of one `kind`, such as pages. A local +/// page may not be served from a folder with a hidden name in its path, so +/// on macOS and Windows it is the user's own temporary folder. #[cfg(any(target_os = "macos", windows))] -fn pages_root() -> Option { - Some(std::env::temp_dir().join("herdr-chat-pages")) +pub(super) fn private_folder(kind: &str) -> Option { + Some(std::env::temp_dir().join(format!("herdr-chat-{kind}"))) } /// Elsewhere `/tmp` is shared with other users, so the page goes in the /// user's runtime or cache folder. #[cfg(not(any(target_os = "macos", windows)))] -fn pages_root() -> Option { +pub(super) fn private_folder(kind: &str) -> Option { let base = std::env::var_os("XDG_RUNTIME_DIR") .or_else(|| std::env::var_os("XDG_CACHE_HOME")) .filter(|path| !path.is_empty()) .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".cache")))?; base.is_absolute() - .then(|| base.join("herdr-gpui-chat-pages")) + .then(|| base.join(format!("herdr-gpui-chat-{kind}"))) } /// Creates `path`, readable by this user alone where the platform says so. -fn private_dir(path: &Path) -> std::io::Result<()> { +pub(super) fn private_dir(path: &Path) -> std::io::Result<()> { let mut builder = std::fs::DirBuilder::new(); builder.recursive(true); #[cfg(unix)] diff --git a/crates/herdr-gpui/src/chat/pasted.rs b/crates/herdr-gpui/src/chat/pasted.rs new file mode 100644 index 000000000..922d88a24 --- /dev/null +++ b/crates/herdr-gpui/src/chat/pasted.rs @@ -0,0 +1,89 @@ +//! A picture pasted into the composer. The program attaches files, so the +//! image is saved to a private file of its own, named by what it holds, and +//! attached as a dropped file would be. + +mod error; + +pub(crate) use error::Error; + +use crate::{ + HerdrWindow, + window::image_source::{PreparedImage, Source}, +}; +use gpui::{AppContext as _, Context, Image, Window}; +use std::{ + hash::{DefaultHasher, Hash, Hasher}, + path::{Path, PathBuf}, +}; + +impl HerdrWindow { + /// Saves `image` off the UI thread, then adds the file to the next + /// prompt of the chat over `pane_id`. Until it is saved the picture is + /// pending, so a prompt sent meanwhile waits for it, and it joins only + /// the conversation it was pasted into. + pub(super) fn attach_chat_image( + &mut self, + pane_id: &str, + image: Image, + window: &mut Window, + cx: &mut Context, + ) { + let Some(pane) = self.chats.pane_mut(pane_id) else { + return; + }; + pane.uploading += 1; + let target = pane.target.clone(); + let cursor = pane.session.since(); + let save = cx.background_spawn(async move { save(Source::Clipboard(image)) }); + let pane_id = pane_id.to_owned(); + cx.spawn_in(window, async move |this, cx| { + let saved = save.await; + let _ = this.update_in(cx, |this, window, cx| { + let Some(pane) = this + .chats + .pane_mut(&pane_id) + .filter(|pane| pane.target == target) + else { + return; + }; + pane.uploading = pane.uploading.saturating_sub(1); + if !pane.is_current(&target, cursor) { + // Another conversation took the pane; the picture was + // for the one that left. + this.redraw_terminal(cx); + return; + } + match saved { + Ok(path) => this.attach_chat_files(&pane_id, vec![path], window, cx), + Err(error) => { + pane.error = Some(format!("Image not attached: {error}")); + this.redraw_terminal(cx); + cx.notify(); + } + } + }); + }) + .detach(); + } +} + +/// Blocking: decodes, may transcode, and writes the file. +fn save(source: Source) -> crate::Result { + let image = source.prepare()?; + let root = super::page::private_folder("images") + .ok_or(Error::Folder) + .map_err(super::Error::from)?; + Ok(write(&root, &image).map_err(super::Error::from)?) +} + +fn write(root: &Path, image: &PreparedImage) -> Result { + super::page::private_dir(root).map_err(Error::Write)?; + let mut hasher = DefaultHasher::new(); + image.bytes.hash(&mut hasher); + let file = root.join(format!("{:016x}.{}", hasher.finish(), image.extension)); + std::fs::write(&file, &image.bytes).map_err(Error::Write)?; + Ok(file) +} + +#[cfg(test)] +mod tests; diff --git a/crates/herdr-gpui/src/chat/pasted/error.rs b/crates/herdr-gpui/src/chat/pasted/error.rs new file mode 100644 index 000000000..82858de77 --- /dev/null +++ b/crates/herdr-gpui/src/chat/pasted/error.rs @@ -0,0 +1,9 @@ +#[derive(Debug, thiserror::Error)] +pub enum Error { + // keep-sorted start + #[error("no private folder holds pasted images")] + Folder, + #[error("could not save the pasted image")] + Write(#[source] std::io::Error), + // keep-sorted end +} diff --git a/crates/herdr-gpui/src/chat/pasted/tests.rs b/crates/herdr-gpui/src/chat/pasted/tests.rs new file mode 100644 index 000000000..ddeb8e49b --- /dev/null +++ b/crates/herdr-gpui/src/chat/pasted/tests.rs @@ -0,0 +1,17 @@ +use super::*; + +#[test] +#[allow(clippy::unwrap_used)] +fn the_same_picture_is_saved_once_with_its_extension() { + let image = PreparedImage { + extension: "png", + bytes: b"not decoded here".to_vec(), + resized: false, + }; + let root = tempfile::tempdir().unwrap(); + let first = write(root.path(), &image).unwrap(); + let again = write(root.path(), &image).unwrap(); + assert_eq!(first, again); + assert_eq!(first.extension().unwrap(), "png"); + assert_eq!(std::fs::read(&first).unwrap(), image.bytes); +} diff --git a/crates/herdr-gpui/src/chat/preview.rs b/crates/herdr-gpui/src/chat/preview.rs index 540cbb084..02e83655b 100644 --- a/crates/herdr-gpui/src/chat/preview.rs +++ b/crates/herdr-gpui/src/chat/preview.rs @@ -77,14 +77,24 @@ You can choose a layout using the **Bubbles** button in the header. See the [fea "#; struct Options { + /// Files on the next prompt, as chips in the composer. + attach: Vec, case: String, capture: Option, + /// Text in the composer's editable line. + draft: Option, + /// A PNG the agent shows at the end of the conversation. + picture: Option, hold: Duration, layout: ChatLayoutMode, light: bool, max_width: Option, narrow: bool, read_only: bool, + /// The agent's picture is shown large, as after a click on it. + view: bool, + /// The agent is mid-turn, so the composer's button can stop it. + working: bool, } impl Options { @@ -107,10 +117,12 @@ impl Options { | "conversation" | "existing" | "input-resize" + | "tools" + | "turns" | "all" ) { bail!( - "HERDR_TEST_RICH_CHAT expects math, code, diagram, activity, activity-open, attachments, questions, selection, user, widgets, widgets-end, conversation, existing, input-resize, or all" + "HERDR_TEST_RICH_CHAT expects math, code, diagram, activity, activity-open, attachments, questions, selection, user, widgets, widgets-end, conversation, existing, input-resize, tools, turns, or all" ); } let hold = std::env::var("HERDR_TEST_RICH_CHAT_HOLD_MS") @@ -140,14 +152,21 @@ impl Options { bail!("preview reading width expects 0 or 320..=4000"); } Ok(Self { + attach: std::env::var_os("HERDR_TEST_RICH_CHAT_ATTACH") + .map(|paths| std::env::split_paths(&paths).collect()) + .unwrap_or_default(), case, capture: std::env::var_os("HERDR_TEST_RICH_CHAT_CAPTURE").map(PathBuf::from), + draft: std::env::var("HERDR_TEST_RICH_CHAT_DRAFT").ok(), + picture: std::env::var_os("HERDR_TEST_RICH_CHAT_PICTURE").map(PathBuf::from), hold: Duration::from_millis(hold), layout, max_width, light: std::env::var("HERDR_TEST_RICH_CHAT_THEME").as_deref() == Ok("light"), narrow: std::env::var("HERDR_TEST_RICH_CHAT_SIZE").as_deref() == Ok("narrow"), read_only: std::env::var_os("HERDR_TEST_RICH_CHAT_READ_ONLY").is_some(), + view: std::env::var_os("HERDR_TEST_RICH_CHAT_VIEW").is_some(), + working: std::env::var_os("HERDR_TEST_RICH_CHAT_WORKING").is_some(), }) } } @@ -181,6 +200,16 @@ async fn run(handle: WindowHandle, options: Options, cx: &mut Async timer.timer(Duration::from_millis(100)).await; let ready = handle.update(cx, |view, window, cx| -> Result { fit_surface(view); + // The window's chat poll does not run here; apply its rule for + // the composer's button so the fixture draws what the app would. + if let Some(pane) = view.chats.pane_mut(PANE) { + let text = pane + .composer + .as_ref() + .map(|input| input.read(cx).text().to_owned()) + .unwrap_or_default(); + pane.send = pane.send_button(&text); + } view.prepare_chat_visuals(window, cx); cx.notify(); visuals_ready(view) @@ -194,6 +223,28 @@ async fn run(handle: WindowHandle, options: Options, cx: &mut Async bail!("rich-chat visual jobs did not finish within {WAIT:?}"); } } + if options.view { + handle.update(cx, |view, window, cx| { + let picture = view.chats.pane(PANE).and_then(|pane| { + pane.session + .transcript() + .entries + .iter() + .rev() + .find_map(|entry| match &entry.kind { + EntryKind::Image { image, .. } => { + Some(super::visual::VisualId::of(&image.data)) + } + _ => None, + }) + }); + if let Some(picture) = picture { + view.open_chat_viewer(PANE, picture, window, cx); + } + })?; + // Past the viewer's motion, so the capture shows it in place. + timer.timer(crate::motion::ENTER * 3).await; + } if options.case == "widgets-end" { handle.update(cx, |view, _, cx| { if let Some(pane) = view.chats.pane(PANE) { @@ -325,7 +376,7 @@ fn install( }; let existing = matches!(options.case.as_str(), "existing" | "input-resize"); let mut transcript = Transcript { - agent: "Codex".into(), status: Status::Idle, read_only: options.read_only || existing, + agent: "Codex".into(), status: if options.working { Status::Working } else { Status::Idle }, read_only: options.read_only || existing, entries: vec![ Entry { id: EntryId(1), kind: EntryKind::User { text: "Show the result with equations, clear formatting, and controls I can explore.".into(), files: 0 } }, Entry { id: EntryId(2), kind: EntryKind::Agent { text: content } }, @@ -345,6 +396,43 @@ fn install( if options.case == "questions" { transcript.entries = question_entries(); } + if options.working { + transcript.turn = Some(herdr_chat_protocol::Turn { + started_ms: super::now_ms().saturating_sub(12_000), + }); + } + if options.case == "tools" { + transcript.entries = tool_entries(); + } + if options.case == "turns" { + transcript.entries = earlier_turns(); + transcript + .entries + .extend(tool_entries().into_iter().map(|mut entry| { + entry.id = EntryId(entry.id.0 + 10); + entry + })); + } + if let Some(path) = &options.picture { + use base64::Engine as _; + let bytes = std::fs::read(path).context("reading the preview picture")?; + let data = base64::engine::general_purpose::STANDARD.encode(bytes); + let next = transcript + .entries + .iter() + .map(|entry| entry.id.0) + .max() + .unwrap_or(0) + + 1; + transcript.entries.push(Entry { + id: EntryId(next), + kind: EntryKind::Image { + author: herdr_chat_protocol::Author::Agent, + image: herdr_chat_protocol::Image::new("image/png", data) + .context("the preview picture is not a usable PNG")?, + }, + }); + } if options.case == "selection" { transcript.entries = vec![Entry { id: EntryId(1), @@ -389,6 +477,12 @@ fn install( { pane.expanded.insert(EntryId(1)); } + // One call's output open, the rest folded, as a reader leaves them. + if options.case == "tools" + && let Some(pane) = view.chats.pane_mut(PANE) + { + pane.expanded.insert(EntryId(3)); + } if !options.read_only && !existing { let font = view.config.ui.clone(); let theme = view.theme.clone(); @@ -397,10 +491,14 @@ fn install( input.set_frameless(cx); input.set_appearance(font, theme, cx); input.set_placeholder("Preview only — no agent is connected", cx); + if let Some(draft) = &options.draft { + input.set_text_at(draft, draft.len(), cx); + } input }); if let Some(pane) = view.chats.pane_mut(PANE) { pane.composer = Some(input); + pane.attachments = options.attach.clone(); } } window.set_window_title("Herdr · Rich chat preview"); @@ -585,6 +683,112 @@ fn attachment_entries() -> Result> { ]) } +/// Two finished exchanges before [`tool_entries`], for how a layout sets +/// what is no longer current. +fn earlier_turns() -> Vec { + let user = |id, text: &str| Entry { + id: EntryId(id), + kind: EntryKind::User { + text: text.into(), + files: 0, + }, + }; + let agent = |id, text: &str| Entry { + id: EntryId(id), + kind: EntryKind::Agent { text: text.into() }, + }; + vec![ + user( + 1, + "I can't drag and drop a screenshot on this field in agent mode, is it supposed to work?", + ), + agent( + 2, + "Yes, it is supposed to work. The chat view is drawn over the terminal and blocks the mouse, so the terminal's drop handler never ran. The chat now takes the drop itself.", + ), + user(3, "Copy the screenshot and paste it does not work either."), + agent( + 4, + "The composer's paste only read text. A copied screenshot is now saved to a private file and attached, and copied Finder files attach instead of pasting their names.", + ), + ] +} + +/// A turn that runs commands, reads and edits files, and fails a test +/// before it answers, for the way tool calls and their output read. +fn tool_entries() -> Vec { + use herdr_chat_protocol::{Tool, ToolContent, ToolKind, ToolStatus}; + let tool = |id, kind, status, title: &str, locations: &[&str], content| Entry { + id: EntryId(id), + kind: EntryKind::Tool(Tool { + call_id: format!("call-{id}"), + content, + kind, + locations: locations.iter().map(|path| (*path).to_owned()).collect(), + status, + title: title.into(), + }), + }; + let text = |text: &str| vec![ToolContent::Text { text: text.into() }]; + vec![ + Entry { + id: EntryId(1), + kind: EntryKind::User { + text: "Why does the send button stay pink when the box is empty?".into(), + files: 0, + }, + }, + Entry { + id: EntryId(2), + kind: EntryKind::Agent { + text: "I'll check the composer's button state and run its tests.".into(), + }, + }, + tool( + 3, + ToolKind::Execute, + ToolStatus::Completed, + "`cargo test -p herdr-gpui --bins chat::tests::stop`", + &[], + text("running 3 tests\ntest chat::tests::stop::the_send_button_stops_a_running_turn ... ok\ntest chat::tests::stop::a_written_prompt_turns_stop_back_into_send ... ok\ntest chat::tests::stop::send_stays_off_until_something_is_written ... FAILED\n\nfailures:\n send button was Send, expected Off\n\ntest result: FAILED. 2 passed; 1 failed"), + ), + tool( + 4, + ToolKind::Read, + ToolStatus::Completed, + "Read crates/herdr-gpui/src/chat/view/composer.rs", + &["crates/herdr-gpui/src/chat/view/composer.rs:191"], + text("let send = match chat.send {\n SendButton::Stop => round(\"chat-stop\"),\n SendButton::Send | SendButton::Off => round(\"chat-send\"),\n};"), + ), + tool( + 5, + ToolKind::Edit, + ToolStatus::Completed, + "Edit crates/herdr-gpui/src/chat/chats.rs", + &["crates/herdr-gpui/src/chat/chats.rs:172"], + vec![ToolContent::Diff { + new: "let drafted = !text.trim().is_empty();".into(), + old: Some("let drafted = true;".into()), + path: "crates/herdr-gpui/src/chat/chats.rs".into(), + }], + ), + tool( + 6, + ToolKind::Execute, + ToolStatus::Failed, + "`just lint-linux`", + &[], + text("error: Docker is not running"), + ), + Entry { + id: EntryId(7), + kind: EntryKind::Agent { + text: "Found it: the button counted the box as **written** even when it was empty, so `Send` stayed on. It now checks the text, and the stop test passes.\n\nThe Linux lint did not run because Docker is stopped; start it and run `just lint-linux` again.".into(), + }, + }, + ] +} + fn question_entries() -> Vec { use herdr_chat_protocol::{NoticeLevel, QuestionAnswer}; let answers = vec![ diff --git a/crates/herdr-gpui/src/chat/tests.rs b/crates/herdr-gpui/src/chat/tests.rs index 7bbd3659d..968d20d29 100644 --- a/crates/herdr-gpui/src/chat/tests.rs +++ b/crates/herdr-gpui/src/chat/tests.rs @@ -26,6 +26,7 @@ mod embeds; mod feature; mod file_links; mod glide; +mod ia; mod imessage; mod layouts; mod link; @@ -35,21 +36,25 @@ mod notices; mod observed_status; mod observer_program; mod overlay; +mod pasted; mod prepared; mod questions; mod recall; mod selection; mod session; mod start; +mod stop; mod terminal_input; mod timeline; mod tool_glance; mod typography; mod user_messages; +mod viewer; mod visual; mod widget_toggle; mod widgets; mod width; +mod working; mod zoom; /// How long a test waits on a worker or a socket before failing. diff --git a/crates/herdr-gpui/src/chat/tests/attachments.rs b/crates/herdr-gpui/src/chat/tests/attachments.rs index 97b1bc917..a23eac13c 100644 --- a/crates/herdr-gpui/src/chat/tests/attachments.rs +++ b/crates/herdr-gpui/src/chat/tests/attachments.rs @@ -138,3 +138,97 @@ fn an_agent_with_its_own_bypass_mode_has_no_shield(cx: &mut TestAppContext) { assert!(cx.debug_bounds("chat-setting-mode").is_some()); assert!(cx.debug_bounds("chat-auto-accept").is_none()); } + +#[gpui::test] +fn files_dropped_on_the_chat_become_attachments(cx: &mut TestAppContext) { + let (view, _peer, cx) = open(cx, ConnectTarget::Local); + let program = Program::new(); + let _stream = attach_with(&view, &program, idle(), cx); + // The chat covers the terminal's own drop handler, so the drop has to + // reach the chat through real event dispatch, not a direct call. + let at = cx.debug_bounds("chat-view").unwrap().center(); + let shot = PathBuf::from("/work/shot.png"); + cx.simulate_event(gpui::FileDropEvent::Entered { + position: at, + paths: gpui::ExternalPaths(std::iter::once(shot.clone()).collect()), + }); + cx.simulate_event(gpui::FileDropEvent::Submit { position: at }); + poll(&view, &program, cx); + let attachments = view.read_with(cx, |view, _| { + view.chats.panes().next().unwrap().attachments.clone() + }); + assert_eq!(attachments, vec![shot]); + assert!(cx.debug_bounds("chat-attachment-0").is_some()); +} + +fn paste_key() -> &'static str { + if cfg!(target_os = "macos") { + "cmd-v" + } else { + "ctrl-shift-v" + } +} + +fn attachments( + view: &gpui::Entity, + cx: &mut gpui::VisualTestContext, +) -> Vec { + view.read_with(cx, |view, _| { + view.chats.panes().next().unwrap().attachments.clone() + }) +} + +#[gpui::test] +fn copied_files_paste_as_attachments_not_their_names(cx: &mut TestAppContext) { + let (view, _peer, cx) = open(cx, ConnectTarget::Local); + let program = Program::new(); + let mut stream = attach_with(&view, &program, idle(), cx); + let shot = PathBuf::from("/work/shot.png"); + cx.write_to_clipboard(gpui::ClipboardItem { + entries: vec![ + gpui::ClipboardEntry::ExternalPaths(gpui::ExternalPaths( + std::iter::once(shot.clone()).collect(), + )), + gpui::ClipboardEntry::String(gpui::ClipboardString::new("shot.png".into())), + ], + }); + cx.simulate_keystrokes(paste_key()); + poll(&view, &program, cx); + assert_eq!(attachments(&view, cx), vec![shot.clone()]); + let send = cx.debug_bounds("chat-send").unwrap(); + cx.simulate_click(send.center(), Modifiers::default()); + assert_eq!( + next_command(&mut stream), + Command::Prompt { + text: String::new(), + attachments: vec![Attachment { + path: "/work/shot.png".into() + }], + } + ); +} + +#[gpui::test] +fn a_copied_screenshot_pastes_as_an_attached_file(cx: &mut TestAppContext) { + let (view, _peer, cx) = open(cx, ConnectTarget::Local); + let program = Program::new(); + let _stream = attach_with(&view, &program, idle(), cx); + // PNG bytes within the upload limit pass through undecoded; unique bytes + // keep this test's file apart from any other run's. + let bytes = format!("screenshot {:?}", std::time::SystemTime::now()).into_bytes(); + cx.write_to_clipboard(gpui::ClipboardItem::new_image(&gpui::Image::from_bytes( + gpui::ImageFormat::Png, + bytes.clone(), + ))); + cx.simulate_keystrokes(paste_key()); + cx.run_until_parked(); + poll(&view, &program, cx); + let attached = attachments(&view, cx); + let [file] = attached.as_slice() else { + panic!("one attachment, got {attached:?}"); + }; + assert_eq!(file.extension().unwrap(), "png"); + assert_eq!(std::fs::read(file).unwrap(), bytes); + assert!(cx.debug_bounds("chat-attachment-0").is_some()); + std::fs::remove_file(file).unwrap(); +} diff --git a/crates/herdr-gpui/src/chat/tests/ia.rs b/crates/herdr-gpui/src/chat/tests/ia.rs new file mode 100644 index 000000000..3a9c63436 --- /dev/null +++ b/crates/herdr-gpui/src/chat/tests/ia.rs @@ -0,0 +1,41 @@ +//! The iA layout: one column, prompts hung from a gutter mark, and the +//! agent's prose aligned with them. + +use super::{ + overlay::{attach_with, open, poll}, + timeline::conversation, + *, +}; +use crate::config::ChatLayoutMode; +use gpui::{TestAppContext, px}; +use herdr_client::ConnectTarget; + +#[gpui::test] +fn prompts_steps_and_prose_share_one_column(cx: &mut TestAppContext) { + let (view, _peer, cx) = open(cx, ConnectTarget::Local); + let program = Program::new(); + let _stream = attach_with(&view, &program, conversation(false), cx); + view.update(cx, |view, cx| { + view.set_chat_layout_with(ChatLayoutMode::IA, |_| Ok(()), cx) + }); + poll(&view, &program, cx); + let prompt = cx.debug_bounds("chat-ia-1").unwrap(); + let prose = cx.debug_bounds("chat-ia-2").unwrap(); + let step = cx.debug_bounds("chat-ia-3").unwrap(); + assert_eq!(prompt.left(), prose.left()); + assert_eq!(step.left(), prose.left()); + assert!(prompt.bottom() <= prose.top()); + // The prompt's mark hangs in the gutter, left of the text. + let mark = cx.debug_bounds("chat-ia-mark-1").unwrap(); + let gutter = view.read_with(cx, |view, _| view.chat_text_size()) * 1.6; + // The row's padding, then the gutter, then the text. + let text = prompt.left() + px(16. + gutter); + assert!( + mark.left() >= prompt.left() && mark.right() <= text, + "{mark:?} in {prompt:?}" + ); + assert!( + cx.debug_bounds("chat-ia-mark-2").is_none(), + "only prompts take a mark" + ); +} diff --git a/crates/herdr-gpui/src/chat/tests/layouts.rs b/crates/herdr-gpui/src/chat/tests/layouts.rs index 31109ff8e..4babec6fe 100644 --- a/crates/herdr-gpui/src/chat/tests/layouts.rs +++ b/crates/herdr-gpui/src/chat/tests/layouts.rs @@ -110,6 +110,7 @@ fn each_layout_marks_the_conversation_its_own_way(cx: &mut TestAppContext) { (ChatLayoutMode::Document, "chat-heading-1"), (ChatLayoutMode::Cards, "chat-card-1"), (ChatLayoutMode::IMessage, "chat-imessage-1"), + (ChatLayoutMode::IA, "chat-ia-1"), ]; assert_eq!(marks.len(), ChatLayoutMode::ALL.len()); for (mode, mark) in marks { diff --git a/crates/herdr-gpui/src/chat/tests/pasted.rs b/crates/herdr-gpui/src/chat/tests/pasted.rs new file mode 100644 index 000000000..ce6853ff4 --- /dev/null +++ b/crates/herdr-gpui/src/chat/tests/pasted.rs @@ -0,0 +1,85 @@ +//! A pasted picture is pending until it is saved, so a prompt sent +//! meanwhile waits for it, and it joins only the conversation it was +//! pasted into. + +use super::{ + overlay::{attach_with, open, poll}, + *, +}; +use gpui::TestAppContext; +use herdr_client::ConnectTarget; + +fn idle() -> ServerMessage { + let mut transcript = Transcript::new("Claude"); + transcript.status = Status::Idle; + transcript.entries = vec![agent(1, "Hi.")]; + ServerMessage::Snapshot { + cursor: cursor(1, 0), + transcript: Box::new(transcript), + } +} + +fn picture() -> gpui::Image { + let bytes = format!("pasted {:?}", std::time::SystemTime::now()).into_bytes(); + gpui::Image::from_bytes(gpui::ImageFormat::Png, bytes) +} + +fn pane_state( + view: &gpui::Entity, + cx: &mut gpui::VisualTestContext, +) -> (usize, Vec) { + view.read_with(cx, |view, _| { + let pane = view.chats.panes().next().unwrap(); + (pane.uploading, pane.attachments.clone()) + }) +} + +#[gpui::test] +fn a_picture_is_pending_until_it_is_saved(cx: &mut TestAppContext) { + let (view, _peer, cx) = open(cx, ConnectTarget::Local); + let program = Program::new(); + let _stream = attach_with(&view, &program, idle(), cx); + let pane = view.read_with(cx, |view, _| { + view.chats.panes().next().unwrap().target.pane_id.clone() + }); + cx.update(|window, cx| { + view.update(cx, |view, cx| { + view.attach_chat_image(&pane, picture(), window, cx) + }); + }); + // Before the save lands, a prompt would wait for it. + let (pending, attached) = pane_state(&view, cx); + assert_eq!(pending, 1); + assert!(attached.is_empty()); + cx.run_until_parked(); + poll(&view, &program, cx); + let (pending, attached) = pane_state(&view, cx); + assert_eq!(pending, 0); + let [file] = attached.as_slice() else { + panic!("one attachment, got {attached:?}"); + }; + std::fs::remove_file(file).unwrap(); +} + +#[gpui::test] +fn a_picture_never_joins_a_conversation_that_replaced_its_own(cx: &mut TestAppContext) { + let (view, _peer, cx) = open(cx, ConnectTarget::Local); + let program = Program::new(); + let _stream = attach_with(&view, &program, idle(), cx); + let pane = view.read_with(cx, |view, _| { + view.chats.panes().next().unwrap().target.pane_id.clone() + }); + cx.update(|window, cx| { + view.update(cx, |view, cx| { + view.attach_chat_image(&pane, picture(), window, cx) + }); + }); + // Another program takes the pane while the picture is saved. + view.update(cx, |view, _| { + let pane = view.chats.pane_mut(&pane).unwrap(); + pane.target.endpoint = EndpointId::random(); + }); + cx.run_until_parked(); + let (_, attached) = pane_state(&view, cx); + assert!(attached.is_empty(), "{attached:?}"); +} diff --git a/crates/herdr-gpui/src/chat/tests/stop.rs b/crates/herdr-gpui/src/chat/tests/stop.rs new file mode 100644 index 000000000..3aa4ee1ec --- /dev/null +++ b/crates/herdr-gpui/src/chat/tests/stop.rs @@ -0,0 +1,97 @@ +//! The composer's round button, as in the phone app: it stops a running +//! turn while nothing is written, sends once something is, and does +//! nothing otherwise. + +use super::{ + overlay::{attach_with, open, poll}, + *, +}; +use crate::chat::chats::SendButton; +use gpui::{Modifiers, TestAppContext}; +use herdr_chat_protocol::Command; +use herdr_client::ConnectTarget; + +fn working() -> ServerMessage { + let mut transcript = Transcript::new("Claude"); + transcript.status = Status::Working; + transcript.entries = vec![agent(1, "Thinking.")]; + ServerMessage::Snapshot { + cursor: cursor(1, 0), + transcript: Box::new(transcript), + } +} + +fn button(view: &gpui::Entity, cx: &mut gpui::VisualTestContext) -> SendButton { + view.read_with(cx, |view, _| view.chats.panes().next().unwrap().send) +} + +fn next_command(stream: &mut Peer) -> Command { + match read(stream) { + Some(ClientMessage::Command { command, .. }) => command, + other => panic!("a command, got {other:?}"), + } +} + +#[gpui::test] +fn the_send_button_stops_a_running_turn(cx: &mut TestAppContext) { + let (view, _peer, cx) = open(cx, ConnectTarget::Local); + let program = Program::new(); + let mut stream = attach_with(&view, &program, working(), cx); + poll(&view, &program, cx); + assert!(cx.debug_bounds("chat-send").is_none()); + let stop = cx.debug_bounds("chat-stop").unwrap(); + cx.simulate_click(stop.center(), Modifiers::default()); + assert_eq!(next_command(&mut stream), Command::Cancel); +} + +#[gpui::test] +fn a_written_prompt_turns_stop_back_into_send(cx: &mut TestAppContext) { + let (view, _peer, cx) = open(cx, ConnectTarget::Local); + let program = Program::new(); + let _stream = attach_with(&view, &program, working(), cx); + poll(&view, &program, cx); + assert!(cx.debug_bounds("chat-stop").is_some()); + + cx.simulate_input("next"); + poll(&view, &program, cx); + assert!(cx.debug_bounds("chat-stop").is_none()); + assert!(cx.debug_bounds("chat-send").is_some()); + + // Taking the text back brings the stop button back. + cx.simulate_keystrokes("backspace backspace backspace backspace"); + poll(&view, &program, cx); + assert!(cx.debug_bounds("chat-stop").is_some()); +} + +#[gpui::test] +fn send_stays_off_until_something_is_written(cx: &mut TestAppContext) { + let (view, _peer, cx) = open(cx, ConnectTarget::Local); + let program = Program::new(); + let mut idle = Transcript::new("Claude"); + idle.status = Status::Idle; + let snapshot = ServerMessage::Snapshot { + cursor: cursor(1, 0), + transcript: Box::new(idle), + }; + let mut stream = attach_with(&view, &program, snapshot, cx); + poll(&view, &program, cx); + assert_eq!(button(&view, cx), SendButton::Off); + assert!(cx.debug_bounds("chat-stop").is_none()); + // Blank text is nothing to send either. + cx.simulate_input(" "); + poll(&view, &program, cx); + assert_eq!(button(&view, cx), SendButton::Off); + let send = cx.debug_bounds("chat-send").unwrap(); + cx.simulate_click(send.center(), Modifiers::default()); + + cx.simulate_input("hi"); + poll(&view, &program, cx); + assert_eq!(button(&view, cx), SendButton::Send); + cx.simulate_click(send.center(), Modifiers::default()); + // The first command the program sees is the written prompt, so the + // click while off sent nothing. + match next_command(&mut stream) { + Command::Prompt { text, .. } => assert_eq!(text.trim(), "hi"), + other => panic!("the prompt, got {other:?}"), + } +} diff --git a/crates/herdr-gpui/src/chat/tests/terminal_input.rs b/crates/herdr-gpui/src/chat/tests/terminal_input.rs index 74260a36b..029c1442e 100644 --- a/crates/herdr-gpui/src/chat/tests/terminal_input.rs +++ b/crates/herdr-gpui/src/chat/tests/terminal_input.rs @@ -339,7 +339,7 @@ fn working_output_cursor_does_not_hide_the_rich_conversation(cx: &mut TestAppCon }); assert_eq!(cx.debug_bounds("chat-view").unwrap(), original); assert!(cx.debug_bounds("chat-terminal-input").is_some()); - assert!(cx.debug_bounds("chat-cancel").is_none()); + assert!(cx.debug_bounds("chat-stop").is_none()); } #[gpui::test] diff --git a/crates/herdr-gpui/src/chat/tests/viewer.rs b/crates/herdr-gpui/src/chat/tests/viewer.rs new file mode 100644 index 000000000..7b4dca29b --- /dev/null +++ b/crates/herdr-gpui/src/chat/tests/viewer.rs @@ -0,0 +1,155 @@ +//! A picture in the conversation opens large on a click, and a click +//! anywhere or Escape closes it again. + +use super::{ + overlay::{attach_with, open, poll}, + *, +}; +use crate::chat::visual::{Shown, VisualId}; +use base64::Engine as _; +use gpui::{Modifiers, TestAppContext, point, px}; +use herdr_chat_protocol::{Author, Image}; +use herdr_client::ConnectTarget; +use std::io::Cursor; + +fn png(width: u32, height: u32) -> Image { + let mut bytes = Vec::new(); + image::RgbaImage::from_pixel(width, height, image::Rgba([30, 120, 200, 255])) + .write_to(&mut Cursor::new(&mut bytes), image::ImageFormat::Png) + .unwrap(); + let data = base64::engine::general_purpose::STANDARD.encode(bytes); + Image::new("image/png", data).unwrap() +} + +fn with_picture(image: &Image) -> ServerMessage { + let mut transcript = Transcript::new("Claude"); + transcript.status = Status::Idle; + transcript.entries = vec![ + agent(1, "Here is the screenshot."), + Entry { + id: EntryId(2), + kind: EntryKind::Image { + author: Author::Agent, + image: image.clone(), + }, + }, + ]; + ServerMessage::Snapshot { + cursor: cursor(1, 0), + transcript: Box::new(transcript), + } +} + +#[gpui::test] +fn a_clicked_picture_opens_large_and_closes(cx: &mut TestAppContext) { + let (view, _peer, cx) = open(cx, ConnectTarget::Local); + let program = Program::new(); + let image = png(40, 30); + let _stream = attach_with(&view, &program, with_picture(&image), cx); + let id = VisualId::of(&image.data); + wait_until(|| { + poll(&view, &program, cx); + cx.run_until_parked(); + view.read_with(cx, |view, _| { + view.chats + .panes() + .next() + .is_some_and(|pane| matches!(pane.prepared.visuals.shown(id), Shown::Ready(_))) + }) + }); + poll(&view, &program, cx); + let small = cx.debug_bounds("chat-visual").unwrap(); + assert!(cx.debug_bounds("chat-viewer").is_none()); + + cx.simulate_click(small.center(), Modifiers::default()); + poll(&view, &program, cx); + let chat = cx.debug_bounds("chat-view").unwrap(); + assert_eq!(cx.debug_bounds("chat-viewer").unwrap(), chat); + // A small picture grows, up to twice its size, to fill the chat. + let large = cx.debug_bounds("chat-viewer-picture").unwrap(); + assert!( + large.size.width > small.size.width, + "{large:?} vs {small:?}" + ); + assert!(large.size.width <= px(80.) + px(1.), "{large:?}"); + + // A click anywhere closes it, after it has faded out. + cx.simulate_click(chat.origin + point(px(4.), px(4.)), Modifiers::default()); + cx.executor().advance_clock(crate::motion::LEAVE * 2); + cx.run_until_parked(); + poll(&view, &program, cx); + assert!(cx.debug_bounds("chat-viewer").is_none()); + + // While it shows, typing never reaches the composer hidden behind it. + cx.simulate_click(small.center(), Modifiers::default()); + poll(&view, &program, cx); + assert!(cx.debug_bounds("chat-viewer").is_some()); + cx.simulate_input("hidden"); + cx.simulate_keystrokes("enter"); + poll(&view, &program, cx); + assert_eq!(composer_text(&view, cx), ""); + assert!(cx.debug_bounds("chat-viewer").is_some()); + + // Escape closes it, and the keyboard is back on the composer. + cx.simulate_keystrokes("escape"); + cx.executor().advance_clock(crate::motion::LEAVE * 2); + cx.run_until_parked(); + poll(&view, &program, cx); + assert!(cx.debug_bounds("chat-viewer").is_none()); + cx.simulate_input("back"); + assert_eq!(composer_text(&view, cx), "back"); +} + +fn composer_text( + view: &gpui::Entity, + cx: &mut gpui::VisualTestContext, +) -> String { + view.read_with(cx, |view, cx| { + let pane = view.chats.panes().next().unwrap(); + let draft = pane.draft.join("\n"); + let line = pane + .composer + .as_ref() + .map(|input| input.read(cx).text().to_owned()) + .unwrap_or_default(); + format!("{draft}{line}") + }) +} + +#[gpui::test] +fn a_picture_in_another_pane_makes_its_pane_active(cx: &mut TestAppContext) { + let (view, mut peer, cx) = open(cx, ConnectTarget::Local); + let program = Program::new(); + let image = png(40, 30); + let _stream = attach_with(&view, &program, with_picture(&image), cx); + // Input waits for the daemon's frame to match the window. + selection::acknowledge_size(&view, cx); + let id = VisualId::of(&image.data); + wait_until(|| { + poll(&view, &program, cx); + cx.run_until_parked(); + view.read_with(cx, |view, _| { + view.chats + .panes() + .next() + .is_some_and(|pane| matches!(pane.prepared.visuals.shown(id), Shown::Ready(_))) + }) + }); + poll(&view, &program, cx); + // Another pane is the active one when the picture is clicked. + let chat_pane = view.update(cx, |view, _| { + let snapshot = Arc::make_mut(view.live.snapshot.as_mut().unwrap()); + snapshot.focused_pane_id = Some("elsewhere".into()); + view.chats.panes().next().unwrap().target.pane_id.clone() + }); + let small = cx.debug_bounds("chat-visual").unwrap(); + cx.simulate_click(small.center(), Modifiers::default()); + poll(&view, &program, cx); + assert!(cx.debug_bounds("chat-viewer").is_some()); + // The clicked chat's pane becomes the active one. + let focus = std::iter::repeat_with(|| peer.request()) + .take(8) + .find(|request| request["method"] == herdr_client::Method::PaneFocus.as_str()) + .expect("a pane focus request"); + assert_eq!(focus["params"]["pane_id"], chat_pane.as_str()); +} diff --git a/crates/herdr-gpui/src/chat/tests/width.rs b/crates/herdr-gpui/src/chat/tests/width.rs index 662ad5c3c..398c05f70 100644 --- a/crates/herdr-gpui/src/chat/tests/width.rs +++ b/crates/herdr-gpui/src/chat/tests/width.rs @@ -132,8 +132,9 @@ fn changing_reading_width_reflows_text_and_preserves_its_selection(cx: &mut Test ); widths.push(cx.debug_bounds("chat-prose").unwrap().size.width); } - assert!( - widths[0] < widths[1] && widths[1] < widths[2] && widths[3] < widths[0], - "{widths:?}" - ); + // At the default width, prose keeps to its measure inside the column; + // a width the user chose is followed as set. + let measure = view.read_with(cx, |view, _| view.chat_text_size()) * 34.; + assert!(widths[0] <= px(measure + 1.), "{widths:?}"); + assert!(widths[3] < widths[1] && widths[1] < widths[2], "{widths:?}"); } diff --git a/crates/herdr-gpui/src/chat/tests/working.rs b/crates/herdr-gpui/src/chat/tests/working.rs new file mode 100644 index 000000000..cf71a9c9b --- /dev/null +++ b/crates/herdr-gpui/src/chat/tests/working.rs @@ -0,0 +1,69 @@ +//! Every layout shows, under the conversation, that the agent is still on +//! a turn, and stops once the turn ends. + +use super::{ + overlay::{attach_with, open, poll}, + timeline::conversation, + *, +}; +use crate::config::ChatLayoutMode; +use gpui::TestAppContext; +use herdr_client::ConnectTarget; + +fn running() -> ServerMessage { + let mut transcript = Transcript::new("Claude"); + transcript.status = Status::Working; + transcript.entries = vec![Entry { + id: EntryId(1), + kind: EntryKind::User { + text: "Fix it".into(), + files: 0, + }, + }]; + ServerMessage::Snapshot { + cursor: cursor(1, 0), + transcript: Box::new(transcript), + } +} + +#[gpui::test] +fn every_layout_shows_the_agent_working(cx: &mut TestAppContext) { + let (view, _peer, cx) = open(cx, ConnectTarget::Local); + let program = Program::new(); + let _stream = attach_with(&view, &program, running(), cx); + for &layout in ChatLayoutMode::ALL { + view.update(cx, |view, cx| { + view.set_chat_layout_with(layout, |_| Ok(()), cx) + }); + poll(&view, &program, cx); + // iMessage says it its own way, as Messages does. + let sign = if layout == ChatLayoutMode::IMessage { + "chat-typing" + } else { + "chat-working" + }; + let working = cx + .debug_bounds(sign) + .unwrap_or_else(|| panic!("{layout:?}")); + let composer = cx.debug_bounds("chat-composer").unwrap(); + assert!( + working.bottom() <= composer.top(), + "{layout:?}: above the composer" + ); + } +} + +#[gpui::test] +fn the_sign_is_gone_between_turns(cx: &mut TestAppContext) { + let (view, _peer, cx) = open(cx, ConnectTarget::Local); + let program = Program::new(); + let _stream = attach_with(&view, &program, conversation(false), cx); + for &layout in ChatLayoutMode::ALL { + view.update(cx, |view, cx| { + view.set_chat_layout_with(layout, |_| Ok(()), cx) + }); + poll(&view, &program, cx); + assert!(cx.debug_bounds("chat-working").is_none(), "{layout:?}"); + assert!(cx.debug_bounds("chat-typing").is_none(), "{layout:?}"); + } +} diff --git a/crates/herdr-gpui/src/chat/view.rs b/crates/herdr-gpui/src/chat/view.rs index f6420550f..a6e14bb71 100644 --- a/crates/herdr-gpui/src/chat/view.rs +++ b/crates/herdr-gpui/src/chat/view.rs @@ -17,7 +17,9 @@ pub(crate) mod selection; mod settings; mod summary; mod text; +pub(super) mod viewer; mod widgets; +mod working; pub(super) use settings::matching; mod visuals; @@ -29,7 +31,7 @@ use crate::{ fonts::StyledFont, }; use gpui::{prelude::*, *}; -use herdr_chat_protocol::{Command, Status}; +use herdr_chat_protocol::Status; use herdr_client::protocol::{PaneSurfaceFrame, SurfaceRect}; /// Where a pane's cells are, relative to the terminal canvas: the box the @@ -79,6 +81,12 @@ impl HerdrWindow { px((self.chat_text_size() - 1.).max(10.)) } + /// A tool call's text: its command, files, and output. Smaller than + /// the prose and muted, so the agent's answer reads first. + pub(in crate::chat) fn chat_tool_text(&self) -> Pixels { + px((self.chat_text_size() - 2.).max(10.)) + } + /// The interface font at the chat's prose size, for its native fields. pub(in crate::chat) fn chat_font(&self) -> crate::config::FontConfig { crate::config::FontConfig { @@ -291,11 +299,14 @@ impl HerdrWindow { .when(!cfg!(target_os = "macos"), |view| { view.bg(rgb(theme.background)) }) - // Files dragged over the chat become attachments when dropped. + // Files dragged over the chat become attachments when dropped. The + // chat occludes the terminal's own drop handler, so it takes the + // drop and routes it the same way. .drag_over::({ let wash = theme.primary_wash(); move |style, _, _, _| style.bg(rgb(wash)) }) + .on_drop(cx.listener(Self::drop_terminal_files)) .text_color(rgb(theme.foreground)) .text_size(px(self.chat_text_size())) .text_font(&self.config.ui) @@ -403,13 +414,14 @@ impl HerdrWindow { ) .children(self.render_chat_rail(chat, bounds)), ) + .children(self.render_chat_viewer(chat, bounds, cx)) .into_any_element() } /// The layout's sign that the agent is working on a turn. fn render_chat_working(&self, chat: &ChatPane) -> Option
{ - let working = chat.session.transcript().status == Status::Working; - layout::layout_for(self.config.chat.layout).working(working, &self.chat_look()) + let working = working::Working::of(chat.session.transcript(), super::now_ms()); + layout::layout_for(self.config.chat.layout).working(working.as_ref(), &self.chat_look()) } /// The chat's background where no page shows: on macOS, where a page @@ -524,17 +536,6 @@ impl HerdrWindow { .hover(|button| button.bg(rgb(theme.active))) .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) }; - let cancel = - (*status == Status::Working && chat.input() == ChatInput::Composer).then(|| { - let pane_id = pane_id.clone(); - button("chat-cancel") - .text_color(rgb(hues.red)) - .child("Stop") - .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { - cx.stop_propagation(); - this.chat_send(&pane_id, Command::Cancel, cx); - })) - }); let watching = (transcript.clients > 1).then(|| { div() .flex_none() @@ -622,7 +623,6 @@ impl HerdrWindow { .child(div().flex_1()) .children(reconnecting) .children(watching) - .children(cancel) .child(layout) .children(rail) .child(terminal), diff --git a/crates/herdr-gpui/src/chat/view/composer.rs b/crates/herdr-gpui/src/chat/view/composer.rs index 7b2fe9ed3..477b14428 100644 --- a/crates/herdr-gpui/src/chat/view/composer.rs +++ b/crates/herdr-gpui/src/chat/view/composer.rs @@ -3,12 +3,13 @@ //! chat's layout arranges. The settings' menus open above them. use super::{ - super::chats::ChatPane, + super::chats::{ChatPane, SendButton}, Hues, layout::{ComposerParts, layout_for}, }; use crate::{HerdrWindow, config::corners}; use gpui::{prelude::*, *}; +use herdr_chat_protocol::Command; /// Draft lines shown above the editable one; earlier ones are counted, so a /// long draft never pushes the editable line out of the pane. @@ -92,9 +93,19 @@ impl HerdrWindow { })) }; let copying = (chat.uploading > 0).then(|| { - let host = match &chat.target.route { - super::super::Route::Ssh(host) => crate::notifications::safe_text(host, 80), - super::super::Route::Local => String::new(), + // On this machine only a pasted picture is ever pending. + let label = match &chat.target.route { + super::super::Route::Ssh(host) => { + let host = crate::notifications::safe_text(host, 80); + match chat.uploading { + 1 => format!("Copying a file to {host}\u{2026}"), + count => format!("Copying {count} files to {host}\u{2026}"), + } + } + super::super::Route::Local => match chat.uploading { + 1 => "Preparing a picture\u{2026}".to_owned(), + count => format!("Preparing {count} pictures\u{2026}"), + }, }; div() .debug_selector(|| "chat-uploading".into()) @@ -106,10 +117,7 @@ impl HerdrWindow { .bg(rgb(theme.active)) .text_size(small) .text_color(rgb(theme.subtext())) - .child(match chat.uploading { - 1 => format!("Copying a file to {host}\u{2026}"), - count => format!("Copying {count} files to {host}\u{2026}"), - }) + .child(label) }); let chips = (!chat.attachments.is_empty() || copying.is_some()).then(|| { div() @@ -187,26 +195,45 @@ impl HerdrWindow { ) })) }); - let accepts = transcript.status.accepts_prompts(); - let send = { - let pane_id = pane_id.clone(); - let fill = if accepts { - theme.primary() - } else { - theme.active - }; - round("chat-send") - .bg(rgb(fill)) - .child( - svg() - .path("icons/arrow-right.svg") - .size(px(12.)) - .text_color(rgb(theme.text_on(fill))), - ) - .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { - cx.stop_propagation(); - this.send_chat_composer(&pane_id, cx); - })) + // As in the phone app: Stop while the agent works and nothing is + // written, Send once something is, and Send greyed out otherwise. + let send = match chat.send { + SendButton::Stop => { + let pane_id = pane_id.clone(); + let fill = hues.red; + round("chat-stop") + .bg(rgb(fill)) + .child( + div() + .size(px(10.)) + .rounded(px(2.)) + .bg(rgb(theme.text_on(fill))), + ) + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + cx.stop_propagation(); + this.chat_send(&pane_id, Command::Cancel, cx); + })) + } + SendButton::Send | SendButton::Off => { + let on = chat.send == SendButton::Send; + let pane_id = pane_id.clone(); + let fill = if on { theme.primary() } else { theme.active }; + round("chat-send") + .bg(rgb(fill)) + .when(!on, |button| button.cursor_default()) + .child( + svg() + .path("icons/arrow-up.svg") + .size(px(12.)) + .text_color(rgb(if on { theme.text_on(fill) } else { theme.muted })), + ) + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + cx.stop_propagation(); + if on { + this.send_chat_composer(&pane_id, cx); + } + })) + } }; let draft = div() .flex() diff --git a/crates/herdr-gpui/src/chat/view/embeds.rs b/crates/herdr-gpui/src/chat/view/embeds.rs index e8801f020..d863a6b25 100644 --- a/crates/herdr-gpui/src/chat/view/embeds.rs +++ b/crates/herdr-gpui/src/chat/view/embeds.rs @@ -208,7 +208,7 @@ impl HerdrWindow { ) -> AnyElement { if visuals.wants(id) { return self - .render_chat_visual(id, visuals, Frame::Image) + .render_chat_visual(pane_id, id, visuals, Frame::Image, cx) .into_any_element(); } let theme = &self.theme; diff --git a/crates/herdr-gpui/src/chat/view/entries.rs b/crates/herdr-gpui/src/chat/view/entries.rs index 7ab9ae2ff..cb4ebc423 100644 --- a/crates/herdr-gpui/src/chat/view/entries.rs +++ b/crates/herdr-gpui/src/chat/view/entries.rs @@ -81,12 +81,20 @@ impl HerdrWindow { let body = chat.prepared.body(entry.id); let content = match &entry.kind { EntryKind::User { text, .. } | EntryKind::Agent { text } => { + // A layout may set the user's prompts in a quieter voice. + let size = match &entry.kind { + EntryKind::User { .. } => { + layout_for(self.config.chat.layout).prompt_text(&self.chat_look()) + } + _ => self.chat_text_size(), + }; let message = match body.as_deref() { Some(Body::Message(parts)) => self.render_chat_message( pane_id, entry.id, parts, &chat.prepared.visuals, + size, cx, ), Some(Body::Summary(summary)) => { @@ -147,7 +155,7 @@ impl HerdrWindow { ), EntryKind::Image { author, .. } => match body.as_deref() { Some(Body::Image(id)) => { - self.render_chat_image(*author, *id, &chat.prepared.visuals) + self.render_chat_image(pane_id, *author, *id, &chat.prepared.visuals, cx) } _ => div(), }, @@ -176,6 +184,11 @@ impl HerdrWindow { .and_then(|entry| side(&entry.kind)) }; let transcript = chat.session.transcript(); + // The current turn starts at the user's latest prompt. + let current = entries + .iter() + .rposition(|entry| matches!(entry.kind, EntryKind::User { .. })) + .is_none_or(|start| index >= start); let row = Row { entry, previous: near(index.checked_sub(1)), @@ -183,6 +196,7 @@ impl HerdrWindow { agent: chat.session.agent(), live: index + 1 == entries.len() && transcript.status == herdr_chat_protocol::Status::Working, + current, content, }; layout_for(self.config.chat.layout).row(row, &self.chat_look()) @@ -276,8 +290,8 @@ impl HerdrWindow { .min_w_0() .truncate() .text_font(&self.config.terminal) - .text_size(px(self.config.terminal.size - 1.)) - .text_color(rgb(theme.subtext())) + .text_size(self.chat_tool_text()) + .text_color(rgb(theme.muted)) .child(self.render_chat_text( pane_id, entry, @@ -302,7 +316,9 @@ impl HerdrWindow { .children( page_written(tool).map(|path| self.render_chat_file_page(pane_id, entry, path, cx)), ) - .children(self.render_chat_tool_glance(entry, tool, summary, expanded, visuals)) + .children( + self.render_chat_tool_glance(pane_id, entry, tool, summary, expanded, visuals, cx), + ) .when(expanded, |card| { card.child( div() @@ -311,9 +327,9 @@ impl HerdrWindow { .min_w_0() .w_full() .overflow_x_scroll() - .text_size(small) + .text_size(self.chat_tool_text()) .text_font(&self.config.terminal) - .text_color(rgb(theme.subtext())) + .text_color(rgb(theme.muted)) .child(self.render_chat_text( pane_id, entry, @@ -364,17 +380,20 @@ impl HerdrWindow { /// What a tool call shows without being opened: each file's changed line /// counts and its pictures always, and while folded, its first locations /// and output lines. Opening it shows the full text in their place. + #[allow(clippy::too_many_arguments)] fn render_chat_tool_glance( &self, + pane_id: &str, entry: herdr_chat_protocol::EntryId, tool: &Tool, summary: Option<&ToolSummary>, expanded: bool, visuals: &Visuals, + cx: &mut Context, ) -> Vec { let theme = &self.theme; let hues = Hues::of(theme); - let small = self.chat_small_text(); + let small = self.chat_tool_text(); let note = |text: String| { div() .text_size(small) @@ -392,6 +411,7 @@ impl HerdrWindow { .flex() .gap(px(6.)) .text_size(small) + .text_color(rgb(theme.muted)) .child(div().min_w_0().truncate().child(safe_text(&diff.path, 300))) .child( div() @@ -414,22 +434,21 @@ impl HerdrWindow { .flex_wrap() .gap(px(6.)) .py(px(2.)) - .children( - summary - .images - .iter() - .map(|id| self.render_chat_visual(*id, visuals, Frame::ToolImage)), - ) + .children(summary.images.iter().map(|id| { + self.render_chat_visual(pane_id, *id, visuals, Frame::ToolImage, cx) + })) .into_any_element(), ); } - if expanded { + // Opened, the details show it all; some layouts keep a folded call + // to its one line. + if expanded || !layout_for(self.config.chat.layout).tool_preview() { return glance; } glance.extend(tool.locations.iter().take(LOCATIONS).map(|location| { div() .text_size(small) - .text_color(rgb(theme.subtext())) + .text_color(rgb(theme.muted)) .truncate() .child(safe_text(location, 300)) .into_any_element() @@ -443,7 +462,7 @@ impl HerdrWindow { .debug_selector(move || format!("chat-tool-preview-{}", entry.0)) .text_size(small) .text_font(&self.config.terminal) - .text_color(rgb(theme.subtext())) + .text_color(rgb(theme.muted)) .whitespace_nowrap() .overflow_hidden() .child(line.clone()) diff --git a/crates/herdr-gpui/src/chat/view/entries/questions.rs b/crates/herdr-gpui/src/chat/view/entries/questions.rs index e43751384..9b0ddd7f3 100644 --- a/crates/herdr-gpui/src/chat/view/entries/questions.rs +++ b/crates/herdr-gpui/src/chat/view/entries/questions.rs @@ -26,8 +26,14 @@ impl HerdrWindow { let Some(Body::Answers { text, parts }) = body.as_deref() else { return div().child("Preparing answers…"); }; - let message = - self.render_chat_message(pane_id, entry.id, parts, &chat.prepared.visuals, cx); + let message = self.render_chat_message( + pane_id, + entry.id, + parts, + &chat.prepared.visuals, + self.chat_text_size(), + cx, + ); let id = entry.id.0; div() .debug_selector(move || format!("chat-answers-{id}")) diff --git a/crates/herdr-gpui/src/chat/view/layout.rs b/crates/herdr-gpui/src/chat/view/layout.rs index 62d934d43..35af4a018 100644 --- a/crates/herdr-gpui/src/chat/view/layout.rs +++ b/crates/herdr-gpui/src/chat/view/layout.rs @@ -10,6 +10,7 @@ mod bubbles; mod cards; mod document; +mod ia; mod imessage; mod terminal; mod timeline; @@ -52,6 +53,8 @@ pub(super) struct Row<'a> { pub(super) agent: &'a str, /// The entry is the last while a turn runs, so it may still grow. pub(super) live: bool, + /// The entry belongs to the turn since the user's latest prompt. + pub(super) current: bool, pub(super) content: Div, } @@ -135,6 +138,17 @@ pub(super) trait ChatLayout { div().h(px(18.)).flex().child(button) } + /// Whether a folded tool call previews its files and first output + /// lines under its title: by default it does. + fn tool_preview(&self) -> bool { + true + } + + /// The size the user's prompts are set at: by default the chat's text. + fn prompt_text(&self, look: &Look<'_>) -> f32 { + look.text + } + /// The color an agent message's prose is drawn in: by default the /// theme's foreground. `None` keeps the color the row gives it. fn prose(&self, look: &Look<'_>) -> Option { @@ -154,12 +168,12 @@ pub(super) trait ChatLayout { None } - /// What shows under the conversation for whether the agent is working - /// on a turn: by default nothing, as the header already says so. A row - /// it draws keeps its height either way, so the list above it does not - /// change size as turns start and end. - fn working(&self, _working: bool, _look: &Look<'_>) -> Option
{ - None + /// What shows under the conversation while the agent works on a turn: + /// by default a pulsing dot, what it is doing, and how long the turn + /// has run. A row it draws keeps its height either way, so the list + /// above it does not change size as turns start and end. + fn working(&self, working: Option<&super::working::Working>, look: &Look<'_>) -> Option
{ + Some(super::working::row(working, look)) } /// How a turn ended, from its description: by default a quiet rule @@ -187,6 +201,7 @@ pub(super) fn layout_for(mode: ChatLayoutMode) -> &'static dyn ChatLayout { ChatLayoutMode::Document => &document::Document, ChatLayoutMode::Cards => &cards::Cards, ChatLayoutMode::IMessage => &imessage::IMessage, + ChatLayoutMode::IA => &ia::IA, } } diff --git a/crates/herdr-gpui/src/chat/view/layout/document/tests.rs b/crates/herdr-gpui/src/chat/view/layout/document/tests.rs index 030c09e2b..00aee27fe 100644 --- a/crates/herdr-gpui/src/chat/view/layout/document/tests.rs +++ b/crates/herdr-gpui/src/chat/view/layout/document/tests.rs @@ -33,6 +33,7 @@ fn document_prompt_body_keeps_normal_text_below_its_speaker_label(cx: &mut TestA next: Some(Side::Agent), agent: "Codex", live: false, + current: true, content, }, &view.chat_look(), diff --git a/crates/herdr-gpui/src/chat/view/layout/ia.rs b/crates/herdr-gpui/src/chat/view/layout/ia.rs new file mode 100644 index 000000000..eabe0eaff --- /dev/null +++ b/crates/herdr-gpui/src/chat/view/layout/ia.rs @@ -0,0 +1,164 @@ +//! The chat as iA Writer sets text: one column at a book's measure, the +//! user's prompts and the agent's steps as quiet notes hung from a gutter, +//! the agent's prose in full ink, and everything before the current turn +//! muted. Pointing at an earlier entry brings it back to full ink. + +use super::{ChatLayout, ComposerParts, Look, Row, Side}; +use crate::config::mix; +use gpui::{prelude::*, *}; +use herdr_chat_protocol::{Author, EntryKind}; + +/// Width of the gutter a prompt's mark hangs in, in ems of the chat's text. +const GUTTER: f32 = 1.6; +/// How much of an entry before the current turn shows. +const QUIET: f32 = 0.4; +/// How many points smaller than the prose a note is set. +const NOTE_STEP: f32 = 3.; + +pub(super) struct IA; + +/// Whether an entry frames the answer, quieter and smaller, or is the +/// answer itself. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Place { + Note, + Prose, +} + +fn place(kind: &EntryKind) -> Place { + match kind { + EntryKind::User { .. } + | EntryKind::Tool(_) + | EntryKind::Thought { .. } + | EntryKind::Turn(_) + | EntryKind::Image { + author: Author::User, + .. + } => Place::Note, + _ => Place::Prose, + } +} + +/// The text size of a note. +fn note_text(look: &Look<'_>) -> f32 { + (look.text - NOTE_STEP).max(10.) +} + +impl ChatLayout for IA { + fn row(&self, row: Row<'_>, look: &Look<'_>) -> AnyElement { + let theme = look.theme; + let em = look.text; + let id = row.entry.id.0; + let place = place(&row.entry.kind); + let prompt = matches!(row.entry.kind, EntryKind::User { .. }); + // A prompt opens a turn, so it keeps its distance from the last one. + let above = if prompt && row.speaker() && row.previous.is_some() { + em * 1.8 + } else if row.side() == Some(Side::Agent) && place == Place::Prose { + em * 0.6 + } else { + em * 0.3 + }; + let ink = match place { + Place::Note if prompt => theme.subtext(), + Place::Note => theme.muted, + Place::Prose => theme.foreground, + }; + let gutter = px(em * GUTTER); + let body = div() + .relative() + .pl(gutter) + .when(prompt, |row| { + row.child( + div() + .debug_selector(move || format!("chat-ia-mark-{id}")) + .absolute() + .left_0() + .top_0() + .w(gutter) + .pr(px(6.)) + .flex() + .justify_end() + .text_color(rgb(theme.muted)) + .child("\u{203a}"), + ) + }) + .child( + div() + .min_w_0() + .text_color(rgb(ink)) + .when(place == Place::Note, |note| { + note.text_size(px(note_text(look))) + .line_height(relative(1.45)) + }) + .when(prompt, |note| note.italic()) + .child(row.content), + ); + look.column( + div() + .id(("chat-entry", id)) + .debug_selector(move || format!("chat-ia-{id}")) + .px(px(16.)) + .pt(px(above)) + .min_w_0() + // Before the current turn the text steps back, and comes + // forward again while the pointer rests on it. + .when(!row.current, |row| { + row.opacity(QUIET).hover(|row| row.opacity(1.)) + }) + .child(body), + ) + .into_any_element() + } + + fn prompt_text(&self, look: &Look<'_>) -> f32 { + note_text(look) + } + + /// A step in the margin is one line until it is opened. + fn tool_preview(&self) -> bool { + false + } + + /// Each row sets its own ink, so a note in the margin reads quieter + /// than the prose in the column. + fn prose(&self, _look: &Look<'_>) -> Option { + None + } + + fn turn(&self, text: AnyElement, look: &Look<'_>) -> Div { + div() + .debug_selector(|| "chat-turn".into()) + .text_size(px(note_text(look))) + .text_color(rgb(look.theme.muted)) + .child(text) + } + + fn composer(&self, parts: ComposerParts, look: &Look<'_>) -> Div { + let theme = look.theme; + div() + .flex() + .flex_col() + .gap(px(8.)) + .px(px(16.)) + .pt(px(12.)) + .pb(px(8.)) + .border_t_1() + .border_color(rgb(mix(theme.background, theme.foreground, 14))) + .children(parts.chips.map(|chips| chips.px_0().pt_0())) + .child(parts.draft) + .child( + div() + .debug_selector(|| "chat-settings".into()) + .flex() + .flex_wrap() + .items_center() + .gap(px(8.)) + .child(parts.attach) + .children(parts.settings) + .child(div().flex_1()) + .children(parts.usage) + .child(parts.send), + ) + } +} diff --git a/crates/herdr-gpui/src/chat/view/layout/imessage.rs b/crates/herdr-gpui/src/chat/view/layout/imessage.rs index 4945178a0..f0136a7bb 100644 --- a/crates/herdr-gpui/src/chat/view/layout/imessage.rs +++ b/crates/herdr-gpui/src/chat/view/layout/imessage.rs @@ -149,11 +149,21 @@ impl ChatLayout for IMessage { /// Muted under the last bubble, where Messages shows that the other /// side is typing; its row stays, empty, between turns. - fn working(&self, working: bool, look: &Look<'_>) -> Option
{ + fn working( + &self, + working: Option<&super::super::working::Working>, + look: &Look<'_>, + ) -> Option
{ Some( div() - .when(working, |row| row.debug_selector(|| "chat-typing".into())) - .when(!working, |row| row.invisible()) + .flex() + .items_center() + .gap(px(6.)) + .when(working.is_some(), |row| { + row.debug_selector(|| "chat-typing".into()) + .child(super::super::working::pulse(look)) + }) + .when(working.is_none(), |row| row.invisible()) .px(px(16. + 12. * look.text / TEXT)) .pb(px(4.)) .text_size(px((look.text - 2.).max(10.))) diff --git a/crates/herdr-gpui/src/chat/view/rich.rs b/crates/herdr-gpui/src/chat/view/rich.rs index 2dcdeb1df..bb6bc6f4d 100644 --- a/crates/herdr-gpui/src/chat/view/rich.rs +++ b/crates/herdr-gpui/src/chat/view/rich.rs @@ -10,6 +10,7 @@ use std::ops::Range; mod code; mod links; +mod rhythm; use super::{ copy, @@ -23,6 +24,8 @@ struct RichContext<'a, 'b, 'c> { visuals: &'a Visuals, links: Links, selection: &'a Scope<'a>, + /// The text's size: the chat's, or a layout's for a quieter voice. + size: f32, cx: &'b mut Context<'c, HerdrWindow>, } @@ -33,6 +36,7 @@ impl HerdrWindow { blocks: &[Block], visuals: &Visuals, selection: &Scope<'_>, + size: f32, cx: &mut Context, ) -> Div { let links = Links::new(self, pane_id, cx); @@ -42,18 +46,22 @@ impl HerdrWindow { visuals, links, selection, + size, cx, }; + // A reading width the user chose is followed as set; at the default, + // prose keeps to a book's measure inside the wider column. + let measure = (self.config.chat.max_width == crate::config::chat::DEFAULT_MAX_WIDTH) + .then_some(size * rhythm::MEASURE); div() .flex() .flex_col() - .gap(px(12.)) .min_w_0() .when( self.config.chat.layout != ChatLayoutMode::Terminal, |view| view.text_font(&self.config.ui), ) - .text_size(px(self.chat_text_size())) + .text_size(px(size)) .when_some(layout.prose(&look), |view, ink| view.text_color(rgb(ink))) .line_height(relative(1.6)) .children(blocks.iter().enumerate().map(|(index, block)| { @@ -66,7 +74,7 @@ impl HerdrWindow { .rich_paragraph(*kind, *indent, content, index, &mut context) .into_any_element(), Block::Math { id, source } => { - let formula = self.rich_math(*id, source, visuals, false); + let formula = self.rich_math(*id, source, visuals, false, size); let formula = match selection.fragment(Cell::block(index)) { Some(fragment) => fragment.atomic(formula, &self.theme, context.cx), None => formula, @@ -90,7 +98,16 @@ impl HerdrWindow { .rich_table(align, rows, index, &mut context) .into_any_element(), }; - div().id(("rich-block", index)).min_w_0().child(element) + let previous = index.checked_sub(1).and_then(|before| blocks.get(before)); + div() + .id(("rich-block", index)) + .min_w_0() + .pt(px(size * rhythm::space_before(previous, block))) + .when_some( + measure.filter(|_| rhythm::is_prose(block)), + |row, measure| row.max_w(px(measure)), + ) + .child(element) })) } @@ -109,10 +126,8 @@ impl HerdrWindow { let row = row .when_some(heading(kind), |row, level| { row.font_weight(FontWeight::SEMIBOLD) - .text_size(px(self.chat_text_size() * heading_scale(level))) + .text_size(px(context.size * rhythm::heading_scale(level))) .line_height(relative(1.3)) - .pt(px(10.)) - .pb(px(2.)) }) .when(kind == Kind::Quote, |row| { row.pl(px(10.)) @@ -243,7 +258,7 @@ impl HerdrWindow { } } Inline::Math { id, source } => { - let formula = self.rich_math(*id, source, context.visuals, true); + let formula = self.rich_math(*id, source, context.visuals, true, context.size); let formula = match &fragment { Some(fragment) => fragment.range(offset..offset + source.len()).atomic( formula, @@ -270,7 +285,16 @@ impl HerdrWindow { .children(children) } - fn rich_math(&self, id: VisualId, source: &str, visuals: &Visuals, inline: bool) -> Div { + /// A formula, drawn for the chat's text and scaled to `size`, the text + /// it sits in, so a quieter message keeps its formulas in proportion. + fn rich_math( + &self, + id: VisualId, + source: &str, + visuals: &Visuals, + inline: bool, + size: f32, + ) -> Div { // Math textures use the theme's ink. Keep that surface behind them // when a layout supplies a different message fill, without re-rasterizing. let backdrop = layout_for(self.config.chat.layout).backdrop(&self.chat_look()); @@ -306,11 +330,14 @@ impl HerdrWindow { fallback }); }; + // The texture is drawn for the chat's text size. + let scale = size / self.chat_text_size(); + let (width, height) = (drawn.width * scale, drawn.height * scale); // The zero-width text establishes a text baseline inside the math // atom; the picture is positioned against its measured TeX baseline. ground(if inline { - let text_baseline = self.chat_text_size() * 0.8; - let baseline = drawn.baseline.unwrap_or(drawn.height); + let text_baseline = size * 0.8; + let baseline = drawn.baseline.unwrap_or(drawn.height) * scale; let top = (text_baseline - baseline).max(0.); let before = (baseline - text_baseline).max(0.); div() @@ -318,18 +345,16 @@ impl HerdrWindow { .flex_none() .relative() .max_w_full() - .w(px(drawn.width)) - .h(px( - (drawn.height + top).max(self.chat_text_size() * 1.4 + before) - )) + .w(px(width)) + .h(px((height + top).max(size * 1.4 + before))) .child(div().pt(px(before)).child("\u{200b}")) .child( img(drawn.texture.clone()) .absolute() .top(px(top)) .left(px(0.)) - .w(px(drawn.width)) - .h(px(drawn.height)) + .w(px(width)) + .h(px(height)) .max_w_full() .object_fit(ObjectFit::Contain), ) @@ -341,8 +366,8 @@ impl HerdrWindow { .py(px(8.)) .child( img(drawn.texture.clone()) - .w(px(drawn.width)) - .h(px(drawn.height)) + .w(px(width)) + .h(px(height)) .max_w_full() .object_fit(ObjectFit::Contain), ) @@ -467,15 +492,6 @@ fn heading(kind: Kind) -> Option { } } -fn heading_scale(level: HeadingLevel) -> f32 { - match level { - HeadingLevel::H1 => 1.65, - HeadingLevel::H2 => 1.4, - HeadingLevel::H3 => 1.2, - _ => 1.05, - } -} - /// Byte ranges are shared by style, font and link handling, including Unicode. /// Mixed formulas use the inline-atom layout; normal text stays one element. struct Prose<'a> { diff --git a/crates/herdr-gpui/src/chat/view/rich/rhythm.rs b/crates/herdr-gpui/src/chat/view/rich/rhythm.rs new file mode 100644 index 000000000..b79fbfbef --- /dev/null +++ b/crates/herdr-gpui/src/chat/view/rich/rhythm.rs @@ -0,0 +1,61 @@ +//! The page's rhythm, after iA Writer: prose keeps a book's line length, +//! headings stay near the text's size and bind to what follows them, and +//! the space between blocks says how they relate. Sizes are in ems of the +//! chat's text, so the rhythm holds at every zoom. + +use crate::chat::rich::{Block, Kind}; +use pulldown_cmark::HeadingLevel; + +/// Widest a line of prose runs at the default reading width, in ems: about +/// 65 to 70 characters, where reading is easiest. Code, tables, and +/// pictures take the whole column. +pub(super) const MEASURE: f32 = 34.; + +/// Space above `block` when `previous` comes before it, in ems. +pub(super) fn space_before(previous: Option<&Block>, block: &Block) -> f32 { + let Some(previous) = previous else { + return 0.; + }; + match (kind(previous), kind(block)) { + // A heading belongs to what follows, so it keeps its distance from + // what came before and stays close to its own text. + (_, Some(Kind::Heading(level))) => match level { + HeadingLevel::H1 | HeadingLevel::H2 => 1.4, + _ => 1.1, + }, + (Some(Kind::Heading(_)), _) => 0.35, + // Items of one list read as one block. + (Some(item), Some(next)) if is_item(item) && is_item(next) => 0.3, + _ => 0.75, + } +} + +/// How much larger than the text a heading is set. Deeper levels are set +/// at the text's size; their weight sets them apart. +pub(super) fn heading_scale(level: HeadingLevel) -> f32 { + match level { + HeadingLevel::H1 => 1.35, + HeadingLevel::H2 => 1.18, + HeadingLevel::H3 => 1.05, + _ => 1., + } +} + +/// Whether `block` is prose, which keeps to [`MEASURE`]. +pub(super) fn is_prose(block: &Block) -> bool { + kind(block).is_some_and(|kind| kind != Kind::Rule) +} + +fn kind(block: &Block) -> Option { + match block { + Block::Paragraph { kind, .. } => Some(*kind), + _ => None, + } +} + +fn is_item(kind: Kind) -> bool { + matches!(kind, Kind::Bullet | Kind::Numbered(_)) +} + +#[cfg(test)] +mod tests; diff --git a/crates/herdr-gpui/src/chat/view/rich/rhythm/tests.rs b/crates/herdr-gpui/src/chat/view/rich/rhythm/tests.rs new file mode 100644 index 000000000..7d4473eb7 --- /dev/null +++ b/crates/herdr-gpui/src/chat/view/rich/rhythm/tests.rs @@ -0,0 +1,60 @@ +use super::*; +use crate::chat::rich::Block; + +fn block(kind: Kind) -> Block { + Block::Paragraph { + kind, + indent: 0, + content: Vec::new(), + } +} + +#[test] +fn the_first_block_starts_flush() { + assert_eq!( + space_before(None, &block(Kind::Heading(HeadingLevel::H1))), + 0. + ); +} + +#[test] +fn a_heading_binds_to_its_text_not_what_came_before() { + let heading = block(Kind::Heading(HeadingLevel::H2)); + let paragraph = block(Kind::Paragraph); + let above = space_before(Some(¶graph), &heading); + let below = space_before(Some(&heading), ¶graph); + assert!(above > 2. * below, "{above} above, {below} below"); +} + +#[test] +fn list_items_sit_closer_than_paragraphs() { + let item = block(Kind::Bullet); + let paragraph = block(Kind::Paragraph); + let between_items = space_before(Some(&item), &block(Kind::Numbered(2))); + let between_paragraphs = space_before(Some(¶graph), ¶graph); + assert!(between_items < between_paragraphs); + // A list still stands apart from the prose around it. + assert_eq!(space_before(Some(¶graph), &item), between_paragraphs); + assert_eq!(space_before(Some(&item), ¶graph), between_paragraphs); +} + +#[test] +fn headings_stay_near_the_text_size_and_shrink_by_level() { + let scales = [ + HeadingLevel::H1, + HeadingLevel::H2, + HeadingLevel::H3, + HeadingLevel::H4, + ] + .map(heading_scale); + assert!(scales.windows(2).all(|pair| pair[0] >= pair[1])); + assert!(scales[0] <= 1.4); + assert_eq!(scales[3], 1.); +} + +#[test] +fn prose_keeps_to_the_measure_but_rules_do_not() { + assert!(is_prose(&block(Kind::Paragraph))); + assert!(is_prose(&block(Kind::Quote))); + assert!(!is_prose(&block(Kind::Rule))); +} diff --git a/crates/herdr-gpui/src/chat/view/rich/tests.rs b/crates/herdr-gpui/src/chat/view/rich/tests.rs index cb65f9617..6209c9249 100644 --- a/crates/herdr-gpui/src/chat/view/rich/tests.rs +++ b/crates/herdr-gpui/src/chat/view/rich/tests.rs @@ -5,6 +5,7 @@ use crate::{ }; mod imessage; +mod math_size; mod typography; #[core::prelude::v1::test] diff --git a/crates/herdr-gpui/src/chat/view/rich/tests/imessage.rs b/crates/herdr-gpui/src/chat/view/rich/tests/imessage.rs index da746e804..162fe1b7f 100644 --- a/crates/herdr-gpui/src/chat/view/rich/tests/imessage.rs +++ b/crates/herdr-gpui/src/chat/view/rich/tests/imessage.rs @@ -27,7 +27,14 @@ fn imessage_prose_inherits_its_bubble_while_rich_surfaces_keep_theme_ink(cx: &mu &selected, &geometry, ); - let mut prose = view.render_chat_rich("fixture", &[], &visuals, &selection, cx); + let mut prose = view.render_chat_rich( + "fixture", + &[], + &visuals, + &selection, + view.chat_text_size(), + cx, + ); assert_eq!(prose.text_style().color, None, "{name}: inherit bubble ink"); for style in [ Style::default(), @@ -56,6 +63,7 @@ fn imessage_prose_inherits_its_bubble_while_rich_surfaces_keep_theme_ink(cx: &mu visuals: &visuals, links, selection: &selection, + size: view.chat_text_size(), cx, }; ( @@ -103,7 +111,8 @@ fn imessage_prose_inherits_its_bubble_while_rich_surfaces_keep_theme_ink(cx: &mu } } for inline in [false, true] { - let mut panel = view.rich_math(math, "x", &visuals, inline); + let mut panel = + view.rich_math(math, "x", &visuals, inline, view.chat_text_size()); assert_eq!( panel.style().background, Some(rgb(view.theme.background).into()) @@ -118,13 +127,20 @@ fn imessage_prose_inherits_its_bubble_while_rich_surfaces_keep_theme_ink(cx: &mu ); view.config.chat.layout = ChatLayoutMode::Document; - let mut prose = view.render_chat_rich("fixture", &[], &visuals, &selection, cx); + let mut prose = view.render_chat_rich( + "fixture", + &[], + &visuals, + &selection, + view.chat_text_size(), + cx, + ); assert_eq!( prose.text_style().color, ink, "other layouts retain theme ink" ); - let mut math = view.rich_math(math, "x", &visuals, true); + let mut math = view.rich_math(math, "x", &visuals, true, view.chat_text_size()); assert!( math.style().background.is_none(), "other layouts need no panel" diff --git a/crates/herdr-gpui/src/chat/view/rich/tests/math_size.rs b/crates/herdr-gpui/src/chat/view/rich/tests/math_size.rs new file mode 100644 index 000000000..4bc87c023 --- /dev/null +++ b/crates/herdr-gpui/src/chat/view/rich/tests/math_size.rs @@ -0,0 +1,48 @@ +//! A formula keeps the size of the text it sits in, so a layout that sets +//! a message smaller does not leave its formulas at full size. + +use super::*; +use crate::chat::visual::{DiagramStyle, Drawn, Output, Source}; +use core::prelude::v1::test; +use std::sync::Arc; + +#[gpui::test] +fn a_formula_scales_with_its_message(cx: &mut TestAppContext) { + let renderer = cx.update(|cx| cx.svg_renderer()); + let (view, cx) = cx.add_window_view(crate::sidebar::layout_tests::fixture_window); + view.update(cx, |view, _| { + let mut visuals = Visuals::default(); + let math = VisualId::of("x"); + visuals.want( + math, + Source::Math { + source: "x".into(), + mode: crate::chat::rich::MathMode::Inline, + }, + ); + for job in visuals.jobs(&DiagramStyle::of(&view.theme, &view.config.ui), &renderer) { + let drawn = Drawn { + texture: Arc::new(RenderImage::new(vec![image::Frame::new( + image::RgbaImage::new(1, 1), + )])), + width: 40., + height: 20., + baseline: Some(16.), + }; + assert!(visuals.finish(job.id, job.ticket, Ok(Output::Image(drawn)))); + } + let full = view.chat_text_size(); + let width = |size: f32| { + view.rich_math(math, "x", &visuals, true, size) + .style() + .size + .width + }; + assert_eq!(width(full), Some(px(40.).into())); + assert_eq!( + width(full / 2.), + Some(px(20.).into()), + "half the text, half the formula" + ); + }); +} diff --git a/crates/herdr-gpui/src/chat/view/rich/tests/typography.rs b/crates/herdr-gpui/src/chat/view/rich/tests/typography.rs index b1f5cfe6b..5d7043787 100644 --- a/crates/herdr-gpui/src/chat/view/rich/tests/typography.rs +++ b/crates/herdr-gpui/src/chat/view/rich/tests/typography.rs @@ -59,7 +59,7 @@ fn mixed_math_retains_inline_atom_layout_and_headings_have_hierarchy() { source: "x".into(), }]; assert!(prose_spans(&content).is_none()); - assert!(heading_scale(HeadingLevel::H1) > heading_scale(HeadingLevel::H2)); - assert!(heading_scale(HeadingLevel::H2) > heading_scale(HeadingLevel::H3)); - assert!(heading_scale(HeadingLevel::H3) > heading_scale(HeadingLevel::H4)); + assert!(rhythm::heading_scale(HeadingLevel::H1) > rhythm::heading_scale(HeadingLevel::H2)); + assert!(rhythm::heading_scale(HeadingLevel::H2) > rhythm::heading_scale(HeadingLevel::H3)); + assert!(rhythm::heading_scale(HeadingLevel::H3) > rhythm::heading_scale(HeadingLevel::H4)); } diff --git a/crates/herdr-gpui/src/chat/view/viewer.rs b/crates/herdr-gpui/src/chat/view/viewer.rs new file mode 100644 index 000000000..8e220f109 --- /dev/null +++ b/crates/herdr-gpui/src/chat/view/viewer.rs @@ -0,0 +1,209 @@ +//! A picture from the conversation shown large over its chat. Clicking a +//! picture opens it; a click anywhere or Escape closes it. It fades and +//! grows in, and fades and shrinks back out before it is let go. + +use super::super::{ + chats::ChatPane, + visual::{Shown, VisualId}, +}; +use crate::{HerdrWindow, config::corners, motion}; +use gpui::{prelude::*, *}; + +/// Space kept between the picture and the chat's edges. +const MARGIN: f32 = 32.; +/// How far past its own size a small picture is enlarged. +const MAX_ZOOM: f32 = 2.; +/// How small the picture starts as it grows in, and ends as it leaves. +const FROM: f32 = 0.94; + +/// The picture a chat shows large. +#[derive(Clone, Debug)] +pub(crate) struct Viewer { + closing: bool, + /// Holds the keyboard while the picture shows, so typing never reaches + /// the composer hidden behind it. + focus: FocusHandle, + /// Bumped for each picture opened, so a close timed for an earlier one + /// never lets a later one go, and each plays its own motion. + generation: u64, + id: VisualId, +} + +impl Viewer { + /// The keyboard's handle while the picture is open, not once it leaves. + pub(crate) fn open_focus(&self) -> Option<&FocusHandle> { + (!self.closing).then_some(&self.focus) + } + + pub(crate) fn focus(&self) -> &FocusHandle { + &self.focus + } +} + +impl HerdrWindow { + /// Whether the chat in `pane_id` shows a picture large and holds the + /// keyboard for it. + pub(crate) fn chat_viewer_open(&self, pane_id: &str) -> bool { + self.chats + .pane(pane_id) + .and_then(|pane| pane.viewer.as_ref()) + .is_some_and(|viewer| viewer.open_focus().is_some()) + } + + /// Shows the picture `id` large over the chat in `pane_id`, with the + /// keyboard on it. The click that opened it never reached the chat, so + /// the chat's pane is made the active one here, as a click on the chat + /// would; otherwise the keyboard would go back to the active pane. + pub(crate) fn open_chat_viewer( + &mut self, + pane_id: &str, + id: VisualId, + window: &mut Window, + cx: &mut Context, + ) { + if self.chats.pane(pane_id).is_none() { + return; + } + self.focus_chat(pane_id, window, cx); + let focus = cx.focus_handle(); + let Some(pane) = self.chats.pane_mut(pane_id) else { + return; + }; + let generation = pane + .viewer + .as_ref() + .map_or(0, |viewer| viewer.generation + 1); + pane.viewer = Some(Viewer { + closing: false, + focus: focus.clone(), + generation, + id, + }); + window.focus(&focus, cx); + self.redraw_terminal(cx); + cx.notify(); + } + + /// Starts closing the picture shown large, letting it go once it has + /// left, and gives the keyboard back to the chat. Whether one was open. + pub(crate) fn close_chat_viewer( + &mut self, + pane_id: &str, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(pane) = self.chats.pane_mut(pane_id) else { + return false; + }; + let Some(viewer) = pane.viewer.as_mut().filter(|viewer| !viewer.closing) else { + return false; + }; + viewer.closing = true; + let generation = viewer.generation; + if let Some(focus) = pane.input_focus(cx) { + window.focus(&focus, cx); + } + let pane_id = pane_id.to_owned(); + cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(motion::duration(motion::LEAVE)) + .await; + let _ = this.update(cx, |this, cx| { + let Some(pane) = this.chats.pane_mut(&pane_id) else { + return; + }; + if pane + .viewer + .as_ref() + .is_some_and(|viewer| viewer.closing && viewer.generation == generation) + { + pane.viewer = None; + this.redraw_terminal(cx); + cx.notify(); + } + }); + }) + .detach(); + self.redraw_terminal(cx); + cx.notify(); + true + } + + /// The picture shown large over a chat drawn in `bounds`, if one is. + pub(super) fn render_chat_viewer( + &self, + chat: &ChatPane, + bounds: Bounds, + cx: &mut Context, + ) -> Option { + let viewer = chat.viewer.as_ref()?; + let Shown::Ready(drawn) = chat.prepared.visuals.shown(viewer.id) else { + return None; + }; + let room_w = (f32::from(bounds.size.width) - 2. * MARGIN).max(1.); + let room_h = (f32::from(bounds.size.height) - 2. * MARGIN).max(1.); + let scale = (room_w / drawn.width) + .min(room_h / drawn.height) + .min(MAX_ZOOM); + let (width, height) = (drawn.width * scale, drawn.height * scale); + let closing = viewer.closing; + let phase = if closing { "out" } else { "in" }; + let animation = || { + Animation::new(motion::duration(if closing { + motion::LEAVE + } else { + motion::ENTER + })) + .with_easing(|k| 1. - (1. - k).powi(3)) + }; + // How much shows at `k` through this phase; tests see it in place. + let shown = move |k: f32| { + let k = if cfg!(test) { 1. } else { k }; + if closing { 1. - k } else { k } + }; + let picture = img(drawn.texture.clone()) + .debug_selector(|| "chat-viewer-picture".into()) + .rounded(px(corners::CONTROL)) + .shadow_lg() + .with_animation( + SharedString::from(format!("chat-viewer-picture-{}-{phase}", viewer.generation)), + animation(), + move |picture, k| { + let grown = FROM + (1. - FROM) * shown(k); + picture.w(px(width * grown)).h(px(height * grown)) + }, + ); + let pane_id = chat.target.pane_id.clone(); + let backdrop = (self.theme.background << 8) | 0xec; + Some( + div() + .id(SharedString::from(format!( + "chat-viewer-{}", + viewer.generation + ))) + .debug_selector(|| "chat-viewer".into()) + .track_focus(viewer.focus()) + .absolute() + .inset_0() + .occlude() + .flex() + .items_center() + .justify_center() + .bg(rgba(backdrop)) + .cursor_pointer() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_scroll_wheel(|_, _, cx| cx.stop_propagation()) + .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| { + cx.stop_propagation(); + this.close_chat_viewer(&pane_id, window, cx); + })) + .child(picture) + .with_animation( + SharedString::from(format!("chat-viewer-{}-{phase}", viewer.generation)), + animation(), + move |overlay, k| overlay.opacity(shown(k)), + ) + .into_any_element(), + ) + } +} diff --git a/crates/herdr-gpui/src/chat/view/visuals.rs b/crates/herdr-gpui/src/chat/view/visuals.rs index 2c12334c2..40c872d3d 100644 --- a/crates/herdr-gpui/src/chat/view/visuals.rs +++ b/crates/herdr-gpui/src/chat/view/visuals.rs @@ -34,6 +34,7 @@ impl HerdrWindow { entry: EntryId, parts: &[Part], visuals: &Visuals, + size: f32, cx: &mut Context, ) -> Div { div() @@ -55,7 +56,7 @@ impl HerdrWindow { Part::Rich(blocks) => { match self.chat_text_scope(pane_id, entry, Region::Body(index)) { Some(scope) => self - .render_chat_rich(pane_id, blocks, visuals, &scope, cx) + .render_chat_rich(pane_id, blocks, visuals, &scope, size, cx) .into_any_element(), None => div().into_any_element(), } @@ -63,7 +64,9 @@ impl HerdrWindow { Part::Widget(document) => self .render_chat_widget(pane_id, entry, index, document, cx) .into_any_element(), - Part::Diagram(id, code) => self.render_chat_diagram(*id, code, visuals), + Part::Diagram(id, code) => { + self.render_chat_diagram(pane_id, *id, code, visuals, cx) + } Part::Html { page } => self.render_chat_html(pane_id, entry, index, page, cx), Part::Picture { alt } => self.render_chat_picture_caption(alt), Part::Remote { id, url, alt } => self.render_chat_remote_picture( @@ -75,20 +78,29 @@ impl HerdrWindow { } /// A picture the user or the agent put in the conversation. - pub(super) fn render_chat_image(&self, author: Author, id: VisualId, visuals: &Visuals) -> Div { + pub(super) fn render_chat_image( + &self, + pane_id: &str, + author: Author, + id: VisualId, + visuals: &Visuals, + cx: &mut Context, + ) -> Div { let row = div().flex().min_w_0(); let row = match author { Author::User => row.justify_end(), Author::Agent => row, }; - row.child(self.render_chat_visual(id, visuals, Frame::Image)) + row.child(self.render_chat_visual(pane_id, id, visuals, Frame::Image, cx)) } fn render_chat_diagram( &self, + pane_id: &str, id: VisualId, code: &[release_notes::Line], visuals: &Visuals, + cx: &mut Context, ) -> AnyElement { if let Shown::Failed(error) = visuals.shown(id) { // The source stays readable when it cannot be drawn. @@ -110,13 +122,20 @@ impl HerdrWindow { ) .into_any_element(); } - self.render_chat_visual(id, visuals, Frame::Diagram) + self.render_chat_visual(pane_id, id, visuals, Frame::Diagram, cx) .into_any_element() } /// A visual at its own size, narrowed to fit, or a placeholder while it - /// is prepared. - pub(super) fn render_chat_visual(&self, id: VisualId, visuals: &Visuals, frame: Frame) -> Div { + /// is prepared. Once drawn, a click shows it large. + pub(super) fn render_chat_visual( + &self, + pane_id: &str, + id: VisualId, + visuals: &Visuals, + frame: Frame, + cx: &mut Context, + ) -> Div { let theme = &self.theme; let widest = match frame { Frame::Diagram | Frame::Image => IMAGE_WIDTH, @@ -144,6 +163,17 @@ impl HerdrWindow { .h(px(height)) .max_w_full() .object_fit(ObjectFit::Contain); + let pane_id = pane_id.to_owned(); + let boxed = boxed + .cursor_pointer() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_mouse_up( + MouseButton::Left, + cx.listener(move |this, _: &MouseUpEvent, window, cx| { + cx.stop_propagation(); + this.open_chat_viewer(&pane_id, id, window, cx); + }), + ); match frame { Frame::Diagram => boxed.p(px(8.)).bg(rgb(theme.background)).child(picture), Frame::Image | Frame::ToolImage => boxed.child(picture), diff --git a/crates/herdr-gpui/src/chat/view/working.rs b/crates/herdr-gpui/src/chat/view/working.rs new file mode 100644 index 000000000..4f1bf3665 --- /dev/null +++ b/crates/herdr-gpui/src/chat/view/working.rs @@ -0,0 +1,120 @@ +//! The sign that the agent is still on a turn, under the conversation in +//! every layout: a pulsing dot, what the agent is doing now, and how long +//! the turn has run. It sits outside the list, so it shows however far the +//! conversation is scrolled back. + +use super::layout::Look; +use crate::motion; +use gpui::{prelude::*, *}; +use herdr_chat_protocol::{EntryKind, Status, ToolStatus, Transcript}; +use std::time::Duration; + +/// One beat of the dot's pulse. +const PULSE: Duration = Duration::from_millis(1200); + +/// What the agent is doing while a turn runs. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct Working { + /// Its activity, such as "Thinking" or the command a step runs. + pub(super) doing: String, + /// How long the turn has run, once it is a second or more. + pub(super) elapsed: Option, +} + +impl Working { + /// What `transcript` shows the agent doing, or `None` while it is not + /// on a turn. + pub(super) fn of(transcript: &Transcript, now_ms: u64) -> Option { + if transcript.status != Status::Working { + return None; + } + let doing = match transcript.entries.last().map(|entry| &entry.kind) { + Some(EntryKind::Tool(tool)) + if matches!(tool.status, ToolStatus::Pending | ToolStatus::InProgress) => + { + step_title(&tool.title) + } + Some(EntryKind::Agent { .. }) => "Writing".to_owned(), + _ => "Thinking".to_owned(), + }; + let elapsed = transcript + .turn + .map(|turn| now_ms.saturating_sub(turn.started_ms)) + .filter(|elapsed| *elapsed >= 1000) + .map(herdr_chat_protocol::display::duration); + Some(Self { doing, elapsed }) + } +} + +/// A step's title as one plain line: an agent often wraps a command in +/// code ticks. +fn step_title(title: &str) -> String { + let line = title.lines().next().unwrap_or_default().trim(); + let line = line + .strip_prefix('`') + .and_then(|inner| inner.strip_suffix('`')) + .filter(|inner| !inner.contains('`')) + .unwrap_or(line); + if line.is_empty() { + "Working".to_owned() + } else { + line.to_owned() + } +} + +/// A dot in the theme's accent that pulses while the agent works. +pub(super) fn pulse(look: &Look<'_>) -> impl IntoElement { + div() + .flex_none() + .size(px((look.text * 0.42).max(6.))) + .rounded_full() + .bg(rgb(look.theme.primary())) + .with_animation( + "chat-working-pulse", + Animation::new(motion::duration(PULSE)).repeat(), + |dot, k| { + // Brightest mid-beat, never quite gone. + let k = if cfg!(test) { 0.5 } else { k }; + let wave = 1. - (2. * k - 1.).powi(2); + dot.opacity(0.3 + 0.7 * wave) + }, + ) +} + +/// The working row every layout shows unless it draws its own: blank but +/// the same height between turns, so the list above never changes size as +/// turns start and end. +pub(super) fn row(working: Option<&Working>, look: &Look<'_>) -> Div { + let theme = look.theme; + let small = (look.text - 1.).max(10.); + div() + .flex() + .items_center() + .gap(px(8.)) + .px(px(16.)) + .py(px(4.)) + .h(px(small * 1.6 + 8.)) + .text_size(px(small)) + .map(|row| match working { + None => row.invisible(), + Some(working) => row + .debug_selector(|| "chat-working".into()) + .child(pulse(look)) + .child( + div() + .min_w_0() + .truncate() + .text_color(rgb(theme.subtext())) + .child(format!("{}\u{2026}", working.doing)), + ) + .children(working.elapsed.clone().map(|elapsed| { + div() + .flex_none() + .text_color(rgb(theme.muted)) + .child(elapsed) + })), + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/herdr-gpui/src/chat/view/working/tests.rs b/crates/herdr-gpui/src/chat/view/working/tests.rs new file mode 100644 index 000000000..8be0a9888 --- /dev/null +++ b/crates/herdr-gpui/src/chat/view/working/tests.rs @@ -0,0 +1,99 @@ +use super::*; +use herdr_chat_protocol::{Entry, EntryId, Tool, ToolKind, Turn}; + +fn working(entries: Vec) -> Transcript { + let mut transcript = Transcript::new("Claude"); + transcript.status = Status::Working; + transcript.entries = entries + .into_iter() + .enumerate() + .map(|(index, kind)| Entry { + id: EntryId(index as u64 + 1), + kind, + }) + .collect(); + transcript +} + +fn step(title: &str, status: ToolStatus) -> EntryKind { + EntryKind::Tool(Tool { + call_id: "c".into(), + content: Vec::new(), + kind: ToolKind::Execute, + locations: Vec::new(), + status, + title: title.into(), + }) +} + +fn doing(transcript: &Transcript) -> String { + Working::of(transcript, 0) + .map(|working| working.doing) + .unwrap_or_default() +} + +#[core::prelude::v1::test] +fn nothing_shows_unless_a_turn_runs() { + let mut transcript = working(Vec::new()); + transcript.status = Status::Idle; + assert_eq!(Working::of(&transcript, 0), None); +} + +#[core::prelude::v1::test] +fn it_says_what_the_agent_is_doing() { + let prompt = EntryKind::User { + text: "Fix it".into(), + files: 0, + }; + assert_eq!(doing(&working(vec![prompt.clone()])), "Thinking"); + assert_eq!( + doing(&working(vec![ + prompt.clone(), + EntryKind::Thought { text: "hm".into() } + ])), + "Thinking" + ); + assert_eq!( + doing(&working(vec![ + prompt.clone(), + EntryKind::Agent { + text: "Look".into() + } + ])), + "Writing" + ); + assert_eq!( + doing(&working(vec![ + prompt.clone(), + step("`cargo test -p herdr-gpui`", ToolStatus::InProgress), + ])), + "cargo test -p herdr-gpui" + ); + // A finished step means the agent has moved on to thinking again. + assert_eq!( + doing(&working(vec![prompt, step("ls", ToolStatus::Completed)])), + "Thinking" + ); +} + +#[core::prelude::v1::test] +fn a_step_title_keeps_inner_ticks_and_its_first_line() { + assert_eq!(step_title("`a` then `b`"), "`a` then `b`"); + assert_eq!(step_title("run\nmore"), "run"); + assert_eq!(step_title(" "), "Working"); +} + +#[core::prelude::v1::test] +fn the_elapsed_time_shows_from_the_first_second() { + let mut transcript = working(Vec::new()); + transcript.turn = Some(Turn { started_ms: 10_000 }); + assert_eq!( + Working::of(&transcript, 10_500).and_then(|w| w.elapsed), + None + ); + assert!( + Working::of(&transcript, 22_000) + .and_then(|w| w.elapsed) + .is_some_and(|elapsed| elapsed.contains("12")) + ); +} diff --git a/crates/herdr-gpui/src/chat/window.rs b/crates/herdr-gpui/src/chat/window.rs index 16a748b59..df864f00d 100644 --- a/crates/herdr-gpui/src/chat/window.rs +++ b/crates/herdr-gpui/src/chat/window.rs @@ -144,17 +144,25 @@ impl HerdrWindow { continue; } } - let composer = pane.composer.get_or_insert_with(|| { - changed = true; - cx.new(|cx| { - let mut input = SearchInput::new(cx); - input.set_frameless(cx); - input + let composer = pane + .composer + .get_or_insert_with(|| { + changed = true; + cx.new(|cx| { + let mut input = SearchInput::new(cx); + input.set_frameless(cx); + input + }) }) - }); + .clone(); let accepts = pane.session.transcript().status.accepts_prompts(); // The selected command shows as ghost text after the typed name. let text = composer.read(cx).text().to_owned(); + let send = pane.send_button(&text); + if pane.send != send { + pane.send = send; + changed = true; + } let found = assist::completions(pane.session.transcript(), &text); if pane.completion >= found.len() { pane.completion = 0; @@ -260,6 +268,9 @@ impl HerdrWindow { return None; } let pane = self.chats.pane(pane_id)?; + if let Some(focus) = pane.viewer.as_ref().and_then(|viewer| viewer.open_focus()) { + return Some(focus.clone()); + } if pane.reading(window) { return pane.text_focus.clone(); } @@ -494,6 +505,16 @@ impl HerdrWindow { window: &mut Window, cx: &mut Context, ) { + // A picture shown large takes the keyboard: Escape closes it, and + // nothing typed reaches the composer hidden behind it. + if self.chat_viewer_open(pane_id) { + if event.keystroke.key == "escape" && !event.keystroke.modifiers.modified() { + self.close_chat_viewer(pane_id, window, cx); + } + cx.stop_propagation(); + window.prevent_default(); + return; + } if self.chat_reader_key(pane_id, event, window, cx) { return; } @@ -590,6 +611,10 @@ impl HerdrWindow { window: &mut Window, cx: &mut Context, ) -> bool { + // Nothing pastes into the composer hidden behind a picture. + if self.chat_viewer_open(pane_id) { + return true; + } if self.chat_reader_focused(pane_id, window) { return true; } @@ -613,9 +638,31 @@ impl HerdrWindow { if !input.read(cx).focus.is_focused(window) { return false; } - let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else { + let Some(item) = cx.read_from_clipboard() else { return false; }; + // Files copied in a file manager carry their names as text too; they + // attach as a drop would instead. + if let Some(paths) = item.entries().iter().find_map(|entry| match entry { + ClipboardEntry::ExternalPaths(paths) if !paths.paths().is_empty() => { + Some(paths.paths().to_vec()) + } + _ => None, + }) { + self.attach_chat_files(pane_id, paths, window, cx); + return true; + } + let Some(text) = item.text() else { + // A picture alone, such as a copied screenshot, attaches as a file. + let Some(image) = item.into_entries().find_map(|entry| match entry { + ClipboardEntry::Image(image) => Some(image), + _ => None, + }) else { + return false; + }; + self.attach_chat_image(pane_id, image, window, cx); + return true; + }; let text = text.replace("\r\n", "\n").replace('\r', "\n"); if !text.contains('\n') { return false; diff --git a/crates/herdr-gpui/src/config/chat.rs b/crates/herdr-gpui/src/config/chat.rs index 212c07621..ceffbb31d 100644 --- a/crates/herdr-gpui/src/config/chat.rs +++ b/crates/herdr-gpui/src/config/chat.rs @@ -157,6 +157,10 @@ chat_layouts! { /// Blue bubbles on the right for the user, gray ones on the left for /// the agent, each run ending in a tail, like Apple's Messages. IMessage => "imessage", "chat-layout-imessage"; + /// One column at a book's measure: prompts and steps as quiet notes + /// hung from a gutter mark, the agent's prose in full ink, and earlier + /// turns muted until pointed at, like iA Writer. + IA => "ia", "chat-layout-ia"; } impl ChatLayoutMode { diff --git a/crates/herdr-gpui/src/icons.rs b/crates/herdr-gpui/src/icons.rs index 866d1004b..2ad41af32 100644 --- a/crates/herdr-gpui/src/icons.rs +++ b/crates/herdr-gpui/src/icons.rs @@ -226,6 +226,7 @@ embedded_icons! { // keep-sorted start "arrow-left.svg", "arrow-right.svg", + "arrow-up.svg", "automations.svg", "beads.svg", "bell.svg", diff --git a/crates/herdr-gpui/src/window.rs b/crates/herdr-gpui/src/window.rs index 85133adba..e335289ba 100644 --- a/crates/herdr-gpui/src/window.rs +++ b/crates/herdr-gpui/src/window.rs @@ -25,7 +25,7 @@ pub(crate) use commands::run_window_command; pub(crate) use flash::Flash; pub(crate) use zen::slide_left; mod function_keys; -mod image_source; +pub(crate) mod image_source; mod images; mod input; mod layout_save; diff --git a/crates/herdr-gpui/src/window/image_source.rs b/crates/herdr-gpui/src/window/image_source.rs index dccefd75d..1a1ca0338 100644 --- a/crates/herdr-gpui/src/window/image_source.rs +++ b/crates/herdr-gpui/src/window/image_source.rs @@ -25,7 +25,7 @@ pub(super) const MAX_IMAGE_INPUT_BYTES: usize = 128 * 1024 * 1024; const MAX_DECODE_PIXELS: u64 = 64 * 1024 * 1024; const MAX_DECODE_BYTES: u64 = 256 * 1024 * 1024; -pub(super) struct PreparedImage { +pub(crate) struct PreparedImage { pub extension: &'static str, pub bytes: Vec, /// True when recompressed or downscaled, rather than passed through unchanged. @@ -43,7 +43,7 @@ impl std::fmt::Debug for PreparedImage { } // Deliberately no Debug: neither local paths nor clipboard bytes belong in logs. -pub(super) enum Source { +pub(crate) enum Source { File { path: PathBuf, extension: &'static str, diff --git a/docs/rich-chat.md b/docs/rich-chat.md index 6884f90e3..644fe8078 100644 --- a/docs/rich-chat.md +++ b/docs/rich-chat.md @@ -20,8 +20,11 @@ render_existing = true `render_existing` opts into discovery of running Codex and Claude sessions. It is disabled by default. The chat header cycles -through **Timeline**, **Bubbles**, **Terminal**, **Document**, **Cards**, and -**iMessage**. +through **Timeline**, **Bubbles**, **Terminal**, **Document**, **Cards**, +**iMessage**, and **iA**. **iA** sets the chat like iA Writer: one column at +about 70 characters a line, your prompts and the agent's steps as quiet notes +hung from a gutter mark, the agent's prose in full ink, and earlier turns muted +until you point at them. Select a layout directly with: ```sh @@ -32,7 +35,10 @@ In **Settings > General**, turn on **Full-width chat** to use the available conversation area, or set **Chat width** with the slider or numeric field. The default is 800 logical pixels; custom widths range from 320 to 4,000 and shrink to fit narrower windows. The transcript and native composer use the -same width. The equivalent commands are: +same width. At the default, the agent's prose keeps to about 70 characters a +line, the length that reads most easily, while code, tables, and pictures use +the whole column; a width you choose is followed as set. The equivalent +commands are: ```sh herdr-gpui config set chat.max_width 0 # full width