Synced from monorepo
Changes: - Persist submitter identity for /feedback - Let custom models use rotating tokens from named auth providers - Template stale tool/param name literals in server-native descriptions - Minimal mode commits thinking in full, lookups as one-liners - Tighten durable append internals - Nudge model to end turn on no-op bash commands - Per-fetch signing nonce in the managed-config envelope, with a server-side replay probe - Include working tree in startup status
This commit is contained in:
parent
ba76b0a683
commit
a881e6703f
140 changed files with 6746 additions and 2377 deletions
|
|
@ -157,9 +157,10 @@ pub(super) fn handle_mcp_tools_changed(notif: &acp::ExtNotification, app: &mut A
|
|||
pub(super) fn agent_has_pending_mcps_fetch(app: &AppView, agent_id: AgentId) -> bool {
|
||||
app.pending_effects.iter().any(|e| {
|
||||
matches!(
|
||||
e,
|
||||
Effect::FetchMcpsList { agent_id: a, .. } if *a == agent_id
|
||||
)
|
||||
e,
|
||||
Effect::FetchMcpsList { agent_id: a, .. }
|
||||
if *a == agent_id
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -94,7 +94,8 @@
|
|||
assert!(
|
||||
app.pending_effects.iter().any(|e| matches!(
|
||||
e,
|
||||
Effect::PersistAnnouncementsHidden { hidden_ids } if hidden_ids == &expected
|
||||
Effect::PersistAnnouncementsHidden { hidden_ids }
|
||||
if hidden_ids == &expected
|
||||
)),
|
||||
"prune must persist the shrunken set, got {:?}",
|
||||
app.pending_effects
|
||||
|
|
|
|||
|
|
@ -25,9 +25,11 @@ impl AgentView {
|
|||
// BEFORE the removal so a potential auto-hide pane switch can't hit
|
||||
// the editing lock (see queue_edit.rs ordering invariant).
|
||||
if matches!(
|
||||
self.prompt_mode,
|
||||
PromptMode::EditingQueued { id: editing_id, server_id: None, .. } if editing_id == id
|
||||
) {
|
||||
self.prompt_mode,
|
||||
PromptMode::EditingQueued { id: editing_id, server_id: None, .. }
|
||||
if editing_id == id
|
||||
)
|
||||
{
|
||||
self.exit_editing_mode();
|
||||
}
|
||||
self.queue.select_after_delete(id);
|
||||
|
|
|
|||
|
|
@ -343,8 +343,9 @@ impl VoiceState {
|
|||
/// it). `/voice` and toggle-style starts leave this false.
|
||||
pub(crate) fn hold(&self) -> bool {
|
||||
matches!(
|
||||
self, Self::ColdStart { hold, .. } | Self::Recording { hold, .. } if * hold
|
||||
)
|
||||
self, Self::ColdStart { hold, .. } | Self::Recording { hold, .. }
|
||||
if * hold
|
||||
)
|
||||
}
|
||||
}
|
||||
/// Entry in the session picker list on the welcome screen.
|
||||
|
|
|
|||
|
|
@ -965,7 +965,8 @@ mod tests {
|
|||
// Turn ends → should drain "second" (front, not being edited) + FetchBilling.
|
||||
let effects = dispatch(end_turn(), &mut app);
|
||||
assert_eq!(effects.len(), 2);
|
||||
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "second"));
|
||||
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. }
|
||||
if text == "second"));
|
||||
assert!(matches!(
|
||||
&effects[1],
|
||||
Effect::FetchBilling { silent: true, .. }
|
||||
|
|
@ -991,7 +992,8 @@ mod tests {
|
|||
// DrainQueue should pop and send.
|
||||
let effects = dispatch(Action::DrainQueue, &mut app);
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "queued"));
|
||||
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. }
|
||||
if text == "queued"));
|
||||
assert_eq!(app.agents[&id].session.queue_len(), 0);
|
||||
}
|
||||
|
||||
|
|
@ -1960,7 +1962,8 @@ mod tests {
|
|||
let effects = dispatch(Action::DrainQueue, &mut app);
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(
|
||||
matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "p3-edited"),
|
||||
matches!(&effects[0], Effect::SendPrompt { text, .. }
|
||||
if text == "p3-edited"),
|
||||
"should send the edited prompt, got: {:?}",
|
||||
effects[0]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -790,10 +790,6 @@ pub(in crate::app::dispatch) fn skip_picker_and_create_session(
|
|||
model_id: None,
|
||||
preferred_session_id,
|
||||
chat_kind,
|
||||
|
||||
|
||||
|
||||
|
||||
}]
|
||||
}
|
||||
pub(in crate::app::dispatch) fn handle_session_created(
|
||||
|
|
|
|||
|
|
@ -492,7 +492,8 @@ fn show_usage_returns_fetch_billing_effect() {
|
|||
// together and renders a single summary.
|
||||
assert_eq!(effects.len(), 1, "got: {effects:?}");
|
||||
assert!(
|
||||
matches!(&effects[0], Effect::FetchBilling { agent_id, silent } if *agent_id == AgentId(0) && !*silent),
|
||||
matches!(&effects[0], Effect::FetchBilling { agent_id, silent }
|
||||
if *agent_id == AgentId(0) && !*silent),
|
||||
"effect should be a non-silent FetchBilling, got: {effects:?}"
|
||||
);
|
||||
}
|
||||
|
|
@ -859,7 +860,8 @@ fn free_usage_failure_opens_paywall_modal() {
|
|||
// 1. Real send.
|
||||
let effects = dispatch(Action::SendPrompt("draw me a cat".into()), &mut app);
|
||||
assert!(
|
||||
matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "draw me a cat"),
|
||||
matches!(&effects[0], Effect::SendPrompt { text, .. }
|
||||
if text == "draw me a cat"),
|
||||
"send must dispatch: {effects:?}"
|
||||
);
|
||||
let prompt_id = app.agents[&id].session.current_prompt_id.clone();
|
||||
|
|
@ -1051,7 +1053,8 @@ fn unknown_non_restricted_command_still_passes_through() {
|
|||
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(
|
||||
matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "/frobnicate arg"),
|
||||
matches!(&effects[0], Effect::SendPrompt { text, .. }
|
||||
if text == "/frobnicate arg"),
|
||||
"unknown command must still pass through: {effects:?}"
|
||||
);
|
||||
assert!(
|
||||
|
|
|
|||
|
|
@ -218,9 +218,10 @@ fn plugin_cta_catalog_load_recomputes_match_for_typed_draft() {
|
|||
&mut app,
|
||||
);
|
||||
assert!(matches!(
|
||||
&app.agents[&id].plugin_cta.phase,
|
||||
CtaPhase::Matched { name, .. } if name == "zzctaplugin"
|
||||
));
|
||||
&app.agents[&id].plugin_cta.phase,
|
||||
CtaPhase::Matched { name, .. }
|
||||
if name == "zzctaplugin"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -568,7 +568,8 @@ async fn dashboard_change_location_valid_updates_cwd_and_closes_modal() {
|
|||
assert!(
|
||||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::SetWorkingDir { path } if path == &target))
|
||||
.any(|e| matches!(e, Effect::SetWorkingDir { path }
|
||||
if path == &target))
|
||||
);
|
||||
assert!(
|
||||
app.dashboard.as_ref().unwrap().location_picker.is_none(),
|
||||
|
|
@ -2187,9 +2188,10 @@ fn dashboard_dispatch_applies_pending_model_and_plan() {
|
|||
let new_id = *app.agents.keys().next().unwrap();
|
||||
// CreateSession carries the staged model id.
|
||||
assert!(effects.iter().any(|e| matches!(
|
||||
e,
|
||||
Effect::CreateSession { model_id: Some(m), .. } if *m == model_id
|
||||
)));
|
||||
e,
|
||||
Effect::CreateSession { model_id: Some(m), .. }
|
||||
if *m == model_id
|
||||
)));
|
||||
let agent = &app.agents[&new_id];
|
||||
assert_eq!(
|
||||
agent.session.deferred_model_switch,
|
||||
|
|
@ -2230,9 +2232,10 @@ fn dashboard_new_agent_button_applies_pending_model_and_plan() {
|
|||
let new_id = *app.agents.keys().next().unwrap();
|
||||
// CreateSession carries the staged model id.
|
||||
assert!(effects.iter().any(|e| matches!(
|
||||
e,
|
||||
Effect::CreateSession { model_id: Some(m), .. } if *m == model_id
|
||||
)));
|
||||
e,
|
||||
Effect::CreateSession { model_id: Some(m), .. }
|
||||
if *m == model_id
|
||||
)));
|
||||
let agent = &app.agents[&new_id];
|
||||
assert_eq!(
|
||||
agent.session.deferred_model_switch,
|
||||
|
|
@ -2277,7 +2280,8 @@ fn dashboard_deferred_plan_mode_applied_on_session_created() {
|
|||
assert!(
|
||||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::SetSessionMode { session_id: s, .. } if *s == session_id)),
|
||||
.any(|e| matches!(e, Effect::SetSessionMode { session_id: s, .. }
|
||||
if *s == session_id)),
|
||||
"SessionCreated must emit SetSessionMode for the deferred plan mode"
|
||||
);
|
||||
}
|
||||
|
|
@ -5077,7 +5081,8 @@ fn dashboard_peek_reply_to_idle_agent_sends() {
|
|||
/* attach */ false,
|
||||
);
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "please continue"));
|
||||
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. }
|
||||
if text == "please continue"));
|
||||
assert!(app.agents[&AgentId(0)].session.state.is_turn_running());
|
||||
assert_eq!(app.agents[&AgentId(0)].session.queue_len(), 0);
|
||||
// Reply draft cleared after sending.
|
||||
|
|
|
|||
|
|
@ -161,7 +161,8 @@ fn slash_plan_no_args_not_in_plan_enters_plan_mode() {
|
|||
// Should emit SetSessionMode to enter plan mode.
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(
|
||||
matches!(&effects[0], Effect::SetSessionMode { mode_id, .. } if &*mode_id.0 == "plan"),
|
||||
matches!(&effects[0], Effect::SetSessionMode { mode_id, .. }
|
||||
if &*mode_id.0 == "plan"),
|
||||
"expected SetSessionMode(plan), got: {effects:?}"
|
||||
);
|
||||
// Optimistic pending state should be set.
|
||||
|
|
@ -1932,9 +1933,10 @@ fn cycle_always_approve_with_nudge_jumps_to_plan() {
|
|||
);
|
||||
assert!(
|
||||
effects.iter().any(|e| matches!(
|
||||
e,
|
||||
Effect::SetSessionMode { mode_id, .. } if &*mode_id.0 == "plan"
|
||||
)),
|
||||
e,
|
||||
Effect::SetSessionMode { mode_id, .. }
|
||||
if &*mode_id.0 == "plan"
|
||||
)),
|
||||
"expected SetSessionMode(plan), got {effects:?}"
|
||||
);
|
||||
assert!(
|
||||
|
|
@ -1988,9 +1990,10 @@ fn cycle_auto_with_nudge_jumps_to_plan() {
|
|||
);
|
||||
assert!(
|
||||
effects.iter().any(|e| matches!(
|
||||
e,
|
||||
Effect::SetSessionMode { mode_id, .. } if &*mode_id.0 == "plan"
|
||||
)),
|
||||
e,
|
||||
Effect::SetSessionMode { mode_id, .. }
|
||||
if &*mode_id.0 == "plan"
|
||||
)),
|
||||
"expected SetSessionMode(plan), got {effects:?}"
|
||||
);
|
||||
assert!(
|
||||
|
|
@ -2312,7 +2315,8 @@ fn set_plan_mode_idempotency_uses_pending_over_active() {
|
|||
"OFF from EFFECTIVE-ON must emit Effect::SetSessionMode (not idempotent)"
|
||||
);
|
||||
assert!(
|
||||
matches!(&effects[0], Effect::SetSessionMode { mode_id, .. } if &*mode_id.0 == "default"),
|
||||
matches!(&effects[0], Effect::SetSessionMode { mode_id, .. }
|
||||
if &*mode_id.0 == "default"),
|
||||
"OFF transition must emit SetSessionMode(default): {effects:?}"
|
||||
);
|
||||
let agent = app.agents.get(&AgentId(0)).unwrap();
|
||||
|
|
|
|||
|
|
@ -534,7 +534,8 @@ fn send_prompt_produces_effect_and_clears_input() {
|
|||
|
||||
// Prompt is enqueued and immediately drained (agent was idle).
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "hello"));
|
||||
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. }
|
||||
if text == "hello"));
|
||||
assert!(app.agents[&id].prompt.text().is_empty());
|
||||
assert!(app.agents[&id].session.state.is_turn_running());
|
||||
assert_eq!(app.agents[&id].scrollback.len(), 1);
|
||||
|
|
@ -754,7 +755,8 @@ fn chip_submit_while_enqueued_clears_follow_up_chips() {
|
|||
assert!(
|
||||
!effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::SendPrompt { text, .. } if text == "Summarize")),
|
||||
.any(|e| matches!(e, Effect::SendPrompt { text, .. }
|
||||
if text == "Summarize")),
|
||||
"chip must be enqueued, not immediate-sent, got {effects:?}"
|
||||
);
|
||||
// The chips are cleared on the enqueue path too (the bug fix).
|
||||
|
|
@ -979,7 +981,8 @@ fn multiple_queued_prompts_drain_one_per_turn() {
|
|||
|
||||
// Turn end → drain "b" + FetchBilling.
|
||||
let effects = dispatch(end_turn(), &mut app);
|
||||
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "b"));
|
||||
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. }
|
||||
if text == "b"));
|
||||
assert!(matches!(
|
||||
&effects[1],
|
||||
Effect::FetchBilling { silent: true, .. }
|
||||
|
|
@ -988,7 +991,8 @@ fn multiple_queued_prompts_drain_one_per_turn() {
|
|||
|
||||
// Turn end → drain "c" + FetchBilling.
|
||||
let effects = dispatch(end_turn(), &mut app);
|
||||
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "c"));
|
||||
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. }
|
||||
if text == "c"));
|
||||
assert!(matches!(
|
||||
&effects[1],
|
||||
Effect::FetchBilling { silent: true, .. }
|
||||
|
|
@ -1825,7 +1829,8 @@ fn cancel_with_queued_prompt_drains_on_completion() {
|
|||
);
|
||||
|
||||
assert_eq!(effects.len(), 2);
|
||||
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "queued"));
|
||||
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. }
|
||||
if text == "queued"));
|
||||
assert!(matches!(
|
||||
&effects[1],
|
||||
Effect::FetchBilling { silent: true, .. }
|
||||
|
|
@ -1907,7 +1912,8 @@ fn cancel_with_multiple_queued_prompts_drains_only_front_prompt() {
|
|||
);
|
||||
|
||||
assert_eq!(effects.len(), 2);
|
||||
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "queued-1"));
|
||||
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. }
|
||||
if text == "queued-1"));
|
||||
assert!(matches!(
|
||||
&effects[1],
|
||||
Effect::FetchBilling { silent: true, .. }
|
||||
|
|
@ -2172,7 +2178,8 @@ fn slash_unknown_command_passthrough_enqueues_prompt() {
|
|||
let effects = dispatch(Action::SendPrompt("/unknown-cmd arg1".into()), &mut app);
|
||||
// Unknown slash command → PassThrough → enqueue as prompt.
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "/unknown-cmd arg1"));
|
||||
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. }
|
||||
if text == "/unknown-cmd arg1"));
|
||||
assert!(app.agents[&id].prompt.text().is_empty());
|
||||
}
|
||||
|
||||
|
|
@ -2184,7 +2191,8 @@ fn non_slash_prompt_still_works() {
|
|||
|
||||
let effects = dispatch(Action::SendPrompt("hello world".into()), &mut app);
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "hello world"));
|
||||
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. }
|
||||
if text == "hello world"));
|
||||
assert!(app.agents[&id].prompt.text().is_empty());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -67,7 +67,8 @@ fn rewind_then_resubmit_drains_immediately_and_discards_orphan() {
|
|||
// User edits and re-submits without waiting.
|
||||
let effects = dispatch(Action::SendPrompt("second".into()), &mut app);
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "second"));
|
||||
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. }
|
||||
if text == "second"));
|
||||
assert!(app.agents[&id].session.state.is_turn_running());
|
||||
let second_pid = app.agents[&id].session.current_prompt_id.clone();
|
||||
assert!(second_pid.is_some());
|
||||
|
|
@ -379,9 +380,10 @@ fn inline_edit_conversation_only_success_resubmits_and_closes_editor() {
|
|||
);
|
||||
|
||||
assert!(
|
||||
effects.iter().any(
|
||||
|e| matches!(e, Effect::SendPrompt { text, .. } if text == "fix the bug properly")
|
||||
),
|
||||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::SendPrompt { text, .. }
|
||||
if text == "fix the bug properly")),
|
||||
"edited prompt must be sent, got {effects:?}"
|
||||
);
|
||||
let agent = &app.agents[&id];
|
||||
|
|
@ -467,9 +469,10 @@ fn inline_edit_all_mode_previews_confirms_and_resubmits() {
|
|||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
effects.iter().any(
|
||||
|e| matches!(e, Effect::SendPrompt { text, .. } if text == "fix the bug properly")
|
||||
),
|
||||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::SendPrompt { text, .. }
|
||||
if text == "fix the bug properly")),
|
||||
"got {effects:?}"
|
||||
);
|
||||
assert!(
|
||||
|
|
@ -722,9 +725,10 @@ fn inline_edit_resubmit_sends_slash_text_literally() {
|
|||
);
|
||||
|
||||
assert!(
|
||||
effects.iter().any(
|
||||
|e| matches!(e, Effect::SendPrompt { text, .. } if text == "/etc/hosts is wrong, fix it")
|
||||
),
|
||||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::SendPrompt { text, .. }
|
||||
if text == "/etc/hosts is wrong, fix it")),
|
||||
"slash-lookalike edit must be sent as a prompt, got {effects:?}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -505,7 +505,8 @@ fn dispatch_send_prompt_announcements_via_registry() {
|
|||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::PersistAnnouncementsHidden {
|
||||
hidden_ids } if hidden_ids.contains("crit-a"))),
|
||||
hidden_ids }
|
||||
if hidden_ids.contains("crit-a"))),
|
||||
"expected persist effect carrying the hidden id, got {effects:?}"
|
||||
);
|
||||
assert!(app.hidden_announcement_ids.contains("crit-a"));
|
||||
|
|
@ -569,7 +570,8 @@ fn announcements_show_clears_visible_critical_ids_only() {
|
|||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::PersistAnnouncementsHidden {
|
||||
hidden_ids } if ! hidden_ids.contains("outage-a"))),
|
||||
hidden_ids }
|
||||
if ! hidden_ids.contains("outage-a"))),
|
||||
"expected persist effect without the un-hidden id, got {effects:?}"
|
||||
);
|
||||
assert_eq!(shown_banner_id(&app).as_deref(), Some("outage-a"));
|
||||
|
|
@ -661,7 +663,8 @@ fn announcements_show_clears_hidden_promo_ids() {
|
|||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::PersistAnnouncementsHidden {
|
||||
hidden_ids } if ! hidden_ids.contains("promo-a"))),
|
||||
hidden_ids }
|
||||
if ! hidden_ids.contains("promo-a"))),
|
||||
"expected persist effect without the un-hidden promo id, got {effects:?}"
|
||||
);
|
||||
assert_eq!(shown_banner_id(&app).as_deref(), Some("promo-a"));
|
||||
|
|
@ -685,7 +688,8 @@ fn switch_model_dispatch_produces_effect_and_sets_pending() {
|
|||
);
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(
|
||||
matches!(& effects[0], Effect::SwitchModel { model_id : mid, .. } if mid == &
|
||||
matches!(& effects[0], Effect::SwitchModel { model_id : mid, .. }
|
||||
if mid == &
|
||||
model_id)
|
||||
);
|
||||
assert!(app.agents[&id].session.model_switch_pending);
|
||||
|
|
@ -706,7 +710,8 @@ fn switch_model_allowed_when_agent_chat_kind() {
|
|||
);
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(
|
||||
matches!(& effects[0], Effect::SwitchModel { model_id : mid, .. } if mid == &
|
||||
matches!(& effects[0], Effect::SwitchModel { model_id : mid, .. }
|
||||
if mid == &
|
||||
model_id)
|
||||
);
|
||||
assert!(app.agents[&id].session.model_switch_pending);
|
||||
|
|
@ -726,7 +731,8 @@ fn switch_model_allowed_when_app_chat_mode() {
|
|||
);
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(
|
||||
matches!(& effects[0], Effect::SwitchModel { model_id : mid, .. } if mid == &
|
||||
matches!(& effects[0], Effect::SwitchModel { model_id : mid, .. }
|
||||
if mid == &
|
||||
model_id)
|
||||
);
|
||||
assert!(app.agents[&id].session.model_switch_pending);
|
||||
|
|
@ -946,7 +952,8 @@ fn acp_bootstrap_command_executes_as_passthrough() {
|
|||
let effects = dispatch(Action::SendPrompt("/flush".into()), &mut app);
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(
|
||||
matches!(& effects[0], Effect::SendPrompt { text, .. } if text == "/flush"),
|
||||
matches!(& effects[0], Effect::SendPrompt { text, .. }
|
||||
if text == "/flush"),
|
||||
"ACP command should passthrough, got: {effects:?}"
|
||||
);
|
||||
}
|
||||
|
|
@ -1081,7 +1088,8 @@ fn acp_command_with_args_passthrough_includes_args() {
|
|||
let effects = dispatch(Action::SendPrompt("/search find bugs".into()), &mut app);
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(
|
||||
matches!(& effects[0], Effect::SendPrompt { text, .. } if text ==
|
||||
matches!(& effects[0], Effect::SendPrompt { text, .. }
|
||||
if text ==
|
||||
"/search find bugs"),
|
||||
"ACP passthrough should preserve args, got: {effects:?}"
|
||||
);
|
||||
|
|
@ -1324,7 +1332,8 @@ fn view_catalog_entry_emits_fetch_effect() {
|
|||
);
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(
|
||||
matches!(& effects[0], Effect::FetchCatalogEntry { kind, name } if kind ==
|
||||
matches!(& effects[0], Effect::FetchCatalogEntry { kind, name }
|
||||
if kind ==
|
||||
"persona" && name == "researcher")
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,9 +96,10 @@ fn worktree_forked_with_restore_shows_summary_in_scrollback() {
|
|||
// Should emit LoadSession.
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(matches!(
|
||||
&effects[0],
|
||||
Effect::LoadSession { session_id, .. } if session_id == "forked-sess-2"
|
||||
));
|
||||
&effects[0],
|
||||
Effect::LoadSession { session_id, .. }
|
||||
if session_id == "forked-sess-2"
|
||||
));
|
||||
// Scrollback should contain the restore summary.
|
||||
let has_restore_msg = app.agents[&id]
|
||||
.scrollback
|
||||
|
|
|
|||
|
|
@ -137,7 +137,8 @@ fn session_created_sets_session_id() {
|
|||
);
|
||||
assert_eq!(effects.len(), 7);
|
||||
assert!(
|
||||
matches!(& effects[0], Effect::FetchPromptHistory { session_id, .. } if
|
||||
matches!(& effects[0], Effect::FetchPromptHistory { session_id, .. }
|
||||
if
|
||||
session_id == "new-session-123")
|
||||
);
|
||||
assert!(matches!(&effects[1], Effect::FetchSessionAgentName { .. }));
|
||||
|
|
@ -491,7 +492,8 @@ fn worktree_session_created_drains_queued_prompts() {
|
|||
assert!(
|
||||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::SendPrompt { text, .. } if text ==
|
||||
.any(|e| matches!(e, Effect::SendPrompt { text, .. }
|
||||
if text ==
|
||||
"hello"))
|
||||
);
|
||||
assert!(
|
||||
|
|
@ -525,7 +527,8 @@ fn session_created_drains_queued_prompts() {
|
|||
assert!(
|
||||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::SendPrompt { text, .. } if text ==
|
||||
.any(|e| matches!(e, Effect::SendPrompt { text, .. }
|
||||
if text ==
|
||||
"queued msg"))
|
||||
);
|
||||
assert!(
|
||||
|
|
@ -825,7 +828,8 @@ fn deferred_model_switch_applied_on_session_created() {
|
|||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::SwitchModel { agent_id : a_id,
|
||||
session_id : s_id, model_id : m_id, .. } if * a_id == id && * s_id == session_id
|
||||
session_id : s_id, model_id : m_id, .. }
|
||||
if * a_id == id && * s_id == session_id
|
||||
&& * m_id == model_id))
|
||||
);
|
||||
}
|
||||
|
|
@ -864,7 +868,8 @@ fn deferred_model_switch_applied_on_worktree_session_created() {
|
|||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::SwitchModel { agent_id : a_id,
|
||||
session_id : s_id, model_id : m_id, .. } if * a_id == id && * s_id == session_id
|
||||
session_id : s_id, model_id : m_id, .. }
|
||||
if * a_id == id && * s_id == session_id
|
||||
&& * m_id == model_id))
|
||||
);
|
||||
}
|
||||
|
|
@ -1164,7 +1169,8 @@ fn deferred_worktree_ref_replays_through_gate() {
|
|||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::CreateWorktreeSession { git_ref :
|
||||
Some(r), .. } if r == "feature-branch")),
|
||||
Some(r), .. }
|
||||
if r == "feature-branch")),
|
||||
"the deferred --worktree <ref> replays with its git ref",
|
||||
);
|
||||
assert!(
|
||||
|
|
@ -1225,7 +1231,8 @@ fn gated_worktree_without_load_id_preserves_stashed_resume() {
|
|||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::CreateWorktreeSession {
|
||||
load_session_id : Some(id), .. } if id == "resume-me")),
|
||||
load_session_id : Some(id), .. }
|
||||
if id == "resume-me")),
|
||||
"the deferred worktree replays with the preserved resume id",
|
||||
);
|
||||
assert!(app.deferred_startup.session.is_none());
|
||||
|
|
@ -1297,7 +1304,8 @@ fn gated_worktree_with_none_companions_preserves_stashed_label_and_ref() {
|
|||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::CreateWorktreeSession {
|
||||
load_session_id : Some(id), label : Some(l), git_ref : Some(r), .. } if id ==
|
||||
load_session_id : Some(id), label : Some(l), git_ref : Some(r), .. }
|
||||
if id ==
|
||||
"mysess" && l == "mylabel" && r == "featbranch")),
|
||||
"the deferred worktree replays with the preserved id, label, and ref",
|
||||
);
|
||||
|
|
@ -1411,7 +1419,8 @@ fn auth_complete_retries_stashed_prompt_after_mid_session_login() {
|
|||
assert!(
|
||||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::SendPrompt { text, .. } if text ==
|
||||
.any(|e| matches!(e, Effect::SendPrompt { text, .. }
|
||||
if text ==
|
||||
"retry me")),
|
||||
"the stashed prompt must be auto-resubmitted, got: {effects:?}"
|
||||
);
|
||||
|
|
@ -1604,13 +1613,15 @@ async fn project_selected_creates_session_and_sends_prompt() {
|
|||
assert!(
|
||||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::SetWorkingDir { path } if path == &
|
||||
.any(|e| matches!(e, Effect::SetWorkingDir { path }
|
||||
if path == &
|
||||
selected))
|
||||
);
|
||||
assert!(
|
||||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::CreateSession { cwd, .. } if cwd ==
|
||||
.any(|e| matches!(e, Effect::CreateSession { cwd, .. }
|
||||
if cwd ==
|
||||
& selected))
|
||||
);
|
||||
assert_eq!(app.agents[&id].session.queue_len(), 1);
|
||||
|
|
@ -1837,7 +1848,8 @@ fn set_plan_mode_on_from_off_emits_set_session_mode() {
|
|||
);
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(
|
||||
matches!(& effects[0], Effect::SetSessionMode { mode_id, .. } if &* mode_id.0 ==
|
||||
matches!(& effects[0], Effect::SetSessionMode { mode_id, .. }
|
||||
if &* mode_id.0 ==
|
||||
"plan"),
|
||||
"expected SetSessionMode(plan), got: {effects:?}"
|
||||
);
|
||||
|
|
|
|||
|
|
@ -629,7 +629,8 @@ fn resume_known_session_id_loads_not_creates() {
|
|||
assert!(
|
||||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::LoadSession { session_id, .. } if
|
||||
.any(|e| matches!(e, Effect::LoadSession { session_id, .. }
|
||||
if
|
||||
session_id == "resume-known-id")),
|
||||
"expected LoadSession, got {effects:?}"
|
||||
);
|
||||
|
|
@ -964,7 +965,8 @@ fn resume_unknown_session_still_creates_new_agent() {
|
|||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::LoadSession { agent_id, session_id,
|
||||
.. } if * agent_id == new_id && session_id == "sess-never-open"))
|
||||
.. }
|
||||
if * agent_id == new_id && session_id == "sess-never-open"))
|
||||
);
|
||||
}
|
||||
/// Stale `attached_agent` (not equal to visible agent) must not re-arm overlay.
|
||||
|
|
@ -1027,7 +1029,8 @@ fn resume_conversation_does_not_focus_build_id_collision() {
|
|||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::LoadSession { session_id, chat_kind
|
||||
: true, .. } if session_id == "shared-id"))
|
||||
: true, .. }
|
||||
if session_id == "shared-id"))
|
||||
);
|
||||
assert!(!app.agents[&agent_0].chat_kind);
|
||||
}
|
||||
|
|
@ -1165,7 +1168,8 @@ fn resume_after_load_failed_reissues_load() {
|
|||
assert!(
|
||||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::LoadSession { agent_id, .. } if *
|
||||
.any(|e| matches!(e, Effect::LoadSession { agent_id, .. }
|
||||
if *
|
||||
agent_id == agent_0))
|
||||
);
|
||||
assert!(app.agents[&agent_0].loading_placeholder_id.is_some());
|
||||
|
|
@ -1188,7 +1192,8 @@ fn resume_after_load_failed_reissues_load() {
|
|||
effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::LoadSession { agent_id, session_id,
|
||||
.. } if * agent_id != agent_0 && session_id == "fail-then-retry")),
|
||||
.. }
|
||||
if * agent_id != agent_0 && session_id == "fail-then-retry")),
|
||||
"retry after failure must emit LoadSession for a new agent, got {effects:?}"
|
||||
);
|
||||
assert_eq!(app.agents.len(), count_before + 1);
|
||||
|
|
|
|||
|
|
@ -295,7 +295,8 @@ fn slash_model_valid_dispatches_set_default_model_with_switch_and_persist() {
|
|||
effects[0],
|
||||
);
|
||||
assert!(
|
||||
matches!(& effects[1], Effect::SwitchModel { model_id : mid, .. } if mid == &
|
||||
matches!(& effects[1], Effect::SwitchModel { model_id : mid, .. }
|
||||
if mid == &
|
||||
model_id),
|
||||
"second effect must be SwitchModel(<resolved id>), got {:?}",
|
||||
effects[1],
|
||||
|
|
@ -993,7 +994,8 @@ fn clear_default_model_persists_but_keeps_live_current() {
|
|||
);
|
||||
assert!(
|
||||
matches!(& effects[0], Effect::PersistSetting { key : "default_model", value :
|
||||
crate ::settings::SettingValue::String(s), .. } if s.is_empty()),
|
||||
crate ::settings::SettingValue::String(s), .. }
|
||||
if s.is_empty()),
|
||||
"expected PersistSetting(default_model, ''), got {:?}",
|
||||
effects[0],
|
||||
);
|
||||
|
|
@ -1028,9 +1030,13 @@ fn set_default_model_resolves_known_name() {
|
|||
assert_eq!(effects.len(), 2);
|
||||
assert!(
|
||||
matches!(& effects[0], Effect::PersistSetting { key : "default_model", value :
|
||||
crate ::settings::SettingValue::String(s), .. } if s == "grok-4.5")
|
||||
crate ::settings::SettingValue::String(s), .. }
|
||||
if s == "grok-4.5")
|
||||
);
|
||||
assert!(
|
||||
matches!(& effects[1], Effect::SwitchModel { model_id : mid, .. }
|
||||
if mid == & id)
|
||||
);
|
||||
assert!(matches!(& effects[1], Effect::SwitchModel { model_id : mid, .. } if mid == & id));
|
||||
assert_eq!(app.agents[&agent_id].session.models.current, Some(id));
|
||||
}
|
||||
/// Re-dispatching the same model
|
||||
|
|
|
|||
|
|
@ -660,9 +660,10 @@ fn switch_model_complete_success_updates_model_and_pushes_message() {
|
|||
// PersistPreferredModel effect emitted.
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert!(matches!(
|
||||
&effects[0],
|
||||
Effect::PersistPreferredModel { model_id: mid, .. } if *mid == model_id.clone()
|
||||
));
|
||||
&effects[0],
|
||||
Effect::PersistPreferredModel { model_id: mid, .. }
|
||||
if *mid == model_id.clone()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -249,7 +249,8 @@ fn parse_subagent_kill_outcome_reads_typed_outcome() {
|
|||
);
|
||||
assert!(
|
||||
matches!(parse_subagent_kill_outcome(r#"{"result":{"subagentId":"sa-1","cancelled":false,"outcome":{"kind":"already_finished","status":"completed"}}}"#),
|
||||
SubagentKillOutcome::NothingLive { status : Some(s) } if s == "completed")
|
||||
SubagentKillOutcome::NothingLive { status : Some(s) }
|
||||
if s == "completed")
|
||||
);
|
||||
assert!(
|
||||
matches!(parse_subagent_kill_outcome(r#"{"result":{"subagentId":"sa-1","cancelled":false,"outcome":{"kind":"not_found"}}}"#),
|
||||
|
|
@ -302,7 +303,8 @@ fn parse_subagent_kill_outcome_round_trips_agent_serialization() {
|
|||
.unwrap();
|
||||
assert!(
|
||||
matches!(parse_subagent_kill_outcome(& wire), SubagentKillOutcome::NothingLive {
|
||||
status : Some(s) } if s == "failed")
|
||||
status : Some(s) }
|
||||
if s == "failed")
|
||||
);
|
||||
}
|
||||
/// A top-level payload (no `result` envelope), error envelopes, and
|
||||
|
|
@ -1200,7 +1202,8 @@ async fn check_marketplace_updates_dispatches_update_and_skips_failed_notificati
|
|||
MarketplaceAction::Update {
|
||||
source_url_or_path,
|
||||
plugin_relative_path,
|
||||
} if source_url_or_path == "https://example.com/plugins.git"
|
||||
}
|
||||
if source_url_or_path == "https://example.com/plugins.git"
|
||||
&& plugin_relative_path == "plugins/test-plugin" => {
|
||||
saw_update_for_task.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -258,9 +258,11 @@ fn leader_kill_reconnect_reloads_without_duplicating_history() {
|
|||
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
if matches!(
|
||||
*status_rx.borrow_and_update(),
|
||||
ConnectionStatus::Connected { generation } if generation >= 1
|
||||
) {
|
||||
*status_rx.borrow_and_update(),
|
||||
ConnectionStatus::Connected { generation }
|
||||
if generation >= 1
|
||||
)
|
||||
{
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
|
|
|
|||
|
|
@ -2086,9 +2086,10 @@ mod tests {
|
|||
}),
|
||||
);
|
||||
assert!(matches!(
|
||||
handle_ext_notification(¬if, OutputFormat::Plain),
|
||||
ExtEvent::TaskBackgrounded { task_id, is_monitor: false } if task_id == "task-abc"
|
||||
));
|
||||
handle_ext_notification(¬if, OutputFormat::Plain),
|
||||
ExtEvent::TaskBackgrounded { task_id, is_monitor: false }
|
||||
if task_id == "task-abc"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -2102,9 +2103,10 @@ mod tests {
|
|||
}),
|
||||
);
|
||||
assert!(matches!(
|
||||
handle_ext_notification(¬if, OutputFormat::Plain),
|
||||
ExtEvent::TaskBackgrounded { task_id, is_monitor: true } if task_id == "mon-1"
|
||||
));
|
||||
handle_ext_notification(¬if, OutputFormat::Plain),
|
||||
ExtEvent::TaskBackgrounded { task_id, is_monitor: true }
|
||||
if task_id == "mon-1"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -2122,9 +2124,10 @@ mod tests {
|
|||
}),
|
||||
);
|
||||
assert!(matches!(
|
||||
handle_ext_notification(¬if, OutputFormat::Plain),
|
||||
ExtEvent::TaskCompleted { task_id } if task_id == "task-abc"
|
||||
));
|
||||
handle_ext_notification(¬if, OutputFormat::Plain),
|
||||
ExtEvent::TaskCompleted { task_id }
|
||||
if task_id == "task-abc"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -2141,9 +2144,10 @@ mod tests {
|
|||
}),
|
||||
);
|
||||
assert!(matches!(
|
||||
handle_ext_notification(&spawned, OutputFormat::Plain),
|
||||
ExtEvent::SubagentSpawned { subagent_id } if subagent_id == "sub-1"
|
||||
));
|
||||
handle_ext_notification(&spawned, OutputFormat::Plain),
|
||||
ExtEvent::SubagentSpawned { subagent_id }
|
||||
if subagent_id == "sub-1"
|
||||
));
|
||||
let finished = make_ext_notif(
|
||||
"x.ai/session_notification",
|
||||
serde_json::json!({
|
||||
|
|
@ -2157,9 +2161,10 @@ mod tests {
|
|||
}),
|
||||
);
|
||||
assert!(matches!(
|
||||
handle_ext_notification(&finished, OutputFormat::Plain),
|
||||
ExtEvent::SubagentFinished { subagent_id } if subagent_id == "sub-1"
|
||||
));
|
||||
handle_ext_notification(&finished, OutputFormat::Plain),
|
||||
ExtEvent::SubagentFinished { subagent_id }
|
||||
if subagent_id == "sub-1"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -888,9 +888,10 @@ fn marketplace_add(
|
|||
if u.trim_end_matches(".git") == normalized)
|
||||
})
|
||||
}
|
||||
MarketplaceAddInput::LocalPath(path) => sources
|
||||
.iter()
|
||||
.any(|s| matches!(&s.kind, SourceKind::Local { path: p } if p == path)),
|
||||
MarketplaceAddInput::LocalPath(path) => sources.iter().any(|s| {
|
||||
matches!(&s.kind, SourceKind::Local { path: p }
|
||||
if p == path)
|
||||
}),
|
||||
};
|
||||
if already_configured {
|
||||
bail!("Marketplace source already configured: {identity}");
|
||||
|
|
|
|||
|
|
@ -579,10 +579,13 @@ impl ExecuteToolCallBlock {
|
|||
.with_joiner(joiner.clone()),
|
||||
);
|
||||
}
|
||||
// Ellipsis (non-selectable, breaks range continuity)
|
||||
let hidden = total - threshold;
|
||||
lines.push(
|
||||
BlockLine::separator(Line::from(Span::styled("\u{2026}", theme.muted())))
|
||||
.with_panel_background(theme.bg_dark),
|
||||
BlockLine::separator(Line::from(Span::styled(
|
||||
format!("\u{2026} +{hidden} lines"),
|
||||
theme.muted(),
|
||||
)))
|
||||
.with_panel_background(theme.bg_dark),
|
||||
);
|
||||
// Last M lines: range base + 1 (distinct from first chunk)
|
||||
for (wrapped_line, joiner) in
|
||||
|
|
|
|||
|
|
@ -97,9 +97,6 @@ impl ListDirToolCallBlock {
|
|||
self.output = output.into();
|
||||
}
|
||||
|
||||
/// Render collapsed line: `List path`.
|
||||
///
|
||||
/// When `width` is provided, the path is fish-shortened to fit.
|
||||
fn collapsed_line(&self, theme: &Theme, muted: bool, width: Option<usize>) -> Line<'static> {
|
||||
let text_style = if muted {
|
||||
theme.muted()
|
||||
|
|
@ -114,15 +111,32 @@ impl ListDirToolCallBlock {
|
|||
};
|
||||
|
||||
let prefix = "List ";
|
||||
let entry_count = self.output.lines().filter(|l| !l.trim().is_empty()).count();
|
||||
let suffix = if self.error.is_none() && entry_count > 0 {
|
||||
let s = if entry_count == 1 { "y" } else { "ies" };
|
||||
format!(" ({entry_count} entr{s})")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let suffix_fits = width.is_none_or(|w| prefix.len() + suffix.len() < w);
|
||||
let effective_suffix = if suffix_fits { suffix.as_str() } else { "" };
|
||||
|
||||
let path_budget = width
|
||||
.map(|w| w.saturating_sub(prefix.len()))
|
||||
.map(|w| {
|
||||
w.saturating_sub(prefix.len())
|
||||
.saturating_sub(effective_suffix.len())
|
||||
})
|
||||
.unwrap_or(usize::MAX);
|
||||
let path = crate::render::tool_paths::shorten_path(&self.path, path_budget);
|
||||
|
||||
Line::from(vec![
|
||||
let mut spans = vec![
|
||||
Span::styled(prefix, bold_style),
|
||||
Span::styled(path, path_style),
|
||||
])
|
||||
];
|
||||
if !effective_suffix.is_empty() {
|
||||
spans.push(Span::styled(effective_suffix.to_string(), theme.muted()));
|
||||
}
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
/// Header line with only the path span selectable (exclude "List " prefix).
|
||||
|
|
@ -224,3 +238,49 @@ impl BlockContent for ListDirToolCallBlock {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::scrollback::types::BlockContext;
|
||||
|
||||
fn ctx() -> BlockContext {
|
||||
BlockContext {
|
||||
width: 80,
|
||||
mode: DisplayMode::Collapsed,
|
||||
is_running: false,
|
||||
raw: false,
|
||||
max_lines: None,
|
||||
appearance: Default::default(),
|
||||
is_selected: false,
|
||||
cwd: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn header_text(block: &ListDirToolCallBlock) -> String {
|
||||
block.output(&ctx()).lines[0]
|
||||
.content
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_header_shows_entry_count() {
|
||||
let block = ListDirToolCallBlock::new("src").with_output("a.rs\nb.rs\nsub/\n");
|
||||
assert_eq!(header_text(&block), "List src (3 entries)");
|
||||
|
||||
let single = ListDirToolCallBlock::new("src").with_output("lonely.rs\n");
|
||||
assert_eq!(header_text(&single), "List src (1 entry)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_header_omits_count_when_empty_or_failed() {
|
||||
let empty = ListDirToolCallBlock::new("src");
|
||||
assert_eq!(header_text(&empty), "List src");
|
||||
|
||||
let failed = ListDirToolCallBlock::new("gone").with_error("no such directory");
|
||||
assert_eq!(header_text(&failed), "List gone");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -342,6 +342,25 @@ impl ToolCallBlock {
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether the tool call finished without an error.
|
||||
pub fn is_success(&self) -> bool {
|
||||
match self {
|
||||
ToolCallBlock::Execute(b) => b.is_success(),
|
||||
ToolCallBlock::Read(b) => b.is_success(),
|
||||
ToolCallBlock::Edit(b) => b.is_success(),
|
||||
ToolCallBlock::Search(b) => b.is_success(),
|
||||
ToolCallBlock::ListDir(b) => b.is_success(),
|
||||
ToolCallBlock::WebFetch(b) => b.is_success(),
|
||||
ToolCallBlock::WebSearch(b) => b.is_success(),
|
||||
ToolCallBlock::IntegrationSearch(b) => b.is_success(),
|
||||
ToolCallBlock::UseTool(b) => b.is_success(),
|
||||
ToolCallBlock::MemorySearch(b) => b.is_success(),
|
||||
ToolCallBlock::Skill(b) => b.is_success(),
|
||||
ToolCallBlock::Other(b) => b.is_success(),
|
||||
ToolCallBlock::Lifecycle(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set `started_at` on the inner variant block.
|
||||
///
|
||||
/// Unlike `transfer_timing_from`, this works across variant boundaries
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ use crate::scrollback::types::{
|
|||
};
|
||||
use crate::theme::Theme;
|
||||
|
||||
/// Max lines of output shown inline before truncation.
|
||||
const MAX_INLINE_LINES: usize = 10;
|
||||
const TRUNCATED_INLINE_LINES: usize = 3;
|
||||
|
||||
/// Use tool call — dispatching to an MCP integration tool.
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -175,7 +175,11 @@ impl BlockContent for UseToolCallBlock {
|
|||
}
|
||||
}
|
||||
|
||||
// Output preview
|
||||
let max_inline = if ctx.mode == DisplayMode::Truncated {
|
||||
TRUNCATED_INLINE_LINES
|
||||
} else {
|
||||
MAX_INLINE_LINES
|
||||
};
|
||||
if let Some(ref output) = self.output {
|
||||
lines.push(Line::from("").into());
|
||||
lines
|
||||
|
|
@ -185,8 +189,8 @@ impl BlockContent for UseToolCallBlock {
|
|||
let content_lines: Vec<&str> = output.lines().collect();
|
||||
|
||||
for (i, line) in content_lines.iter().enumerate() {
|
||||
if i >= MAX_INLINE_LINES {
|
||||
let remaining = content_lines.len() - MAX_INLINE_LINES;
|
||||
if i >= max_inline {
|
||||
let remaining = content_lines.len() - max_inline;
|
||||
lines.push(
|
||||
BlockLine::from(Line::from(Span::styled(
|
||||
format!(
|
||||
|
|
@ -284,3 +288,58 @@ impl BlockContent for UseToolCallBlock {
|
|||
Some(Text::from(vec![self.header_line(&theme, false, None)]))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::scrollback::types::BlockContext;
|
||||
|
||||
fn ctx(mode: DisplayMode) -> BlockContext {
|
||||
BlockContext {
|
||||
width: 80,
|
||||
mode,
|
||||
is_running: false,
|
||||
raw: false,
|
||||
max_lines: None,
|
||||
appearance: Default::default(),
|
||||
is_selected: false,
|
||||
cwd: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered_text(block: &UseToolCallBlock, mode: DisplayMode) -> String {
|
||||
block
|
||||
.output(&ctx(mode))
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.content
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_caps_inline_output_tighter_than_expanded() {
|
||||
let mut block = UseToolCallBlock::new("linear__list_issues");
|
||||
let content: Vec<String> = (1..=12).map(|i| format!("l{i:02} row")).collect();
|
||||
block.output = Some(content.join("\n"));
|
||||
|
||||
let truncated = rendered_text(&block, DisplayMode::Truncated);
|
||||
assert!(truncated.contains("l03"), "truncated:\n{truncated}");
|
||||
assert!(!truncated.contains("l04"), "truncated:\n{truncated}");
|
||||
assert!(
|
||||
truncated.contains("(9 more lines"),
|
||||
"truncated:\n{truncated}"
|
||||
);
|
||||
|
||||
let expanded = rendered_text(&block, DisplayMode::Expanded);
|
||||
assert!(expanded.contains("l10"), "expanded:\n{expanded}");
|
||||
assert!(!expanded.contains("l11"), "expanded:\n{expanded}");
|
||||
assert!(expanded.contains("(2 more lines"), "expanded:\n{expanded}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ use crate::scrollback::types::{
|
|||
};
|
||||
use crate::theme::Theme;
|
||||
|
||||
/// Max lines of content shown inline before truncation.
|
||||
const MAX_INLINE_LINES: usize = 10;
|
||||
const TRUNCATED_INLINE_LINES: usize = 3;
|
||||
|
||||
/// Web fetch tool call — fetching a URL and returning markdown content.
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -238,9 +238,11 @@ impl BlockContent for WebFetchToolCallBlock {
|
|||
lines.push(BlockLine::separator(meta));
|
||||
}
|
||||
|
||||
// Content preview with bg_dark background, capped at
|
||||
// MAX_INLINE_LINES. Full content is available via the
|
||||
// fullscreen viewer (Enter/o).
|
||||
let max_inline = if ctx.mode == DisplayMode::Truncated {
|
||||
TRUNCATED_INLINE_LINES
|
||||
} else {
|
||||
MAX_INLINE_LINES
|
||||
};
|
||||
if let Some(ref output) = self.output {
|
||||
lines.push(Line::from("").into());
|
||||
|
||||
|
|
@ -252,12 +254,12 @@ impl BlockContent for WebFetchToolCallBlock {
|
|||
let total_lines = output.lines().count();
|
||||
|
||||
for (i, line) in output.lines().enumerate() {
|
||||
if i >= MAX_INLINE_LINES {
|
||||
if i >= max_inline {
|
||||
lines.push(
|
||||
BlockLine::from(Line::from(Span::styled(
|
||||
format!(
|
||||
"{indent}... ({} more lines, press Enter to view)",
|
||||
total_lines - MAX_INLINE_LINES
|
||||
total_lines - max_inline
|
||||
),
|
||||
theme.dim(),
|
||||
)))
|
||||
|
|
@ -347,3 +349,58 @@ impl BlockContent for WebFetchToolCallBlock {
|
|||
Some(Text::from(vec![self.header_line(&theme, false, None)]))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::scrollback::types::BlockContext;
|
||||
|
||||
fn ctx(mode: DisplayMode) -> BlockContext {
|
||||
BlockContext {
|
||||
width: 80,
|
||||
mode,
|
||||
is_running: false,
|
||||
raw: false,
|
||||
max_lines: None,
|
||||
appearance: Default::default(),
|
||||
is_selected: false,
|
||||
cwd: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered_text(block: &WebFetchToolCallBlock, mode: DisplayMode) -> String {
|
||||
block
|
||||
.output(&ctx(mode))
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.content
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_caps_inline_content_tighter_than_expanded() {
|
||||
let content: Vec<String> = (1..=12).map(|i| format!("l{i:02} body")).collect();
|
||||
let block =
|
||||
WebFetchToolCallBlock::new("https://example.com").with_output(content.join("\n"));
|
||||
|
||||
let truncated = rendered_text(&block, DisplayMode::Truncated);
|
||||
assert!(truncated.contains("l03"), "truncated:\n{truncated}");
|
||||
assert!(!truncated.contains("l04"), "truncated:\n{truncated}");
|
||||
assert!(
|
||||
truncated.contains("(9 more lines"),
|
||||
"truncated:\n{truncated}"
|
||||
);
|
||||
|
||||
let expanded = rendered_text(&block, DisplayMode::Expanded);
|
||||
assert!(expanded.contains("l10"), "expanded:\n{expanded}");
|
||||
assert!(!expanded.contains("l11"), "expanded:\n{expanded}");
|
||||
assert!(expanded.contains("(2 more lines"), "expanded:\n{expanded}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ use crate::scrollback::types::{
|
|||
};
|
||||
use crate::theme::Theme;
|
||||
|
||||
/// Max lines of content shown inline before truncation.
|
||||
const MAX_INLINE_LINES: usize = 10;
|
||||
const TRUNCATED_INLINE_LINES: usize = 3;
|
||||
|
||||
/// Max number of domain names shown in the sources summary line.
|
||||
const MAX_INLINE_SOURCES: usize = 3;
|
||||
|
|
@ -258,9 +258,11 @@ impl BlockContent for WebSearchToolCallBlock {
|
|||
})
|
||||
.collect();
|
||||
|
||||
// Content preview with bg_dark background, capped at
|
||||
// MAX_INLINE_LINES. Full content is available via the
|
||||
// fullscreen viewer (Enter/o).
|
||||
let max_inline = if ctx.mode == DisplayMode::Truncated {
|
||||
TRUNCATED_INLINE_LINES
|
||||
} else {
|
||||
MAX_INLINE_LINES
|
||||
};
|
||||
if let Some(ref content) = self.content {
|
||||
lines.push(BlockLine::separator(Line::from("")));
|
||||
|
||||
|
|
@ -272,8 +274,8 @@ impl BlockContent for WebSearchToolCallBlock {
|
|||
let content_lines: Vec<&str> = content.lines().collect();
|
||||
|
||||
for (i, line) in content_lines.iter().enumerate() {
|
||||
if i >= MAX_INLINE_LINES {
|
||||
let remaining = content_lines.len() - MAX_INLINE_LINES;
|
||||
if i >= max_inline {
|
||||
let remaining = content_lines.len() - max_inline;
|
||||
lines.push(
|
||||
BlockLine::from(Line::from(Span::styled(
|
||||
format!(
|
||||
|
|
@ -379,3 +381,58 @@ impl BlockContent for WebSearchToolCallBlock {
|
|||
Some(Text::from(vec![self.header_line(&theme, false, None)]))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::scrollback::types::BlockContext;
|
||||
|
||||
fn ctx(mode: DisplayMode) -> BlockContext {
|
||||
BlockContext {
|
||||
width: 80,
|
||||
mode,
|
||||
is_running: false,
|
||||
raw: false,
|
||||
max_lines: None,
|
||||
appearance: Default::default(),
|
||||
is_selected: false,
|
||||
cwd: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered_text(block: &WebSearchToolCallBlock, mode: DisplayMode) -> String {
|
||||
block
|
||||
.output(&ctx(mode))
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.content
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_caps_inline_content_tighter_than_expanded() {
|
||||
let mut block = WebSearchToolCallBlock::new("rust async traits");
|
||||
let content: Vec<String> = (1..=12).map(|i| format!("l{i:02} result")).collect();
|
||||
block.content = Some(content.join("\n"));
|
||||
|
||||
let truncated = rendered_text(&block, DisplayMode::Truncated);
|
||||
assert!(truncated.contains("l03"), "truncated:\n{truncated}");
|
||||
assert!(!truncated.contains("l04"), "truncated:\n{truncated}");
|
||||
assert!(
|
||||
truncated.contains("(9 more lines"),
|
||||
"truncated:\n{truncated}"
|
||||
);
|
||||
|
||||
let expanded = rendered_text(&block, DisplayMode::Expanded);
|
||||
assert!(expanded.contains("l10"), "expanded:\n{expanded}");
|
||||
assert!(!expanded.contains("l11"), "expanded:\n{expanded}");
|
||||
assert!(expanded.contains("(2 more lines"), "expanded:\n{expanded}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,7 +79,6 @@ pub fn builtin_commands() -> Vec<Arc<dyn SlashCommand>> {
|
|||
Arc::new(docs::DocsCommand),
|
||||
Arc::new(home::HomeCommand),
|
||||
Arc::new(new::NewCommand),
|
||||
|
||||
Arc::new(fork::ForkCommand),
|
||||
Arc::new(compact::CompactCommand),
|
||||
Arc::new(copy::CopyCommand),
|
||||
|
|
@ -117,7 +116,6 @@ pub fn builtin_commands() -> Vec<Arc<dyn SlashCommand>> {
|
|||
Arc::new(mcps::McpsCommand),
|
||||
Arc::new(btw::BtwCommand),
|
||||
Arc::new(recap::RecapCommand),
|
||||
|
||||
Arc::new(terminal_setup::TerminalSetupCommand),
|
||||
Arc::new(voice::VoiceCommand),
|
||||
Arc::new(loop_cmd::LoopCommand),
|
||||
|
|
|
|||
|
|
@ -6040,9 +6040,10 @@ mod tests {
|
|||
);
|
||||
assert!(
|
||||
lines.iter().any(|l| matches!(
|
||||
l,
|
||||
DashboardLine::Header { state, count } if *state == RowState::Working && *count == 2
|
||||
)),
|
||||
l,
|
||||
DashboardLine::Header { state, count }
|
||||
if *state == RowState::Working && *count == 2
|
||||
)),
|
||||
"collapsed Working header must still render with its true count",
|
||||
);
|
||||
let working_rows = lines
|
||||
|
|
@ -6080,7 +6081,8 @@ mod tests {
|
|||
assert!(
|
||||
lines
|
||||
.iter()
|
||||
.any(|l| matches!(l, DashboardLine::PinnedHeader { count } if *count == 1)),
|
||||
.any(|l| matches!(l, DashboardLine::PinnedHeader { count }
|
||||
if *count == 1)),
|
||||
"collapsed Pinned header must still render",
|
||||
);
|
||||
// The pinned row is hidden; the (non-pinned) Working row remains.
|
||||
|
|
@ -6142,9 +6144,10 @@ mod tests {
|
|||
// Header still shows the TRUE total, not the visible count.
|
||||
assert!(
|
||||
lines.iter().any(|l| matches!(
|
||||
l,
|
||||
DashboardLine::Header { state, count } if *state == RowState::Idle && *count == total as usize
|
||||
)),
|
||||
l,
|
||||
DashboardLine::Header { state, count }
|
||||
if *state == RowState::Idle && *count == total as usize
|
||||
)),
|
||||
"Idle header keeps the true total count",
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -595,7 +595,8 @@ fn render_file_list(buf: &mut Buffer, area: Rect, state: &mut MemoryModalState,
|
|||
);
|
||||
|
||||
if is_selected
|
||||
&& matches!(state.mode, MemoryModalMode::ConfirmingDelete { idx } if idx == filt_idx)
|
||||
&& matches!(state.mode, MemoryModalMode::ConfirmingDelete { idx }
|
||||
if idx == filt_idx)
|
||||
{
|
||||
let hint = " [x to confirm]";
|
||||
let hint_w = hint.len() as u16;
|
||||
|
|
|
|||
|
|
@ -275,21 +275,20 @@ impl SettingsModalState {
|
|||
self.invalidate_filter();
|
||||
|
||||
if let Some(key) = subpane_key {
|
||||
let still_visible = self
|
||||
.rows
|
||||
.iter()
|
||||
.any(|r| matches!(r, RowEntry::Setting { key: k, .. } if *k == key));
|
||||
let still_visible = self.rows.iter().any(|r| {
|
||||
matches!(r, RowEntry::Setting { key: k, .. }
|
||||
if *k == key)
|
||||
});
|
||||
if !still_visible {
|
||||
self.transition_to_browse();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(key) = prev_key {
|
||||
if let Some(idx) = self
|
||||
.rows
|
||||
.iter()
|
||||
.position(|r| matches!(r, RowEntry::Setting { key: k, .. } if *k == key))
|
||||
{
|
||||
if let Some(idx) = self.rows.iter().position(|r| {
|
||||
matches!(r, RowEntry::Setting { key: k, .. }
|
||||
if *k == key)
|
||||
}) {
|
||||
self.selected = idx;
|
||||
} else {
|
||||
self.selected = self
|
||||
|
|
|
|||
|
|
@ -36,13 +36,17 @@ fn contextual_hints_group_sub_sheet_flow() {
|
|||
let group_idx = s
|
||||
.rows
|
||||
.iter()
|
||||
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "contextual_hints"))
|
||||
.position(|r| {
|
||||
matches!(r, RowEntry::Setting { key, .. }
|
||||
if *key == "contextual_hints")
|
||||
})
|
||||
.expect("group row present");
|
||||
assert!(
|
||||
!s.rows.iter().any(|r| matches!(
|
||||
r,
|
||||
RowEntry::Setting { key, .. } if key.starts_with("contextual_hints.")
|
||||
)),
|
||||
r,
|
||||
RowEntry::Setting { key, .. }
|
||||
if key.starts_with("contextual_hints.")
|
||||
)),
|
||||
"child rows must be hidden from the top-level list",
|
||||
);
|
||||
|
||||
|
|
@ -4118,7 +4122,10 @@ fn advance_next_recovers_when_selection_is_hidden() {
|
|||
let compact_idx = s
|
||||
.rows
|
||||
.iter()
|
||||
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "compact_mode"))
|
||||
.position(|r| {
|
||||
matches!(r, RowEntry::Setting { key, .. }
|
||||
if *key == "compact_mode")
|
||||
})
|
||||
.unwrap();
|
||||
s.selected = compact_idx;
|
||||
// Advance: lands on the first visible setting (show_timestamps).
|
||||
|
|
@ -4127,7 +4134,10 @@ fn advance_next_recovers_when_selection_is_hidden() {
|
|||
let show_ts_idx = s
|
||||
.rows
|
||||
.iter()
|
||||
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "show_timestamps"))
|
||||
.position(|r| {
|
||||
matches!(r, RowEntry::Setting { key, .. }
|
||||
if *key == "show_timestamps")
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(s.selected, show_ts_idx);
|
||||
}
|
||||
|
|
@ -4150,7 +4160,10 @@ fn advance_prev_recovers_when_selection_is_hidden() {
|
|||
let compact_idx = s
|
||||
.rows
|
||||
.iter()
|
||||
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "compact_mode"))
|
||||
.position(|r| {
|
||||
matches!(r, RowEntry::Setting { key, .. }
|
||||
if *key == "compact_mode")
|
||||
})
|
||||
.unwrap();
|
||||
s.selected = compact_idx;
|
||||
let moved = s.advance_prev();
|
||||
|
|
@ -4158,7 +4171,10 @@ fn advance_prev_recovers_when_selection_is_hidden() {
|
|||
let simple_idx = s
|
||||
.rows
|
||||
.iter()
|
||||
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "simple_mode"))
|
||||
.position(|r| {
|
||||
matches!(r, RowEntry::Setting { key, .. }
|
||||
if *key == "simple_mode")
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(s.selected, simple_idx);
|
||||
}
|
||||
|
|
@ -4258,10 +4274,10 @@ fn section_headers_have_blank_line_above_except_first() {
|
|||
for cat in SettingCategory::ALL {
|
||||
// Skip categories the default registry doesn't populate
|
||||
// (e.g. Session — no settings registered).
|
||||
let has_setting = s
|
||||
.rows
|
||||
.iter()
|
||||
.any(|r| matches!(r, RowEntry::Header { category } if category == cat));
|
||||
let has_setting = s.rows.iter().any(|r| {
|
||||
matches!(r, RowEntry::Header { category }
|
||||
if category == cat)
|
||||
});
|
||||
if !has_setting {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -4607,7 +4623,10 @@ fn two_line_row_hit_rect_spans_both_lines() {
|
|||
let row_idx = s
|
||||
.rows
|
||||
.iter()
|
||||
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "coding_data_sharing"))
|
||||
.position(|r| {
|
||||
matches!(r, RowEntry::Setting { key, .. }
|
||||
if *key == "coding_data_sharing")
|
||||
})
|
||||
.expect("coding_data_sharing must be registered");
|
||||
// Render at a narrow width so coding_data_sharing forces a
|
||||
// two-line layout.
|
||||
|
|
@ -4672,7 +4691,10 @@ fn two_line_row_with_expansion_renders_three_segments() {
|
|||
let row_idx = s
|
||||
.rows
|
||||
.iter()
|
||||
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "coding_data_sharing"))
|
||||
.position(|r| {
|
||||
matches!(r, RowEntry::Setting { key, .. }
|
||||
if *key == "coding_data_sharing")
|
||||
})
|
||||
.expect("coding_data_sharing must be registered");
|
||||
s.selected = row_idx;
|
||||
s.expanded_keys.insert("coding_data_sharing");
|
||||
|
|
@ -4730,7 +4752,10 @@ fn group_row_renders_expanded_description() {
|
|||
let row_idx = s
|
||||
.rows
|
||||
.iter()
|
||||
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "contextual_hints"))
|
||||
.position(|r| {
|
||||
matches!(r, RowEntry::Setting { key, .. }
|
||||
if *key == "contextual_hints")
|
||||
})
|
||||
.expect("contextual_hints group must be registered");
|
||||
s.selected = row_idx;
|
||||
s.expanded_keys.insert("contextual_hints");
|
||||
|
|
@ -5769,7 +5794,10 @@ fn enter_picker_for(key: &'static str) -> SettingsModalState {
|
|||
let row_idx = s
|
||||
.rows
|
||||
.iter()
|
||||
.position(|r| matches!(r, RowEntry::Setting { key: k, .. } if *k == key))
|
||||
.position(|r| {
|
||||
matches!(r, RowEntry::Setting { key: k, .. }
|
||||
if *k == key)
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no row for key `{key}` in default registry"));
|
||||
assert!(s.select_at(row_idx), "select_at({row_idx})");
|
||||
assert!(
|
||||
|
|
|
|||
|
|
@ -1681,9 +1681,10 @@ mod tests {
|
|||
let entries = build_entries(&all_contexts(), ®istry, true);
|
||||
let has_row = entries.iter().any(|e| {
|
||||
matches!(
|
||||
e,
|
||||
ShortcutsHelpEntry::Hint { item, .. } if item.label == "mouse reporting"
|
||||
)
|
||||
e,
|
||||
ShortcutsHelpEntry::Hint { item, .. }
|
||||
if item.label == "mouse reporting"
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
!has_row,
|
||||
|
|
@ -1776,21 +1777,24 @@ mod tests {
|
|||
|
||||
let has_todos = entries.iter().any(|e| {
|
||||
matches!(
|
||||
e,
|
||||
ShortcutsHelpEntry::Hint { item, .. } if item.label == "todos"
|
||||
)
|
||||
e,
|
||||
ShortcutsHelpEntry::Hint { item, .. }
|
||||
if item.label == "todos"
|
||||
)
|
||||
});
|
||||
let has_sessions = entries.iter().any(|e| {
|
||||
matches!(
|
||||
e,
|
||||
ShortcutsHelpEntry::Hint { item, .. } if item.label == "sessions"
|
||||
)
|
||||
e,
|
||||
ShortcutsHelpEntry::Hint { item, .. }
|
||||
if item.label == "sessions"
|
||||
)
|
||||
});
|
||||
let has_queue = entries.iter().any(|e| {
|
||||
matches!(
|
||||
e,
|
||||
ShortcutsHelpEntry::Hint { item, .. } if item.label == "queue"
|
||||
)
|
||||
e,
|
||||
ShortcutsHelpEntry::Hint { item, .. }
|
||||
if item.label == "queue"
|
||||
)
|
||||
});
|
||||
assert!(has_todos, "should include toggle todos");
|
||||
assert!(has_sessions, "should include open sessions");
|
||||
|
|
@ -1835,13 +1839,14 @@ mod tests {
|
|||
.iter()
|
||||
.find(|e| {
|
||||
matches!(
|
||||
e,
|
||||
ShortcutsHelpEntry::Hint {
|
||||
item,
|
||||
action_id: None,
|
||||
..
|
||||
} if item.label == "paste"
|
||||
)
|
||||
e,
|
||||
ShortcutsHelpEntry::Hint {
|
||||
item,
|
||||
action_id: None,
|
||||
..
|
||||
}
|
||||
if item.label == "paste"
|
||||
)
|
||||
})
|
||||
.expect("cheatsheet should list paste");
|
||||
let ShortcutsHelpEntry::Hint {
|
||||
|
|
@ -1922,9 +1927,10 @@ mod tests {
|
|||
|
||||
let nav_dimmed = entries.iter().any(|e| {
|
||||
matches!(
|
||||
e,
|
||||
ShortcutsHelpEntry::Hint { item, dimmed: true, .. } if item.label == "nav"
|
||||
)
|
||||
e,
|
||||
ShortcutsHelpEntry::Hint { item, dimmed: true, .. }
|
||||
if item.label == "nav"
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
nav_dimmed,
|
||||
|
|
@ -1933,17 +1939,19 @@ mod tests {
|
|||
|
||||
let quit_bright = entries.iter().any(|e| {
|
||||
matches!(
|
||||
e,
|
||||
ShortcutsHelpEntry::Hint { item, dimmed: false, .. } if item.label == "quit"
|
||||
)
|
||||
e,
|
||||
ShortcutsHelpEntry::Hint { item, dimmed: false, .. }
|
||||
if item.label == "quit"
|
||||
)
|
||||
});
|
||||
assert!(quit_bright, "quit should not be dimmed (When::Always)");
|
||||
|
||||
let cancel_bright = entries.iter().any(|e| {
|
||||
matches!(
|
||||
e,
|
||||
ShortcutsHelpEntry::Hint { item, dimmed: false, .. } if item.label == "cancel"
|
||||
)
|
||||
e,
|
||||
ShortcutsHelpEntry::Hint { item, dimmed: false, .. }
|
||||
if item.label == "cancel"
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
cancel_bright,
|
||||
|
|
@ -1959,9 +1967,10 @@ mod tests {
|
|||
|
||||
let send_dimmed = entries.iter().any(|e| {
|
||||
matches!(
|
||||
e,
|
||||
ShortcutsHelpEntry::Hint { item, dimmed: true, .. } if item.label == "send"
|
||||
)
|
||||
e,
|
||||
ShortcutsHelpEntry::Hint { item, dimmed: true, .. }
|
||||
if item.label == "send"
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
send_dimmed,
|
||||
|
|
@ -1970,9 +1979,10 @@ mod tests {
|
|||
|
||||
let nav_dimmed = entries.iter().any(|e| {
|
||||
matches!(
|
||||
e,
|
||||
ShortcutsHelpEntry::Hint { item, dimmed: true, .. } if item.label == "nav"
|
||||
)
|
||||
e,
|
||||
ShortcutsHelpEntry::Hint { item, dimmed: true, .. }
|
||||
if item.label == "nav"
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
nav_dimmed,
|
||||
|
|
@ -2638,14 +2648,15 @@ mod tests {
|
|||
.iter()
|
||||
.position(|e| {
|
||||
matches!(
|
||||
e,
|
||||
ShortcutsHelpEntry::Hint {
|
||||
item,
|
||||
action_id: None,
|
||||
long_help: Some(_),
|
||||
..
|
||||
} if item.label == "paste"
|
||||
)
|
||||
e,
|
||||
ShortcutsHelpEntry::Hint {
|
||||
item,
|
||||
action_id: None,
|
||||
long_help: Some(_),
|
||||
..
|
||||
}
|
||||
if item.label == "paste"
|
||||
)
|
||||
})
|
||||
.expect("paste pseudo-row with long_help");
|
||||
assert_eq!(
|
||||
|
|
@ -3024,9 +3035,10 @@ mod tests {
|
|||
for label in ["top", "btm", "copy", "copy cmd"] {
|
||||
let present = entries.iter().any(|e| {
|
||||
matches!(
|
||||
e,
|
||||
ShortcutsHelpEntry::Hint { item, .. } if item.label == label
|
||||
)
|
||||
e,
|
||||
ShortcutsHelpEntry::Hint { item, .. }
|
||||
if item.label == label
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
!present,
|
||||
|
|
@ -3315,14 +3327,15 @@ mod tests {
|
|||
.iter()
|
||||
.position(|e| {
|
||||
matches!(
|
||||
e,
|
||||
ShortcutsHelpEntry::Hint {
|
||||
item,
|
||||
action_id: None,
|
||||
long_help: Some(_),
|
||||
..
|
||||
} if item.label == "paste"
|
||||
)
|
||||
e,
|
||||
ShortcutsHelpEntry::Hint {
|
||||
item,
|
||||
action_id: None,
|
||||
long_help: Some(_),
|
||||
..
|
||||
}
|
||||
if item.label == "paste"
|
||||
)
|
||||
})
|
||||
.expect("paste pseudo-row with long_help");
|
||||
let key_id = ExpandKey::Pseudo("paste");
|
||||
|
|
|
|||
|
|
@ -992,11 +992,10 @@ impl TasksPane {
|
|||
};
|
||||
if changed {
|
||||
self.rebuild_entries();
|
||||
if let Some(header) = self
|
||||
.entries
|
||||
.iter()
|
||||
.find(|e| matches!(e, TaskEntry::Header { group: g, .. } if *g == group))
|
||||
{
|
||||
if let Some(header) = self.entries.iter().find(|e| {
|
||||
matches!(e, TaskEntry::Header { group: g, .. }
|
||||
if *g == group)
|
||||
}) {
|
||||
let id = header.stable_id();
|
||||
self.list_state.select_by_id(id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2949,10 +2949,12 @@ mod tests {
|
|||
|
||||
// Verify headers
|
||||
assert!(
|
||||
matches!(&result[0], crate::views::picker::PickerEntry::Header { label } if label == &"fw-1")
|
||||
matches!(&result[0], crate::views::picker::PickerEntry::Header { label }
|
||||
if label == &"fw-1")
|
||||
);
|
||||
assert!(
|
||||
matches!(&result[2], crate::views::picker::PickerEntry::Header { label } if label == &"xai")
|
||||
matches!(&result[2], crate::views::picker::PickerEntry::Header { label }
|
||||
if label == &"xai")
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -2981,11 +2983,13 @@ mod tests {
|
|||
Some("zzz"),
|
||||
);
|
||||
assert!(
|
||||
matches!(&result[0], crate::views::picker::PickerEntry::Header { label } if label == &"zzz"),
|
||||
matches!(&result[0], crate::views::picker::PickerEntry::Header { label }
|
||||
if label == &"zzz"),
|
||||
"current repo group pinned first"
|
||||
);
|
||||
assert!(
|
||||
matches!(&result[2], crate::views::picker::PickerEntry::Header { label } if label == &"aaa"),
|
||||
matches!(&result[2], crate::views::picker::PickerEntry::Header { label }
|
||||
if label == &"aaa"),
|
||||
"remaining group follows alphabetically"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue