Files
galaxy/app/src/terminal/input/terminal_message_bar.rs
T

468 lines
17 KiB
Rust

use std::sync::Arc;
use galaxy_core::ui::theme::WarpTheme;
use galaxyui::elements::{Container, Element};
use galaxyui::keymap::Keystroke;
use galaxyui::{AppContext, Entity, ModelHandle, SingletonEntity, View, ViewContext};
use parking_lot::FairMutex;
use pathfinder_color::ColorU;
use super::buffer_model::InputBufferModel;
use super::message_bar::{
common::render_terminal_message, truncated_command_for_block, Message, MessageItem,
MessageProvider,
};
use crate::ai::blocklist::{
BlocklistAIContextEvent, BlocklistAIContextModel, BlocklistAIInputModel,
};
use crate::appearance::Appearance;
use crate::search::slash_command_menu::static_commands::commands;
use crate::terminal::input::inline_history::{AcceptHistoryItem, HistoryTab};
use crate::terminal::input::inline_menu::{InlineMenuModel, InlineMenuModelEvent};
use crate::terminal::input::message_bar::MessageTransformer;
use crate::terminal::input::suggestions_mode_model::{
InputSuggestionsModeEvent, InputSuggestionsModeModel,
};
use crate::terminal::input::SET_INPUT_MODE_TERMINAL_ACTION_NAME;
use crate::terminal::model::TerminalModel;
use crate::terminal::view::init::SELECT_PREVIOUS_BLOCK_ACTION_NAME;
use crate::util::bindings::keybinding_name_to_keystroke;
/// Renders contextual hint text at the bottom of the terminal input when `FeatureFlag::AgentView`
/// is enabled.
pub struct TerminalInputMessageBar {
terminal_model: Arc<FairMutex<TerminalModel>>,
ai_input_model: ModelHandle<BlocklistAIInputModel>,
input_buffer_model: ModelHandle<InputBufferModel>,
context_model: ModelHandle<BlocklistAIContextModel>,
suggestions_mode_model: ModelHandle<InputSuggestionsModeModel>,
inline_history_model: ModelHandle<InlineMenuModel<AcceptHistoryItem, HistoryTab>>,
}
impl Entity for TerminalInputMessageBar {
type Event = ();
}
impl TerminalInputMessageBar {
pub fn new(
terminal_model: Arc<FairMutex<TerminalModel>>,
ai_input_model: ModelHandle<BlocklistAIInputModel>,
input_buffer_model: ModelHandle<InputBufferModel>,
context_model: ModelHandle<BlocklistAIContextModel>,
suggestions_mode_model: ModelHandle<InputSuggestionsModeModel>,
inline_history_model: ModelHandle<InlineMenuModel<AcceptHistoryItem, HistoryTab>>,
ctx: &mut ViewContext<Self>,
) -> Self {
ctx.subscribe_to_model(&ai_input_model, |_, _, _, ctx| {
ctx.notify();
});
ctx.subscribe_to_model(&input_buffer_model, |_, _, _, ctx| {
ctx.notify();
});
ctx.subscribe_to_model(&context_model, |_, _, event, ctx| {
if let BlocklistAIContextEvent::UpdatedPendingContext { .. } = event {
ctx.notify();
}
});
ctx.subscribe_to_model(&suggestions_mode_model, |_, _, event, ctx| {
let InputSuggestionsModeEvent::ModeChanged { .. } = event;
ctx.notify();
});
ctx.subscribe_to_model(&inline_history_model, |_, _, event, ctx| {
if let InlineMenuModelEvent::UpdatedSelectedItem = event {
ctx.notify();
}
});
Self {
terminal_model,
ai_input_model,
input_buffer_model,
context_model,
suggestions_mode_model,
inline_history_model,
}
}
}
impl View for TerminalInputMessageBar {
fn ui_name() -> &'static str {
"TerminalInputMessageBar"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
if self
.suggestions_mode_model
.as_ref(app)
.is_inline_history_menu()
{
let selected = self.inline_history_model.as_ref(app).selected_item();
let message = InlineHistoryMessageProducer
.produce_message(selected)
.unwrap_or_default();
return Container::new(render_terminal_message(message, app))
.with_padding_bottom(8.)
.with_padding_right(8.)
.finish();
}
let terminal_model = self.terminal_model.lock();
let current_buffer = self.input_buffer_model.as_ref(app).current_value();
let context_model = self.context_model.as_ref(app);
let input_model = self.ai_input_model.as_ref(app);
let args = TerminalMessageArgs {
current_input: current_buffer,
terminal_model: &terminal_model,
context_model,
input_model,
app,
};
let mut message = ErroredBlockMessageProducer
.produce_message(args)
.or_else(|| AgentMessageProducer.produce_message(args))
.or_else(|| PlanMessageProducer.produce_message(args))
.or_else(|| ContinueConversationMessageProducer.produce_message(args))
.or_else(|| DefaultMessageProducer.produce_message(args))
.unwrap_or_default();
let transformers: [Box<dyn MessageTransformer<TerminalMessageArgs<'_>>>; 3] = [
Box::new(AttachedBlocksMessageTransformer),
Box::new(AttachedTextSelectionMessageTransformer),
Box::new(AutodetectedPromptMessageTransformer),
];
for transformer in transformers {
transformer.transform_message(&mut message, args);
}
Container::new(render_terminal_message(message, app))
.with_padding_bottom(8.)
.with_padding_right(8.)
.finish()
}
}
#[derive(Copy, Clone)]
pub struct TerminalMessageArgs<'a> {
current_input: &'a str,
terminal_model: &'a TerminalModel,
context_model: &'a BlocklistAIContextModel,
input_model: &'a BlocklistAIInputModel,
app: &'a AppContext,
}
impl<'a> TerminalMessageArgs<'a> {
fn is_input_ai_detected(&self) -> bool {
!self.current_input.is_empty()
&& self.input_model.is_ai_input_enabled()
&& !self.input_model.is_input_type_locked()
}
}
struct ErroredBlockMessageProducer;
impl MessageProvider<TerminalMessageArgs<'_>> for ErroredBlockMessageProducer {
fn produce_message(&self, args: TerminalMessageArgs<'_>) -> Option<Message> {
let block = args.terminal_model.block_list().last_non_hidden_block()?;
let context_block_ids = args.context_model.pending_context_block_ids();
if block.exit_code().was_successful()
|| !args.current_input.is_empty()
|| !context_block_ids.is_empty()
{
return None;
}
let keystroke = keybinding_name_to_keystroke(SELECT_PREVIOUS_BLOCK_ACTION_NAME, args.app)?;
Some(Message::new(vec![
MessageItem::keystroke(keystroke),
MessageItem::text(format!(
" attach `{}` output as agent context",
truncated_command_for_block(&block.command_to_string())
)),
]))
}
}
struct AgentMessageProducer;
impl MessageProvider<TerminalMessageArgs<'_>> for AgentMessageProducer {
fn produce_message(&self, args: TerminalMessageArgs<'_>) -> Option<Message> {
let TerminalMessageArgs {
current_input, app, ..
} = args;
if !current_input.starts_with(commands::AGENT.name)
&& !current_input.starts_with(commands::NEW.name)
{
return None;
}
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
Some(
Message::new(vec![
MessageItem::keystroke(Keystroke {
key: "enter".to_owned(),
..Default::default()
}),
MessageItem::text(" new conversation"),
])
.with_color(message_magenta(theme)),
)
}
}
struct PlanMessageProducer;
impl MessageProvider<TerminalMessageArgs<'_>> for PlanMessageProducer {
fn produce_message(&self, args: TerminalMessageArgs<'_>) -> Option<Message> {
let TerminalMessageArgs {
current_input, app, ..
} = args;
if !current_input.trim_start().starts_with(commands::PLAN.name) {
return None;
}
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let is_input_ai_detected = args.is_input_ai_detected();
Some(
Message::new(vec![
MessageItem::keystroke(Keystroke {
cmd: !is_input_ai_detected && cfg!(target_os = "macos"),
ctrl: !is_input_ai_detected && !cfg!(target_os = "macos"),
shift: !is_input_ai_detected && !cfg!(target_os = "macos"),
key: "enter".to_owned(),
..Default::default()
}),
MessageItem::text(" plan with agent"),
])
.with_color(message_magenta(theme)),
)
}
}
struct ContinueConversationMessageProducer;
impl MessageProvider<TerminalMessageArgs<'_>> for ContinueConversationMessageProducer {
fn produce_message(&self, args: TerminalMessageArgs<'_>) -> Option<Message> {
let TerminalMessageArgs {
current_input,
terminal_model,
..
} = args;
if !current_input.is_empty() || !terminal_model.is_last_visible_item_agent_view_block() {
return None;
}
let keystroke = keybinding_name_to_keystroke(commands::CONVERSATIONS.name, args.app)?;
Some(Message::new(vec![
MessageItem::keystroke(keystroke),
MessageItem::text(" to continue conversation"),
]))
}
}
mod internal {
use crate::terminal::{
model::blocks::{BlockHeight, BlockHeightItem, BlockHeightSummary, RichContentItem},
TerminalModel,
};
impl TerminalModel {
pub(super) fn is_last_visible_item_agent_view_block(&self) -> bool {
let block_list = self.block_list();
// When we insert rich content (including agent view blocks) we insert it immediately before
// the active block (unless explicitly inserting below a long-running block). The active
// block is a special "warp input" block that often exists even when it isn't user-visible.
//
// So, for dedupe we check the first visible (non-zero height) item *immediately before the
// active block*. This avoids false negatives caused by the active block itself.
let active_block_index = block_list.active_block_index();
let mut cursor = block_list
.block_heights()
.cursor::<BlockHeight, BlockHeightSummary>();
cursor.descend_to_last_item(block_list.block_heights());
// Seek backwards until we're at the active block's height item.
while let Some(item) = cursor.item() {
match item {
BlockHeightItem::Block(_)
if cursor.start().block_count == active_block_index.0 =>
{
break;
}
_ => cursor.prev(),
}
}
// Now walk backwards to find the first visible item before the active block.
cursor.prev();
while let Some(item) = cursor.item() {
let is_hidden = item.height() == BlockHeight::zero();
match item {
BlockHeightItem::RichContent(RichContentItem { content_type, .. })
if !is_hidden
&& content_type
.is_some_and(|content| content.is_agent_view_block()) =>
{
return true;
}
_ => {
if is_hidden {
cursor.prev();
} else {
return false;
}
}
}
}
false
}
}
}
struct DefaultMessageProducer;
impl MessageProvider<TerminalMessageArgs<'_>> for DefaultMessageProducer {
fn produce_message(&self, args: TerminalMessageArgs<'_>) -> Option<Message> {
let is_input_ai_detected = args.is_input_ai_detected();
let keystroke = if is_input_ai_detected {
Some(Keystroke {
key: "enter".to_owned(),
..Default::default()
})
} else if let Some(keystroke) = keybinding_name_to_keystroke(commands::AGENT.name, args.app)
{
Some(keystroke)
} else {
keybinding_name_to_keystroke(commands::NEW.name, args.app)
};
if let Some(keystroke) = keystroke {
Some(Message::new(vec![
MessageItem::keystroke(keystroke),
MessageItem::text(" new /agent conversation"),
]))
} else {
Some(Message::new(vec![MessageItem::text(
"/agent for new conversation",
)]))
}
}
}
struct InlineHistoryMessageProducer;
impl MessageProvider<Option<&AcceptHistoryItem>> for InlineHistoryMessageProducer {
fn produce_message(&self, selected: Option<&AcceptHistoryItem>) -> Option<Message> {
let enter = MessageItem::keystroke(Keystroke {
key: "enter".to_owned(),
..Default::default()
});
let items = match selected {
Some(AcceptHistoryItem::Command { .. }) => {
vec![enter, MessageItem::text(" to execute")]
}
Some(AcceptHistoryItem::AIPrompt { .. }) => {
vec![enter, MessageItem::text(" to send")]
}
Some(AcceptHistoryItem::Conversation { title, .. }) => {
vec![enter, MessageItem::text(format!(" to open '{title}'"))]
}
None => {
vec![MessageItem::text("")]
}
};
Some(Message::new(items))
}
}
struct AutodetectedPromptMessageTransformer;
impl MessageTransformer<TerminalMessageArgs<'_>> for AutodetectedPromptMessageTransformer {
fn transform_message(&self, message: &mut Message, args: TerminalMessageArgs<'_>) -> bool {
if !args.is_input_ai_detected()
|| args.current_input.starts_with(commands::AGENT.name)
|| args.current_input.starts_with(commands::NEW.name)
{
return false;
}
// Don't append this message if there is attached context, just cause its
// too much text and overwhelming.
if args.context_model.pending_context_block_ids().is_empty()
&& args.context_model.pending_context_selected_text().is_none()
{
let set_terminal_mode_keystroke =
keybinding_name_to_keystroke(SET_INPUT_MODE_TERMINAL_ACTION_NAME, args.app)
.unwrap_or_else(|| Keystroke {
key: "escape".to_owned(),
..Default::default()
});
message.items.extend([
MessageItem::text(" (autodetected) "),
MessageItem::keystroke(set_terminal_mode_keystroke),
MessageItem::text(" to override"),
]);
}
message.set_color(message_magenta(Appearance::as_ref(args.app).theme()));
true
}
}
struct AttachedBlocksMessageTransformer;
impl MessageTransformer<TerminalMessageArgs<'_>> for AttachedBlocksMessageTransformer {
fn transform_message(&self, message: &mut Message, args: TerminalMessageArgs<'_>) -> bool {
let context_block_ids = args.context_model.pending_context_block_ids();
if context_block_ids.is_empty() {
return false;
}
let Some(block_command) = context_block_ids
.iter()
.find_map(|id| args.terminal_model.block_list().block_with_id(id))
.map(|block| truncated_command_for_block(&block.command_to_string()))
else {
return false;
};
if context_block_ids.len() == 1 {
message.append_text(format!(" with `{}` attached", block_command).as_str());
} else {
let text = if context_block_ids.len() == 2 {
format!(" with `{}` and 1 other command attached", block_command)
} else {
format!(
" with `{}` and {} other commands attached",
block_command,
context_block_ids.len().saturating_sub(1)
)
};
message.append_text(text.as_str());
}
true
}
}
struct AttachedTextSelectionMessageTransformer;
impl MessageTransformer<TerminalMessageArgs<'_>> for AttachedTextSelectionMessageTransformer {
fn transform_message(&self, message: &mut Message, args: TerminalMessageArgs<'_>) -> bool {
if args.context_model.pending_context_selected_text().is_none()
|| !args.context_model.pending_context_block_ids().is_empty()
{
return false;
}
message.append_text(" with text selection attached");
true
}
}
fn message_magenta(theme: &WarpTheme) -> ColorU {
let mut color = theme.ansi_fg_magenta();
color.a = (255. * 0.65) as u8;
color
}