Synced from monorepo Changes: - Temporarily disable session share link creation in the TUI - Do not approve plan on empty Enter from the revise prompt - Expose chat product Skills via ACP available_commands_update - Return immediately from a blocking wait on an already-completed ACP task - Split headless pager module for clearer structure - Stop git worktree prune from removing user registrations on resume - Use compaction sampler tokenizer for item token counts - Opt-in extra root CAs via GROK_EXTRA_CA_BUNDLE - Cancel all session subagents when the user stops - Let the session persistence actor exit when its session ends - Make fullscreen terminal resize much cheaper on long sessions - Report honestly from kill_task when an ACP task does not exist - Hide /usage for external-auth deployments - Forward the history-load trailer’s computer_reason to the client - Remove ineffective no-op tool reminder - Declare slash-command screen-mode support in one place - Keep settings enum picker on the committed value until Enter - Reap a PTY’s full process tree - Stream tool calls from headless mode over ACP - Bridge gateway task lifecycle to ACP for chat session background tasks - Don’t warn about truncated history on a suppressed replay - Fit full-replace summarizer input and recover on context-length errors - Stop dropping agents over an unrecognized frontmatter color - Add /undo as a slash alias for /rewind - Harden sleep/wake token-refresh paths against forced re-login - Add session/list ACP method - Give each sampling backend its own conversion module - Treat an unenrolled child process as a lint error - Suppress the cancelled marker on send-now wake turns - Stop tearing down Roslyn on every edit, and read C# diagnostics Source-Revision: 2a28b4a86cfc4a4c133c35b7fc2a6a9964387c39
597 lines
22 KiB
Rust
597 lines
22 KiB
Rust
//! Session status, sharing, privacy, usage, and info dispatchers.
|
|
|
|
use agent_client_protocol as acp;
|
|
|
|
use super::ctx::get_active_agent;
|
|
use super::settings::ui::refresh_open_settings_modals;
|
|
use crate::app::actions::Effect;
|
|
use crate::app::agent::AgentId;
|
|
use crate::app::agent_view::AgentView;
|
|
use crate::app::app_view::{ActiveView, AppView};
|
|
use crate::notifications::{NotificationEvent, NotificationEventKind};
|
|
use crate::scrollback::block::RenderBlock;
|
|
|
|
/// Temporary kill switch: client share links are disabled.
|
|
pub(super) fn dispatch_share_session(app: &mut AppView) -> Vec<Effect> {
|
|
app.show_toast("Session sharing is temporarily disabled");
|
|
vec![]
|
|
}
|
|
|
|
/// Show session info: fetch via x.ai/session/info and display in scrollback.
|
|
///
|
|
/// Produces Effect::ShowSessionInfo which spawns an async ACP ext request.
|
|
/// On completion, TaskResult::SessionInfoComplete shows the formatted info.
|
|
pub(super) fn dispatch_show_session_info(app: &mut AppView) -> Vec<Effect> {
|
|
let ActiveView::Agent(id) = app.active_view else {
|
|
return vec![];
|
|
};
|
|
let Some(agent) = app.agents.get_mut(&id) else {
|
|
return vec![];
|
|
};
|
|
let Some(session_id) = agent.session.session_id.clone() else {
|
|
// No active session — error should have been caught by slash command,
|
|
// but guard here just in case.
|
|
return vec![];
|
|
};
|
|
|
|
vec![Effect::ShowSessionInfo {
|
|
agent_id: id,
|
|
session_id,
|
|
show_resolved_model: app.show_resolved_model,
|
|
}]
|
|
}
|
|
|
|
/// State-only mutation for `coding_data_sharing`. SHELL-owned.
|
|
pub(super) fn set_coding_data_sharing_inner(app: &mut AppView, opted_in: bool) {
|
|
app.coding_data_retention_opt_out = !opted_in;
|
|
}
|
|
|
|
/// Agent the coding-data ACP write is attributed to. Privacy is app-level,
|
|
/// so the id only routes the result back; `AgentId(0)` is the synthetic
|
|
/// stand-in for the welcome screen, where the banner is reachable before a
|
|
/// session exists.
|
|
fn coding_data_sharing_agent_id(app: &AppView) -> AgentId {
|
|
match app.active_view {
|
|
ActiveView::Agent(id) => id,
|
|
_ => app.agents.keys().next().copied().unwrap_or(AgentId(0)),
|
|
}
|
|
}
|
|
|
|
/// Claim the next write generation. Every `SetCodingDataSharing` must take
|
|
/// one so its reply can be matched against the newest write.
|
|
fn next_coding_data_write_seq(app: &mut AppView) -> u64 {
|
|
app.coding_data_write_seq += 1;
|
|
app.coding_data_write_seq
|
|
}
|
|
|
|
/// Is this reply from the newest write? Writes to this endpoint run
|
|
/// concurrently and can land out of order, so an older reply must not touch
|
|
/// state: its `rollback_to_opted_in` predates the newer write, and applying
|
|
/// it would silently undo whatever the user did since.
|
|
fn is_current_coding_data_write(app: &AppView, seq: u64, agent_id: AgentId) -> bool {
|
|
if seq == app.coding_data_write_seq {
|
|
return true;
|
|
}
|
|
tracing::debug!(
|
|
target: "settings",
|
|
key = "coding_data_sharing",
|
|
?agent_id,
|
|
seq,
|
|
current = app.coding_data_write_seq,
|
|
"dropping superseded coding-data reply",
|
|
);
|
|
false
|
|
}
|
|
|
|
fn log_coding_data_consent_selected(
|
|
source: xai_grok_telemetry::events::CodingDataConsentSource,
|
|
opted_in: bool,
|
|
previous_opted_in: bool,
|
|
) {
|
|
use xai_grok_telemetry::events::{CodingDataConsentChoice, CodingDataConsentSelected};
|
|
xai_grok_telemetry::session_ctx::log_event(CodingDataConsentSelected {
|
|
source,
|
|
choice: CodingDataConsentChoice::from_opted_in(opted_in),
|
|
previous_choice: CodingDataConsentChoice::from_opted_in(previous_opted_in),
|
|
changed: opted_in != previous_opted_in,
|
|
});
|
|
}
|
|
|
|
/// Set coding-data-sharing preference. SHELL-owned, auth-metadata-backed
|
|
/// (persists via ACP ext-request, NOT `~/.grok/config.toml`).
|
|
pub(super) fn set_coding_data_sharing(
|
|
app: &mut AppView,
|
|
opted_in: bool,
|
|
source: xai_grok_telemetry::events::CodingDataConsentSource,
|
|
) -> Vec<Effect> {
|
|
// ── Guard 1: Enterprise ZDR ──────────────────────────────────────
|
|
if app.is_zdr {
|
|
app.show_toast("\u{2717} Cannot change: Zero Data Retention enabled");
|
|
return vec![];
|
|
}
|
|
// ── Guard 2: Non-admin team member ───────────────────────────────
|
|
if app.team_name.is_some() {
|
|
let is_admin = app
|
|
.team_role
|
|
.as_deref()
|
|
.is_some_and(|r| r.eq_ignore_ascii_case("admin"));
|
|
if !is_admin {
|
|
app.show_toast("\u{2717} Data sharing is controlled by your team admin");
|
|
return vec![];
|
|
}
|
|
}
|
|
let agent_id = coding_data_sharing_agent_id(app);
|
|
let prev = !app.coding_data_retention_opt_out;
|
|
log_coding_data_consent_selected(source, opted_in, prev);
|
|
|
|
// ── Idempotent path: skip the ACP round-trip. ────────────────────
|
|
if prev == opted_in {
|
|
return vec![];
|
|
}
|
|
|
|
// Optimistic mutation. Success is silent; only the refusals above and
|
|
// the failure handler toast.
|
|
set_coding_data_sharing_inner(app, opted_in);
|
|
refresh_open_settings_modals(app);
|
|
|
|
tracing::info!(
|
|
target: "settings",
|
|
key = "coding_data_sharing",
|
|
opted_in,
|
|
"setting changed",
|
|
);
|
|
|
|
vec![Effect::SetCodingDataSharing {
|
|
agent_id,
|
|
opted_in,
|
|
rollback_to_opted_in: prev,
|
|
seq: next_coding_data_write_seq(app),
|
|
}]
|
|
}
|
|
|
|
/// Scrub an untrusted error string for toast display. Substitutes a
|
|
/// generic placeholder when the input exceeds 120 chars or contains
|
|
/// control / bidi-override characters (prevents escape-sequence
|
|
/// injection and visual spoofing). Full error stays in tracing logs.
|
|
pub(super) fn scrub_error_for_toast(error: &str) -> String {
|
|
const MAX_TOAST_ERROR_LEN: usize = 120;
|
|
if error.len() > MAX_TOAST_ERROR_LEN
|
|
|| error
|
|
.chars()
|
|
.any(crate::render::line_utils::is_unsafe_display_char)
|
|
{
|
|
"server error (see logs for details)".to_string()
|
|
} else {
|
|
error.to_string()
|
|
}
|
|
}
|
|
|
|
/// Show context info: fetch via x.ai/session/info and display rich breakdown.
|
|
///
|
|
/// Produces Effect::ShowContextInfo which spawns an async ACP ext request.
|
|
/// On completion, TaskResult::ContextInfoComplete shows the formatted info.
|
|
pub(super) fn dispatch_show_context_info(app: &mut AppView) -> Vec<Effect> {
|
|
let ActiveView::Agent(id) = app.active_view else {
|
|
return vec![];
|
|
};
|
|
let Some(agent) = app.agents.get_mut(&id) else {
|
|
return vec![];
|
|
};
|
|
let Some(session_id) = agent.session.session_id.clone() else {
|
|
return vec![];
|
|
};
|
|
|
|
vec![Effect::ShowContextInfo {
|
|
agent_id: id,
|
|
session_id,
|
|
}]
|
|
}
|
|
|
|
/// `/usage` — session token/cost, then consumer credits when visible.
|
|
/// Credits are chained after the session block so layout stays ordered.
|
|
pub(super) fn dispatch_show_usage(app: &mut AppView) -> Vec<Effect> {
|
|
let ActiveView::Agent(id) = app.active_view else {
|
|
return vec![];
|
|
};
|
|
let session_id = {
|
|
let Some(agent) = app.agents.get_mut(&id) else {
|
|
return vec![];
|
|
};
|
|
agent.session.session_id.clone()
|
|
};
|
|
match session_id {
|
|
Some(session_id) => vec![Effect::FetchSessionUsage {
|
|
agent_id: id,
|
|
session_id,
|
|
}],
|
|
None => {
|
|
if let Some(agent) = app.agents.get_mut(&id) {
|
|
agent.scrollback.push_block(RenderBlock::system(
|
|
"Session usage is unavailable until the session starts.".to_string(),
|
|
));
|
|
}
|
|
append_consumer_billing_surface(app, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Commit a session-usage block if still on `session_id`, then consumer credits.
|
|
pub(super) fn commit_session_usage_block(
|
|
app: &mut AppView,
|
|
agent_id: AgentId,
|
|
session_id: &acp::SessionId,
|
|
text: String,
|
|
) -> Vec<Effect> {
|
|
let Some(agent) = app.agents.get_mut(&agent_id) else {
|
|
return vec![];
|
|
};
|
|
if agent.session.session_id.as_ref() != Some(session_id) {
|
|
return vec![];
|
|
}
|
|
agent.scrollback.push_block(RenderBlock::system(text));
|
|
append_consumer_billing_surface(app, agent_id)
|
|
}
|
|
|
|
/// Consumer credit follow-up for `/usage` (redirect or non-silent billing fetch).
|
|
pub(super) fn append_consumer_billing_surface(app: &mut AppView, agent_id: AgentId) -> Vec<Effect> {
|
|
if !app.usage_visible {
|
|
return vec![];
|
|
}
|
|
// Remote-settings kill switch (`grok_build_usage_redirect_url`): link out
|
|
// instead of fetching billing from the backend.
|
|
if let Some(url) = app.usage_billing_redirect_url.clone() {
|
|
if let Some(agent) = app.agents.get_mut(&agent_id) {
|
|
agent.scrollback.push_block(RenderBlock::System(
|
|
crate::scrollback::blocks::SystemMessageBlock::new(format!(
|
|
"Please check your usage on {url}"
|
|
)),
|
|
));
|
|
}
|
|
return vec![];
|
|
}
|
|
if !app.agents.contains_key(&agent_id) {
|
|
return vec![];
|
|
}
|
|
// Non-silent: the effect also pulls the auto top-up rule so the summary
|
|
// renders usage, prepaid credits, and auto top-up together.
|
|
vec![Effect::FetchBilling {
|
|
agent_id,
|
|
silent: false,
|
|
}]
|
|
}
|
|
|
|
/// `/usage manage` — open consumer billing. No-op when the surface is hidden.
|
|
pub(super) fn dispatch_manage_billing(app: &mut AppView) -> Vec<Effect> {
|
|
if !app.usage_visible {
|
|
return vec![];
|
|
}
|
|
super::router::dispatch(
|
|
crate::app::actions::Action::OpenUrl("https://grok.com/?_s=usage".to_string()),
|
|
app,
|
|
)
|
|
}
|
|
|
|
/// Commit a one-line "update available" notice into the active agent's
|
|
/// scrollback. Minimal mode has no welcome screen (the full TUI's update
|
|
/// surface), so the background update check's result is shown here instead
|
|
/// No-op when there is no active agent.
|
|
pub(crate) fn commit_minimal_update_notice(app: &mut AppView, latest_version: &str) {
|
|
if let ActiveView::Agent(id) = app.active_view
|
|
&& let Some(agent) = app.agents.get_mut(&id)
|
|
{
|
|
agent.scrollback.push_block(RenderBlock::system(format!(
|
|
"Update available: v{latest_version} — restart to apply."
|
|
)));
|
|
}
|
|
}
|
|
|
|
/// `/queue` — commit a read-only list of the queued prompts as a system block.
|
|
/// The text is built by [`crate::app::status_blocks::queue_block_text`]; this
|
|
/// just resolves the active agent and pushes it. Works in every render mode; the
|
|
/// primary inspection surface in minimal, which has no interactive `QueuePane`.
|
|
pub(super) fn dispatch_show_queue(app: &mut AppView) -> Vec<Effect> {
|
|
if let ActiveView::Agent(id) = app.active_view
|
|
&& let Some(agent) = app.agents.get_mut(&id)
|
|
{
|
|
let text = crate::app::status_blocks::queue_block_text(agent);
|
|
agent.scrollback.push_block(RenderBlock::system(text));
|
|
}
|
|
vec![]
|
|
}
|
|
|
|
/// `/tasks` — commit a read-only list of background tasks, subagents, and
|
|
/// scheduled (`/loop`) tasks as a system block. The text is built by
|
|
/// [`crate::app::status_blocks::tasks_block_text`]; this just resolves the
|
|
/// active agent and pushes it. Works in every render mode; the primary snapshot
|
|
/// surface in minimal, which has no interactive `TasksPane`.
|
|
pub(super) fn dispatch_show_tasks(app: &mut AppView) -> Vec<Effect> {
|
|
if let ActiveView::Agent(id) = app.active_view
|
|
&& let Some(agent) = app.agents.get_mut(&id)
|
|
{
|
|
let text = crate::app::status_blocks::tasks_block_text(agent);
|
|
agent.scrollback.push_block(RenderBlock::system(text));
|
|
}
|
|
vec![]
|
|
}
|
|
|
|
/// Open the hidden `/gboom` easter egg as a modal over the active agent
|
|
/// view. Requires a graphics-capable terminal (kitty protocol or iTerm2);
|
|
/// otherwise a toast explains why nothing happened. On session-less
|
|
/// surfaces (dashboard, welcome) this is a silent no-op.
|
|
///
|
|
/// Targets the top-level agent view (where the prompt lives), not a
|
|
/// focused subagent view: the modal's tick/draw plumbing runs on the
|
|
/// top-level view, mirroring the video viewer.
|
|
pub(super) fn dispatch_open_gboom(app: &mut AppView) -> Vec<Effect> {
|
|
use crate::terminal::image::{GraphicsProtocol, detect_graphics_protocol};
|
|
let ActiveView::Agent(id) = app.active_view else {
|
|
return vec![];
|
|
};
|
|
let Some(agent) = app.agents.get_mut(&id) else {
|
|
return vec![];
|
|
};
|
|
if detect_graphics_protocol() == GraphicsProtocol::None {
|
|
agent.show_toast(
|
|
"No demons here \u{2014} GBOOM needs a graphics-capable terminal \
|
|
(kitty, Ghostty, WezTerm, iTerm2)",
|
|
);
|
|
return vec![];
|
|
}
|
|
// Close other media modals: they share the kitty placement id. Drop the
|
|
// image viewer's in-flight loader too (its close path clears both —
|
|
// a leaked rx would mis-feed the next image viewer's poll loop).
|
|
agent.image_viewer = None;
|
|
agent.image_load_rx = None;
|
|
agent.video_viewer = None;
|
|
agent.gboom = Some(crate::gboom::GboomState::new());
|
|
vec![]
|
|
}
|
|
|
|
/// Emit a `SessionReady` notification for the given agent.
|
|
///
|
|
/// Takes `&NotificationService` separately from `&AgentView` to avoid
|
|
/// borrow-checker conflicts when `agent` is borrowed from `app.agents`.
|
|
pub(super) fn notify_session_ready(
|
|
notification_service: &crate::notifications::NotificationService,
|
|
agent: &AgentView,
|
|
) {
|
|
notification_service.notify(NotificationEvent {
|
|
kind: NotificationEventKind::SessionReady,
|
|
title: "Grok".into(),
|
|
body: NotificationEventKind::SessionReady.as_str().into(),
|
|
session_id: agent.session.session_id.as_ref().map(|s| s.0.to_string()),
|
|
});
|
|
}
|
|
|
|
// TaskResult handlers.
|
|
|
|
pub(super) fn handle_coding_data_sharing_updated(
|
|
app: &mut AppView,
|
|
agent_id: AgentId,
|
|
opted_in: bool,
|
|
seq: u64,
|
|
) -> Vec<Effect> {
|
|
if !is_current_coding_data_write(app, seq, agent_id) {
|
|
return vec![];
|
|
}
|
|
// Re-anchor mirror to server-confirmed value (defense-in-depth against
|
|
// server reshaping the boolean). `agent_id` discarded — privacy is
|
|
// app-level, not per-agent.
|
|
set_coding_data_sharing_inner(app, opted_in);
|
|
refresh_open_settings_modals(app);
|
|
tracing::info!(
|
|
target: "settings",
|
|
key = "coding_data_sharing",
|
|
?agent_id,
|
|
opted_in,
|
|
"ACP update confirmed; mirror re-anchored",
|
|
);
|
|
let mut effects = vec![];
|
|
// Ack only after a successful opt-in from the banner's [Opt in].
|
|
if app.privacy_banner_opt_in_inflight {
|
|
app.privacy_banner_opt_in_inflight = false;
|
|
if opted_in {
|
|
effects.extend(ack_privacy_banner(app));
|
|
}
|
|
}
|
|
effects
|
|
}
|
|
|
|
pub(super) fn handle_coding_data_sharing_failed(
|
|
app: &mut AppView,
|
|
agent_id: AgentId,
|
|
error: String,
|
|
rollback_to_opted_in: bool,
|
|
seq: u64,
|
|
) -> Vec<Effect> {
|
|
// A superseded failure must not revert: `rollback_to_opted_in` predates
|
|
// the newer write, so applying it would undo a change the user made
|
|
// after this one was sent. It must not toast either — nothing the user
|
|
// is looking at failed.
|
|
if !is_current_coding_data_write(app, seq, agent_id) {
|
|
return vec![];
|
|
}
|
|
// Revert optimistic mutation: inner → refresh → toast. `agent_id`
|
|
// discarded — privacy is global.
|
|
set_coding_data_sharing_inner(app, rollback_to_opted_in);
|
|
refresh_open_settings_modals(app);
|
|
// Scrub long/unsafe error strings before toasting.
|
|
let scrubbed = scrub_error_for_toast(&error);
|
|
app.show_toast(&format!(
|
|
"\u{2717} Couldn't update coding data sharing: {scrubbed}"
|
|
));
|
|
tracing::warn!(
|
|
target: "settings",
|
|
key = "coding_data_sharing",
|
|
?agent_id,
|
|
rollback_to_opted_in,
|
|
%error,
|
|
"ACP update failed; reverted optimistic mutation",
|
|
);
|
|
// Opt-in failure: no ack; clear inflight so the banner stays.
|
|
app.privacy_banner_opt_in_inflight = false;
|
|
vec![]
|
|
}
|
|
|
|
/// Stamp `[privacy].privacy_banner_acked` (in-memory + disk).
|
|
pub(in crate::app::dispatch) fn ack_privacy_banner(app: &mut AppView) -> Vec<Effect> {
|
|
let acked_at = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
|
|
app.privacy_banner_acked = Some(acked_at.clone());
|
|
vec![Effect::PersistPrivacyBannerAcked { acked_at }]
|
|
}
|
|
|
|
/// `[Opt in]`: opt in via the settings path; ack only after ACP success, so
|
|
/// a failed round trip leaves the banner up instead of recording a change
|
|
/// that did not happen.
|
|
pub(in crate::app::dispatch) fn dispatch_privacy_banner_opt_in(app: &mut AppView) -> Vec<Effect> {
|
|
if app.privacy_banner_opt_in_inflight || !app.privacy_banner_should_show() {
|
|
return vec![];
|
|
}
|
|
let effects = set_coding_data_sharing(
|
|
app,
|
|
true,
|
|
xai_grok_telemetry::events::CodingDataConsentSource::PrivacyBanner,
|
|
);
|
|
// should_show guarantees opted-out + unguarded, so effects is only empty
|
|
// if a guard regresses; leaving inflight false keeps [Opt in] clickable.
|
|
app.privacy_banner_opt_in_inflight = !effects.is_empty();
|
|
effects
|
|
}
|
|
|
|
/// `[Opt out]`: ack locally, then record the decline.
|
|
///
|
|
/// The ack does NOT wait on the server, unlike `[Opt in]`'s: the user asked
|
|
/// for no change, so gating dismissal on a round trip would only re-ask a
|
|
/// question they answered.
|
|
///
|
|
/// The write is built here rather than through `set_coding_data_sharing`,
|
|
/// whose idempotent guard would skip it — the user is already opted out,
|
|
/// and recording that is the point. Its response re-anchors the mirror;
|
|
/// concurrent writes to this endpoint are still unordered.
|
|
pub(in crate::app::dispatch) fn dispatch_privacy_banner_opt_out(app: &mut AppView) -> Vec<Effect> {
|
|
if app.privacy_banner_opt_in_inflight || !app.privacy_banner_should_show() {
|
|
return vec![];
|
|
}
|
|
let previous_opted_in = !app.coding_data_retention_opt_out;
|
|
log_coding_data_consent_selected(
|
|
xai_grok_telemetry::events::CodingDataConsentSource::PrivacyBanner,
|
|
false,
|
|
previous_opted_in,
|
|
);
|
|
let mut effects = ack_privacy_banner(app);
|
|
effects.push(Effect::SetCodingDataSharing {
|
|
agent_id: coding_data_sharing_agent_id(app),
|
|
opted_in: false,
|
|
// Already opted out, so the revert is a no-op — and the generation
|
|
// guard drops it entirely if the user has opted in since.
|
|
rollback_to_opted_in: false,
|
|
seq: next_coding_data_write_seq(app),
|
|
});
|
|
effects
|
|
}
|
|
|
|
pub(super) fn handle_context_info_complete(
|
|
app: &mut AppView,
|
|
agent_id: AgentId,
|
|
info: Box<xai_grok_shell::session::SessionInfoResponse>,
|
|
) -> Vec<Effect> {
|
|
if let Some(agent) = app.agents.get_mut(&agent_id) {
|
|
let model = info.data.model.as_deref().unwrap_or("unknown").to_string();
|
|
// Take ownership of the snapshot once, hand a clone to the
|
|
// agent's running counters, then move the original into the
|
|
// scrollback block (which keeps it for theme-reactive
|
|
// re-rendering). This still costs one clone but reads as
|
|
// "the agent needs a copy" rather than "the block needs a
|
|
// copy", which matches the lifetime story.
|
|
let snapshot = info.data.context;
|
|
agent.apply_full_context_info(snapshot.clone());
|
|
agent
|
|
.scrollback
|
|
.push_block(crate::scrollback::block::RenderBlock::context_info(
|
|
snapshot, model,
|
|
));
|
|
}
|
|
vec![]
|
|
}
|
|
|
|
// Action handlers.
|
|
|
|
pub(super) fn dispatch_copy_session_id(app: &mut AppView, index: usize) -> Vec<Effect> {
|
|
use crate::views::modal::ActiveModal;
|
|
// Try agent modal first, then fall back to app fields (welcome screen).
|
|
let id = get_active_agent(app)
|
|
.and_then(|agent| {
|
|
if let Some(ActiveModal::SessionPicker {
|
|
entries: Some(ref e),
|
|
..
|
|
}) = agent.active_modal
|
|
{
|
|
e.get(index).map(|entry| entry.id.clone())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.or_else(|| {
|
|
app.session_picker_entries
|
|
.as_ref()
|
|
.and_then(|s| s.get(index))
|
|
.map(|e| e.id.clone())
|
|
});
|
|
if let Some(id) = id {
|
|
let delivery = crate::clipboard::copy_text_or_file(&id);
|
|
app.show_toast(delivery.toast_message().as_ref());
|
|
}
|
|
vec![]
|
|
}
|
|
|
|
/// Open the onboarding tutorial overlay (top-level modal — works over both
|
|
/// the welcome screen and an agent session). Toggles: dispatching while
|
|
/// open closes instead of stacking.
|
|
pub(super) fn dispatch_open_tutorial(app: &mut AppView) -> Vec<Effect> {
|
|
// Minimal mode has no modal host: the overlay would render nothing
|
|
// while the app-level intercept swallowed all input.
|
|
if app.screen_mode.is_minimal() {
|
|
return vec![];
|
|
}
|
|
if app.tutorial.is_some() {
|
|
app.tutorial = None;
|
|
return vec![];
|
|
}
|
|
app.tutorial = Some(crate::views::tutorial::TutorialState::new());
|
|
vec![]
|
|
}
|
|
|
|
pub(super) fn dispatch_show_release_notes(
|
|
app: &mut AppView,
|
|
title: String,
|
|
content: String,
|
|
) -> Vec<Effect> {
|
|
match app.active_view {
|
|
ActiveView::Agent(id) => {
|
|
if let Some(agent) = app.agents.get_mut(&id) {
|
|
agent.active_modal = Some(crate::views::modal::ActiveModal::DocViewer {
|
|
title,
|
|
content,
|
|
scroll: 0,
|
|
window: crate::views::modal_window::ModalWindowState::new(),
|
|
cached_lines: None,
|
|
previous_palette: None,
|
|
standalone: true,
|
|
});
|
|
}
|
|
}
|
|
ActiveView::Welcome => {
|
|
app.welcome_doc_viewer = Some(crate::views::modal::ActiveModal::DocViewer {
|
|
title,
|
|
content,
|
|
scroll: 0,
|
|
window: crate::views::modal_window::ModalWindowState::new(),
|
|
cached_lines: None,
|
|
previous_palette: None,
|
|
standalone: true,
|
|
});
|
|
}
|
|
_ => {}
|
|
}
|
|
vec![]
|
|
}
|