{
- 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