aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/tui/view/turn.rs
diff options
context:
space:
mode:
authorMichelle Tilley <michelle@michelletilley.net>2026-03-26 19:19:47 -0700
committerGitHub <noreply@github.com>2026-03-27 02:19:47 +0000
commitb649a7ab8de6488c1341e94c37d032c07d5b3f13 (patch)
treeca9aadc1175b8439dd85de135f3804681b755776 /crates/atuin-ai/src/tui/view/turn.rs
parentfix: set WorkingDirectory in PowerShell Invoke-AtuinSearch (#3351) (diff)
downloadatuin-b649a7ab8de6488c1341e94c37d032c07d5b3f13.zip
feat: Use eye-declare for more performant and flexible AI TUI (#3343)
This PR replaces the mess of custom rendering code in Atuin AI with [eye-declare](https://github.com/BinaryMuse/eye-declare), and updates the TUI to feel more terminal-native: output appears inline and persists in scrollback, so you can scroll up and look at previous conversations for reference. The "review" state — which used to exist between the LLM generating a response and the user either executing or following up — has been removed; just start typing to follow up with the LLM, or press `enter` at the empty input box to execute the suggested command. <img width="1203" height="633" alt="image" src="https://github.com/user-attachments/assets/159ee447-9a2a-4edd-b56e-a79bf1aaaa94" />
Diffstat (limited to 'crates/atuin-ai/src/tui/view/turn.rs')
-rw-r--r--crates/atuin-ai/src/tui/view/turn.rs409
1 files changed, 409 insertions, 0 deletions
diff --git a/crates/atuin-ai/src/tui/view/turn.rs b/crates/atuin-ai/src/tui/view/turn.rs
new file mode 100644
index 00000000..861da64c
--- /dev/null
+++ b/crates/atuin-ai/src/tui/view/turn.rs
@@ -0,0 +1,409 @@
+use crate::tui::ConversationEvent;
+
+#[derive(Debug)]
+pub(crate) enum DangerLevel {
+ Low(Option<String>),
+ Medium(Option<String>),
+ High(Option<String>),
+ Unknown(Option<String>),
+}
+
+impl DangerLevel {
+ pub(crate) fn notes(&self) -> Option<&String> {
+ match self {
+ DangerLevel::Low(notes) => notes.as_ref(),
+ DangerLevel::Medium(notes) => notes.as_ref(),
+ DangerLevel::High(notes) => notes.as_ref(),
+ DangerLevel::Unknown(notes) => notes.as_ref(),
+ }
+ }
+}
+
+impl From<(&String, &String)> for DangerLevel {
+ fn from((danger_level, danger_notes): (&String, &String)) -> Self {
+ let notes = if danger_notes.is_empty() {
+ None
+ } else {
+ Some(danger_notes.to_string())
+ };
+
+ match danger_level.as_str() {
+ "low" => DangerLevel::Low(notes),
+ "medium" => DangerLevel::Medium(notes),
+ "med" => DangerLevel::Medium(notes),
+ "high" => DangerLevel::High(notes),
+ _ => DangerLevel::Unknown(notes),
+ }
+ }
+}
+
+#[derive(Debug)]
+pub(crate) enum ConfidenceLevel {
+ Low(Option<String>),
+ Medium(Option<String>),
+ High(Option<String>),
+ Unknown(Option<String>),
+}
+
+impl ConfidenceLevel {
+ pub(crate) fn notes(&self) -> Option<&String> {
+ match self {
+ ConfidenceLevel::Low(notes) => notes.as_ref(),
+ ConfidenceLevel::Medium(notes) => notes.as_ref(),
+ ConfidenceLevel::High(notes) => notes.as_ref(),
+ ConfidenceLevel::Unknown(notes) => notes.as_ref(),
+ }
+ }
+}
+
+impl From<(&String, &String)> for ConfidenceLevel {
+ fn from((confidence_level, confidence_notes): (&String, &String)) -> Self {
+ let notes = if confidence_notes.is_empty() {
+ None
+ } else {
+ Some(confidence_notes.to_string())
+ };
+
+ match confidence_level.as_str() {
+ "low" => ConfidenceLevel::Low(notes),
+ "medium" => ConfidenceLevel::Medium(notes),
+ "med" => ConfidenceLevel::Medium(notes),
+ "high" => ConfidenceLevel::High(notes),
+ _ => ConfidenceLevel::Unknown(notes),
+ }
+ }
+}
+
+#[derive(Debug)]
+pub(crate) enum UiEvent {
+ Text { content: String },
+ ToolCall(ToolCallDetails),
+ ToolSummary(ToolSummary),
+ SuggestedCommand(SuggestedCommandDetails),
+ OutOfBandOutput(OutOfBandOutputDetails),
+}
+
+#[derive(Debug)]
+pub(crate) struct ToolCallDetails {
+ tool_use_id: String,
+ name: String,
+ status: ToolResultStatus,
+}
+
+#[derive(Debug)]
+pub(crate) struct SuggestedCommandDetails {
+ pub(crate) command: String,
+ pub(crate) danger_level: DangerLevel,
+ pub(crate) confidence_level: ConfidenceLevel,
+ pub(crate) first_event_in_turn: bool,
+}
+
+#[derive(Debug)]
+pub(crate) struct OutOfBandOutputDetails {
+ pub(crate) command: Option<String>,
+ pub(crate) content: String,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub(crate) enum ToolResultStatus {
+ Pending,
+ Success,
+ Error,
+}
+
+#[derive(Debug)]
+pub(crate) enum UiTurn {
+ User { events: Vec<UiEvent> },
+ Agent { events: Vec<UiEvent> },
+ OutOfBand { events: Vec<UiEvent> },
+}
+
+pub(crate) struct TurnBuilder {
+ turns: Vec<UiTurn>,
+ current_turn: Option<UiTurn>,
+}
+
+impl TurnBuilder {
+ pub(crate) fn new() -> Self {
+ Self {
+ turns: Vec::new(),
+ current_turn: None,
+ }
+ }
+
+ pub(crate) fn add_event(&mut self, event: &ConversationEvent) {
+ match event {
+ ConversationEvent::UserMessage { content } => {
+ self.add_user_message(content);
+ }
+ ConversationEvent::Text { content } => {
+ self.add_agent_text(content);
+ }
+ ConversationEvent::ToolCall { id, name, input } => {
+ if name == "suggest_command" {
+ self.add_suggested_command(input);
+ } else {
+ self.add_tool_call(id, name, input);
+ }
+ }
+ ConversationEvent::ToolResult {
+ tool_use_id,
+ content,
+ is_error,
+ } => {
+ self.add_tool_result(tool_use_id, content, *is_error);
+ }
+ ConversationEvent::OutOfBandOutput {
+ name,
+ command,
+ content,
+ } => {
+ self.add_out_of_band_output(name, command.as_deref(), content);
+ }
+ }
+ }
+
+ pub(crate) fn build(&mut self) -> Vec<UiTurn> {
+ self.commit_turn();
+
+ // Collapse consecutive tool calls within each agent turn into ToolSummary
+ for turn in &mut self.turns {
+ if let UiTurn::Agent { events } = turn {
+ let mut new_events: Vec<UiEvent> = Vec::new();
+ let mut pending_tools: Vec<ToolCallDetails> = Vec::new();
+
+ for event in events.drain(..) {
+ match event {
+ UiEvent::ToolCall(details) => {
+ pending_tools.push(details);
+ }
+ other => {
+ if !pending_tools.is_empty() {
+ new_events.push(UiEvent::ToolSummary(ToolSummary {
+ tool_calls: std::mem::take(&mut pending_tools),
+ }));
+ }
+ new_events.push(other);
+ }
+ }
+ }
+
+ if !pending_tools.is_empty() {
+ new_events.push(UiEvent::ToolSummary(ToolSummary {
+ tool_calls: pending_tools,
+ }));
+ }
+
+ *events = new_events;
+ }
+ }
+
+ std::mem::take(&mut self.turns)
+ }
+
+ fn commit_turn(&mut self) {
+ if let Some(turn) = self.current_turn.take() {
+ self.turns.push(turn);
+ }
+ }
+
+ fn start_user_turn(&mut self) {
+ if !matches!(self.current_turn, Some(UiTurn::User { .. })) {
+ self.commit_turn();
+ self.current_turn = Some(UiTurn::User { events: vec![] });
+ }
+ }
+
+ fn start_agent_turn(&mut self) {
+ if !matches!(self.current_turn, Some(UiTurn::Agent { .. })) {
+ self.commit_turn();
+ self.current_turn = Some(UiTurn::Agent { events: vec![] });
+ }
+ }
+
+ fn start_out_of_band_turn(&mut self) {
+ if !matches!(self.current_turn, Some(UiTurn::OutOfBand { .. })) {
+ self.commit_turn();
+ self.current_turn = Some(UiTurn::OutOfBand { events: vec![] });
+ }
+ }
+
+ fn turn_mut_unsafe(&mut self) -> &mut UiTurn {
+ self.current_turn.as_mut().unwrap()
+ }
+
+ fn add_user_message(&mut self, content: &str) {
+ self.start_user_turn();
+ if let UiTurn::User { events } = self.turn_mut_unsafe() {
+ events.push(UiEvent::Text {
+ content: content.to_string(),
+ });
+ }
+ }
+
+ fn add_agent_text(&mut self, content: &str) {
+ self.start_agent_turn();
+ if let UiTurn::Agent { events } = self.turn_mut_unsafe() {
+ events.push(UiEvent::Text {
+ content: content.to_string(),
+ });
+ }
+ }
+
+ fn add_suggested_command(&mut self, input: &serde_json::Value) {
+ let command = input
+ .get("command")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+
+ if command.is_empty() {
+ return;
+ }
+
+ self.start_agent_turn();
+ if let UiTurn::Agent { events } = self.turn_mut_unsafe() {
+ let danger_level = input
+ .get("danger")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+
+ let confidence_level = input
+ .get("confidence")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+
+ let danger_notes = input
+ .get("danger_notes")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+
+ let confidence_notes = input
+ .get("confidence_notes")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+
+ let danger = DangerLevel::from((&danger_level, &danger_notes));
+ let confidence = ConfidenceLevel::from((&confidence_level, &confidence_notes));
+
+ let first_event_in_turn = events.is_empty();
+
+ events.push(UiEvent::SuggestedCommand(SuggestedCommandDetails {
+ command: input
+ .get("command")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string(),
+ danger_level: danger,
+ confidence_level: confidence,
+ first_event_in_turn,
+ }));
+ }
+ }
+
+ fn add_tool_call(&mut self, id: &str, name: &str, _input: &serde_json::Value) {
+ self.start_agent_turn();
+ if let UiTurn::Agent { events } = self.turn_mut_unsafe() {
+ events.push(UiEvent::ToolCall(ToolCallDetails {
+ tool_use_id: id.to_string(),
+ name: name.to_string(),
+ status: ToolResultStatus::Pending,
+ }));
+ }
+ }
+
+ fn add_tool_result(&mut self, tool_use_id: &str, _content: &str, is_error: bool) {
+ self.start_agent_turn();
+ if let UiTurn::Agent { events } = self.turn_mut_unsafe() {
+ let event = events.iter_mut().find(|e| match e {
+ UiEvent::ToolCall(ToolCallDetails {
+ tool_use_id: id, ..
+ }) => id == tool_use_id,
+ _ => false,
+ });
+ if let Some(UiEvent::ToolCall(ToolCallDetails { status, .. })) = event {
+ *status = if is_error {
+ ToolResultStatus::Error
+ } else {
+ ToolResultStatus::Success
+ };
+ }
+ }
+ }
+
+ fn add_out_of_band_output(&mut self, _name: &str, command: Option<&str>, content: &str) {
+ self.start_out_of_band_turn();
+ if let UiTurn::OutOfBand { events } = self.turn_mut_unsafe() {
+ events.push(UiEvent::OutOfBandOutput(OutOfBandOutputDetails {
+ command: command.map(|c| c.to_string()),
+ content: content.to_string(),
+ }));
+ }
+ }
+}
+
+#[derive(Debug)]
+pub(crate) struct ToolSummary {
+ tool_calls: Vec<ToolCallDetails>,
+}
+
+impl ToolSummary {
+ /// Determines the summary line:
+ /// - If any call is pending, use present tense verb with `-ing`
+ /// - If multiple calls are complete, say "Used n tools"
+ /// - If a single call is complete, use past tense verb
+ pub(crate) fn summary(&self) -> String {
+ if self.any_pending() {
+ // Find the last pending tool for the active verb
+ if let Some(pending) = self
+ .tool_calls
+ .iter()
+ .rev()
+ .find(|t| t.status == ToolResultStatus::Pending)
+ {
+ return Self::progressive_verb(&pending.name);
+ }
+ }
+
+ if self.tool_calls.len() == 1 {
+ return Self::past_verb(&self.tool_calls[0].name);
+ }
+
+ format!("Used {} tools", self.tool_calls.len())
+ }
+
+ /// Determines if the spinner should be spinning
+ pub(crate) fn any_pending(&self) -> bool {
+ self.tool_calls
+ .iter()
+ .any(|tool_call| tool_call.status == ToolResultStatus::Pending)
+ }
+
+ /// Present-tense progressive verb for a tool name (e.g. "Searching...")
+ fn progressive_verb(name: &str) -> String {
+ match name {
+ "search" => "Searching...".into(),
+ "read" | "read_file" => "Reading file...".into(),
+ "write" | "write_file" => "Writing file...".into(),
+ "execute" | "run" | "bash" => "Running command...".into(),
+ "list" | "list_files" => "Listing files...".into(),
+ _ => format!("Running {}...", name.replace('_', " ")),
+ }
+ }
+
+ /// Past-tense verb for a tool name (e.g. "Searched")
+ fn past_verb(name: &str) -> String {
+ match name {
+ "search" => "Searched".into(),
+ "read" | "read_file" => "Read file".into(),
+ "write" | "write_file" => "Wrote file".into(),
+ "execute" | "run" | "bash" => "Ran command".into(),
+ "list" | "list_files" => "Listed files".into(),
+ _ => format!("Ran {}", name.replace('_', " ")),
+ }
+ }
+}