Replace local input model and improve shell command feedback
This commit is contained in:
@@ -1010,6 +1010,17 @@ impl BlocklistAIActionExecutor {
|
||||
.async_executing_actions
|
||||
.remove(conversation_id, &action_id)
|
||||
else {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
crate::ai::remote_logging::log_model_event(ctx, crate::ai::remote_logging::RemoteLogRecord {
|
||||
level: crate::ai::remote_logging::RemoteLogLevel::Info,
|
||||
message: "Async tool result discarded after action removal".to_string(),
|
||||
context: serde_json::json!({
|
||||
"event": "tool_result_discarded",
|
||||
"conversation_id": conversation_id.to_string(),
|
||||
"action_id": action_id.to_string(),
|
||||
"reason": "action_no_longer_running",
|
||||
}),
|
||||
});
|
||||
log::warn!("try_to_execute_action: async action completed but not found in executing map, conversation_id={conversation_id}, action_id={action_id:?}");
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -77,8 +77,64 @@ impl ShellCommandExecutor {
|
||||
&mut self,
|
||||
_: ModelHandle<ModelEventDispatcher>,
|
||||
event: &ModelEvent,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
if let ModelEvent::Exit { reason } = event {
|
||||
let context = {
|
||||
let model = self.terminal_model.lock();
|
||||
let block = model.block_list().active_block();
|
||||
serde_json::json!({
|
||||
"event": "shell_terminal_exited",
|
||||
"reason": format!("{reason:?}"),
|
||||
"block_id": block.id().to_string(),
|
||||
"conversation_id": block.ai_conversation_id().map(|id| id.to_string()),
|
||||
"action_id": block.requested_command_action_id().map(|id| id.to_string()),
|
||||
"completion_waiter_groups": self.block_finished_senders.len(),
|
||||
"refresh_waiter_groups": self.force_refresh_senders.len(),
|
||||
})
|
||||
};
|
||||
crate::ai::remote_logging::log_model_event(
|
||||
ctx,
|
||||
crate::ai::remote_logging::RemoteLogRecord {
|
||||
level: crate::ai::remote_logging::RemoteLogLevel::Info,
|
||||
message: "Shell terminal exited".to_string(),
|
||||
context,
|
||||
},
|
||||
);
|
||||
}
|
||||
// Log definitive terminal completion independently of tool-result delivery.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
if let ModelEvent::BlockCompleted(completed) = event {
|
||||
let context = {
|
||||
let model = self.terminal_model.lock();
|
||||
model
|
||||
.block_list()
|
||||
.block_with_id(&completed.block_id)
|
||||
.and_then(|block| {
|
||||
block.requested_command_action_id().map(|action_id| serde_json::json!({
|
||||
"event": "shell_command_terminal_completed",
|
||||
"conversation_id": block.ai_conversation_id().map(|id| id.to_string()),
|
||||
"action_id": action_id.to_string(),
|
||||
"block_id": block.id().to_string(),
|
||||
"exit_code": block.exit_code().value(),
|
||||
"start_ts": block.start_ts(),
|
||||
"completed_ts": block.completed_ts(),
|
||||
"output_rows": block.output_grid().len_displayed(),
|
||||
}))
|
||||
})
|
||||
};
|
||||
if let Some(context) = context {
|
||||
crate::ai::remote_logging::log_model_event(
|
||||
ctx,
|
||||
crate::ai::remote_logging::RemoteLogRecord {
|
||||
level: crate::ai::remote_logging::RemoteLogLevel::Info,
|
||||
message: "Shell command terminal completed".to_string(),
|
||||
context,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
// Precmd provides fresh CWD metadata, while BlockCompleted is definitive completion
|
||||
// evidence for shells that never deliver a subsequent precmd.
|
||||
if matches!(
|
||||
@@ -217,6 +273,8 @@ impl ShellCommandExecutor {
|
||||
|
||||
// Determine the action we want to take based on the input.
|
||||
let action_id = input.action.id.clone();
|
||||
let diagnostic_action_id = action_id.clone();
|
||||
let conversation_id = input.conversation_id;
|
||||
|
||||
let handle = ctx.handle();
|
||||
match &input.action.action {
|
||||
@@ -278,6 +336,12 @@ impl ShellCommandExecutor {
|
||||
// Remove the senders from the maps.
|
||||
if let Some(handle) = handle.upgrade(ctx) {
|
||||
handle.update(ctx, |me, ctx| {
|
||||
log_shell_wait_result(
|
||||
conversation_id,
|
||||
&diagnostic_action_id,
|
||||
&result,
|
||||
ctx,
|
||||
);
|
||||
me.prune_closed_senders(&block_selector);
|
||||
if matches!(
|
||||
result,
|
||||
@@ -313,16 +377,17 @@ impl ShellCommandExecutor {
|
||||
let exit_code = block.exit_code();
|
||||
let start_ts = block.start_ts().cloned();
|
||||
let completed_ts = block.completed_ts().cloned();
|
||||
let result = ActionResult::CommandFinished {
|
||||
block_id: block.id().clone(),
|
||||
output,
|
||||
exit_code,
|
||||
start_ts,
|
||||
completed_ts,
|
||||
};
|
||||
drop(model);
|
||||
log_shell_wait_result(conversation_id, &action_id, &result, ctx);
|
||||
return ActionExecution::Sync(
|
||||
AIAgentActionResultType::WriteToLongRunningShellCommand(
|
||||
WriteToLongRunningShellCommandResult::CommandFinished {
|
||||
block_id: block.id().clone(),
|
||||
output,
|
||||
exit_code,
|
||||
start_ts,
|
||||
completed_ts,
|
||||
},
|
||||
),
|
||||
action_result_for_write_to_long_running_shell_command(result),
|
||||
);
|
||||
}
|
||||
// Drop immutable borrow.
|
||||
@@ -348,7 +413,13 @@ impl ShellCommandExecutor {
|
||||
move |result, ctx| {
|
||||
// Remove the senders from the maps.
|
||||
if let Some(handle) = handle.upgrade(ctx) {
|
||||
handle.update(ctx, |me, _| {
|
||||
handle.update(ctx, |me, ctx| {
|
||||
log_shell_wait_result(
|
||||
conversation_id,
|
||||
&diagnostic_action_id,
|
||||
&result,
|
||||
ctx,
|
||||
);
|
||||
me.prune_closed_senders(&block_selector);
|
||||
});
|
||||
}
|
||||
@@ -369,15 +440,17 @@ impl ShellCommandExecutor {
|
||||
let exit_code = block.exit_code();
|
||||
let start_ts = block.start_ts().cloned();
|
||||
let completed_ts = block.completed_ts().cloned();
|
||||
return ActionExecution::Sync(AIAgentActionResultType::ReadShellCommandOutput(
|
||||
ReadShellCommandOutputResult::CommandFinished {
|
||||
command,
|
||||
block_id: block_id.clone(),
|
||||
output,
|
||||
exit_code,
|
||||
start_ts,
|
||||
completed_ts,
|
||||
},
|
||||
let result = ActionResult::CommandFinished {
|
||||
block_id: block_id.clone(),
|
||||
output,
|
||||
exit_code,
|
||||
start_ts,
|
||||
completed_ts,
|
||||
};
|
||||
drop(model);
|
||||
log_shell_wait_result(conversation_id, &action_id, &result, ctx);
|
||||
return ActionExecution::Sync(action_result_for_read_shell_command_output(
|
||||
command, result,
|
||||
));
|
||||
}
|
||||
let command = block.command_with_secrets_unobfuscated(false);
|
||||
@@ -389,7 +462,13 @@ impl ShellCommandExecutor {
|
||||
move |result, ctx| {
|
||||
// Remove the senders from the maps.
|
||||
if let Some(handle) = handle.upgrade(ctx) {
|
||||
handle.update(ctx, |me, _| {
|
||||
handle.update(ctx, |me, ctx| {
|
||||
log_shell_wait_result(
|
||||
conversation_id,
|
||||
&diagnostic_action_id,
|
||||
&result,
|
||||
ctx,
|
||||
);
|
||||
me.prune_closed_senders(&block_selector);
|
||||
});
|
||||
}
|
||||
@@ -487,6 +566,7 @@ impl ShellCommandExecutor {
|
||||
cursor: CURSOR_MARKER,
|
||||
is_alt_screen_active: model.is_alt_screen_active(),
|
||||
is_preempted: false,
|
||||
wake_reason: "control_handed_back",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -501,7 +581,13 @@ impl ShellCommandExecutor {
|
||||
ActionExecution::new_async(transfer_future, move |result, ctx| {
|
||||
// Clean up.
|
||||
if let Some(handle) = handle.upgrade(ctx) {
|
||||
handle.update(ctx, |me, _| {
|
||||
handle.update(ctx, |me, ctx| {
|
||||
log_shell_wait_result(
|
||||
conversation_id,
|
||||
&diagnostic_action_id,
|
||||
&result,
|
||||
ctx,
|
||||
);
|
||||
me.prune_closed_senders(&block_selector);
|
||||
me.control_handback_sender = None;
|
||||
});
|
||||
@@ -640,6 +726,11 @@ impl ShellCommandExecutor {
|
||||
cursor: CURSOR_MARKER,
|
||||
is_alt_screen_active: selected_block_owns_alt_screen,
|
||||
is_preempted,
|
||||
wake_reason: match wake_reason {
|
||||
WakeReason::BlockFinished => "block_finished",
|
||||
WakeReason::Timeout => "timeout",
|
||||
WakeReason::ForceRefresh => "force_refresh",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -704,29 +795,29 @@ impl ShellCommandExecutor {
|
||||
let terminal_model = self.terminal_model.lock();
|
||||
// Find every pending poll whose selector resolves to this block. Multiple provider polls
|
||||
// may legitimately wait on the same command and must be refreshed together.
|
||||
let matching_selector = self
|
||||
let matching_selectors = self
|
||||
.force_refresh_senders
|
||||
.keys()
|
||||
.find(|selector| {
|
||||
.filter(|selector| {
|
||||
selector.get_block(&terminal_model).is_some_and(|block| {
|
||||
block.id() == block_id
|
||||
&& block.is_active_and_long_running()
|
||||
&& !block.finished()
|
||||
})
|
||||
})
|
||||
.cloned();
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
drop(terminal_model);
|
||||
|
||||
if let Some(selector) = matching_selector {
|
||||
let mut refreshed = false;
|
||||
for selector in matching_selectors {
|
||||
if let Some(senders) = self.force_refresh_senders.remove(&selector) {
|
||||
let mut refreshed = false;
|
||||
for sender in senders {
|
||||
refreshed |= sender.send(()).is_ok();
|
||||
}
|
||||
return refreshed;
|
||||
}
|
||||
}
|
||||
false
|
||||
refreshed
|
||||
}
|
||||
|
||||
pub(super) fn preprocess_action(
|
||||
@@ -738,6 +829,57 @@ impl ShellCommandExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
fn log_shell_wait_result(
|
||||
conversation_id: crate::AIConversationId,
|
||||
action_id: &AIAgentActionId,
|
||||
result: &ActionResult,
|
||||
ctx: &mut ModelContext<ShellCommandExecutor>,
|
||||
) {
|
||||
crate::ai::remote_logging::log_model_event(
|
||||
ctx,
|
||||
crate::ai::remote_logging::RemoteLogRecord {
|
||||
level: crate::ai::remote_logging::RemoteLogLevel::Info,
|
||||
message: "Shell command wait resolved".to_string(),
|
||||
context: serde_json::json!({
|
||||
"event": "shell_command_wait_resolved",
|
||||
"conversation_id": conversation_id.to_string(),
|
||||
"action_id": action_id.to_string(),
|
||||
"result": shell_result_diagnostics(result),
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn shell_result_diagnostics(result: &ActionResult) -> serde_json::Value {
|
||||
match result {
|
||||
ActionResult::CommandFinished {
|
||||
block_id,
|
||||
output,
|
||||
exit_code,
|
||||
start_ts,
|
||||
completed_ts,
|
||||
} => serde_json::json!({
|
||||
"state": "finished", "block_id": block_id.to_string(),
|
||||
"exit_code": exit_code.value(), "output_bytes": output.len(),
|
||||
"start_ts": start_ts, "completed_ts": completed_ts,
|
||||
}),
|
||||
ActionResult::LongRunningCommandSnapshot {
|
||||
block_id,
|
||||
grid_contents,
|
||||
is_alt_screen_active,
|
||||
is_preempted,
|
||||
wake_reason,
|
||||
..
|
||||
} => serde_json::json!({
|
||||
"state": "running", "block_id": block_id.to_string(),
|
||||
"snapshot_bytes": grid_contents.len(), "alt_screen": is_alt_screen_active,
|
||||
"preempted": is_preempted, "wake_reason": wake_reason,
|
||||
}),
|
||||
ActionResult::Cancelled => serde_json::json!({"state": "cancelled"}),
|
||||
ActionResult::BlockNotFound => serde_json::json!({"state": "block_not_found"}),
|
||||
}
|
||||
}
|
||||
|
||||
fn command_for_execution(
|
||||
command: &str,
|
||||
shell_type: Option<ShellType>,
|
||||
@@ -863,6 +1005,7 @@ fn action_result_for_write_to_long_running_shell_command(
|
||||
cursor,
|
||||
is_alt_screen_active,
|
||||
is_preempted,
|
||||
..
|
||||
} => AIAgentActionResultType::WriteToLongRunningShellCommand(
|
||||
WriteToLongRunningShellCommandResult::Snapshot {
|
||||
block_id,
|
||||
@@ -909,6 +1052,7 @@ fn action_result_for_read_shell_command_output(
|
||||
cursor,
|
||||
is_alt_screen_active,
|
||||
is_preempted,
|
||||
..
|
||||
} => AIAgentActionResultType::ReadShellCommandOutput(
|
||||
ReadShellCommandOutputResult::LongRunningCommandSnapshot {
|
||||
command,
|
||||
@@ -954,6 +1098,7 @@ fn action_result_for_transfer_shell_command_control_to_user(
|
||||
cursor,
|
||||
is_alt_screen_active,
|
||||
is_preempted,
|
||||
..
|
||||
} => AIAgentActionResultType::TransferShellCommandControlToUser(
|
||||
TransferShellCommandControlToUserResult::Snapshot {
|
||||
block_id,
|
||||
@@ -1020,6 +1165,7 @@ enum ActionResult {
|
||||
cursor: &'static str,
|
||||
is_alt_screen_active: bool,
|
||||
is_preempted: bool,
|
||||
wake_reason: &'static str,
|
||||
},
|
||||
Cancelled,
|
||||
BlockNotFound,
|
||||
|
||||
@@ -23,6 +23,7 @@ use crate::terminal::model::session::Sessions;
|
||||
use crate::terminal::model::terminal_model::{BlockIndex, TerminalModel};
|
||||
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
|
||||
use crate::terminal::shell::ShellType;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::AIConversationId;
|
||||
|
||||
#[test]
|
||||
@@ -178,16 +179,43 @@ fn force_refresh_block_reports_and_resolves_matching_poll() {
|
||||
)
|
||||
});
|
||||
|
||||
let action_id = AIAgentActionId::from("refresh-command".to_string());
|
||||
terminal_model
|
||||
.lock()
|
||||
.block_list_mut()
|
||||
.active_block_mut()
|
||||
.set_agent_interaction_mode_for_requested_command(
|
||||
action_id.clone(),
|
||||
None,
|
||||
AIConversationId::new(),
|
||||
);
|
||||
let (tx, mut rx) = oneshot::channel();
|
||||
let (action_tx, mut action_rx) = oneshot::channel();
|
||||
let (second_tx, mut second_rx) = oneshot::channel();
|
||||
let (unrelated_tx, mut unrelated_rx) = oneshot::channel();
|
||||
let unrelated_selector = BlockSelector::Id(BlockId::new());
|
||||
executor.update(&mut app, |executor, _| {
|
||||
executor
|
||||
.force_refresh_senders
|
||||
.insert(BlockSelector::Id(block_id.clone()), vec![tx]);
|
||||
executor.force_refresh_senders.insert(
|
||||
BlockSelector::RequestedCommandId(action_id),
|
||||
vec![action_tx, second_tx],
|
||||
);
|
||||
executor
|
||||
.force_refresh_senders
|
||||
.insert(unrelated_selector.clone(), vec![unrelated_tx]);
|
||||
assert!(executor.force_refresh_block(&block_id));
|
||||
assert!(executor
|
||||
.force_refresh_senders
|
||||
.contains_key(&unrelated_selector));
|
||||
assert!(!executor.force_refresh_block(&block_id));
|
||||
});
|
||||
|
||||
assert!(matches!(rx.try_recv(), Ok(Some(()))));
|
||||
assert!(matches!(action_rx.try_recv(), Ok(Some(()))));
|
||||
assert!(matches!(second_rx.try_recv(), Ok(Some(()))));
|
||||
assert!(matches!(unrelated_rx.try_recv(), Ok(None)));
|
||||
|
||||
let (tx, _rx) = oneshot::channel();
|
||||
executor.update(&mut app, |executor, _| {
|
||||
@@ -205,6 +233,7 @@ fn force_refresh_block_reports_and_resolves_matching_poll() {
|
||||
#[test]
|
||||
fn requested_command_waiter_survives_early_metadata_and_resolves_after_association() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
let terminal_view_id = EntityId::new();
|
||||
let sessions = app.add_model(|_| Sessions::new_for_test());
|
||||
let (_model_events_tx, model_events_rx) = unbounded();
|
||||
@@ -393,3 +422,20 @@ fn force_refresh_wakes_on_completion_poll_with_preempted_snapshot() {
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_diagnostics_report_snapshot_reason_without_output_contents() {
|
||||
let result = ActionResult::LongRunningCommandSnapshot {
|
||||
block_id: BlockId::new(),
|
||||
grid_contents: "private terminal output".to_string(),
|
||||
cursor: "cursor",
|
||||
is_alt_screen_active: false,
|
||||
is_preempted: true,
|
||||
wake_reason: "force_refresh",
|
||||
};
|
||||
let metadata = super::shell_result_diagnostics(&result);
|
||||
assert_eq!(metadata["state"], "running");
|
||||
assert_eq!(metadata["wake_reason"], "force_refresh");
|
||||
assert_eq!(metadata["snapshot_bytes"], "private terminal output".len());
|
||||
assert!(!metadata.to_string().contains("private terminal output"));
|
||||
}
|
||||
|
||||
@@ -698,6 +698,19 @@ impl CLISubagentController {
|
||||
let block_id = block_id.clone();
|
||||
let refreshed =
|
||||
executor_handle.update(ctx, |executor, _| executor.force_refresh_block(&block_id));
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
crate::ai::remote_logging::log_model_event(
|
||||
ctx,
|
||||
crate::ai::remote_logging::RemoteLogRecord {
|
||||
level: crate::ai::remote_logging::RemoteLogLevel::Info,
|
||||
message: "Shell command refresh requested".to_string(),
|
||||
context: serde_json::json!({
|
||||
"event": "shell_command_refresh_requested",
|
||||
"block_id": block_id.to_string(),
|
||||
"refreshed": refreshed,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if refreshed {
|
||||
self.active_subagents_by_block.entry(block_id).or_default();
|
||||
}
|
||||
|
||||
@@ -1419,7 +1419,7 @@ impl RequestedCommandView {
|
||||
}
|
||||
Some(AIActionStatus::RunningAsync) if self.action_type.is_requested_command() => {
|
||||
config = config.with_interaction_mode(InteractionMode::ManuallyExpandable(
|
||||
self.get_expansion_config(requested_command_block, app),
|
||||
self.get_expansion_config(app),
|
||||
));
|
||||
}
|
||||
Some(AIActionStatus::Finished(result)) => {
|
||||
@@ -1447,7 +1447,7 @@ impl RequestedCommandView {
|
||||
|
||||
if should_be_expandable {
|
||||
config = config.with_interaction_mode(InteractionMode::ManuallyExpandable(
|
||||
self.get_expansion_config(requested_command_block, app),
|
||||
self.get_expansion_config(app),
|
||||
));
|
||||
} else {
|
||||
// Commands cancelled before execution should be right clickable only.
|
||||
@@ -1471,7 +1471,7 @@ impl RequestedCommandView {
|
||||
// for this action, the command ran and the header should be expandable.
|
||||
if requested_command_block.is_some_and(|block| block.finished()) {
|
||||
config = config.with_interaction_mode(InteractionMode::ManuallyExpandable(
|
||||
self.get_expansion_config(requested_command_block, app),
|
||||
self.get_expansion_config(app),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1487,11 +1487,7 @@ impl RequestedCommandView {
|
||||
}
|
||||
}
|
||||
|
||||
fn get_expansion_config(
|
||||
&self,
|
||||
requested_command_block: Option<&Block>,
|
||||
app: &AppContext,
|
||||
) -> ExpandedConfig {
|
||||
fn get_expansion_config(&self, app: &AppContext) -> ExpandedConfig {
|
||||
let command_text_for_callback = self.command_text().to_string();
|
||||
let mut expansion_config =
|
||||
ExpandedConfig::new(self.is_header_expanded, self.header_mouse_state.clone())
|
||||
@@ -1499,16 +1495,11 @@ impl RequestedCommandView {
|
||||
ctx.dispatch_typed_action(AIBlockAction::StoreRightClickedCommand {
|
||||
command: command_text_for_callback.clone(),
|
||||
});
|
||||
})
|
||||
.with_toggle_callback(|ctx| {
|
||||
ctx.dispatch_typed_action(RequestedCommandViewAction::ToggleExpanded);
|
||||
});
|
||||
|
||||
let is_active_agent_monitored_command = requested_command_block
|
||||
.is_some_and(|block| block.is_agent_monitoring() && !block.finished());
|
||||
if !is_active_agent_monitored_command {
|
||||
expansion_config = expansion_config.with_toggle_callback(|ctx| {
|
||||
ctx.dispatch_typed_action(RequestedCommandViewAction::ToggleExpanded);
|
||||
});
|
||||
}
|
||||
|
||||
if InputModeSettings::as_ref(app).is_pinned_to_top()
|
||||
&& self.action_type.is_requested_command()
|
||||
{
|
||||
@@ -1723,7 +1714,7 @@ impl View for RequestedCommandView {
|
||||
command_output_grid
|
||||
{
|
||||
let mut output_grid = TerminalGridSnapshotElement::new(
|
||||
&grid,
|
||||
grid,
|
||||
row_count,
|
||||
colors,
|
||||
override_colors,
|
||||
|
||||
@@ -11,7 +11,6 @@ use futures::stream::AbortHandle;
|
||||
use galaxy_completer::completer::CompletionContext;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
use input_classifier::util::{is_agent_follow_up_input, is_one_off_natural_language_word};
|
||||
pub use input_classifier::{InputClassifierDecisionSource, InputType};
|
||||
use instant::Instant;
|
||||
use parking_lot::FairMutex;
|
||||
@@ -90,20 +89,15 @@ use super::agent_view::AgentViewEntryOrigin;
|
||||
use super::context_model::BlocklistAIContextModel;
|
||||
use super::telemetry_banner::should_collect_ai_ugc_telemetry;
|
||||
use super::{ConversationSelectionEvent, ConversationSelectionHandle};
|
||||
use crate::input_classifier::InputClassifierModel;
|
||||
use crate::settings::{AISettings, AISettingsChangedEvent, InputBoxType, InputSettings};
|
||||
use crate::terminal::cli_agent_sessions::{
|
||||
CLIAgentInputState, CLIAgentSessionsModel, CLIAgentSessionsModelEvent,
|
||||
};
|
||||
use crate::terminal::input::decorations::ParsedTokensSnapshot;
|
||||
use crate::terminal::model::rich_content::RichContentType;
|
||||
use crate::terminal::model::session::SessionId;
|
||||
use crate::terminal::{History, TerminalModel};
|
||||
use crate::{report_if_error, send_telemetry_from_ctx, PrivacySettings, TelemetryEvent};
|
||||
|
||||
/// Cutoff score for deciding an user input matches a history command entry.
|
||||
const HISTORY_ENTRY_MATCH_CUTOFF: f32 = 0.9;
|
||||
|
||||
/// Duration to temporarily disable autodetection during operations like history selection.
|
||||
const AUTODETECTION_DISABLE_DURATION_MS: u64 = 250;
|
||||
|
||||
@@ -696,7 +690,7 @@ impl BlocklistAIInputModel {
|
||||
/// nothing.
|
||||
///
|
||||
/// When `session_id` is `Some`, history matching is performed. The `completion_context`
|
||||
/// is always used for alias expansion (callers without a live session should pass an
|
||||
/// supplies the shell command inventory (callers without a live session should pass an
|
||||
/// `EmptyCompletionContext`).
|
||||
pub fn detect_and_set_input_type<C: CompletionContext + Clone + Send + 'static>(
|
||||
&mut self,
|
||||
@@ -760,10 +754,7 @@ impl BlocklistAIInputModel {
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|entry| {
|
||||
if entry
|
||||
.exit_code
|
||||
.is_some_and(|code| code.was_command_not_found())
|
||||
{
|
||||
if !crate::input_classifier::is_verified_history_command(entry.exit_code) {
|
||||
return None;
|
||||
}
|
||||
Some(entry.command.to_string())
|
||||
@@ -771,105 +762,27 @@ impl BlocklistAIInputModel {
|
||||
.collect::<Vec<String>>()
|
||||
});
|
||||
|
||||
let buffer_cloned = input.buffer_text.clone();
|
||||
let other_buffer_cloned = buffer_cloned.clone();
|
||||
let other_buffer_cloned = input.buffer_text.clone();
|
||||
let current_input_type = self.input_type();
|
||||
|
||||
let is_udi_enabled = InputSettings::as_ref(ctx).is_universal_developer_input_enabled(ctx);
|
||||
|
||||
// Determine if the input is a follow-up to an AI block.
|
||||
let is_agent_follow_up = {
|
||||
let model = self.model.lock();
|
||||
let block_list = model.block_list();
|
||||
let block_index = block_list.last_non_hidden_block_by_index();
|
||||
match block_list.last_non_hidden_rich_content_block_after_block(block_index) {
|
||||
Some((_, content)) => content.content_type == Some(RichContentType::AIBlock),
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
|
||||
let classifier = InputClassifierModel::as_ref(ctx).classifier();
|
||||
let generation = self.autodetection_generation;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
let use_local_model = AISettings::as_ref(ctx).should_use_local_model();
|
||||
let handle = ctx
|
||||
.spawn(
|
||||
async move {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
if use_local_model {
|
||||
// A cancelled debounce never starts inference. The local worker
|
||||
// also cancels obsolete requests already queued or generating.
|
||||
warpui::r#async::Timer::after(std::time::Duration::from_millis(100)).await;
|
||||
let result = crate::local_inference::classify(
|
||||
buffer_cloned.to_string(),
|
||||
history_entries.as_deref().unwrap_or_default(),
|
||||
is_agent_follow_up,
|
||||
)
|
||||
.await;
|
||||
return match result {
|
||||
Some(input_type) => (
|
||||
input_type,
|
||||
InputClassifierDecisionSource::LocalLanguageModel.into(),
|
||||
),
|
||||
None => (
|
||||
current_input_type,
|
||||
InputClassifierDecisionSource::InputClassifierFallbackCurrentInput
|
||||
.into(),
|
||||
),
|
||||
};
|
||||
}
|
||||
// First check if the token is a natural language word, if current input type is AI
|
||||
if matches!(current_input_type, InputType::AI)
|
||||
&& is_one_off_natural_language_word(first_token_str.to_lowercase().as_str())
|
||||
{
|
||||
return (
|
||||
InputType::AI,
|
||||
InputClassifierDecisionSource::NaturalLanguageOneOffAllowlist.into(),
|
||||
);
|
||||
}
|
||||
|
||||
// If this is clearly intended to be a follow-up to an AI block, classify it as AI.
|
||||
if is_agent_follow_up
|
||||
&& is_agent_follow_up_input(&buffer_cloned.trim().to_lowercase())
|
||||
{
|
||||
return (
|
||||
InputType::AI,
|
||||
InputTypeAutoDetectionSource::NaturalLanguageAgentFollowUpAllowList,
|
||||
);
|
||||
}
|
||||
|
||||
// If we have history entries (i.e., a live session), check for
|
||||
// close matches to short-circuit as shell input.
|
||||
if let Some(history_entries) = history_entries {
|
||||
if has_any_close_matches(
|
||||
&buffer_cloned,
|
||||
history_entries.iter().map(AsRef::as_ref),
|
||||
HISTORY_ENTRY_MATCH_CUTOFF,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return (InputType::Shell, InputTypeAutoDetectionSource::HistoryMatch);
|
||||
}
|
||||
}
|
||||
|
||||
// Yield so that an attempt to abort the classification is handled. We do this periodically
|
||||
// so that we can skip doing additional expensive work if the classification is aborted.
|
||||
futures_lite::future::yield_now().await;
|
||||
|
||||
let input =
|
||||
galaxy_completer::util::expand_aliases(input, &completion_context).await;
|
||||
|
||||
futures_lite::future::yield_now().await;
|
||||
|
||||
let context = input_classifier::Context {
|
||||
// Coalesce keystrokes; aborting this task prevents obsolete decisions.
|
||||
warpui::r#async::Timer::after(std::time::Duration::from_millis(100)).await;
|
||||
let input_type = crate::input_classifier::detect_input_type(
|
||||
&input,
|
||||
&completion_context,
|
||||
history_entries.as_deref().unwrap_or_default(),
|
||||
current_input_type,
|
||||
is_agent_follow_up,
|
||||
};
|
||||
let classification =
|
||||
classifier.detect_input_type(input.clone(), &context).await;
|
||||
|
||||
futures_lite::future::yield_now().await;
|
||||
(classification.input_type, classification.source.into())
|
||||
);
|
||||
(
|
||||
input_type,
|
||||
InputClassifierDecisionSource::ShellCommandLookup.into(),
|
||||
)
|
||||
},
|
||||
move |me, (new_input_type, decision_source), ctx| {
|
||||
// In theory, we shouldn't need to check this, as we only run autodetection if the input
|
||||
@@ -958,41 +871,3 @@ impl BlocklistAIInputEvent {
|
||||
impl Entity for BlocklistAIInputModel {
|
||||
type Event = BlocklistAIInputEvent;
|
||||
}
|
||||
|
||||
/// Returns whether the set of possibilities contains any close matches
|
||||
/// to the provided word, using the given similarity threshold.
|
||||
///
|
||||
/// Adapted from [`difflib::get_close_matches`], but an async function with
|
||||
/// periodic yields such that the operation can be aborted if necessary. Also,
|
||||
/// unlike the original function, this returns as soon as it finds any match
|
||||
/// above the threshold, instead of finding _all_ matches above the threshold
|
||||
/// and returning the top N matches.
|
||||
async fn has_any_close_matches<'a>(
|
||||
word: &str,
|
||||
possibilities: impl Iterator<Item = &'a str>,
|
||||
cutoff: f32,
|
||||
) -> bool {
|
||||
const BATCH_SIZE: usize = 50;
|
||||
|
||||
if !(0.0..=1.0).contains(&cutoff) {
|
||||
panic!("Cutoff must be greater than 0.0 and lower than 1.0");
|
||||
}
|
||||
let mut matcher = difflib::sequencematcher::SequenceMatcher::new("", word);
|
||||
for (idx, i) in possibilities.enumerate() {
|
||||
// Periodically, yield to the executor so this task can be aborted if
|
||||
// requested.
|
||||
if idx % BATCH_SIZE == 0 {
|
||||
futures_lite::future::yield_now().await;
|
||||
}
|
||||
|
||||
matcher.set_first_seq(i);
|
||||
// The fast ratio computations produce an upper bound on the value of
|
||||
// ratio, so if a faster check fails, the slower checks are guaranteed
|
||||
// to also fail.
|
||||
if matcher.real_quick_ratio() >= cutoff && matcher.ratio() >= cutoff {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user