1use std::io::{self, BufRead, Write};
12
13use ag_ui::client::transport::Transport;
14use ag_ui::client::{
15 MessageChangeKind, MessageUpdate, ReasoningChangeKind, RunEnd, RunStream, Session,
16 SubagentChangeKind, SubagentStatus, SubagentUpdate, Update,
17};
18use ag_ui::{Interrupt, Message};
19use ag_ui_a2ui::binding::Scope;
20use ag_ui_a2ui::constants::ROOT_ID;
21use ag_ui_a2ui::message::{AgentPayload, ChildList, Component};
22use ag_ui_a2ui::toolkit::envelope::{is_operations_envelope, unwrap_operations_envelope};
23use futures_util::StreamExt as _;
24use serde_json::{Value, json};
25
26use crate::board::Board;
27
28#[derive(Debug)]
34pub struct Terminal<R, W> {
35 input: R,
36 output: W,
37 echo: bool,
38}
39
40impl<R: BufRead, W: Write> Terminal<R, W> {
41 pub fn new(input: R, output: W) -> Self {
43 Self {
44 input,
45 output,
46 echo: false,
47 }
48 }
49
50 #[must_use]
52 pub fn echoing(mut self) -> Self {
53 self.echo = true;
54 self
55 }
56
57 pub fn into_output(self) -> W {
59 self.output
60 }
61
62 fn prompt(&mut self, label: &str) -> io::Result<Option<String>> {
64 write!(self.output, "{label}")?;
65 self.output.flush()?;
66
67 let mut line = String::new();
68 if self.input.read_line(&mut line)? == 0 {
69 writeln!(self.output)?;
70 return Ok(None);
71 }
72 if self.echo {
73 writeln!(self.output, "{}", line.trim_end())?;
74 }
75 Ok(Some(line))
76 }
77}
78
79impl<R, W: Write> Write for Terminal<R, W> {
81 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
82 self.output.write(buf)
83 }
84
85 fn flush(&mut self) -> io::Result<()> {
86 self.output.flush()
87 }
88}
89
90pub async fn converse<T: Transport>(
94 session: &mut Session<T, Board>,
95 terminal: &mut Terminal<impl BufRead, impl Write>,
96) -> io::Result<()> {
97 while let Some(line) = terminal.prompt("you> ")? {
98 let said = line.trim().to_owned();
99 if said.is_empty() {
100 continue;
101 }
102 if said.eq_ignore_ascii_case("quit") || said.eq_ignore_ascii_case("exit") {
103 break;
104 }
105 turn(session, &said, terminal).await?;
106 }
107 Ok(())
108}
109
110async fn turn<T: Transport>(
116 session: &mut Session<T, Board>,
117 said: &str,
118 terminal: &mut Terminal<impl BufRead, impl Write>,
119) -> io::Result<()> {
120 let mut pending = drive(session.send(said), terminal).await?;
123
124 while let Some(interrupt) = pending {
125 pending = if approved(&interrupt, terminal)? {
126 drive(
127 session.resume(&interrupt, json!({"confirm": true})),
128 terminal,
129 )
130 .await?
131 } else {
132 drive(session.cancel(&interrupt), terminal).await?
133 };
134 }
135 Ok(())
136}
137
138async fn drive<T: Transport>(
140 mut run: RunStream<'_, T, Board>,
141 output: &mut impl Write,
142) -> io::Result<Option<Interrupt>> {
143 let mut pending = None;
144 let mut open_line = false;
150 let mut board = None;
151
152 while let Some(update) = run.next().await {
153 match update {
154 Update::Message(message) => {
155 let speaker = message
160 .message
161 .subagent_run_id()
162 .and_then(|id| run.session().subagent(id))
163 .map(|subagent| subagent.name.clone());
164 open_line = print_message(output, &message, open_line, speaker.as_deref())?;
165 }
166
167 Update::Subagent(subagent) => {
170 if let Some(said) = lifecycle(&subagent) {
171 writeln!(output, " ⟂ {} {said}", subagent.subagent.name)?;
172 }
173 }
174
175 Update::Reasoning(reasoning) if reasoning.change == ReasoningChangeKind::Ended => {
180 writeln!(output, " ~ {}", reasoning.text)?;
181 }
182
183 Update::State(state) => board = Some(state.summary()),
187
188 Update::Interrupt(interrupt) => pending = Some(interrupt),
189 Update::Error(error) => writeln!(output, " !! {error}")?,
190 Update::Done(RunEnd::Failed { message, .. }) => {
191 writeln!(output, " !! the run failed: {message}")?;
192 }
193 _ => {}
194 }
195
196 if !open_line {
197 if let Some(summary) = board.take() {
198 writeln!(output, " [state] {summary}")?;
199 }
200 }
201 }
202 Ok(pending)
203}
204
205fn lifecycle(update: &SubagentUpdate) -> Option<String> {
207 Some(match &update.change {
208 SubagentChangeKind::Started => "started".to_owned(),
209 SubagentChangeKind::Resumed => "resumed".to_owned(),
211 SubagentChangeKind::Finished => "done".to_owned(),
212 SubagentChangeKind::Suspended => "waiting on a human".to_owned(),
213 SubagentChangeKind::Failed => match &update.subagent.status {
214 SubagentStatus::Failed { message, .. } => format!("failed: {message}"),
215 _ => "failed".to_owned(),
216 },
217 _ => return None,
218 })
219}
220
221fn print_message(
228 output: &mut impl Write,
229 update: &MessageUpdate,
230 open_line: bool,
231 speaker: Option<&str>,
232) -> io::Result<bool> {
233 match &update.change {
234 MessageChangeKind::Started => {
235 write!(output, " {}> ", speaker.unwrap_or("agent"))?;
236 output.flush()?;
237 Ok(true)
238 }
239 MessageChangeKind::Content { delta } => {
242 write!(output, "{delta}")?;
243 output.flush()?;
244 Ok(true)
245 }
246 MessageChangeKind::Ended => {
247 writeln!(output)?;
248 Ok(false)
249 }
250
251 MessageChangeKind::ToolCallStarted { name, .. } => {
252 match speaker {
253 Some(speaker) => write!(output, " · [{speaker}] {name}(")?,
254 None => write!(output, " · {name}(")?,
255 }
256 Ok(true)
257 }
258 MessageChangeKind::ToolCallArgs { delta, .. } => {
259 write!(output, "{delta}")?;
260 Ok(true)
261 }
262 MessageChangeKind::ToolCallEnded { .. } => {
263 writeln!(output, ")")?;
264 Ok(false)
265 }
266 MessageChangeKind::ToolResult { .. } => {
267 print_result(output, &update.message)?;
268 Ok(false)
269 }
270
271 _ => Ok(open_line),
273 }
274}
275
276fn print_result(output: &mut impl Write, message: &Message) -> io::Result<()> {
278 let Message::Tool(tool) = message else {
279 return Ok(());
280 };
281 let Ok(value) = serde_json::from_str::<Value>(&tool.content) else {
282 writeln!(output, " → {}", tool.content)?;
283 return Ok(());
284 };
285
286 if !is_operations_envelope(&value) {
289 writeln!(output, " → {value}")?;
290 return Ok(());
291 }
292
293 match surface_lines(&value) {
294 Some(lines) => {
295 writeln!(output, " ┌ a2ui surface")?;
296 for line in lines {
297 writeln!(output, " │ {line}")?;
298 }
299 writeln!(output, " └")
300 }
301 None => writeln!(output, " → an A2UI envelope this client cannot draw"),
302 }
303}
304
305fn surface_lines(envelope: &Value) -> Option<Vec<String>> {
312 let operations = unwrap_operations_envelope(envelope).ok()?;
313
314 let mut components: Vec<Component> = Vec::new();
315 let mut data = Value::Null;
316 for operation in &operations {
317 match &operation.payload {
318 AgentPayload::UpdateComponents(payload) => components.clone_from(&payload.components),
319 AgentPayload::UpdateDataModel(payload) => data = payload.value.clone(),
320 _ => {}
321 }
322 }
323 if components.is_empty() {
324 return None;
325 }
326
327 let mut lines = Vec::new();
328 draw(&components, &Scope::root(&data), ROOT_ID, &mut lines);
329 Some(lines)
330}
331
332fn draw(components: &[Component], scope: &Scope<'_>, id: &str, lines: &mut Vec<String>) {
334 let Some(component) = components.iter().find(|component| component.id == id) else {
335 lines.push(format!("<no component {id}>"));
336 return;
337 };
338
339 match component.component.as_str() {
340 "Text" => lines.push(bound(scope, component, "text")),
341 "CheckBox" => {
342 let mark = if bound(scope, component, "value") == "true" {
343 "x"
344 } else {
345 " "
346 };
347 lines.push(format!("[{mark}] {}", bound(scope, component, "label")));
348 }
349 "Card" => {
350 if let Some(child) = component.prop("child").and_then(Value::as_str) {
351 draw(components, scope, child, lines);
352 }
353 }
354 _ => match component.prop("children").and_then(ChildList::from_value) {
357 Some(ChildList::Ids(ids)) => {
358 for child in ids {
359 draw(components, scope, &child, lines);
360 }
361 }
362 Some(ChildList::Template(template)) => {
363 let count = scope
364 .resolve(&template.path)
365 .and_then(Value::as_array)
366 .map_or(0, Vec::len);
367 for index in 0..count {
368 let item = scope.item(&template.path, index);
371 draw(components, &item, &template.component_id, lines);
372 }
373 }
374 None => lines.push(format!("<{} has no children>", component.component)),
375 },
376 }
377}
378
379fn bound(scope: &Scope<'_>, component: &Component, key: &str) -> String {
382 let Some(raw) = component.prop(key) else {
383 return String::new();
384 };
385 match scope.resolve_dynamic(raw) {
386 Ok(Value::String(text)) => text,
387 Ok(Value::Null) => String::new(),
388 Ok(other) => other.to_string(),
389 Err(error) => format!("<{error}>"),
390 }
391}
392
393fn approved(
396 interrupt: &Interrupt,
397 terminal: &mut Terminal<impl BufRead, impl Write>,
398) -> io::Result<bool> {
399 let question = interrupt
400 .message
401 .as_deref()
402 .unwrap_or("The agent is waiting for a decision.");
403 writeln!(terminal, " ?? {question}")?;
404
405 let Some(answer) = terminal.prompt(" [y/N] ")? else {
406 writeln!(terminal, " (no answer — declining)")?;
407 return Ok(false);
408 };
409 let answer = answer.trim();
410 Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
411}