Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4be8119
fix(chat): attach dropped files and pasted screenshots in agent mode
penso Oct 29, 2026
a88744f
feat(chat): stop a running turn from the send button
penso Oct 29, 2026
a09b2a9
fix(chat): disable the send button until a prompt is written
penso Oct 29, 2026
91af2aa
feat(acp): draw Markdown and quiet tool calls in the terminal chat view
penso Oct 29, 2026
72256ea
feat(chat): point the send button's arrow up, as Messages does
penso Oct 29, 2026
5b61428
feat(chat): open a picture large with a click, and close it with Escape
penso Oct 29, 2026
61d8b66
feat(chat): set tool calls smaller and muted so the agent's answer re…
penso Oct 29, 2026
4ab8eb6
feat(chat): show what the agent is doing while it works, in every layout
penso Oct 29, 2026
d63eaa1
feat(chat): add an iA layout that mutes earlier turns, like iA Writer
penso Oct 29, 2026
c9d44f7
feat(chat): keep the agent's prose to a book's measure, with gentler …
penso Oct 29, 2026
53a1f10
test(chat): preview tool calls, earlier turns, attachments, and a pic…
penso Oct 29, 2026
5f7f342
style: format the terminal tool and iA layout tests
penso Oct 29, 2026
ac88956
Merge remote-tracking branch 'origin/main' into worktree/agent-mode-i…
penso Oct 29, 2026
74404dd
fix(chat): keep a pasted picture with the prompt it was pasted for
penso Oct 29, 2026
63269fb
fix(chat): keep typing out of the composer while a picture is shown l…
penso Oct 29, 2026
2fdafd0
fix(chat): scale formulas with the text they sit in
penso Oct 29, 2026
685242e
fix(acp): keep a link's address readable in the terminal chat view
penso Oct 29, 2026
2b2c683
fix(chat): make a picture's pane active when it opens large
penso Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions assets/icons/arrow-up.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
49 changes: 37 additions & 12 deletions crates/herdr-acp/src/tui/lines.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -158,7 +162,7 @@ pub(crate) fn entry(entry: &Entry, width: usize) -> Vec<Line<'static>> {
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<Line<'static>>, text: &str, width: usize) {
let mut code = false;
for raw in text.trim_end().split('\n') {
Expand All @@ -170,13 +174,11 @@ fn agent_text(lines: &mut Vec<Line<'static>>, 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));
}
Expand All @@ -190,15 +192,38 @@ fn tool_lines(lines: &mut Vec<Line<'static>>, 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::<usize>()
> 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));
}
Expand Down
225 changes: 225 additions & 0 deletions crates/herdr-acp/src/tui/lines/prose.rs
Original file line number Diff line number Diff line change
@@ -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<Line<'static>>, 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;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
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<Vec<(char, Style)>> {
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<Span<'static>> {
let mut spans: Vec<Span<'static>> = 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;
Loading
Loading