Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions charts/openab/templates/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ data:
[reactions]
enabled = {{ ($cfg.reactions).enabled | default true }}
remove_after_reply = {{ ($cfg.reactions).removeAfterReply | default false }}
tool_display = "{{ ($cfg.reactions).toolDisplay | default "compact" }}"
{{- if ($cfg.stt).enabled }}
{{- if not ($cfg.stt).apiKey }}
{{ fail (printf "agents.%s.stt.apiKey is required when stt.enabled=true" $name) }}
Expand Down
1 change: 1 addition & 0 deletions charts/openab/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ agents:
reactions:
enabled: true
removeAfterReply: false
toolDisplay: "compact" # full | compact | none
stt:
enabled: false
apiKey: ""
Expand Down
1 change: 1 addition & 0 deletions config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ session_ttl_hours = 24
[reactions]
enabled = true
remove_after_reply = false
tool_display = "compact" # full (complete command) | compact (name only) | none (hide tool lines)

[reactions.emojis]
queued = "👀"
Expand Down
1 change: 1 addition & 0 deletions k8s/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ data:
[reactions]
enabled = true
remove_after_reply = false
tool_display = "compact" # full (complete command) | compact (name only) | none (hide tool lines)
42 changes: 42 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,15 @@ pub struct PoolConfig {
pub session_ttl_hours: u64,
}

#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum ToolDisplay {
Full,
#[default]
Compact,
None,
}

