diff options
| author | Michelle Tilley <michelle@michelletilley.net> | 2026-04-23 13:43:01 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-23 13:43:01 -0700 |
| commit | 461ef4c43589c6ca68176c180fd04f2755c9f036 (patch) | |
| tree | c646ea272d6016533c4941592f9a22baa2a54488 /crates/atuin-ai/src/tui | |
| parent | feat: Send user-defined context with `TERMINAL.md` (#3443) (diff) | |
| download | atuin-461ef4c43589c6ca68176c180fd04f2755c9f036.zip | |
feat: Add skill discovery, loading, and invocation (#3444)
Adds a skills system that lets users define reusable LLM instructions as `SKILL.md` files with YAML frontmatter.
Diffstat (limited to 'crates/atuin-ai/src/tui')
| -rw-r--r-- | crates/atuin-ai/src/tui/state.rs | 24 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/view/mod.rs | 30 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/view/turn.rs | 14 |
3 files changed, 56 insertions, 12 deletions
diff --git a/crates/atuin-ai/src/tui/state.rs b/crates/atuin-ai/src/tui/state.rs index e008bd3c..71da6ff5 100644 --- a/crates/atuin-ai/src/tui/state.rs +++ b/crates/atuin-ai/src/tui/state.rs @@ -37,6 +37,14 @@ pub(crate) enum ConversationEvent { /// Context injected for the LLM that is not rendered in the TUI. /// Converted to a user message in the API protocol. SystemContext { content: String }, + /// A skill was loaded and its content injected into the conversation. + /// Serialized as a full user message for the API but rendered compactly + /// in the TUI (just the `/name args` invocation line). + SkillInvocation { + name: String, + arguments: Option<String>, + content: String, + }, } impl ConversationEvent { @@ -49,6 +57,7 @@ impl ConversationEvent { ConversationEvent::ToolResult { .. } => true, ConversationEvent::OutOfBandOutput { .. } => false, ConversationEvent::SystemContext { .. } => false, + ConversationEvent::SkillInvocation { .. } => true, } } @@ -206,6 +215,21 @@ pub(crate) fn events_to_messages(events: &[ConversationEvent]) -> Vec<serde_json })); i += 1; } + ConversationEvent::SkillInvocation { + name, + arguments, + content, + } => { + let header = match arguments { + Some(args) => format!("[Loaded skill: {name}]\n[Arguments: {args}]"), + None => format!("[Loaded skill: {name}]"), + }; + messages.push(serde_json::json!({ + "role": "user", + "content": format!("{header}\n\n{content}") + })); + i += 1; + } } } diff --git a/crates/atuin-ai/src/tui/view/mod.rs b/crates/atuin-ai/src/tui/view/mod.rs index 2061ec38..96ad5d85 100644 --- a/crates/atuin-ai/src/tui/view/mod.rs +++ b/crates/atuin-ai/src/tui/view/mod.rs @@ -73,7 +73,7 @@ pub(crate) fn ai_view(state: &ViewState) -> Elements { user_turn_view(events, index == 0) } turn::UiTurn::Agent { events } => { - agent_turn_view(events, busy && index == last_index) + agent_turn_view(events, busy && index == last_index, state.tools.awaiting_permission().is_some()) } turn::UiTurn::OutOfBand { events } => { out_of_band_turn_view(events) @@ -85,7 +85,7 @@ pub(crate) fn ai_view(state: &ViewState) -> Elements { let needs_pending_banner = busy && !matches!(turns.last(), Some(turn::UiTurn::Agent { .. })); if needs_pending_banner { let empty: &[turn::UiEvent] = &[]; - agent_turn_view(empty, true) + agent_turn_view(empty, true, false) } else { element! {} } @@ -170,6 +170,7 @@ fn tool_call_view(tool_call: &crate::fsm::tools::TrackedTool, in_git_project: bo ClientToolCall::Write(tool) => tool.path.display().to_string(), ClientToolCall::Shell(tool) => tool.command.clone(), ClientToolCall::AtuinHistory(tool) => tool.query.clone(), + ClientToolCall::LoadSkill(tool) => format!("skill: {}", tool.name), }; let select_options = permission_options_for_tool(&tool_call.tool, in_git_project); @@ -273,21 +274,16 @@ fn user_turn_view(events: &[turn::UiEvent], first_turn: bool) -> Elements { } } -fn agent_turn_view(events: &[turn::UiEvent], busy: bool) -> Elements { +fn agent_turn_view(events: &[turn::UiEvent], busy: bool, showing_ui: bool) -> Elements { let label_style = Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD); element! { View { - Spinner( - label: " Atuin AI ", - label_style: label_style.reversed(), - done_label_style: label_style.reversed(), - hide_checkmark: true, - label_first: true, - done: !busy, - ) + Text { + Span(text: " Atuin AI ", style: label_style.reversed()) + } #(for (i, event) in events.iter().enumerate() { #(if i > 0 { Text { Span(text: "") } @@ -325,7 +321,8 @@ fn agent_turn_view(events: &[turn::UiEvent], busy: bool) -> Elements { tool_status_view(&details.name, &details.status) }, turn::ToolRenderData::FileRead { .. } - | turn::ToolRenderData::HistorySearch { .. } => { + | turn::ToolRenderData::HistorySearch { .. } + | turn::ToolRenderData::SkillLoad { .. } => { element!{} }, }) @@ -350,6 +347,15 @@ fn agent_turn_view(events: &[turn::UiEvent], busy: bool) -> Elements { _ => element!{} }) }) + + #(if busy && !showing_ui { + View(key: "agent-working-spinner", padding_left: Cells::from(2), padding_top: Cells::from(1)) { + Spinner( + label: "", + spinner_style: Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + ) + } + }) } } } diff --git a/crates/atuin-ai/src/tui/view/turn.rs b/crates/atuin-ai/src/tui/view/turn.rs index 98ae5eff..9f4460eb 100644 --- a/crates/atuin-ai/src/tui/view/turn.rs +++ b/crates/atuin-ai/src/tui/view/turn.rs @@ -151,6 +151,8 @@ pub(crate) enum ToolRenderData { query: String, filter_modes: Vec<HistorySearchFilterMode>, }, + /// Skill loading — read-only, auto-approved. + SkillLoad { _name: String }, /// Server-side tool — no client rendering data available. Remote, } @@ -257,6 +259,15 @@ impl<'a> TurnBuilder<'a> { ConversationEvent::SystemContext { .. } => { // Not rendered in the TUI — only sent to the API } + ConversationEvent::SkillInvocation { + name, arguments, .. + } => { + let display = match arguments { + Some(args) => format!("/{name} {args}"), + None => format!("/{name}"), + }; + self.add_user_message(&display); + } } } @@ -459,6 +470,9 @@ impl<'a> TurnBuilder<'a> { query: history.query.clone(), filter_modes: history.filter_modes.clone(), }, + ClientToolCall::LoadSkill(skill) => ToolRenderData::SkillLoad { + _name: skill.name.clone(), + }, } } else { // Not in tracker → server-side tool |
