aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/tui/view
diff options
context:
space:
mode:
authorMichelle Tilley <michelle@michelletilley.net>2026-04-23 20:31:53 -0700
committerGitHub <noreply@github.com>2026-04-23 20:31:53 -0700
commitf72fdf7565d18b044f035fa6aca9ae8dbba34fc6 (patch)
treed0c08204ed712f0f788cd262f56894b8a2af7d49 /crates/atuin-ai/src/tui/view
parentfeat: Add skill discovery, loading, and invocation (#3444) (diff)
downloadatuin-f72fdf7565d18b044f035fa6aca9ae8dbba34fc6.zip
perf: Reduce AI TUI rendering overhead for long conversations (#3447)
Fixes keystroke lag in Atuin AI that scales with conversation length. After extended use (many turns, lots of tool calls with output viewports), pressing a key had noticeable delay before the letter appeared. Three layers of optimization: - **Skip `sync_view_state` for `InputUpdated`** — every keystroke was cloning all events, tools, and archived data even though no FSM state changed. Uses `handle.update_tracked()` (eye_declare 0.5) to skip rebuilds when values haven't actually changed. - **Pre-compute turns and `has_command` on the driver thread** — the view function was rebuilding the full turn structure from raw events and scanning for `suggest_command` tool calls 3× per render frame. Now computed once per FSM state change and cached in ViewState. - **Commit-based element tree pruning** — turns that scroll into terminal scrollback are tracked via `on_commit` and filtered from the element tree, keeping rendering work proportional to visible content. Turn views are now direct children of the root VStack (not nested inside AtuinAi) so `detect_committed` can see them.
Diffstat (limited to 'crates/atuin-ai/src/tui/view')
-rw-r--r--crates/atuin-ai/src/tui/view/mod.rs81
-rw-r--r--crates/atuin-ai/src/tui/view/turn.rs111
2 files changed, 109 insertions, 83 deletions
diff --git a/crates/atuin-ai/src/tui/view/mod.rs b/crates/atuin-ai/src/tui/view/mod.rs
index 96ad5d85..73dc2ad7 100644
--- a/crates/atuin-ai/src/tui/view/mod.rs
+++ b/crates/atuin-ai/src/tui/view/mod.rs
@@ -18,7 +18,7 @@ use super::components::markdown::Markdown;
use super::components::select::Select;
use super::state::AppMode;
-mod turn;
+pub(crate) mod turn;
impl From<&AgentState> for AppMode {
fn from(state: &AgentState) -> Self {
@@ -42,50 +42,48 @@ impl From<&AgentState> for AppMode {
/// - Spacer
/// - Input box (bordered, with contextual keybindings)
pub(crate) fn ai_view(state: &ViewState) -> Elements {
- let mut turn_builder = turn::TurnBuilder::new(&state.tools);
-
- for event in &state.archived_events {
- turn_builder.add_event(event);
- }
- for event in &state.visible_events {
- turn_builder.add_event(event);
- }
- let turns = turn_builder.build();
-
+ let committed = state.committed_turn_count;
+ let turns: Vec<&turn::UiTurn> = state.turns.iter().filter(|t| t.id >= committed).collect();
let busy = state.is_busy();
let last_index = turns.len().saturating_sub(1);
+ // Turns are direct children of the root VStack so that eye_declare's
+ // on_commit can detect them scrolling into terminal scrollback and
+ // prune them from the tree. AtuinAi wraps only the interactive footer
+ // (input box, error display, pending banner) so its event capture/bubble
+ // handlers still fire for keyboard events.
element! {
+ #(if state.is_resumed && (!state.is_exiting() || !turns.is_empty()) {
+ SessionContinue(key: "continuation-notice", continued_at: state.last_event_time)
+ })
+
+ #(for (index, turn) in turns.iter().enumerate() {
+ #(match &turn.kind {
+ turn::UiTurnKind::User { events } => {
+ user_turn_view(events, index == 0, turn.id)
+ }
+ turn::UiTurnKind::Agent { events } => {
+ agent_turn_view(events, busy && index == last_index, state.tools.awaiting_permission().is_some(), turn.id)
+ }
+ turn::UiTurnKind::OutOfBand { events } => {
+ out_of_band_turn_view(events, turn.id)
+ }
+ })
+ })
+
AtuinAi(
+ key: "footer",
mode: AppMode::from(&state.agent_state),
- has_command: state.has_command(),
+ has_command: state.has_command,
is_input_blank: state.is_input_blank,
pending_confirmation: state.has_confirmation(),
has_executing_preview: state.tools.has_executing_preview(),
) {
- #(if state.is_resumed && (!state.is_exiting() || !turns.is_empty()) {
- SessionContinue(key: "continuation-notice", continued_at: state.last_event_time)
- })
-
- #(for (index, turn) in turns.iter().enumerate() {
- #(match turn {
- turn::UiTurn::User { events } => {
- user_turn_view(events, index == 0)
- }
- turn::UiTurn::Agent { events } => {
- agent_turn_view(events, busy && index == last_index, state.tools.awaiting_permission().is_some())
- }
- turn::UiTurn::OutOfBand { events } => {
- out_of_band_turn_view(events)
- }
- })
- })
-
#({
- let needs_pending_banner = busy && !matches!(turns.last(), Some(turn::UiTurn::Agent { .. }));
+ let needs_pending_banner = busy && !matches!(turns.last(), Some(turn::UiTurn { kind: turn::UiTurnKind::Agent { .. }, .. }));
if needs_pending_banner {
let empty: &[turn::UiEvent] = &[];
- agent_turn_view(empty, true, false)
+ agent_turn_view(empty, true, false, usize::MAX)
} else {
element! {}
}
@@ -133,7 +131,7 @@ fn input_view(state: &ViewState) -> Elements {
slash_suggestion: first_slash_result.cloned()
)
- #(if state.is_input_blank && state.has_command() && state.is_input_active() {
+ #(if state.is_input_blank && state.has_command && state.is_input_active() {
#(if state.has_confirmation() {
Text { Span(text: "[Enter] Confirm dangerous command [Esc] Cancel", style: Style::default().fg(Color::Gray)) }
} else {
@@ -244,7 +242,7 @@ fn permission_options_for_tool(tool: &ClientToolCall, in_git_project: bool) -> V
}
}
-fn user_turn_view(events: &[turn::UiEvent], first_turn: bool) -> Elements {
+fn user_turn_view(events: &[turn::UiEvent], first_turn: bool, turn_id: usize) -> Elements {
let label_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
@@ -252,7 +250,7 @@ fn user_turn_view(events: &[turn::UiEvent], first_turn: bool) -> Elements {
let padding = if first_turn { 0 } else { 1 };
element! {
- View(padding_top: Cells::from(padding)) {
+ View(key: format!("turn-{turn_id}"), padding_top: Cells::from(padding)) {
Text {
Span(text: " You ", style: label_style.reversed())
}
@@ -274,13 +272,18 @@ fn user_turn_view(events: &[turn::UiEvent], first_turn: bool) -> Elements {
}
}
-fn agent_turn_view(events: &[turn::UiEvent], busy: bool, showing_ui: bool) -> Elements {
+fn agent_turn_view(
+ events: &[turn::UiEvent],
+ busy: bool,
+ showing_ui: bool,
+ turn_id: usize,
+) -> Elements {
let label_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
element! {
- View {
+ View(key: format!("turn-{turn_id}")) {
Text {
Span(text: " Atuin AI ", style: label_style.reversed())
}
@@ -360,9 +363,9 @@ fn agent_turn_view(events: &[turn::UiEvent], busy: bool, showing_ui: bool) -> El
}
}
-fn out_of_band_turn_view(events: &[turn::UiEvent]) -> Elements {
+fn out_of_band_turn_view(events: &[turn::UiEvent], turn_id: usize) -> Elements {
element! {
- View {
+ View(key: format!("turn-{turn_id}")) {
Text {
Span(text: " System ", style: Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD).add_modifier(Modifier::REVERSED))
}
diff --git a/crates/atuin-ai/src/tui/view/turn.rs b/crates/atuin-ai/src/tui/view/turn.rs
index 9f4460eb..c74395b8 100644
--- a/crates/atuin-ai/src/tui/view/turn.rs
+++ b/crates/atuin-ai/src/tui/view/turn.rs
@@ -204,16 +204,23 @@ pub(crate) enum ToolResultStatus {
}
#[derive(Debug)]
-pub(crate) enum UiTurn {
+pub(crate) struct UiTurn {
+ pub(crate) id: usize,
+ pub(crate) kind: UiTurnKind,
+}
+
+#[derive(Debug)]
+pub(crate) enum UiTurnKind {
User { events: Vec<UiEvent> },
Agent { events: Vec<UiEvent> },
OutOfBand { events: Vec<UiEvent> },
}
pub(crate) struct TurnBuilder<'a> {
- turns: Vec<UiTurn>,
- current_turn: Option<UiTurn>,
+ turns: Vec<UiTurnKind>,
+ current_turn: Option<UiTurnKind>,
tracker: &'a ToolManager,
+ next_id: usize,
}
/// A struct to iteratively build [UiTurn] events from [ConversationEvent]s.
@@ -223,6 +230,16 @@ impl<'a> TurnBuilder<'a> {
turns: Vec::new(),
current_turn: None,
tracker,
+ next_id: 0,
+ }
+ }
+
+ pub(crate) fn new_starting_at(tracker: &'a ToolManager, start_id: usize) -> Self {
+ Self {
+ turns: Vec::new(),
+ current_turn: None,
+ tracker,
+ next_id: start_id,
}
}
@@ -280,7 +297,7 @@ impl<'a> TurnBuilder<'a> {
// into a ToolGroup (e.g. N file reads → one group)
// - All other events pass through unchanged
for turn in &mut self.turns {
- if let UiTurn::Agent { events } = turn {
+ if let UiTurnKind::Agent { events } = turn {
let mut new_events: Vec<UiEvent> = Vec::new();
let mut pending_remote: Vec<ToolCallDetails> = Vec::new();
let mut pending_group: Option<(ToolGroupKind, Vec<ToolCallDetails>)> = None;
@@ -322,7 +339,15 @@ impl<'a> TurnBuilder<'a> {
}
}
- std::mem::take(&mut self.turns)
+ let kinds = std::mem::take(&mut self.turns);
+ kinds
+ .into_iter()
+ .enumerate()
+ .map(|(i, kind)| UiTurn {
+ id: self.next_id + i,
+ kind,
+ })
+ .collect()
}
fn commit_turn(&mut self) {
@@ -332,37 +357,39 @@ impl<'a> TurnBuilder<'a> {
}
fn start_user_turn(&mut self) {
- if !matches!(self.current_turn, Some(UiTurn::User { .. })) {
+ if !matches!(self.current_turn, Some(UiTurnKind::User { .. })) {
self.commit_turn();
- self.current_turn = Some(UiTurn::User { events: vec![] });
+ self.current_turn = Some(UiTurnKind::User { events: vec![] });
}
}
fn start_agent_turn(&mut self) {
- if !matches!(self.current_turn, Some(UiTurn::Agent { .. })) {
+ if !matches!(self.current_turn, Some(UiTurnKind::Agent { .. })) {
self.commit_turn();
- self.current_turn = Some(UiTurn::Agent { events: vec![] });
+ self.current_turn = Some(UiTurnKind::Agent { events: vec![] });
}
}
fn start_out_of_band_turn(&mut self) {
- if !matches!(self.current_turn, Some(UiTurn::OutOfBand { .. })) {
+ if !matches!(self.current_turn, Some(UiTurnKind::OutOfBand { .. })) {
self.commit_turn();
- self.current_turn = Some(UiTurn::OutOfBand { events: vec![] });
+ self.current_turn = Some(UiTurnKind::OutOfBand { events: vec![] });
}
}
- fn turn_mut_unsafe(&mut self) -> &mut UiTurn {
- self.current_turn.as_mut().unwrap()
+ fn current_events_mut(&mut self) -> &mut Vec<UiEvent> {
+ match self.current_turn.as_mut().unwrap() {
+ UiTurnKind::User { events }
+ | UiTurnKind::Agent { events }
+ | UiTurnKind::OutOfBand { events } => events,
+ }
}
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(),
- });
- }
+ self.current_events_mut().push(UiEvent::Text {
+ content: content.to_string(),
+ });
}
fn add_agent_text(&mut self, content: &str) {
@@ -370,11 +397,9 @@ impl<'a> TurnBuilder<'a> {
return;
}
self.start_agent_turn();
- if let UiTurn::Agent { events } = self.turn_mut_unsafe() {
- events.push(UiEvent::Text {
- content: content.to_string(),
- });
- }
+ self.current_events_mut().push(UiEvent::Text {
+ content: content.to_string(),
+ });
}
fn add_suggested_command(&mut self, input: &serde_json::Value) {
@@ -389,7 +414,8 @@ impl<'a> TurnBuilder<'a> {
}
self.start_agent_turn();
- if let UiTurn::Agent { events } = self.turn_mut_unsafe() {
+ {
+ let events = self.current_events_mut();
let danger_level = input
.get("danger")
.and_then(|v| v.as_str())
@@ -433,14 +459,13 @@ impl<'a> TurnBuilder<'a> {
let render_data = self.build_render_data(id, name);
self.start_agent_turn();
- if let UiTurn::Agent { events } = self.turn_mut_unsafe() {
- events.push(UiEvent::ToolCall(ToolCallDetails {
+ self.current_events_mut()
+ .push(UiEvent::ToolCall(ToolCallDetails {
tool_use_id: id.to_string(),
name: name.to_string(),
status: ToolResultStatus::Pending,
render_data,
}));
- }
}
/// Build tool-type-specific render data from the ToolTracker.
@@ -482,31 +507,29 @@ impl<'a> TurnBuilder<'a> {
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
- };
- }
+ let events = self.current_events_mut();
+ 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 {
+ self.current_events_mut()
+ .push(UiEvent::OutOfBandOutput(OutOfBandOutputDetails {
command: command.map(|c| c.to_string()),
content: content.to_string(),
}));
- }
}
}