steer_tui/tui/widgets/formatters/
dispatch_agent.rs1use super::{
2 ToolFormatter,
3 helpers::{separator_line, tool_error_user_message, truncate_lines},
4};
5use crate::tui::theme::Theme;
6use ratatui::{
7 style::Style,
8 text::{Line, Span},
9};
10use serde_json::Value;
11use steer_grpc::client_api::{ToolResult, default_agent_spec_id};
12use steer_tools::tools::dispatch_agent::{
13 DispatchAgentParams, DispatchAgentTarget, WorkspaceTarget,
14};
15
16pub struct DispatchAgentFormatter;
17
18impl ToolFormatter for DispatchAgentFormatter {
19 fn compact(
20 &self,
21 params: &Value,
22 result: &Option<ToolResult>,
23 _wrap_width: usize,
24 theme: &Theme,
25 ) -> Vec<Line<'static>> {
26 let mut lines = Vec::new();
27
28 let Ok(params) = serde_json::from_value::<DispatchAgentParams>(params.clone()) else {
29 return vec![Line::from(Span::styled(
30 "Invalid agent params",
31 theme.error_text(),
32 ))];
33 };
34
35 let preview = if params.prompt.len() > 60 {
36 format!("{}...", ¶ms.prompt[..57])
37 } else {
38 params.prompt.clone()
39 };
40
41 let (agent_id, workspace_summary, params_session_id) = match ¶ms.target {
42 DispatchAgentTarget::New { workspace, agent } => {
43 let agent_id = agent
44 .as_deref()
45 .filter(|value| !value.trim().is_empty())
46 .map_or_else(|| default_agent_spec_id().to_string(), str::to_string);
47 let workspace_summary = match workspace {
48 WorkspaceTarget::Current => "current".to_string(),
49 WorkspaceTarget::New { name } => format!("{name} (new)"),
50 };
51 (agent_id, workspace_summary, None)
52 }
53 DispatchAgentTarget::Resume { session_id } => (
54 "resume".to_string(),
55 "session".to_string(),
56 Some(session_id.clone()),
57 ),
58 };
59
60 let session_label = match result {
61 Some(ToolResult::Agent(agent_result)) => agent_result.session_id.clone(),
62 _ => None,
63 }
64 .or(params_session_id);
65
66 let info = match result {
67 Some(ToolResult::Agent(agent_result)) => {
68 let line_count = agent_result.content.lines().count();
69 format!("{line_count} lines")
70 }
71 Some(ToolResult::Error(_)) => "failed".to_string(),
72 Some(_) => "unexpected result type".to_string(),
73 None => "running...".to_string(),
74 };
75
76 lines.push(Line::from(vec![
77 Span::styled(format!("agent={agent_id} "), theme.subtle_text()),
78 Span::styled(
79 format!("workspace={workspace_summary} "),
80 theme.subtle_text(),
81 ),
82 session_label.as_ref().map_or_else(
83 || Span::raw(""),
84 |id| Span::styled(format!("session={id} "), theme.subtle_text()),
85 ),
86 Span::styled(format!("task='{preview}' "), Style::default()),
87 Span::styled(format!("({info})"), theme.subtle_text()),
88 ]));
89
90 lines
91 }
92
93 fn detailed(
94 &self,
95 params: &Value,
96 result: &Option<ToolResult>,
97 wrap_width: usize,
98 theme: &Theme,
99 ) -> Vec<Line<'static>> {
100 let mut lines = Vec::new();
101
102 let Ok(params) = serde_json::from_value::<DispatchAgentParams>(params.clone()) else {
103 return vec![Line::from(Span::styled(
104 "Invalid agent params",
105 theme.error_text(),
106 ))];
107 };
108
109 let (agent_id, workspace_label, params_session_id) = match ¶ms.target {
110 DispatchAgentTarget::New { workspace, agent } => {
111 let agent_id = agent
112 .as_deref()
113 .filter(|value| !value.trim().is_empty())
114 .map_or_else(|| default_agent_spec_id().to_string(), str::to_string);
115 let workspace_label = match workspace {
116 WorkspaceTarget::Current => "current".to_string(),
117 WorkspaceTarget::New { name } => format!("{name} (new)"),
118 };
119 (agent_id, workspace_label, None)
120 }
121 DispatchAgentTarget::Resume { session_id } => (
122 "resume".to_string(),
123 "session".to_string(),
124 Some(session_id.clone()),
125 ),
126 };
127
128 lines.push(Line::from(vec![
129 Span::styled("Agent: ", theme.subtle_text()),
130 Span::styled(agent_id, Style::default()),
131 ]));
132 lines.push(Line::from(vec![
133 Span::styled("Workspace: ", theme.subtle_text()),
134 Span::styled(workspace_label, Style::default()),
135 ]));
136 if let Some(session_id) = params_session_id.as_ref() {
137 lines.push(Line::from(vec![
138 Span::styled("Session: ", theme.subtle_text()),
139 Span::styled(session_id.clone(), Style::default()),
140 ]));
141 }
142
143 lines.push(Line::from(Span::styled("Instructions:", theme.text())));
144 for line in params.prompt.lines() {
145 for wrapped_line in textwrap::wrap(line, wrap_width) {
146 lines.push(Line::from(Span::styled(
147 wrapped_line.to_string(),
148 Style::default(),
149 )));
150 }
151 }
152
153 if let Some(result) = result {
155 match result {
156 ToolResult::Agent(agent_result) => {
157 if let Some(session_id) = agent_result.session_id.as_ref() {
158 lines.push(separator_line(wrap_width, theme.dim_text()));
159 lines.push(Line::from(Span::styled("Session:", theme.subtle_text())));
160 lines.push(Line::from(Span::styled(
161 format!(" {session_id}"),
162 Style::default(),
163 )));
164 }
165
166 if let Some(workspace) = agent_result.workspace.as_ref() {
167 lines.push(separator_line(wrap_width, theme.dim_text()));
168 lines.push(Line::from(Span::styled(
169 "Workspace Revision:",
170 theme.subtle_text(),
171 )));
172 if let Some(workspace_id) = workspace.workspace_id.as_ref() {
173 lines.push(Line::from(Span::styled(
174 format!(" id: {workspace_id}"),
175 Style::default(),
176 )));
177 }
178 if let Some(revision) = workspace.revision.as_ref() {
179 let kind = revision.vcs_kind.as_str();
180 let mut summary_line =
181 format!(" {}: revision {}", kind, revision.revision_id);
182 if let Some(change_id) = revision.change_id.as_ref() {
183 summary_line.push_str(&format!(" change {change_id}"));
184 }
185 lines.push(Line::from(Span::styled(summary_line, Style::default())));
186 if !revision.summary.trim().is_empty() {
187 lines.push(Line::from(Span::styled(
188 format!(" summary: {}", revision.summary),
189 Style::default(),
190 )));
191 }
192 }
193 }
194
195 if !agent_result.content.trim().is_empty() {
196 lines.push(separator_line(wrap_width, theme.dim_text()));
197
198 const MAX_OUTPUT_LINES: usize = 30;
199 let (output_lines, truncated) =
200 truncate_lines(&agent_result.content, MAX_OUTPUT_LINES);
201
202 for line in output_lines {
203 for wrapped in textwrap::wrap(line, wrap_width) {
204 lines.push(Line::from(Span::raw(wrapped.to_string())));
205 }
206 }
207
208 if truncated {
209 lines.push(Line::from(Span::styled(
210 format!(
211 "... ({} more lines)",
212 agent_result.content.lines().count() - MAX_OUTPUT_LINES
213 ),
214 theme.subtle_text(),
215 )));
216 }
217 }
218 }
219 ToolResult::Error(error) => {
220 lines.push(separator_line(wrap_width, theme.dim_text()));
221 lines.push(Line::from(Span::styled(
222 tool_error_user_message(error).into_owned(),
223 theme.error_text(),
224 )));
225 }
226 _ => {
227 lines.push(Line::from(Span::styled(
228 "Unexpected result type",
229 theme.error_text(),
230 )));
231 }
232 }
233 }
234
235 lines
236 }
237
238 fn approval(&self, params: &Value, wrap_width: usize, theme: &Theme) -> Vec<Line<'static>> {
239 let mut lines = Vec::new();
240
241 let Ok(params) = serde_json::from_value::<DispatchAgentParams>(params.clone()) else {
242 return vec![Line::from(Span::styled(
243 "Invalid agent params",
244 theme.error_text(),
245 ))];
246 };
247
248 let (agent_id, workspace_label, params_session_id) = match ¶ms.target {
249 DispatchAgentTarget::New { workspace, agent } => {
250 let agent_id = agent
251 .as_deref()
252 .filter(|value| !value.trim().is_empty())
253 .map_or_else(|| default_agent_spec_id().to_string(), str::to_string);
254 let workspace_label = match workspace {
255 WorkspaceTarget::Current => "current".to_string(),
256 WorkspaceTarget::New { name } => format!("{name} (new)"),
257 };
258 (agent_id, workspace_label, None)
259 }
260 DispatchAgentTarget::Resume { session_id } => (
261 "resume".to_string(),
262 "session".to_string(),
263 Some(session_id.clone()),
264 ),
265 };
266
267 lines.push(Line::from(vec![
268 Span::styled("Agent: ", theme.subtle_text()),
269 Span::styled(agent_id, Style::default()),
270 ]));
271 lines.push(Line::from(vec![
272 Span::styled("Workspace: ", theme.subtle_text()),
273 Span::styled(workspace_label, Style::default()),
274 ]));
275 if let Some(session_id) = params_session_id.as_ref() {
276 lines.push(Line::from(vec![
277 Span::styled("Session: ", theme.subtle_text()),
278 Span::styled(session_id.clone(), Style::default()),
279 ]));
280 }
281
282 lines.push(Line::from(Span::styled(
283 "Instructions:",
284 theme.subtle_text(),
285 )));
286 for line in params.prompt.lines() {
287 for wrapped_line in textwrap::wrap(line, wrap_width) {
288 lines.push(Line::from(Span::styled(
289 wrapped_line.to_string(),
290 Style::default(),
291 )));
292 }
293 }
294
295 lines
296 }
297}