#[derive(Debug, Deserialize)]
pub struct ReactionsConfig {
#[serde(default = "default_true")]
Expand All @@ -79,6 +88,8 @@ pub struct ReactionsConfig {
pub emojis: ReactionEmojis,
#[serde(default)]
pub timing: ReactionTiming,
#[serde(default)]
pub tool_display: ToolDisplay,
}

#[derive(Debug, Clone, Deserialize)]
Expand Down Expand Up @@ -147,6 +158,7 @@ impl Default for ReactionsConfig {
remove_after_reply: false,
emojis: ReactionEmojis::default(),
timing: ReactionTiming::default(),
tool_display: ToolDisplay::default(),
}
}
}
Expand Down Expand Up @@ -188,3 +200,33 @@ pub fn load_config(path: &Path) -> anyhow::Result<Config> {
.map_err(|e| anyhow::anyhow!("failed to parse {}: {e}", path.display()))?;
Ok(config)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn tool_display_deserializes_all_modes() {
let toml_full: ReactionsConfig = toml::from_str(r#"tool_display = "full""#).unwrap();
assert_eq!(toml_full.tool_display, ToolDisplay::Full);

let toml_compact: ReactionsConfig = toml::from_str(r#"tool_display = "compact""#).unwrap();
assert_eq!(toml_compact.tool_display, ToolDisplay::Compact);

let toml_none: ReactionsConfig = toml::from_str(r#"tool_display = "none""#).unwrap();
assert_eq!(toml_none.tool_display, ToolDisplay::None);
}

#[test]
fn tool_display_defaults_to_compact() {
// Empty config → all defaults, including tool_display = compact
let config: ReactionsConfig = toml::from_str("").unwrap();
assert_eq!(config.tool_display, ToolDisplay::Compact);
}

#[test]
fn tool_display_rejects_invalid_value() {
let result: Result<ReactionsConfig, _> = toml::from_str(r#"tool_display = "abbreviated""#);
assert!(result.is_err(), "invalid tool_display value must be rejected");
}
}
116 changes: 106 additions & 10 deletions src/discord.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::acp::{classify_notification, AcpEvent, ContentBlock, SessionPool};
use crate::config::{ReactionsConfig, SttConfig};
use crate::config::{ReactionsConfig, SttConfig, ToolDisplay};
use crate::error_display::{format_coded_error, format_user_error};
use crate::format;
use crate::reactions::StatusReactionController;
Expand Down Expand Up @@ -209,6 +209,7 @@ impl EventHandler for Handler {
thread_channel,
thinking_msg.id,
reactions.clone(),
self.reactions_config.tool_display,
)
.await;

Expand Down Expand Up @@ -422,6 +423,7 @@ async fn stream_prompt(
channel: ChannelId,
msg_id: MessageId,
reactions: Arc<StatusReactionController>,
tool_display: ToolDisplay,
) -> anyhow::Result<()> {
let reactions = reactions.clone();

Expand Down Expand Up @@ -507,7 +509,7 @@ async fn stream_prompt(
// Reaction: back to thinking after tools
}
text_buf.push_str(&t);
let _ = buf_tx.send(compose_display(&tool_lines, &text_buf));
let _ = buf_tx.send(compose_display(&tool_lines, &text_buf, tool_display));
}
AcpEvent::Thinking => {
reactions.set_thinking().await;
Expand All @@ -532,7 +534,7 @@ async fn stream_prompt(
state: ToolState::Running,
});
}
let _ = buf_tx.send(compose_display(&tool_lines, &text_buf));
let _ = buf_tx.send(compose_display(&tool_lines, &text_buf, tool_display));
}
AcpEvent::ToolDone { id, title, status } => {
reactions.set_thinking().await;
Expand Down Expand Up @@ -560,7 +562,7 @@ async fn stream_prompt(
state: new_state,
});
}
let _ = buf_tx.send(compose_display(&tool_lines, &text_buf));
let _ = buf_tx.send(compose_display(&tool_lines, &text_buf, tool_display));
}
_ => {}
}
Expand All @@ -572,7 +574,7 @@ async fn stream_prompt(
let _ = edit_handle.await;

// Final edit
let final_content = compose_display(&tool_lines, &text_buf);
let final_content = compose_display(&tool_lines, &text_buf, tool_display);
// If ACP returned both an error and partial text, show both.
// This can happen when the agent started producing content before hitting an error
// (e.g. context length limit, rate limit mid-stream). Showing both gives users
Expand Down Expand Up @@ -614,6 +616,35 @@ fn sanitize_title(title: &str) -> String {
title.replace('\r', "").replace('\n', " ; ").replace('`', "'")
}

/// For `ToolDisplay::Compact` mode: return a short label for the tool call.
///
/// Two title formats are in use across ACP backends:
///
/// - **`"Verb: command"` (Kiro and similar):** the segment before `:` is a
/// short semantic label ("Running", "Edit", "Read file"). Detected when the
/// colon is NOT a URL scheme separator (i.e. not followed by `//`) and the
/// prefix is short (≤ 3 words, ≤ 20 chars).
/// - **Raw command (claude-agent-acp):** the title is the shell command itself
/// ("curl -s https://...", "git status"). Colons appear inside URLs, so
/// splitting naively produces garbage ("curl -s https"). We fall back to the
/// first word (the executable name).
fn compact_title(title: &str) -> String {
if let Some(idx) = title.find(':') {
// A URL scheme separator looks like "://"; skip it so "curl -s https://..."
// doesn't yield a garbled "curl -s https" prefix.
if !title[idx..].starts_with("://") {
let prefix = title[..idx].trim();
let word_count = prefix.split_whitespace().count();
// Accept as a semantic label: ≤ 3 words and ≤ 20 chars
if word_count <= 3 && prefix.len() <= 20 {
return prefix.to_string();
}
}
}
// Fallback: first word (executable name), safe for raw-command titles
title.split_whitespace().next().unwrap_or(title).to_string()
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ToolState {
Running,
Expand All @@ -629,22 +660,26 @@ struct ToolEntry {
}

impl ToolEntry {
fn render(&self) -> String {
fn render(&self, display: ToolDisplay) -> String {
let icon = match self.state {
ToolState::Running => "🔧",
ToolState::Completed => "✅",
ToolState::Failed => "❌",
};
let suffix = if self.state == ToolState::Running { "..." } else { "" };
format!("{icon} `{}`{}", self.title, suffix)
let title = match display {
ToolDisplay::Compact => compact_title(&self.title),
ToolDisplay::Full | ToolDisplay::None => self.title.clone(),
};
format!("{icon} `{}`{}", title, suffix)
}
}

fn compose_display(tool_lines: &[ToolEntry], text: &str) -> String {
fn compose_display(tool_lines: &[ToolEntry], text: &str, display: ToolDisplay) -> String {
let mut out = String::new();
if !tool_lines.is_empty() {
if !matches!(display, ToolDisplay::None) && !tool_lines.is_empty() {
for entry in tool_lines {
out.push_str(&entry.render());
out.push_str(&entry.render(display));
out.push('\n');
}
out.push('\n');
Expand Down Expand Up @@ -779,4 +814,65 @@ mod tests {
let garbage = vec![0x00, 0x01, 0x02, 0x03];
assert!(resize_and_compress(&garbage).is_err());
}

#[test]
fn compact_title_verb_colon_format() {
// "Verb: command" format: extract the short label before ':'
assert_eq!(compact_title("Running: curl -s url"), "Running");
assert_eq!(compact_title("Edit: /path/to/file.rs"), "Edit");
assert_eq!(compact_title("Read file: /etc/passwd"), "Read file");
assert_eq!(compact_title(" Running: curl "), "Running");
}

#[test]
fn compact_title_raw_command_format() {
// claude-agent-acp / raw command titles: return executable name
assert_eq!(compact_title("git status"), "git");
assert_eq!(compact_title("ls -la && echo done"), "ls");
assert_eq!(compact_title("pwd"), "pwd");
assert_eq!(compact_title("Terminal"), "Terminal");
}

#[test]
fn compact_title_url_in_command_not_truncated() {
// URL colons must not produce garbage like "curl -s https"
assert_eq!(compact_title("curl -s https://example.com"), "curl");
assert_eq!(compact_title("curl -s https://example.com | python3 -c 'import sys'"), "curl");
}

fn make_tool(title: &str, state: ToolState) -> ToolEntry {
ToolEntry { id: "1".into(), title: title.into(), state }
}

#[test]
fn compose_display_full_shows_complete_title() {
let tools = vec![make_tool("Running: curl -s https://example.com | python3 -c 'import sys'", ToolState::Completed)];
let out = compose_display(&tools, "done", ToolDisplay::Full);
assert!(out.contains("Running: curl -s https://example.com"), "full mode must show complete title");
assert!(out.contains("done"));
}

#[test]
fn compose_display_compact_shows_only_prefix() {
let tools = vec![make_tool("Running: curl -s https://example.com | python3 -c 'import sys'", ToolState::Completed)];
let out = compose_display(&tools, "done", ToolDisplay::Compact);
assert!(out.contains("Running"), "compact mode must include prefix");
assert!(!out.contains("curl"), "compact mode must not include args");
assert!(out.contains("done"));
}

#[test]
fn compose_display_none_hides_all_tool_lines() {
let tools = vec![make_tool("Running: curl -s url", ToolState::Completed)];
let out = compose_display(&tools, "done", ToolDisplay::None);
assert!(!out.contains("Running"), "none mode must hide tool lines");
assert!(!out.contains("✅"), "none mode must hide icons");
assert!(out.contains("done"), "none mode must still show text");
}

#[test]
fn compose_display_empty_tools_no_blank_line() {
let out = compose_display(&[], "hello", ToolDisplay::Compact);
assert_eq!(out, "hello");
}
}