From 56dbbb7aea2cdb63dfada98bcdbae8faacbbd19d Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sat, 21 Mar 2026 12:53:02 +0100 Subject: [PATCH 01/63] feat: add first-class agent ops commands --- crates/taskers-cli/src/main.rs | 490 +++++++++++++++++++++-- crates/taskers-control/src/controller.rs | 105 +++++ crates/taskers-control/src/protocol.rs | 45 ++- crates/taskers-domain/src/lib.rs | 13 +- crates/taskers-domain/src/model.rs | 376 +++++++++++++++++ 5 files changed, 990 insertions(+), 39 deletions(-) diff --git a/crates/taskers-cli/src/main.rs b/crates/taskers-cli/src/main.rs index 17d40bb..686c7f8 100644 --- a/crates/taskers-cli/src/main.rs +++ b/crates/taskers-cli/src/main.rs @@ -11,8 +11,9 @@ use taskers_control::{ default_socket_path, serve, }; use taskers_domain::{ - AppModel, Direction, KEYBOARD_RESIZE_STEP, PaneId, PaneKind, PaneMetadataPatch, SignalEvent, - SignalKind, SplitAxis, SurfaceId, WorkspaceId, + AgentTarget, AppModel, AttentionState, Direction, KEYBOARD_RESIZE_STEP, PaneId, PaneKind, + PaneMetadataPatch, ProgressState, SignalEvent, SignalKind, SplitAxis, SurfaceId, + WorkspaceId, WorkspaceLogEntry, }; use time::OffsetDateTime; @@ -80,6 +81,10 @@ enum Command { #[arg(long)] agent: Option, }, + Agent { + #[command(subcommand)] + command: AgentCommand, + }, Workspace { #[command(subcommand)] command: WorkspaceCommand, @@ -256,6 +261,144 @@ enum AgentHookCommand { }, } +#[derive(Debug, Subcommand)] +enum AgentCommand { + Status { + #[command(subcommand)] + command: AgentStatusCommand, + }, + Progress { + #[command(subcommand)] + command: AgentProgressCommand, + }, + Log { + #[command(subcommand)] + command: AgentLogCommand, + }, + Notify { + #[command(subcommand)] + command: AgentNotifyCommand, + }, + Flash { + #[arg(long)] + socket: Option, + #[arg(long)] + workspace: Option, + #[arg(long)] + pane: Option, + #[arg(long)] + surface: Option, + }, + FocusUnread { + #[arg(long)] + socket: Option, + }, +} + +#[derive(Debug, Subcommand)] +enum AgentStatusCommand { + Set { + #[arg(long)] + socket: Option, + #[arg(long)] + workspace: Option, + #[arg(long)] + text: String, + }, + Clear { + #[arg(long)] + socket: Option, + #[arg(long)] + workspace: Option, + }, +} + +#[derive(Debug, Subcommand)] +enum AgentProgressCommand { + Set { + #[arg(long)] + socket: Option, + #[arg(long)] + workspace: Option, + #[arg(long)] + value: u16, + #[arg(long)] + label: Option, + }, + Clear { + #[arg(long)] + socket: Option, + #[arg(long)] + workspace: Option, + }, +} + +#[derive(Debug, Subcommand)] +enum AgentLogCommand { + Append { + #[arg(long)] + socket: Option, + #[arg(long)] + workspace: Option, + #[arg(long)] + message: String, + #[arg(long)] + source: Option, + }, + List { + #[arg(long)] + socket: Option, + #[arg(long)] + workspace: Option, + }, + Clear { + #[arg(long)] + socket: Option, + #[arg(long)] + workspace: Option, + }, +} + +#[derive(Debug, Subcommand)] +enum AgentNotifyCommand { + Create { + #[arg(long)] + socket: Option, + #[arg(long)] + workspace: Option, + #[arg(long)] + pane: Option, + #[arg(long)] + surface: Option, + #[arg(long, value_enum, default_value_t = CliAgentTargetScope::Surface)] + scope: CliAgentTargetScope, + #[arg(long)] + title: Option, + #[arg(long)] + message: String, + #[arg(long, value_enum, default_value_t = CliAttentionState::Waiting)] + state: CliAttentionState, + }, + List { + #[arg(long)] + socket: Option, + #[arg(long)] + workspace: Option, + }, + Clear { + #[arg(long)] + socket: Option, + #[arg(long)] + workspace: Option, + #[arg(long)] + pane: Option, + #[arg(long)] + surface: Option, + #[arg(long, value_enum, default_value_t = CliAgentTargetScope::Surface)] + scope: CliAgentTargetScope, + }, +} + #[derive(Debug, Subcommand)] enum BrowserCommand { Open { @@ -445,6 +588,22 @@ enum CliPaneKind { Browser, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +enum CliAgentTargetScope { + Workspace, + Pane, + Surface, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +enum CliAttentionState { + Normal, + Busy, + Completed, + Waiting, + Error, +} + impl From for SignalKind { fn from(value: CliSignalKind) -> Self { match value { @@ -488,6 +647,18 @@ impl From for PaneKind { } } +impl From for AttentionState { + fn from(value: CliAttentionState) -> Self { + match value { + CliAttentionState::Normal => AttentionState::Normal, + CliAttentionState::Busy => AttentionState::Busy, + CliAttentionState::Completed => AttentionState::Completed, + CliAttentionState::Waiting => AttentionState::WaitingInput, + CliAttentionState::Error => AttentionState::Error, + } + } +} + #[tokio::main] async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); @@ -640,14 +811,15 @@ async fn main() -> anyhow::Result<()> { body, agent, } => { - let workspace_id = workspace - .or_else(env_workspace_id) - .context("missing workspace id; pass --workspace or run from inside Taskers")?; - let pane_id = pane - .or_else(env_pane_id) - .context("missing pane id; pass --pane or run from inside Taskers")?; - let surface_id = surface.or_else(env_surface_id); let client = ControlClient::new(resolve_socket_path(socket)); + let model = query_model(&client).await?; + let target = resolve_agent_target( + &model, + workspace, + pane, + surface, + CliAgentTargetScope::Surface, + )?; let normalized_title = title.trim(); let normalized_body = body .as_deref() @@ -655,33 +827,243 @@ async fn main() -> anyhow::Result<()> { .filter(|value| !value.is_empty()) .map(str::to_owned); let message = normalized_body.unwrap_or_else(|| normalized_title.to_string()); - let inferred_agent = agent.or_else(|| infer_agent_kind(normalized_title)); - let metadata = Some(taskers_domain::SignalPaneMetadata { - title: None, - agent_title: Some(normalized_title.to_string()), - cwd: None, - repo_name: None, - git_branch: None, - ports: Vec::new(), - agent_kind: inferred_agent, - agent_active: None, - }); + let title = if agent.is_some() { + Some(normalized_title.to_string()) + } else { + Some(normalized_title.to_string()) + }; let response = client - .send(ControlCommand::EmitSignal { + .send(ControlCommand::AgentCreateNotification { + target, + title, + message, + state: AttentionState::WaitingInput, + }) + .await?; + println!("{}", serde_json::to_string_pretty(&response)?); + } + Command::Agent { command } => match command { + AgentCommand::Status { command } => match command { + AgentStatusCommand::Set { + socket, + workspace, + text, + } => { + let client = ControlClient::new(resolve_socket_path(socket)); + let model = query_model(&client).await?; + let workspace_id = resolve_workspace_id_from_model(&model, workspace)?; + let response = send_control_command( + &client, + ControlCommand::AgentSetStatus { workspace_id, text }, + ) + .await?; + println!("{}", serde_json::to_string_pretty(&response)?); + } + AgentStatusCommand::Clear { socket, workspace } => { + let client = ControlClient::new(resolve_socket_path(socket)); + let model = query_model(&client).await?; + let workspace_id = resolve_workspace_id_from_model(&model, workspace)?; + let response = send_control_command( + &client, + ControlCommand::AgentClearStatus { workspace_id }, + ) + .await?; + println!("{}", serde_json::to_string_pretty(&response)?); + } + }, + AgentCommand::Progress { command } => match command { + AgentProgressCommand::Set { + socket, + workspace, + value, + label, + } => { + let client = ControlClient::new(resolve_socket_path(socket)); + let model = query_model(&client).await?; + let workspace_id = resolve_workspace_id_from_model(&model, workspace)?; + let response = send_control_command( + &client, + ControlCommand::AgentSetProgress { + workspace_id, + progress: ProgressState { + value, + label, + }, + }, + ) + .await?; + println!("{}", serde_json::to_string_pretty(&response)?); + } + AgentProgressCommand::Clear { socket, workspace } => { + let client = ControlClient::new(resolve_socket_path(socket)); + let model = query_model(&client).await?; + let workspace_id = resolve_workspace_id_from_model(&model, workspace)?; + let response = send_control_command( + &client, + ControlCommand::AgentClearProgress { workspace_id }, + ) + .await?; + println!("{}", serde_json::to_string_pretty(&response)?); + } + }, + AgentCommand::Log { command } => match command { + AgentLogCommand::Append { + socket, + workspace, + message, + source, + } => { + let client = ControlClient::new(resolve_socket_path(socket)); + let model = query_model(&client).await?; + let workspace_id = resolve_workspace_id_from_model(&model, workspace)?; + let response = send_control_command( + &client, + ControlCommand::AgentAppendLog { + workspace_id, + entry: WorkspaceLogEntry { + source, + message, + created_at: OffsetDateTime::now_utc(), + }, + }, + ) + .await?; + println!("{}", serde_json::to_string_pretty(&response)?); + } + AgentLogCommand::List { socket, workspace } => { + let client = ControlClient::new(resolve_socket_path(socket)); + let model = query_model(&client).await?; + let workspace_id = resolve_workspace_id_from_model(&model, workspace)?; + let workspace = model + .workspaces + .get(&workspace_id) + .ok_or_else(|| anyhow!("workspace {workspace_id} not found"))?; + println!("{}", serde_json::to_string_pretty(&workspace.log_entries)?); + } + AgentLogCommand::Clear { socket, workspace } => { + let client = ControlClient::new(resolve_socket_path(socket)); + let model = query_model(&client).await?; + let workspace_id = resolve_workspace_id_from_model(&model, workspace)?; + let response = send_control_command( + &client, + ControlCommand::AgentClearLog { workspace_id }, + ) + .await?; + println!("{}", serde_json::to_string_pretty(&response)?); + } + }, + AgentCommand::Notify { command } => match command { + AgentNotifyCommand::Create { + socket, + workspace, + pane, + surface, + scope, + title, + message, + state, + } => { + let client = ControlClient::new(resolve_socket_path(socket)); + let model = query_model(&client).await?; + let target = resolve_agent_target(&model, workspace, pane, surface, scope)?; + let response = send_control_command( + &client, + ControlCommand::AgentCreateNotification { + target, + title, + message, + state: state.into(), + }, + ) + .await?; + println!("{}", serde_json::to_string_pretty(&response)?); + } + AgentNotifyCommand::List { socket, workspace } => { + let client = ControlClient::new(resolve_socket_path(socket)); + let model = query_model(&client).await?; + let workspace_filter = workspace.or_else(env_workspace_id); + let payload = model + .activity_items() + .into_iter() + .filter(|item| workspace_filter.is_none_or(|workspace_id| item.workspace_id == workspace_id)) + .map(|item| { + serde_json::json!({ + "workspace_id": item.workspace_id, + "workspace_window_id": item.workspace_window_id, + "pane_id": item.pane_id, + "surface_id": item.surface_id, + "kind": format!("{:?}", item.kind).to_lowercase(), + "state": format!("{:?}", item.state).to_lowercase(), + "title": item.title, + "message": item.message, + "created_at": item.created_at, + }) + }) + .collect::>(); + println!("{}", serde_json::to_string_pretty(&payload)?); + } + AgentNotifyCommand::Clear { + socket, + workspace, + pane, + surface, + scope, + } => { + let client = ControlClient::new(resolve_socket_path(socket)); + let model = query_model(&client).await?; + let target = resolve_agent_target(&model, workspace, pane, surface, scope)?; + let response = send_control_command( + &client, + ControlCommand::AgentClearNotifications { target }, + ) + .await?; + println!("{}", serde_json::to_string_pretty(&response)?); + } + }, + AgentCommand::Flash { + socket, + workspace, + pane, + surface, + } => { + let client = ControlClient::new(resolve_socket_path(socket)); + let model = query_model(&client).await?; + let target = resolve_agent_target( + &model, + workspace, + pane, + surface, + CliAgentTargetScope::Surface, + )?; + let AgentTarget::Surface { workspace_id, pane_id, surface_id, - event: SignalEvent { - source: format!("notify:{normalized_title}"), - kind: SignalKind::Notification, - message: Some(message), - metadata, - timestamp: OffsetDateTime::now_utc(), + } = target + else { + bail!("surface flash requires a surface target"); + }; + let response = send_control_command( + &client, + ControlCommand::AgentTriggerFlash { + workspace_id, + pane_id, + surface_id, }, - }) + ) .await?; - println!("{}", serde_json::to_string_pretty(&response)?); - } + println!("{}", serde_json::to_string_pretty(&response)?); + } + AgentCommand::FocusUnread { socket } => { + let client = ControlClient::new(resolve_socket_path(socket)); + let response = send_control_command( + &client, + ControlCommand::AgentFocusLatestUnread { window_id: None }, + ) + .await?; + println!("{}", serde_json::to_string_pretty(&response)?); + } + }, Command::Workspace { command } => match command { WorkspaceCommand::List { socket } => { let client = ControlClient::new(resolve_socket_path(socket)); @@ -1290,6 +1672,54 @@ fn active_surface_for_pane( .ok_or_else(|| anyhow!("pane {pane_id} is not present in workspace {workspace_id}")) } +fn resolve_workspace_id_from_model( + model: &AppModel, + workspace: Option, +) -> anyhow::Result { + workspace + .or_else(env_workspace_id) + .or_else(|| model.active_workspace_id()) + .context("missing workspace id; pass --workspace or run from inside Taskers") +} + +fn resolve_agent_target( + model: &AppModel, + workspace: Option, + pane: Option, + surface: Option, + scope: CliAgentTargetScope, +) -> anyhow::Result { + let workspace_id = resolve_workspace_id_from_model(model, workspace)?; + let workspace_record = model + .workspaces + .get(&workspace_id) + .ok_or_else(|| anyhow!("workspace {workspace_id} not found"))?; + + let resolved_pane = pane + .or_else(env_pane_id) + .unwrap_or(workspace_record.active_pane); + let pane_record = workspace_record + .panes + .get(&resolved_pane) + .ok_or_else(|| anyhow!("pane {resolved_pane} is not present in workspace {workspace_id}"))?; + let resolved_surface = surface + .or_else(env_surface_id) + .unwrap_or(pane_record.active_surface); + + match scope { + CliAgentTargetScope::Workspace => Ok(AgentTarget::Workspace { workspace_id }), + CliAgentTargetScope::Pane => Ok(AgentTarget::Pane { + workspace_id, + pane_id: resolved_pane, + }), + CliAgentTargetScope::Surface => Ok(AgentTarget::Surface { + workspace_id, + pane_id: resolved_pane, + surface_id: resolved_surface, + }), + } +} + async fn create_surface( client: &ControlClient, workspace_id: WorkspaceId, diff --git a/crates/taskers-control/src/controller.rs b/crates/taskers-control/src/controller.rs index 1f348d3..2d69403 100644 --- a/crates/taskers-control/src/controller.rs +++ b/crates/taskers-control/src/controller.rs @@ -434,6 +434,111 @@ impl InMemoryController { true, ) } + ControlCommand::AgentSetStatus { workspace_id, text } => { + model.set_workspace_status(workspace_id, text)?; + ( + ControlResponse::Ack { + message: "workspace agent status updated".into(), + }, + true, + ) + } + ControlCommand::AgentClearStatus { workspace_id } => { + model.clear_workspace_status(workspace_id)?; + ( + ControlResponse::Ack { + message: "workspace agent status cleared".into(), + }, + true, + ) + } + ControlCommand::AgentSetProgress { + workspace_id, + progress, + } => { + model.set_workspace_progress(workspace_id, progress)?; + ( + ControlResponse::Ack { + message: "workspace progress updated".into(), + }, + true, + ) + } + ControlCommand::AgentClearProgress { workspace_id } => { + model.clear_workspace_progress(workspace_id)?; + ( + ControlResponse::Ack { + message: "workspace progress cleared".into(), + }, + true, + ) + } + ControlCommand::AgentAppendLog { + workspace_id, + entry, + } => { + model.append_workspace_log(workspace_id, entry)?; + ( + ControlResponse::Ack { + message: "workspace log appended".into(), + }, + true, + ) + } + ControlCommand::AgentClearLog { workspace_id } => { + model.clear_workspace_log(workspace_id)?; + ( + ControlResponse::Ack { + message: "workspace log cleared".into(), + }, + true, + ) + } + ControlCommand::AgentCreateNotification { + target, + title, + message, + state, + } => { + model.create_agent_notification(target, title, message, state)?; + ( + ControlResponse::Ack { + message: "agent notification created".into(), + }, + true, + ) + } + ControlCommand::AgentClearNotifications { target } => { + model.clear_agent_notifications(target)?; + ( + ControlResponse::Ack { + message: "agent notifications cleared".into(), + }, + true, + ) + } + ControlCommand::AgentTriggerFlash { + workspace_id, + pane_id, + surface_id, + } => { + model.trigger_surface_flash(workspace_id, pane_id, surface_id)?; + ( + ControlResponse::Ack { + message: "surface flash triggered".into(), + }, + true, + ) + } + ControlCommand::AgentFocusLatestUnread { window_id } => { + model.focus_latest_unread(window_id.unwrap_or(model.active_window))?; + ( + ControlResponse::Ack { + message: "focused latest unread activity".into(), + }, + true, + ) + } ControlCommand::QueryStatus { query } => match query { ControlQuery::ActiveWindow | ControlQuery::All => ( ControlResponse::Status { diff --git a/crates/taskers-control/src/protocol.rs b/crates/taskers-control/src/protocol.rs index 7bb1601..e6863d4 100644 --- a/crates/taskers-control/src/protocol.rs +++ b/crates/taskers-control/src/protocol.rs @@ -2,9 +2,10 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use taskers_domain::{ - AppModel, Direction, PaneId, PaneKind, PaneMetadataPatch, PersistedSession, SignalEvent, - SplitAxis, SurfaceId, WindowId, WorkspaceColumnId, WorkspaceId, WorkspaceViewport, - WorkspaceWindowId, WorkspaceWindowMoveTarget, + AgentTarget, AppModel, AttentionState, Direction, PaneId, PaneKind, PaneMetadataPatch, + PersistedSession, ProgressState, SignalEvent, SplitAxis, SurfaceId, WindowId, + WorkspaceColumnId, WorkspaceId, WorkspaceLogEntry, WorkspaceViewport, WorkspaceWindowId, + WorkspaceWindowMoveTarget, }; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -153,6 +154,44 @@ pub enum ControlCommand { surface_id: Option, event: SignalEvent, }, + AgentSetStatus { + workspace_id: WorkspaceId, + text: String, + }, + AgentClearStatus { + workspace_id: WorkspaceId, + }, + AgentSetProgress { + workspace_id: WorkspaceId, + progress: ProgressState, + }, + AgentClearProgress { + workspace_id: WorkspaceId, + }, + AgentAppendLog { + workspace_id: WorkspaceId, + entry: WorkspaceLogEntry, + }, + AgentClearLog { + workspace_id: WorkspaceId, + }, + AgentCreateNotification { + target: AgentTarget, + title: Option, + message: String, + state: AttentionState, + }, + AgentClearNotifications { + target: AgentTarget, + }, + AgentTriggerFlash { + workspace_id: WorkspaceId, + pane_id: PaneId, + surface_id: SurfaceId, + }, + AgentFocusLatestUnread { + window_id: Option, + }, QueryStatus { query: ControlQuery, }, diff --git a/crates/taskers-domain/src/lib.rs b/crates/taskers-domain/src/lib.rs index 84ebea7..c8398e1 100644 --- a/crates/taskers-domain/src/lib.rs +++ b/crates/taskers-domain/src/lib.rs @@ -10,12 +10,13 @@ pub use ids::{ }; pub use layout::{Direction, LayoutNode, SplitAxis}; pub use model::{ - ActivityItem, AppModel, DEFAULT_WORKSPACE_WINDOW_GAP, DEFAULT_WORKSPACE_WINDOW_HEIGHT, - DEFAULT_WORKSPACE_WINDOW_WIDTH, DomainError, KEYBOARD_RESIZE_STEP, MIN_WORKSPACE_WINDOW_HEIGHT, - MIN_WORKSPACE_WINDOW_WIDTH, NotificationItem, PaneKind, PaneMetadata, PaneMetadataPatch, - PaneRecord, PersistedSession, PrStatus, ProgressState, PullRequestState, - SESSION_SCHEMA_VERSION, SurfaceRecord, WindowFrame, WindowRecord, Workspace, - WorkspaceAgentState, WorkspaceAgentSummary, WorkspaceColumnRecord, WorkspaceSummary, + ActivityItem, AgentTarget, AppModel, DEFAULT_WORKSPACE_WINDOW_GAP, + DEFAULT_WORKSPACE_WINDOW_HEIGHT, DEFAULT_WORKSPACE_WINDOW_WIDTH, DomainError, + KEYBOARD_RESIZE_STEP, MIN_WORKSPACE_WINDOW_HEIGHT, MIN_WORKSPACE_WINDOW_WIDTH, + NotificationItem, PaneKind, PaneMetadata, PaneMetadataPatch, PaneRecord, + PersistedSession, PrStatus, ProgressState, PullRequestState, SESSION_SCHEMA_VERSION, + SurfaceRecord, WindowFrame, WindowRecord, Workspace, WorkspaceAgentState, + WorkspaceAgentSummary, WorkspaceColumnRecord, WorkspaceLogEntry, WorkspaceSummary, WorkspaceViewport, WorkspaceWindowMoveTarget, WorkspaceWindowRecord, }; pub use signal::{SignalEvent, SignalKind, SignalPaneMetadata}; diff --git a/crates/taskers-domain/src/model.rs b/crates/taskers-domain/src/model.rs index d460a8d..70a1a03 100644 --- a/crates/taskers-domain/src/model.rs +++ b/crates/taskers-domain/src/model.rs @@ -17,6 +17,7 @@ pub const DEFAULT_WORKSPACE_WINDOW_GAP: i32 = 10; pub const MIN_WORKSPACE_WINDOW_WIDTH: i32 = 720; pub const MIN_WORKSPACE_WINDOW_HEIGHT: i32 = 420; pub const KEYBOARD_RESIZE_STEP: i32 = 80; +const WORKSPACE_LOG_RETENTION: usize = 200; fn split_top_level_extent(extent: i32, min_extent: i32) -> (i32, i32) { let extent = extent.max(min_extent); @@ -347,6 +348,14 @@ pub struct NotificationItem { pub cleared_at: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceLogEntry { + #[serde(default)] + pub source: Option, + pub message: String, + pub created_at: OffsetDateTime, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ActivityItem { pub workspace_id: WorkspaceId, @@ -397,6 +406,23 @@ pub struct WorkspaceAgentSummary { pub last_signal_at: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "scope", rename_all = "snake_case")] +pub enum AgentTarget { + Workspace { + workspace_id: WorkspaceId, + }, + Pane { + workspace_id: WorkspaceId, + pane_id: PaneId, + }, + Surface { + workspace_id: WorkspaceId, + pane_id: PaneId, + surface_id: SurfaceId, + }, +} + fn default_notification_kind() -> SignalKind { SignalKind::Notification } @@ -558,6 +584,16 @@ pub struct Workspace { pub viewport: WorkspaceViewport, pub notifications: Vec, #[serde(default)] + pub status_text: Option, + #[serde(default)] + pub progress: Option, + #[serde(default)] + pub log_entries: Vec, + #[serde(default)] + pub surface_flash_tokens: BTreeMap, + #[serde(default)] + pub next_flash_token: u64, + #[serde(default)] pub custom_color: Option, } @@ -594,6 +630,11 @@ impl Workspace { active_pane, viewport: WorkspaceViewport::default(), notifications: Vec::new(), + status_text: None, + progress: None, + log_entries: Vec::new(), + surface_flash_tokens: BTreeMap::new(), + next_flash_token: 0, custom_color: None, } } @@ -699,6 +740,145 @@ impl Workspace { } } + fn active_surface_for_pane(&self, pane_id: PaneId) -> Option { + self.panes.get(&pane_id).map(|pane| pane.active_surface) + } + + fn notification_target_ids( + &self, + target: &AgentTarget, + ) -> Result<(WorkspaceId, PaneId, SurfaceId), DomainError> { + match *target { + AgentTarget::Workspace { workspace_id } => { + if workspace_id != self.id { + return Err(DomainError::MissingWorkspace(workspace_id)); + } + let pane_id = self.active_pane; + let surface_id = self + .active_surface_for_pane(pane_id) + .ok_or(DomainError::PaneNotInWorkspace { + workspace_id, + pane_id, + })?; + Ok((workspace_id, pane_id, surface_id)) + } + AgentTarget::Pane { + workspace_id, + pane_id, + } => { + if workspace_id != self.id { + return Err(DomainError::MissingWorkspace(workspace_id)); + } + let surface_id = self + .active_surface_for_pane(pane_id) + .ok_or(DomainError::PaneNotInWorkspace { + workspace_id, + pane_id, + })?; + Ok((workspace_id, pane_id, surface_id)) + } + AgentTarget::Surface { + workspace_id, + pane_id, + surface_id, + } => { + if workspace_id != self.id { + return Err(DomainError::MissingWorkspace(workspace_id)); + } + if !self + .panes + .get(&pane_id) + .is_some_and(|pane| pane.surfaces.contains_key(&surface_id)) + { + return Err(DomainError::SurfaceNotInPane { + workspace_id, + pane_id, + surface_id, + }); + } + Ok((workspace_id, pane_id, surface_id)) + } + } + } + + fn clear_notifications_matching(&mut self, target: &AgentTarget) -> Result<(), DomainError> { + let now = OffsetDateTime::now_utc(); + match *target { + AgentTarget::Workspace { workspace_id } => { + if workspace_id != self.id { + return Err(DomainError::MissingWorkspace(workspace_id)); + } + for notification in &mut self.notifications { + if notification.cleared_at.is_none() { + notification.cleared_at = Some(now); + } + } + } + AgentTarget::Pane { + workspace_id, + pane_id, + } => { + if workspace_id != self.id { + return Err(DomainError::MissingWorkspace(workspace_id)); + } + if !self.panes.contains_key(&pane_id) { + return Err(DomainError::PaneNotInWorkspace { + workspace_id, + pane_id, + }); + } + for notification in &mut self.notifications { + if notification.pane_id == pane_id && notification.cleared_at.is_none() { + notification.cleared_at = Some(now); + } + } + } + AgentTarget::Surface { + workspace_id, + pane_id, + surface_id, + } => { + if workspace_id != self.id { + return Err(DomainError::MissingWorkspace(workspace_id)); + } + if !self + .panes + .get(&pane_id) + .is_some_and(|pane| pane.surfaces.contains_key(&surface_id)) + { + return Err(DomainError::SurfaceNotInPane { + workspace_id, + pane_id, + surface_id, + }); + } + for notification in &mut self.notifications { + if notification.pane_id == pane_id + && notification.surface_id == surface_id + && notification.cleared_at.is_none() + { + notification.cleared_at = Some(now); + } + } + } + } + Ok(()) + } + + fn append_log_entry(&mut self, entry: WorkspaceLogEntry) { + self.log_entries.push(entry); + let overflow = self.log_entries.len().saturating_sub(WORKSPACE_LOG_RETENTION); + if overflow > 0 { + self.log_entries.drain(0..overflow); + } + } + + fn trigger_surface_flash(&mut self, surface_id: SurfaceId) { + self.next_flash_token = self.next_flash_token.saturating_add(1); + self.surface_flash_tokens + .insert(surface_id, self.next_flash_token); + } + fn top_level_neighbor( &self, source_window_id: WorkspaceWindowId, @@ -971,6 +1151,7 @@ pub struct WorkspaceSummary { pub display_attention: AttentionState, pub unread_count: usize, pub latest_notification: Option, + pub status_text: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -1846,6 +2027,185 @@ impl AppModel { Ok(()) } + pub fn set_workspace_status( + &mut self, + workspace_id: WorkspaceId, + text: String, + ) -> Result<(), DomainError> { + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + let normalized = text.trim(); + workspace.status_text = (!normalized.is_empty()).then(|| normalized.to_owned()); + Ok(()) + } + + pub fn clear_workspace_status(&mut self, workspace_id: WorkspaceId) -> Result<(), DomainError> { + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + workspace.status_text = None; + Ok(()) + } + + pub fn set_workspace_progress( + &mut self, + workspace_id: WorkspaceId, + progress: ProgressState, + ) -> Result<(), DomainError> { + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + workspace.progress = Some(ProgressState { + value: progress.value.min(1000), + label: progress.label, + }); + Ok(()) + } + + pub fn clear_workspace_progress( + &mut self, + workspace_id: WorkspaceId, + ) -> Result<(), DomainError> { + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + workspace.progress = None; + Ok(()) + } + + pub fn append_workspace_log( + &mut self, + workspace_id: WorkspaceId, + entry: WorkspaceLogEntry, + ) -> Result<(), DomainError> { + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + workspace.append_log_entry(entry); + Ok(()) + } + + pub fn clear_workspace_log(&mut self, workspace_id: WorkspaceId) -> Result<(), DomainError> { + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + workspace.log_entries.clear(); + Ok(()) + } + + pub fn create_agent_notification( + &mut self, + target: AgentTarget, + title: Option, + message: String, + state: AttentionState, + ) -> Result<(), DomainError> { + let workspace_id = match target { + AgentTarget::Workspace { workspace_id } + | AgentTarget::Pane { workspace_id, .. } + | AgentTarget::Surface { workspace_id, .. } => workspace_id, + }; + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + let (_, pane_id, surface_id) = workspace.notification_target_ids(&target)?; + let now = OffsetDateTime::now_utc(); + + if let Some(pane) = workspace.panes.get_mut(&pane_id) + && let Some(surface) = pane.surfaces.get_mut(&surface_id) + { + surface.attention = state; + } + + workspace.notifications.push(NotificationItem { + pane_id, + surface_id, + kind: SignalKind::Notification, + state, + title: title.map(|value| value.trim().to_owned()).filter(|value| !value.is_empty()), + message, + created_at: now, + cleared_at: None, + }); + Ok(()) + } + + pub fn clear_agent_notifications(&mut self, target: AgentTarget) -> Result<(), DomainError> { + let workspace_id = match target { + AgentTarget::Workspace { workspace_id } + | AgentTarget::Pane { workspace_id, .. } + | AgentTarget::Surface { workspace_id, .. } => workspace_id, + }; + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + workspace.clear_notifications_matching(&target) + } + + pub fn trigger_surface_flash( + &mut self, + workspace_id: WorkspaceId, + pane_id: PaneId, + surface_id: SurfaceId, + ) -> Result<(), DomainError> { + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + if !workspace + .panes + .get(&pane_id) + .is_some_and(|pane| pane.surfaces.contains_key(&surface_id)) + { + return Err(DomainError::SurfaceNotInPane { + workspace_id, + pane_id, + surface_id, + }); + } + workspace.trigger_surface_flash(surface_id); + Ok(()) + } + + pub fn focus_latest_unread(&mut self, window_id: WindowId) -> Result { + let window = self + .windows + .get(&window_id) + .ok_or(DomainError::MissingWindow(window_id))?; + let target = window + .workspace_order + .iter() + .filter_map(|workspace_id| self.workspaces.get(workspace_id)) + .flat_map(|workspace| { + workspace + .notifications + .iter() + .filter(|notification| notification.cleared_at.is_none()) + .map(move |notification| (workspace.id, notification)) + }) + .max_by_key(|(_, notification)| notification.created_at) + .map(|(workspace_id, notification)| { + (workspace_id, notification.pane_id, notification.surface_id) + }); + + let Some((workspace_id, pane_id, surface_id)) = target else { + return Ok(false); + }; + + self.switch_workspace(window_id, workspace_id)?; + self.focus_surface(workspace_id, pane_id, surface_id)?; + Ok(true) + } + pub fn close_surface( &mut self, workspace_id: WorkspaceId, @@ -2421,6 +2781,7 @@ impl AppModel { display_attention: unread_attention.unwrap_or(highest_attention), unread_count: unread.len(), latest_notification, + status_text: workspace.status_text.clone(), } }) .collect(); @@ -2478,6 +2839,16 @@ struct CurrentWorkspaceSerde { #[serde(default)] notifications: Vec, #[serde(default)] + status_text: Option, + #[serde(default)] + progress: Option, + #[serde(default)] + log_entries: Vec, + #[serde(default)] + surface_flash_tokens: BTreeMap, + #[serde(default)] + next_flash_token: u64, + #[serde(default)] custom_color: Option, } @@ -2493,6 +2864,11 @@ impl CurrentWorkspaceSerde { active_pane: self.active_pane, viewport: self.viewport, notifications: self.notifications, + status_text: self.status_text, + progress: self.progress, + log_entries: self.log_entries, + surface_flash_tokens: self.surface_flash_tokens, + next_flash_token: self.next_flash_token, custom_color: self.custom_color, }; workspace.normalize(); From eec3bd8c464fc30a983ddf4f3139a24c10ed7253 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sat, 21 Mar 2026 12:59:29 +0100 Subject: [PATCH 02/63] feat: surface agent ops in shell --- crates/taskers-cli/src/main.rs | 97 +++++++++-- crates/taskers-domain/src/model.rs | 123 +++++++++++++ crates/taskers-shell-core/src/lib.rs | 250 ++++++++++++++++++++++++--- crates/taskers-shell/src/lib.rs | 53 +++++- crates/taskers-shell/src/theme.rs | 68 ++++++++ 5 files changed, 555 insertions(+), 36 deletions(-) diff --git a/crates/taskers-cli/src/main.rs b/crates/taskers-cli/src/main.rs index 686c7f8..b6dcae0 100644 --- a/crates/taskers-cli/src/main.rs +++ b/crates/taskers-cli/src/main.rs @@ -809,7 +809,7 @@ async fn main() -> anyhow::Result<()> { surface, title, body, - agent, + agent: _agent, } => { let client = ControlClient::new(resolve_socket_path(socket)); let model = query_model(&client).await?; @@ -827,15 +827,10 @@ async fn main() -> anyhow::Result<()> { .filter(|value| !value.is_empty()) .map(str::to_owned); let message = normalized_body.unwrap_or_else(|| normalized_title.to_string()); - let title = if agent.is_some() { - Some(normalized_title.to_string()) - } else { - Some(normalized_title.to_string()) - }; let response = client .send(ControlCommand::AgentCreateNotification { target, - title, + title: Some(normalized_title.to_string()), message, state: AttentionState::WaitingInput, }) @@ -1802,9 +1797,17 @@ async fn emit_agent_hook( | CliSignalKind::Notification )), }); - - let response = client - .send(ControlCommand::EmitSignal { + let normalized_message = message + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned); + let status_text = normalized_message + .clone() + .unwrap_or_else(|| normalized_title.clone()); + let signal_response = send_control_command( + &client, + ControlCommand::EmitSignal { workspace_id, pane_id, surface_id, @@ -1815,9 +1818,79 @@ async fn emit_agent_hook( metadata, timestamp: OffsetDateTime::now_utc(), }, - }) + }, + ) + .await?; + + if let Some(log_message) = normalized_message.clone() { + let _ = send_control_command( + &client, + ControlCommand::AgentAppendLog { + workspace_id, + entry: WorkspaceLogEntry { + source: Some(normalized_agent.clone()), + message: log_message, + created_at: OffsetDateTime::now_utc(), + }, + }, + ) .await?; - println!("{}", serde_json::to_string_pretty(&response)?); + } + + match kind { + CliSignalKind::Started + | CliSignalKind::Progress + | CliSignalKind::WaitingInput + | CliSignalKind::Notification => { + let _ = send_control_command( + &client, + ControlCommand::AgentSetStatus { + workspace_id, + text: status_text, + }, + ) + .await?; + } + CliSignalKind::Completed => { + let _ = send_control_command( + &client, + ControlCommand::AgentClearStatus { workspace_id }, + ) + .await?; + let _ = send_control_command( + &client, + ControlCommand::AgentClearProgress { workspace_id }, + ) + .await?; + } + CliSignalKind::Metadata | CliSignalKind::Error => {} + } + + if matches!( + kind, + CliSignalKind::WaitingInput | CliSignalKind::Notification | CliSignalKind::Error + ) { + let flash_surface_id = match surface_id.or_else(env_surface_id) { + Some(surface_id) => Some(surface_id), + None => { + let model = query_model(&client).await?; + Some(active_surface_for_pane(&model, workspace_id, pane_id)?) + } + }; + if let Some(surface_id) = flash_surface_id { + let _ = send_control_command( + &client, + ControlCommand::AgentTriggerFlash { + workspace_id, + pane_id, + surface_id, + }, + ) + .await?; + } + } + + println!("{}", serde_json::to_string_pretty(&signal_response)?); Ok(()) } diff --git a/crates/taskers-domain/src/model.rs b/crates/taskers-domain/src/model.rs index 70a1a03..f4d09fd 100644 --- a/crates/taskers-domain/src/model.rs +++ b/crates/taskers-domain/src/model.rs @@ -4227,4 +4227,127 @@ mod tests { assert_eq!(model.active_workspace_id(), Some(workspace_id)); assert_ne!(workspace_id, other_workspace_id); } + + #[test] + fn workspace_agent_state_flows_into_summary_and_logs_are_bounded() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + + model + .set_workspace_status(workspace_id, "Running import".into()) + .expect("set status"); + model + .set_workspace_progress( + workspace_id, + ProgressState { + value: 420, + label: Some("42%".into()), + }, + ) + .expect("set progress"); + + for index in 0..205 { + model + .append_workspace_log( + workspace_id, + WorkspaceLogEntry { + source: Some("codex".into()), + message: format!("log {index}"), + created_at: OffsetDateTime::now_utc(), + }, + ) + .expect("append log"); + } + + let summary = model + .workspace_summaries(model.active_window) + .expect("workspace summaries") + .into_iter() + .find(|summary| summary.workspace_id == workspace_id) + .expect("workspace summary"); + let workspace = model.workspaces.get(&workspace_id).expect("workspace"); + + assert_eq!(summary.status_text.as_deref(), Some("Running import")); + assert_eq!(workspace.progress.as_ref().map(|progress| progress.value), Some(420)); + assert_eq!(workspace.log_entries.len(), 200); + assert_eq!(workspace.log_entries.first().map(|entry| entry.message.as_str()), Some("log 5")); + assert_eq!(workspace.log_entries.last().map(|entry| entry.message.as_str()), Some("log 204")); + } + + #[test] + fn focusing_latest_unread_prefers_newest_notification_in_active_window() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let pane_id = model.active_workspace().expect("workspace").active_pane; + let surface_id = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&pane_id)) + .map(|pane| pane.active_surface) + .expect("surface"); + let second_workspace_id = model.create_workspace("Secondary"); + + model + .create_agent_notification( + AgentTarget::Surface { + workspace_id, + pane_id, + surface_id, + }, + Some("Older".into()), + "First".into(), + AttentionState::WaitingInput, + ) + .expect("older notification"); + std::thread::sleep(std::time::Duration::from_millis(2)); + model + .create_agent_notification( + AgentTarget::Workspace { + workspace_id: second_workspace_id, + }, + Some("Newest".into()), + "Second".into(), + AttentionState::WaitingInput, + ) + .expect("newer notification"); + + let focused = model + .focus_latest_unread(model.active_window) + .expect("focus latest unread"); + + assert!(focused); + assert_eq!(model.active_workspace_id(), Some(second_workspace_id)); + } + + #[test] + fn triggering_surface_flash_advances_workspace_flash_token() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let pane_id = model.active_workspace().expect("workspace").active_pane; + let surface_id = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&pane_id)) + .map(|pane| pane.active_surface) + .expect("surface"); + + model + .trigger_surface_flash(workspace_id, pane_id, surface_id) + .expect("trigger first flash"); + let first_token = model + .workspaces + .get(&workspace_id) + .and_then(|workspace| workspace.surface_flash_tokens.get(&surface_id)) + .copied() + .expect("first flash token"); + model + .trigger_surface_flash(workspace_id, pane_id, surface_id) + .expect("trigger second flash"); + let second_token = model + .workspaces + .get(&workspace_id) + .and_then(|workspace| workspace.surface_flash_tokens.get(&surface_id)) + .copied() + .expect("second flash token"); + + assert!(second_token > first_token); + } } diff --git a/crates/taskers-shell-core/src/lib.rs b/crates/taskers-shell-core/src/lib.rs index 7f4977d..6bd103d 100644 --- a/crates/taskers-shell-core/src/lib.rs +++ b/crates/taskers-shell-core/src/lib.rs @@ -191,6 +191,7 @@ impl ShortcutPreset { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ShortcutAction { ToggleOverview, + FocusLatestUnread, CloseTerminal, OpenBrowserSplit, FocusBrowserAddress, @@ -221,8 +222,9 @@ pub enum ShortcutAction { } impl ShortcutAction { - pub const ALL: [Self; 28] = [ + pub const ALL: [Self; 29] = [ Self::ToggleOverview, + Self::FocusLatestUnread, Self::CloseTerminal, Self::OpenBrowserSplit, Self::FocusBrowserAddress, @@ -255,6 +257,7 @@ impl ShortcutAction { pub fn id(self) -> &'static str { match self { Self::ToggleOverview => "toggle_overview", + Self::FocusLatestUnread => "focus_latest_unread", Self::CloseTerminal => "close_terminal", Self::OpenBrowserSplit => "open_browser_split", Self::FocusBrowserAddress => "focus_browser_address", @@ -288,6 +291,7 @@ impl ShortcutAction { pub fn label(self) -> &'static str { match self { Self::ToggleOverview => "Toggle overview", + Self::FocusLatestUnread => "Jump to latest unread", Self::CloseTerminal => "Close terminal", Self::OpenBrowserSplit => "Open browser in split", Self::FocusBrowserAddress => "Focus browser address bar", @@ -321,6 +325,9 @@ impl ShortcutAction { pub fn detail(self) -> &'static str { match self { Self::ToggleOverview => "Zoom the current workspace out to fit the full column strip.", + Self::FocusLatestUnread => { + "Focus the most recent unread attention item in the current app window." + } Self::CloseTerminal => "Close the active pane.", Self::OpenBrowserSplit => { "Split the active pane to the right and open a browser surface." @@ -365,7 +372,7 @@ impl ShortcutAction { pub fn category(self) -> &'static str { match self { - Self::ToggleOverview | Self::CloseTerminal => "General", + Self::ToggleOverview | Self::FocusLatestUnread | Self::CloseTerminal => "General", Self::OpenBrowserSplit | Self::FocusBrowserAddress | Self::ReloadBrowserPage @@ -395,6 +402,7 @@ impl ShortcutAction { match preset { ShortcutPreset::Balanced => match self { Self::ToggleOverview => &["o"], + Self::FocusLatestUnread => &["u"], Self::CloseTerminal => &["x"], Self::OpenBrowserSplit => &["l"], Self::FocusBrowserAddress => &["l"], @@ -425,6 +433,7 @@ impl ShortcutAction { }, ShortcutPreset::PowerUser => match self { Self::ToggleOverview => &["o"], + Self::FocusLatestUnread => &["u"], Self::CloseTerminal => &["x"], Self::OpenBrowserSplit => &["l"], Self::FocusBrowserAddress => &["l"], @@ -696,6 +705,7 @@ pub struct WorkspaceSummary { pub unread_activity: usize, pub attention: AttentionState, pub notification_text: Option, + pub status_text: Option, pub git_branch: Option, pub working_directory: Option, pub listening_ports: Vec, @@ -710,6 +720,13 @@ pub struct ProgressSnapshot { pub label: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceLogEntrySnapshot { + pub source: Option, + pub message: String, + pub timestamp: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct PullRequestSnapshot { pub number: u32, @@ -892,6 +909,9 @@ pub struct ShellSnapshot { pub agents: Vec, pub activity: Vec, pub done_activity: Vec, + pub current_workspace_status: Option, + pub current_workspace_progress: Option, + pub current_workspace_log: Vec, pub portal: SurfacePortalPlan, pub metrics: LayoutMetrics, pub runtime_status: RuntimeStatus, @@ -1012,6 +1032,7 @@ pub enum ShellAction { surface_id: SurfaceId, url: String, }, + FocusLatestUnread, BrowserBack { surface_id: SurfaceId, }, @@ -1133,6 +1154,18 @@ impl TaskersCore { .workspaces .get(&workspace_id) .expect("active workspace should exist"); + let current_workspace_progress = workspace_progress_snapshot(workspace); + let current_workspace_log = workspace + .log_entries + .iter() + .rev() + .take(12) + .map(|entry| WorkspaceLogEntrySnapshot { + source: entry.source.clone(), + message: entry.message.clone(), + timestamp: format_relative_time(entry.created_at), + }) + .collect::>(); let active_window = workspace .active_window_record() .expect("active workspace window should exist"); @@ -1207,6 +1240,9 @@ impl TaskersCore { agents, activity, done_activity, + current_workspace_status: workspace.status_text.clone(), + current_workspace_progress, + current_workspace_log, portal: SurfacePortalPlan { window: Frame::new(0, 0, self.ui.window_size.width, self.ui.window_size.height), content: viewport, @@ -1299,24 +1335,12 @@ impl TaskersCore { unread_activity: summary.unread_count, attention: summary.display_attention.into(), notification_text: summary.latest_notification, + status_text: summary.status_text, git_branch, working_directory, listening_ports, custom_color: workspace.and_then(|ws| ws.custom_color.clone()), - progress: workspace - .into_iter() - .flat_map(|ws| ws.panes.values()) - .flat_map(|pane| pane.surfaces.values()) - .find_map(|surface| { - surface - .metadata - .progress - .as_ref() - .map(|p| ProgressSnapshot { - fraction: f32::from(p.value.min(1000)) / 1000.0, - label: p.label.clone(), - }) - }), + progress: workspace.and_then(workspace_progress_snapshot), pull_requests: workspace .into_iter() .flat_map(|ws| ws.panes.values()) @@ -1497,11 +1521,19 @@ impl TaskersCore { ) -> PaneSnapshot { let is_active = workspace.active_pane == pane.id; let has_unread = pane.highest_attention() != taskers_domain::AttentionState::Normal; - let flash_token = if is_active && has_unread { + let explicit_flash_token = pane + .surfaces + .values() + .filter_map(|surface| workspace.surface_flash_tokens.get(&surface.id)) + .copied() + .max() + .unwrap_or(0); + let focus_flash_token = if is_active && has_unread { self.revision } else { 0 }; + let flash_token = focus_flash_token.max(explicit_flash_token); PaneSnapshot { id: pane.id, active: is_active, @@ -1792,6 +1824,9 @@ impl TaskersCore { ShellAction::NavigateBrowser { surface_id, url } => { self.navigate_browser_surface(surface_id, &url) } + ShellAction::FocusLatestUnread => { + self.dispatch_control(ControlCommand::AgentFocusLatestUnread { window_id: None }) + } ShellAction::BrowserBack { surface_id } => { self.queue_host_command(HostCommand::BrowserBack { surface_id }) } @@ -1836,6 +1871,9 @@ impl TaskersCore { ShortcutAction::ToggleOverview => { self.dispatch_shell_action(ShellAction::ToggleOverview) } + ShortcutAction::FocusLatestUnread => { + self.dispatch_shell_action(ShellAction::FocusLatestUnread) + } ShortcutAction::CloseTerminal => self.run_workspace_shortcut(|core, workspace_id| { let pane_id = core .app_state @@ -3424,19 +3462,41 @@ fn next_workspace_label(model: &AppModel) -> String { format!("Workspace {}", model.workspaces.len() + 1) } +fn workspace_progress_snapshot(workspace: &Workspace) -> Option { + workspace.progress.as_ref().map(|progress| ProgressSnapshot { + fraction: f32::from(progress.value.min(1000)) / 1000.0, + label: progress.label.clone(), + }).or_else(|| { + workspace + .panes + .values() + .flat_map(|pane| pane.surfaces.values()) + .find_map(|surface| { + surface.metadata.progress.as_ref().map(|progress| ProgressSnapshot { + fraction: f32::from(progress.value.min(1000)) / 1000.0, + label: progress.label.clone(), + }) + }) + }) +} + fn attention_panel_visible(model: &AppModel) -> bool { model .workspace_summaries(model.active_window) .map(|summaries| { summaries .iter() - .any(|summary| !summary.agent_summaries.is_empty()) + .any(|summary| !summary.agent_summaries.is_empty() || summary.status_text.is_some()) }) .unwrap_or(false) || model - .workspaces - .values() - .any(|workspace| !workspace.notifications.is_empty()) + .workspaces + .values() + .any(|workspace| { + !workspace.notifications.is_empty() + || !workspace.log_entries.is_empty() + || workspace.progress.is_some() + }) } fn fallback_surface_descriptor(surface: &SurfaceRecord) -> SurfaceDescriptor { @@ -4234,4 +4294,152 @@ mod tests { ); assert_eq!(snapshot.current_workspace.active_pane, moved_pane_id); } + + #[test] + fn snapshot_exposes_workspace_agent_status_progress_and_log() { + let app_state = default_preview_app_state(); + let workspace_id = app_state + .snapshot_model() + .active_workspace_id() + .expect("workspace"); + let _ = app_state + .dispatch(ControlCommand::AgentSetStatus { + workspace_id, + text: "Running agent sync".into(), + }) + .expect("set status"); + let _ = app_state + .dispatch(ControlCommand::AgentSetProgress { + workspace_id, + progress: taskers_domain::ProgressState { + value: 650, + label: Some("65%".into()), + }, + }) + .expect("set progress"); + let _ = app_state + .dispatch(ControlCommand::AgentAppendLog { + workspace_id, + entry: taskers_domain::WorkspaceLogEntry { + source: Some("codex".into()), + message: "Applied patch".into(), + created_at: OffsetDateTime::now_utc(), + }, + }) + .expect("append log"); + + let core = SharedCore::bootstrap(BootstrapModel { + app_state, + ..bootstrap() + }); + let snapshot = core.snapshot(); + + assert_eq!( + snapshot.current_workspace_status.as_deref(), + Some("Running agent sync") + ); + assert_eq!( + snapshot + .current_workspace_progress + .as_ref() + .map(|progress| progress.label.as_deref()), + Some(Some("65%")) + ); + assert_eq!(snapshot.current_workspace_log.len(), 1); + assert_eq!( + snapshot.current_workspace_log[0].source.as_deref(), + Some("codex") + ); + } + + #[test] + fn agent_focus_latest_unread_command_switches_to_newest_workspace() { + let app_state = default_preview_app_state(); + let core = SharedCore::bootstrap(BootstrapModel { + app_state: app_state.clone(), + ..bootstrap() + }); + core.dispatch_shell_action(ShellAction::CreateWorkspace); + let second_workspace_id = core.snapshot().current_workspace.id; + let second_pane_id = core.snapshot().current_workspace.active_pane; + let second_surface_id = find_pane(&core.snapshot().current_workspace.layout, second_pane_id) + .map(|pane| pane.active_surface) + .expect("second surface"); + let first_workspace_id = core + .snapshot() + .workspaces + .iter() + .find(|workspace| workspace.id != second_workspace_id) + .map(|workspace| workspace.id) + .expect("first workspace"); + + core.dispatch_shell_action(ShellAction::FocusWorkspace { + workspace_id: first_workspace_id, + }); + let first_pane_id = core.snapshot().current_workspace.active_pane; + let first_surface_id = find_pane(&core.snapshot().current_workspace.layout, first_pane_id) + .map(|pane| pane.active_surface) + .expect("first surface"); + + let _ = app_state + .dispatch(ControlCommand::AgentCreateNotification { + target: taskers_domain::AgentTarget::Surface { + workspace_id: first_workspace_id, + pane_id: first_pane_id, + surface_id: first_surface_id, + }, + title: Some("Older".into()), + message: "Older".into(), + state: DomainAttentionState::WaitingInput, + }) + .expect("create older notification"); + std::thread::sleep(std::time::Duration::from_millis(2)); + let _ = app_state + .dispatch(ControlCommand::AgentCreateNotification { + target: taskers_domain::AgentTarget::Surface { + workspace_id: second_workspace_id, + pane_id: second_pane_id, + surface_id: second_surface_id, + }, + title: Some("Newest".into()), + message: "Newest".into(), + state: DomainAttentionState::WaitingInput, + }) + .expect("create newest notification"); + core.sync_external_changes(); + + core.dispatch_shortcut_action(super::ShortcutAction::FocusLatestUnread); + + assert_eq!(core.snapshot().current_workspace.id, second_workspace_id); + } + + #[test] + fn surface_flash_command_updates_pane_flash_token() { + let app_state = default_preview_app_state(); + let snapshot_model = app_state.snapshot_model(); + let workspace_id = snapshot_model.active_workspace_id().expect("workspace"); + let pane_id = snapshot_model + .active_workspace() + .expect("workspace") + .active_pane; + let surface_id = snapshot_model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&pane_id)) + .map(|pane| pane.active_surface) + .expect("surface"); + let _ = app_state + .dispatch(ControlCommand::AgentTriggerFlash { + workspace_id, + pane_id, + surface_id, + }) + .expect("trigger flash"); + let core = SharedCore::bootstrap(BootstrapModel { + app_state, + ..bootstrap() + }); + let snapshot = core.snapshot(); + let pane = find_pane(&snapshot.current_workspace.layout, pane_id).expect("pane"); + assert!(pane.focus_flash_token > 0); + } } diff --git a/crates/taskers-shell/src/lib.rs b/crates/taskers-shell/src/lib.rs index 3264e3b..653a5df 100644 --- a/crates/taskers-shell/src/lib.rs +++ b/crates/taskers-shell/src/lib.rs @@ -6,8 +6,9 @@ use taskers_core::{ ActivityItemSnapshot, AgentSessionSnapshot, AttentionState, BrowserChromeSnapshot, Direction, LayoutNodeSnapshot, PaneId, PaneSnapshot, ProgressSnapshot, PullRequestSnapshot, RuntimeStatus, SettingsSnapshot, SharedCore, ShellAction, ShellSection, ShellSnapshot, ShortcutAction, - ShortcutBindingSnapshot, SplitAxis, SurfaceId, SurfaceKind, SurfaceSnapshot, WorkspaceId, - WorkspaceSummary, WorkspaceViewSnapshot, WorkspaceWindowMoveTarget, WorkspaceWindowSnapshot, + ShortcutBindingSnapshot, SplitAxis, SurfaceId, SurfaceKind, SurfaceSnapshot, + WorkspaceId, WorkspaceLogEntrySnapshot, WorkspaceSummary, WorkspaceViewSnapshot, + WorkspaceWindowMoveTarget, WorkspaceWindowSnapshot, }; #[derive(Clone, Copy, PartialEq, Eq)] @@ -192,6 +193,10 @@ pub fn TaskersShell(core: SharedCore) -> Element { core.dispatch_shell_action(ShellAction::ShowSection { section }); } }; + let jump_unread = { + let core = core.clone(); + move |_| core.dispatch_shell_action(ShellAction::FocusLatestUnread) + }; let drag_source = use_signal(|| None::); let drag_target = use_signal(|| None::); let surface_drag_source = use_signal(|| None::); @@ -302,9 +307,25 @@ pub fn TaskersShell(core: SharedCore) -> Element { span { class: "notification-count-pill notification-count-unread", "{snapshot.activity.len()} unread" } + button { + class: "notification-jump-button", + onclick: jump_unread, + "Jump unread" + } } } } + if snapshot.current_workspace_status.is_some() + || snapshot.current_workspace_progress.is_some() + { + section { class: "attention-section attention-status", + div { class: "attention-section-title", "Workspace status" } + if let Some(status) = &snapshot.current_workspace_status { + div { class: "attention-status-text", "{status}" } + } + {render_workspace_progress(&snapshot.current_workspace_progress)} + } + } if !snapshot.agents.is_empty() { div { class: "agent-session-list", for agent in &snapshot.agents { @@ -326,6 +347,16 @@ pub fn TaskersShell(core: SharedCore) -> Element { } } } + if !snapshot.current_workspace_log.is_empty() { + section { class: "attention-section attention-log", + div { class: "attention-section-title", "Workspace log" } + div { class: "workspace-log-list", + for entry in &snapshot.current_workspace_log { + {render_workspace_log_entry(entry)} + } + } + } + } } } } @@ -506,7 +537,9 @@ fn render_workspace_item( } } } - if let Some(notification) = &workspace.notification_text { + if let Some(status) = &workspace.status_text { + div { class: "workspace-notification workspace-status", "{status}" } + } else if let Some(notification) = &workspace.notification_text { div { class: "workspace-notification", "{notification}" } } if let Some(branch) = &branch_row { @@ -555,6 +588,20 @@ fn render_workspace_pull_requests(pull_requests: &[PullRequestSnapshot]) -> Elem } } +fn render_workspace_log_entry(entry: &WorkspaceLogEntrySnapshot) -> Element { + rsx! { + div { class: "workspace-log-entry", + div { class: "workspace-log-entry-header", + if let Some(source) = &entry.source { + span { class: "workspace-log-source", "{source}" } + } + span { class: "workspace-log-time", "{entry.timestamp}" } + } + div { class: "workspace-log-message", "{entry.message}" } + } + } +} + fn render_layout( node: &LayoutNodeSnapshot, overview_mode: bool, diff --git a/crates/taskers-shell/src/theme.rs b/crates/taskers-shell/src/theme.rs index 1b372f2..091cace 100644 --- a/crates/taskers-shell/src/theme.rs +++ b/crates/taskers-shell/src/theme.rs @@ -483,6 +483,10 @@ button {{ overflow: hidden; }} +.workspace-status {{ + color: {text_bright}; +}} + .workspace-branch-row {{ color: {text_muted}; font-size: 10px; @@ -1446,9 +1450,19 @@ button {{ .notification-counts {{ display: flex; + align-items: center; + flex-wrap: wrap; gap: 6px; }} +.notification-jump-button {{ + border: 1px solid {border_08}; + background: transparent; + color: {text_bright}; + font-size: 10px; + padding: 3px 7px; +}} + .notification-count-pill {{ font-size: 10px; font-weight: 600; @@ -1468,6 +1482,25 @@ button {{ gap: 4px; }} +.attention-section {{ + display: flex; + flex-direction: column; + gap: 6px; +}} + +.attention-section-title {{ + font-size: 10px; + font-weight: 600; + color: {text_dim}; + letter-spacing: 0.08em; + text-transform: uppercase; +}} + +.attention-status-text {{ + font-size: 12px; + color: {text_bright}; +}} + .notification-timeline {{ flex: 1; min-height: 0; @@ -1494,6 +1527,41 @@ button {{ transition: background 0.14s ease-in-out; }} +.workspace-log-list {{ + display: flex; + flex-direction: column; + gap: 6px; + max-height: 180px; + overflow-y: auto; +}} + +.workspace-log-entry {{ + background: {border_03}; + padding: 8px; + display: flex; + flex-direction: column; + gap: 4px; +}} + +.workspace-log-entry-header {{ + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +}} + +.workspace-log-source, +.workspace-log-time {{ + font-size: 10px; + color: {text_dim}; +}} + +.workspace-log-message {{ + font-size: 11px; + color: {text_bright}; + line-height: 1.35; +}} + .notification-row-button:hover .notification-row {{ background: {border_06}; }} From 1269ba1854fb75870c2ae7c1233b920bbbfbb1aa Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sat, 21 Mar 2026 14:35:30 +0100 Subject: [PATCH 03/63] feat: add browser p0 automation parity --- Cargo.lock | 2 + crates/taskers-app/src/main.rs | 213 +++- crates/taskers-cli/src/main.rs | 1099 +++++++++++++++-- crates/taskers-control/src/controller.rs | 5 + crates/taskers-control/src/lib.rs | 6 +- crates/taskers-control/src/protocol.rs | 260 +++- crates/taskers-control/src/socket.rs | 29 +- crates/taskers-host/Cargo.toml | 2 + crates/taskers-host/src/browser_automation.rs | 996 +++++++++++++++ crates/taskers-host/src/lib.rs | 327 +++-- crates/taskers-shell-core/src/lib.rs | 131 +- 11 files changed, 2812 insertions(+), 258 deletions(-) create mode 100644 crates/taskers-host/src/browser_automation.rs diff --git a/Cargo.lock b/Cargo.lock index ccfbce7..b7a8ee4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2514,6 +2514,8 @@ version = "0.3.0" dependencies = [ "anyhow", "gtk4", + "serde_json", + "taskers-control", "taskers-domain", "taskers-ghostty", "taskers-shell-core", diff --git a/crates/taskers-app/src/main.rs b/crates/taskers-app/src/main.rs index fa0178e..68efa3b 100644 --- a/crates/taskers-app/src/main.rs +++ b/crates/taskers-app/src/main.rs @@ -12,20 +12,26 @@ use std::{ path::PathBuf, process::{Command, Stdio}, rc::Rc, - sync::{Arc, Mutex}, + sync::{ + Arc, Mutex, + mpsc::{self, Receiver, Sender, TryRecvError}, + }, thread, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; -use taskers_core::{AppState, default_session_path, load_or_bootstrap}; -use taskers_control::{bind_socket, default_socket_path, serve_with_handler}; -use taskers_shell_core::{ - BootstrapModel, LayoutNodeSnapshot, PixelSize, RuntimeCapability, RuntimeStatus, SharedCore, - ShellSection, ShortcutAction, ShortcutPreset, SurfaceKind, +use taskers_control::{ + BrowserControlCommand, ControlCommand, ControlError, ControlResponse, bind_socket, + default_socket_path, serve_with_handler, }; +use taskers_core::{AppState, default_session_path, load_or_bootstrap}; use taskers_domain::AppModel; use taskers_ghostty::{BackendChoice, GhosttyHost, GhosttyHostOptions, ensure_runtime_installed}; use taskers_host::{DiagnosticCategory, DiagnosticRecord, DiagnosticsSink, TaskersHost}; use taskers_runtime::{ShellLaunchSpec, install_shell_integration, scrub_inherited_terminal_env}; +use taskers_shell_core::{ + BootstrapModel, LayoutNodeSnapshot, PixelSize, RuntimeCapability, RuntimeStatus, SharedCore, + ShellAction, ShellSection, ShortcutAction, ShortcutPreset, SurfaceKind, +}; use webkit6::{Settings as WebKitSettings, WebView, prelude::*}; const APP_ID: &str = taskers_paths::APP_ID; @@ -66,6 +72,8 @@ impl GhosttyProbeMode { struct BootstrapContext { core: SharedCore, + app_state: AppState, + socket_path: PathBuf, ghostty_host: Option, startup_notes: Vec, } @@ -79,6 +87,11 @@ struct RuntimeBootstrap { startup_notes: Vec, } +struct BrowserAutomationRequest { + command: BrowserControlCommand, + response_tx: tokio::sync::oneshot::Sender>, +} + fn main() -> glib::ExitCode { let cli = Cli::parse(); scrub_inherited_terminal_env(); @@ -181,6 +194,23 @@ fn build_ui_result( let host_widget = host.borrow().widget(); window.set_content(Some(&host_widget)); connect_navigation_shortcuts(&window, &shell_view, &core); + let last_revision = Rc::new(Cell::new(0_u64)); + let last_size = Rc::new(Cell::new((0_i32, 0_i32))); + let (browser_request_tx, browser_request_rx) = mpsc::channel::(); + let control_server_note = spawn_control_server( + bootstrap.app_state.clone(), + bootstrap.socket_path, + browser_request_tx, + ); + install_browser_bridge( + browser_request_rx, + &window, + &core, + &host, + &last_revision, + &last_size, + diagnostics.clone(), + ); for note in bootstrap.startup_notes { log_diagnostic( @@ -189,6 +219,15 @@ fn build_ui_result( ); eprintln!("{note}"); } + log_diagnostic( + diagnostics.as_ref(), + DiagnosticRecord::new( + DiagnosticCategory::Startup, + Some(core.revision()), + control_server_note.clone(), + ), + ); + eprintln!("{control_server_note}"); log_diagnostic( diagnostics.as_ref(), DiagnosticRecord::new( @@ -201,8 +240,6 @@ fn build_ui_result( let smoke_script = cli.smoke_script; let quit_after_ms = cli.quit_after_ms.unwrap_or(8_000); - let last_revision = Rc::new(Cell::new(0_u64)); - let last_size = Rc::new(Cell::new((0_i32, 0_i32))); let tick_window = window.clone(); let tick_core = core.clone(); let tick_host = host.clone(); @@ -405,12 +442,8 @@ fn bootstrap_runtime(diagnostics: Option<&DiagnosticsWriter>) -> Result) -> Result String { +fn install_browser_bridge( + receiver: Receiver, + window: &adw::ApplicationWindow, + core: &SharedCore, + host: &Rc>, + last_revision: &Rc>, + last_size: &Rc>, + diagnostics: Option, +) { + let receiver = Rc::new(receiver); + let bridge_window = window.clone(); + let bridge_core = core.clone(); + let bridge_host = host.clone(); + let bridge_revision = last_revision.clone(); + let bridge_size = last_size.clone(); + glib::timeout_add_local(Duration::from_millis(8), move || { + loop { + let request = match receiver.try_recv() { + Ok(request) => request, + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => return glib::ControlFlow::Break, + }; + let request_window = bridge_window.clone(); + let request_core = bridge_core.clone(); + let request_host = bridge_host.clone(); + let request_revision = bridge_revision.clone(); + let request_size = bridge_size.clone(); + let request_diagnostics = diagnostics.clone(); + glib::MainContext::default().spawn_local(async move { + let response = handle_browser_request( + &request_window, + &request_core, + &request_host, + &request_revision, + &request_size, + request_diagnostics.as_ref(), + request.command, + ) + .await; + let _ = request.response_tx.send(response); + }); + } + glib::ControlFlow::Continue + }); +} + +async fn handle_browser_request( + window: &adw::ApplicationWindow, + core: &SharedCore, + host: &Rc>, + last_revision: &Rc>, + last_size: &Rc>, + diagnostics: Option<&DiagnosticsWriter>, + command: BrowserControlCommand, +) -> Result { + sync_window(window, core, host, last_revision, last_size, diagnostics); + + if let BrowserControlCommand::FocusWebview { surface_id } = &command { + let snapshot = core.snapshot(); + let Some(entry) = snapshot + .browser_catalog + .iter() + .find(|entry| entry.surface_id == *surface_id) + else { + return Err(ControlError::not_found(format!( + "browser surface {surface_id} not found" + ))); + }; + core.dispatch_shell_action(ShellAction::FocusSurface { + pane_id: entry.pane_id, + surface_id: *surface_id, + }); + sync_window(window, core, host, last_revision, last_size, diagnostics); + } + + let handle = { + let host_ref = host.borrow(); + host_ref.browser_surface_handle(browser_surface_id(&command))? + }; + let result = handle.execute(command).await?; + sync_window(window, core, host, last_revision, last_size, diagnostics); + Ok(ControlResponse::Browser { result }) +} + +fn browser_surface_id(command: &BrowserControlCommand) -> taskers_shell_core::SurfaceId { + match command { + BrowserControlCommand::Navigate { surface_id, .. } + | BrowserControlCommand::Back { surface_id } + | BrowserControlCommand::Forward { surface_id } + | BrowserControlCommand::Reload { surface_id } + | BrowserControlCommand::FocusWebview { surface_id } + | BrowserControlCommand::IsWebviewFocused { surface_id } + | BrowserControlCommand::Snapshot { surface_id } + | BrowserControlCommand::Eval { surface_id, .. } + | BrowserControlCommand::Wait { surface_id, .. } + | BrowserControlCommand::Click { surface_id, .. } + | BrowserControlCommand::Dblclick { surface_id, .. } + | BrowserControlCommand::Type { surface_id, .. } + | BrowserControlCommand::Fill { surface_id, .. } + | BrowserControlCommand::Press { surface_id, .. } + | BrowserControlCommand::Keydown { surface_id, .. } + | BrowserControlCommand::Keyup { surface_id, .. } + | BrowserControlCommand::Hover { surface_id, .. } + | BrowserControlCommand::Focus { surface_id, .. } + | BrowserControlCommand::Check { surface_id, .. } + | BrowserControlCommand::Uncheck { surface_id, .. } + | BrowserControlCommand::Select { surface_id, .. } + | BrowserControlCommand::Scroll { surface_id, .. } + | BrowserControlCommand::ScrollIntoView { surface_id, .. } + | BrowserControlCommand::Get { surface_id, .. } + | BrowserControlCommand::Is { surface_id, .. } + | BrowserControlCommand::Screenshot { surface_id, .. } => *surface_id, + } +} + +fn spawn_control_server( + app_state: AppState, + socket_path: PathBuf, + browser_tx: Sender, +) -> String { if let Some(parent) = socket_path.parent() && let Err(error) = std::fs::create_dir_all(parent) { @@ -781,9 +935,34 @@ fn spawn_control_server(app_state: AppState, socket_path: PathBuf) -> String { match bind_socket(&socket_path) { Ok(listener) => { let handler = move |command| { - app_state - .dispatch(command) - .map_err(|error| error.to_string()) + let app_state = app_state.clone(); + let browser_tx = browser_tx.clone(); + async move { + match command { + ControlCommand::Browser { browser_command } => { + let (response_tx, response_rx) = + tokio::sync::oneshot::channel(); + browser_tx + .send(BrowserAutomationRequest { + command: browser_command, + response_tx, + }) + .map_err(|_| { + ControlError::internal( + "browser automation bridge is unavailable", + ) + })?; + response_rx.await.map_err(|_| { + ControlError::internal( + "browser automation bridge dropped the response", + ) + })? + } + other => app_state + .dispatch(other) + .map_err(|error| ControlError::internal(error.to_string())), + } + } }; if let Err(error) = serve_with_handler(listener, handler, pending::<()>()).await { diff --git a/crates/taskers-cli/src/main.rs b/crates/taskers-cli/src/main.rs index b6dcae0..27da2e2 100644 --- a/crates/taskers-cli/src/main.rs +++ b/crates/taskers-cli/src/main.rs @@ -1,19 +1,16 @@ -use std::{ - env, - future::pending, - path::PathBuf, -}; +use std::{env, future::pending, path::PathBuf}; use anyhow::{Context, anyhow, bail}; -use clap::{Parser, Subcommand, ValueEnum}; +use clap::{Args, Parser, Subcommand, ValueEnum}; use taskers_control::{ - ControlClient, ControlCommand, ControlQuery, ControlResponse, InMemoryController, bind_socket, - default_socket_path, serve, + BrowserControlCommand, BrowserGetCommand, BrowserLoadState, BrowserPredicateCommand, + BrowserTarget, BrowserWaitCondition, ControlClient, ControlCommand, ControlQuery, + ControlResponse, InMemoryController, bind_socket, default_socket_path, serve, }; use taskers_domain::{ AgentTarget, AppModel, AttentionState, Direction, KEYBOARD_RESIZE_STEP, PaneId, PaneKind, - PaneMetadataPatch, ProgressState, SignalEvent, SignalKind, SplitAxis, SurfaceId, - WorkspaceId, WorkspaceLogEntry, + PaneMetadataPatch, ProgressState, SignalEvent, SignalKind, SplitAxis, SurfaceId, WorkspaceId, + WorkspaceLogEntry, }; use time::OffsetDateTime; @@ -412,16 +409,298 @@ enum BrowserCommand { url: Option, }, Navigate { + #[command(flatten)] + browser: BrowserSurfaceArgs, #[arg(long)] - socket: Option, + url: String, + }, + Back { + #[command(flatten)] + browser: BrowserSurfaceArgs, + }, + Forward { + #[command(flatten)] + browser: BrowserSurfaceArgs, + }, + Reload { + #[command(flatten)] + browser: BrowserSurfaceArgs, + }, + Snapshot { + #[command(flatten)] + browser: BrowserSurfaceArgs, + }, + Eval { + #[command(flatten)] + browser: BrowserSurfaceArgs, #[arg(long)] - workspace: Option, + script: String, + }, + Wait { + #[command(flatten)] + browser: BrowserSurfaceArgs, #[arg(long)] - pane: Option, + selector: Option, #[arg(long)] - surface: SurfaceId, + text: Option, #[arg(long)] - url: String, + url_contains: Option, + #[arg(long, value_enum)] + load_state: Option, + #[arg(long)] + script: Option, + #[arg(long)] + delay_ms: Option, + #[arg(long, default_value_t = 3_000)] + timeout_ms: u64, + #[arg(long, default_value_t = 100)] + poll_interval_ms: u64, + }, + Click { + #[command(flatten)] + browser: BrowserSurfaceArgs, + #[command(flatten)] + target: BrowserTargetArgs, + #[arg(long, default_value_t = false)] + snapshot_after: bool, + }, + Dblclick { + #[command(flatten)] + browser: BrowserSurfaceArgs, + #[command(flatten)] + target: BrowserTargetArgs, + #[arg(long, default_value_t = false)] + snapshot_after: bool, + }, + Type { + #[command(flatten)] + browser: BrowserSurfaceArgs, + #[command(flatten)] + target: BrowserTargetArgs, + #[arg(long)] + text: String, + #[arg(long, default_value_t = false)] + snapshot_after: bool, + }, + Fill { + #[command(flatten)] + browser: BrowserSurfaceArgs, + #[command(flatten)] + target: BrowserTargetArgs, + #[arg(long)] + text: String, + #[arg(long, default_value_t = false)] + snapshot_after: bool, + }, + Press { + #[command(flatten)] + browser: BrowserSurfaceArgs, + #[command(flatten)] + target: BrowserOptionalTargetArgs, + #[arg(long)] + key: String, + #[arg(long, default_value_t = false)] + snapshot_after: bool, + }, + Keydown { + #[command(flatten)] + browser: BrowserSurfaceArgs, + #[command(flatten)] + target: BrowserOptionalTargetArgs, + #[arg(long)] + key: String, + #[arg(long, default_value_t = false)] + snapshot_after: bool, + }, + Keyup { + #[command(flatten)] + browser: BrowserSurfaceArgs, + #[command(flatten)] + target: BrowserOptionalTargetArgs, + #[arg(long)] + key: String, + #[arg(long, default_value_t = false)] + snapshot_after: bool, + }, + Hover { + #[command(flatten)] + browser: BrowserSurfaceArgs, + #[command(flatten)] + target: BrowserTargetArgs, + #[arg(long, default_value_t = false)] + snapshot_after: bool, + }, + Focus { + #[command(flatten)] + browser: BrowserSurfaceArgs, + #[command(flatten)] + target: BrowserTargetArgs, + #[arg(long, default_value_t = false)] + snapshot_after: bool, + }, + Check { + #[command(flatten)] + browser: BrowserSurfaceArgs, + #[command(flatten)] + target: BrowserTargetArgs, + #[arg(long, default_value_t = false)] + snapshot_after: bool, + }, + Uncheck { + #[command(flatten)] + browser: BrowserSurfaceArgs, + #[command(flatten)] + target: BrowserTargetArgs, + #[arg(long, default_value_t = false)] + snapshot_after: bool, + }, + Select { + #[command(flatten)] + browser: BrowserSurfaceArgs, + #[command(flatten)] + target: BrowserTargetArgs, + #[arg(long = "value")] + values: Vec, + #[arg(long, default_value_t = false)] + snapshot_after: bool, + }, + Scroll { + #[command(flatten)] + browser: BrowserSurfaceArgs, + #[command(flatten)] + target: BrowserOptionalTargetArgs, + #[arg(long, default_value_t = 0)] + dx: i32, + #[arg(long, default_value_t = 0)] + dy: i32, + #[arg(long, default_value_t = false)] + snapshot_after: bool, + }, + ScrollIntoView { + #[command(flatten)] + browser: BrowserSurfaceArgs, + #[command(flatten)] + target: BrowserTargetArgs, + #[arg(long, default_value_t = false)] + snapshot_after: bool, + }, + Get { + #[command(flatten)] + browser: BrowserSurfaceArgs, + #[command(subcommand)] + command: BrowserGetSubcommand, + }, + Is { + #[command(flatten)] + browser: BrowserSurfaceArgs, + #[command(subcommand)] + command: BrowserIsSubcommand, + }, + Screenshot { + #[command(flatten)] + browser: BrowserSurfaceArgs, + #[arg(long)] + out: Option, + #[arg(long, short = 'f', default_value_t = false)] + full: bool, + }, + FocusWebview { + #[command(flatten)] + browser: BrowserSurfaceArgs, + }, + IsWebviewFocused { + #[command(flatten)] + browser: BrowserSurfaceArgs, + }, +} + +#[derive(Debug, Clone, Args)] +struct BrowserSurfaceArgs { + #[arg(long)] + socket: Option, + #[arg(long)] + workspace: Option, + #[arg(long)] + pane: Option, + #[arg(long)] + surface: Option, +} + +#[derive(Debug, Clone, Args)] +struct BrowserTargetArgs { + #[arg(long = "ref")] + reference: Option, + #[arg(long)] + selector: Option, +} + +#[derive(Debug, Clone, Args)] +struct BrowserOptionalTargetArgs { + #[arg(long = "ref")] + reference: Option, + #[arg(long)] + selector: Option, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum CliBrowserLoadState { + Started, + Redirected, + Committed, + Finished, +} + +#[derive(Debug, Subcommand)] +enum BrowserGetSubcommand { + Url, + Title, + Text { + #[command(flatten)] + target: BrowserTargetArgs, + }, + Html { + #[command(flatten)] + target: BrowserTargetArgs, + }, + Value { + #[command(flatten)] + target: BrowserTargetArgs, + }, + Attr { + #[command(flatten)] + target: BrowserTargetArgs, + #[arg(long)] + name: String, + }, + Count { + #[arg(long)] + selector: String, + }, + Box { + #[command(flatten)] + target: BrowserTargetArgs, + }, + Styles { + #[command(flatten)] + target: BrowserTargetArgs, + #[arg(long = "property")] + properties: Vec, + }, +} + +#[derive(Debug, Subcommand)] +enum BrowserIsSubcommand { + Visible { + #[command(flatten)] + target: BrowserTargetArgs, + }, + Enabled { + #[command(flatten)] + target: BrowserTargetArgs, + }, + Checked { + #[command(flatten)] + target: BrowserTargetArgs, }, } @@ -659,6 +938,17 @@ impl From for AttentionState { } } +impl From for BrowserLoadState { + fn from(value: CliBrowserLoadState) -> Self { + match value { + CliBrowserLoadState::Started => BrowserLoadState::Started, + CliBrowserLoadState::Redirected => BrowserLoadState::Redirected, + CliBrowserLoadState::Committed => BrowserLoadState::Committed, + CliBrowserLoadState::Finished => BrowserLoadState::Finished, + } + } +} + #[tokio::main] async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); @@ -677,9 +967,7 @@ async fn main() -> anyhow::Result<()> { eprintln!("serving taskers control API on {}", socket.display()); serve(listener, controller, pending()).await?; } - Command::Query { - query, - } => match query { + Command::Query { query } => match query { QueryCommand::Status { socket } => { let client = ControlClient::new(resolve_socket_path(socket)); let response = client @@ -742,7 +1030,7 @@ async fn main() -> anyhow::Result<()> { let model = query_model(&client).await?; println!("{}", serde_json::to_string_pretty(&model)?); } - } + }, Command::Signal { socket, workspace, @@ -880,10 +1168,7 @@ async fn main() -> anyhow::Result<()> { &client, ControlCommand::AgentSetProgress { workspace_id, - progress: ProgressState { - value, - label, - }, + progress: ProgressState { value, label }, }, ) .await?; @@ -980,7 +1265,10 @@ async fn main() -> anyhow::Result<()> { let payload = model .activity_items() .into_iter() - .filter(|item| workspace_filter.is_none_or(|workspace_id| item.workspace_id == workspace_id)) + .filter(|item| { + workspace_filter + .is_none_or(|workspace_id| item.workspace_id == workspace_id) + }) .map(|item| { serde_json::json!({ "workspace_id": item.workspace_id, @@ -1240,103 +1528,9 @@ async fn main() -> anyhow::Result<()> { .await?; } }, - Command::Browser { command } => match command { - BrowserCommand::Open { - socket, - workspace, - pane, - url, - } => { - let client = ControlClient::new(resolve_socket_path(socket)); - let model = query_model(&client).await?; - let workspace_id = workspace - .or_else(env_workspace_id) - .or_else(|| model.active_workspace_id()) - .context("missing workspace id; pass --workspace or run from inside Taskers")?; - let target_pane = pane - .or_else(env_pane_id) - .or_else(|| { - model.workspaces - .get(&workspace_id) - .map(|workspace| workspace.active_pane) - }); - let response = send_control_command( - &client, - ControlCommand::SplitPane { - workspace_id, - pane_id: target_pane, - axis: SplitAxis::Horizontal, - }, - ) - .await?; - let pane_id = match response { - ControlResponse::PaneSplit { pane_id } => pane_id, - other => bail!("unexpected browser open response: {other:?}"), - }; - let placeholder_surface_id = - active_surface_for_pane(&query_model(&client).await?, workspace_id, pane_id)?; - let surface_id = - create_surface(&client, workspace_id, pane_id, PaneKind::Browser, url.clone()) - .await?; - send_control_command( - &client, - ControlCommand::CloseSurface { - workspace_id, - pane_id, - surface_id: placeholder_surface_id, - }, - ) - .await?; - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "status": "browser_opened", - "workspace_id": workspace_id, - "pane_id": pane_id, - "surface_id": surface_id, - "url": url, - }))? - ); - } - BrowserCommand::Navigate { - socket, - workspace, - pane, - surface, - url, - } => { - let client = ControlClient::new(resolve_socket_path(socket)); - if let Some(pane_id) = pane { - let workspace_id = workspace - .or_else(env_workspace_id) - .context("missing workspace id; pass --workspace or run from inside Taskers")?; - let _ = send_control_command( - &client, - ControlCommand::FocusSurface { - workspace_id, - pane_id, - surface_id: surface, - }, - ) - .await; - } - let response = client - .send(ControlCommand::UpdateSurfaceMetadata { - surface_id: surface, - patch: PaneMetadataPatch { - title: None, - cwd: None, - url: Some(url), - repo_name: None, - git_branch: None, - ports: None, - agent_kind: None, - }, - }) - .await?; - println!("{}", serde_json::to_string_pretty(&response)?); - } - }, + Command::Browser { command } => { + handle_browser_cli_command(command).await?; + } Command::Pane { command } => match command { PaneCommand::NewWindow { socket, @@ -1693,10 +1887,9 @@ fn resolve_agent_target( let resolved_pane = pane .or_else(env_pane_id) .unwrap_or(workspace_record.active_pane); - let pane_record = workspace_record - .panes - .get(&resolved_pane) - .ok_or_else(|| anyhow!("pane {resolved_pane} is not present in workspace {workspace_id}"))?; + let pane_record = workspace_record.panes.get(&resolved_pane).ok_or_else(|| { + anyhow!("pane {resolved_pane} is not present in workspace {workspace_id}") + })?; let resolved_surface = surface .or_else(env_surface_id) .unwrap_or(pane_record.active_surface); @@ -1758,6 +1951,581 @@ async fn create_surface( Ok(surface_id) } +async fn handle_browser_cli_command(command: BrowserCommand) -> anyhow::Result<()> { + match command { + BrowserCommand::Open { + socket, + workspace, + pane, + url, + } => { + let client = ControlClient::new(resolve_socket_path(socket)); + let model = query_model(&client).await?; + let workspace_id = resolve_workspace_id_from_model(&model, workspace)?; + let target_pane = pane.or_else(env_pane_id).or_else(|| { + model + .workspaces + .get(&workspace_id) + .map(|workspace| workspace.active_pane) + }); + let response = send_control_command( + &client, + ControlCommand::SplitPane { + workspace_id, + pane_id: target_pane, + axis: SplitAxis::Horizontal, + }, + ) + .await?; + let pane_id = match response { + ControlResponse::PaneSplit { pane_id } => pane_id, + other => bail!("unexpected browser open response: {other:?}"), + }; + let placeholder_surface_id = + active_surface_for_pane(&query_model(&client).await?, workspace_id, pane_id)?; + let surface_id = create_surface( + &client, + workspace_id, + pane_id, + PaneKind::Browser, + url.clone(), + ) + .await?; + send_control_command( + &client, + ControlCommand::CloseSurface { + workspace_id, + pane_id, + surface_id: placeholder_surface_id, + }, + ) + .await?; + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "status": "browser_opened", + "workspace_id": workspace_id, + "pane_id": pane_id, + "surface_id": surface_id, + "url": url, + }))? + ); + } + BrowserCommand::Navigate { browser, url } => { + let client = ControlClient::new(resolve_socket_path(browser.socket.clone())); + let (_, _, surface_id) = resolve_browser_surface(&client, &browser).await?; + let result = + send_browser_command(&client, BrowserControlCommand::Navigate { surface_id, url }) + .await?; + print_browser_result(&result)?; + } + BrowserCommand::Back { browser } => { + run_browser_surface_command(&browser, |surface_id| BrowserControlCommand::Back { + surface_id, + }) + .await?; + } + BrowserCommand::Forward { browser } => { + run_browser_surface_command(&browser, |surface_id| BrowserControlCommand::Forward { + surface_id, + }) + .await?; + } + BrowserCommand::Reload { browser } => { + run_browser_surface_command(&browser, |surface_id| BrowserControlCommand::Reload { + surface_id, + }) + .await?; + } + BrowserCommand::Snapshot { browser } => { + run_browser_surface_command(&browser, |surface_id| BrowserControlCommand::Snapshot { + surface_id, + }) + .await?; + } + BrowserCommand::Eval { browser, script } => { + run_browser_surface_command(&browser, |surface_id| BrowserControlCommand::Eval { + surface_id, + script, + }) + .await?; + } + BrowserCommand::Wait { + browser, + selector, + text, + url_contains, + load_state, + script, + delay_ms, + timeout_ms, + poll_interval_ms, + } => { + let condition = + resolve_wait_condition(selector, text, url_contains, load_state, script, delay_ms)?; + run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Wait { + surface_id, + condition, + timeout_ms, + poll_interval_ms, + }) + .await?; + } + BrowserCommand::Click { + browser, + target, + snapshot_after, + } => { + let target = resolve_required_browser_target(target)?; + run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Click { + surface_id, + target, + snapshot_after, + }) + .await?; + } + BrowserCommand::Dblclick { + browser, + target, + snapshot_after, + } => { + let target = resolve_required_browser_target(target)?; + run_browser_surface_command(&browser, move |surface_id| { + BrowserControlCommand::Dblclick { + surface_id, + target, + snapshot_after, + } + }) + .await?; + } + BrowserCommand::Type { + browser, + target, + text, + snapshot_after, + } => { + let target = resolve_required_browser_target(target)?; + run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Type { + surface_id, + target, + text, + snapshot_after, + }) + .await?; + } + BrowserCommand::Fill { + browser, + target, + text, + snapshot_after, + } => { + let target = resolve_required_browser_target(target)?; + run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Fill { + surface_id, + target, + text, + snapshot_after, + }) + .await?; + } + BrowserCommand::Press { + browser, + target, + key, + snapshot_after, + } => { + let target = resolve_optional_browser_target(target)?; + run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Press { + surface_id, + target, + key, + snapshot_after, + }) + .await?; + } + BrowserCommand::Keydown { + browser, + target, + key, + snapshot_after, + } => { + let target = resolve_optional_browser_target(target)?; + run_browser_surface_command(&browser, move |surface_id| { + BrowserControlCommand::Keydown { + surface_id, + target, + key, + snapshot_after, + } + }) + .await?; + } + BrowserCommand::Keyup { + browser, + target, + key, + snapshot_after, + } => { + let target = resolve_optional_browser_target(target)?; + run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Keyup { + surface_id, + target, + key, + snapshot_after, + }) + .await?; + } + BrowserCommand::Hover { + browser, + target, + snapshot_after, + } => { + let target = resolve_required_browser_target(target)?; + run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Hover { + surface_id, + target, + snapshot_after, + }) + .await?; + } + BrowserCommand::Focus { + browser, + target, + snapshot_after, + } => { + let target = resolve_required_browser_target(target)?; + run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Focus { + surface_id, + target, + snapshot_after, + }) + .await?; + } + BrowserCommand::Check { + browser, + target, + snapshot_after, + } => { + let target = resolve_required_browser_target(target)?; + run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Check { + surface_id, + target, + snapshot_after, + }) + .await?; + } + BrowserCommand::Uncheck { + browser, + target, + snapshot_after, + } => { + let target = resolve_required_browser_target(target)?; + run_browser_surface_command(&browser, move |surface_id| { + BrowserControlCommand::Uncheck { + surface_id, + target, + snapshot_after, + } + }) + .await?; + } + BrowserCommand::Select { + browser, + target, + values, + snapshot_after, + } => { + let target = resolve_required_browser_target(target)?; + run_browser_surface_command(&browser, move |surface_id| { + BrowserControlCommand::Select { + surface_id, + target, + values, + snapshot_after, + } + }) + .await?; + } + BrowserCommand::Scroll { + browser, + target, + dx, + dy, + snapshot_after, + } => { + let target = resolve_optional_browser_target(target)?; + run_browser_surface_command(&browser, move |surface_id| { + BrowserControlCommand::Scroll { + surface_id, + target, + dx, + dy, + snapshot_after, + } + }) + .await?; + } + BrowserCommand::ScrollIntoView { + browser, + target, + snapshot_after, + } => { + let target = resolve_required_browser_target(target)?; + run_browser_surface_command(&browser, move |surface_id| { + BrowserControlCommand::ScrollIntoView { + surface_id, + target, + snapshot_after, + } + }) + .await?; + } + BrowserCommand::Get { browser, command } => { + let query = match command { + BrowserGetSubcommand::Url => BrowserGetCommand::Url, + BrowserGetSubcommand::Title => BrowserGetCommand::Title, + BrowserGetSubcommand::Text { target } => BrowserGetCommand::Text { + target: resolve_required_browser_target(target)?, + }, + BrowserGetSubcommand::Html { target } => BrowserGetCommand::Html { + target: resolve_required_browser_target(target)?, + }, + BrowserGetSubcommand::Value { target } => BrowserGetCommand::Value { + target: resolve_required_browser_target(target)?, + }, + BrowserGetSubcommand::Attr { target, name } => BrowserGetCommand::Attr { + target: resolve_required_browser_target(target)?, + name, + }, + BrowserGetSubcommand::Count { selector } => BrowserGetCommand::Count { selector }, + BrowserGetSubcommand::Box { target } => BrowserGetCommand::Box { + target: resolve_required_browser_target(target)?, + }, + BrowserGetSubcommand::Styles { target, properties } => BrowserGetCommand::Styles { + target: resolve_required_browser_target(target)?, + properties, + }, + }; + run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Get { + surface_id, + query, + }) + .await?; + } + BrowserCommand::Is { browser, command } => { + let query = match command { + BrowserIsSubcommand::Visible { target } => BrowserPredicateCommand::Visible { + target: resolve_required_browser_target(target)?, + }, + BrowserIsSubcommand::Enabled { target } => BrowserPredicateCommand::Enabled { + target: resolve_required_browser_target(target)?, + }, + BrowserIsSubcommand::Checked { target } => BrowserPredicateCommand::Checked { + target: resolve_required_browser_target(target)?, + }, + }; + run_browser_surface_command(&browser, move |surface_id| BrowserControlCommand::Is { + surface_id, + query, + }) + .await?; + } + BrowserCommand::Screenshot { browser, out, full } => { + run_browser_surface_command(&browser, move |surface_id| { + BrowserControlCommand::Screenshot { + surface_id, + path: out, + full_document: full, + } + }) + .await?; + } + BrowserCommand::FocusWebview { browser } => { + run_browser_surface_command(&browser, |surface_id| { + BrowserControlCommand::FocusWebview { surface_id } + }) + .await?; + } + BrowserCommand::IsWebviewFocused { browser } => { + run_browser_surface_command(&browser, |surface_id| { + BrowserControlCommand::IsWebviewFocused { surface_id } + }) + .await?; + } + } + + Ok(()) +} + +async fn run_browser_surface_command( + browser: &BrowserSurfaceArgs, + build: F, +) -> anyhow::Result<()> +where + F: FnOnce(SurfaceId) -> BrowserControlCommand, +{ + let client = ControlClient::new(resolve_socket_path(browser.socket.clone())); + let (_, _, surface_id) = resolve_browser_surface(&client, browser).await?; + let result = send_browser_command(&client, build(surface_id)).await?; + print_browser_result(&result) +} + +async fn send_browser_command( + client: &ControlClient, + browser_command: BrowserControlCommand, +) -> anyhow::Result { + let response = + send_control_command(client, ControlCommand::Browser { browser_command }).await?; + match response { + ControlResponse::Browser { result } => Ok(result), + other => bail!("unexpected browser response: {other:?}"), + } +} + +fn print_browser_result(result: &serde_json::Value) -> anyhow::Result<()> { + println!("{}", serde_json::to_string_pretty(result)?); + Ok(()) +} + +async fn resolve_browser_surface( + client: &ControlClient, + browser: &BrowserSurfaceArgs, +) -> anyhow::Result<(WorkspaceId, PaneId, SurfaceId)> { + let model = query_model(client).await?; + if let Some(surface_id) = browser.surface.or_else(env_surface_id) { + let (workspace_id, pane_id, kind) = find_surface_location(&model, surface_id) + .ok_or_else(|| anyhow!("surface {surface_id} is not present in the current session"))?; + if kind != PaneKind::Browser { + bail!("surface {surface_id} is not a browser"); + } + if let Some(workspace_id_arg) = browser.workspace + && workspace_id_arg != workspace_id + { + bail!( + "surface {surface_id} belongs to workspace {workspace_id}, not {workspace_id_arg}" + ); + } + if let Some(pane_id_arg) = browser.pane + && pane_id_arg != pane_id + { + bail!("surface {surface_id} belongs to pane {pane_id}, not {pane_id_arg}"); + } + return Ok((workspace_id, pane_id, surface_id)); + } + + let workspace_id = resolve_workspace_id_from_model(&model, browser.workspace)?; + let workspace = model + .workspaces + .get(&workspace_id) + .ok_or_else(|| anyhow!("workspace {workspace_id} not found"))?; + let pane_id = browser + .pane + .or_else(env_pane_id) + .unwrap_or(workspace.active_pane); + let pane = workspace + .panes + .get(&pane_id) + .ok_or_else(|| anyhow!("pane {pane_id} is not present in workspace {workspace_id}"))?; + let surface_id = pane.active_surface; + let surface = pane + .surfaces + .get(&surface_id) + .ok_or_else(|| anyhow!("surface {surface_id} is not present in pane {pane_id}"))?; + if surface.kind != PaneKind::Browser { + bail!( + "active surface {surface_id} in pane {pane_id} is not a browser; pass --surface or activate a browser pane" + ); + } + Ok((workspace_id, pane_id, surface_id)) +} + +fn find_surface_location( + model: &AppModel, + surface_id: SurfaceId, +) -> Option<(WorkspaceId, PaneId, PaneKind)> { + model + .workspaces + .iter() + .find_map(|(workspace_id, workspace)| { + workspace.panes.iter().find_map(|(pane_id, pane)| { + pane.surfaces + .get(&surface_id) + .map(|surface| (*workspace_id, *pane_id, surface.kind.clone())) + }) + }) +} + +fn resolve_required_browser_target(target: BrowserTargetArgs) -> anyhow::Result { + resolve_browser_target(target.reference, target.selector, true) + .map(|target| target.expect("required browser target")) +} + +fn resolve_optional_browser_target( + target: BrowserOptionalTargetArgs, +) -> anyhow::Result> { + resolve_browser_target(target.reference, target.selector, false) +} + +fn resolve_browser_target( + reference: Option, + selector: Option, + required: bool, +) -> anyhow::Result> { + match (reference, selector) { + (Some(reference), None) => Ok(Some(BrowserTarget::Ref { value: reference })), + (None, Some(selector)) => Ok(Some(BrowserTarget::Selector { value: selector })), + (None, None) if !required => Ok(None), + (None, None) => bail!("missing browser target; pass --ref or --selector"), + (Some(_), Some(_)) => bail!("pass only one of --ref or --selector"), + } +} + +fn resolve_wait_condition( + selector: Option, + text: Option, + url_contains: Option, + load_state: Option, + script: Option, + delay_ms: Option, +) -> anyhow::Result { + let mut condition = None; + let mut set = |next| -> anyhow::Result<()> { + if condition.is_some() { + bail!( + "browser wait requires exactly one of --selector, --text, --url-contains, --load-state, --script, or --delay-ms" + ); + } + condition = Some(next); + Ok(()) + }; + + if let Some(selector) = selector { + set(BrowserWaitCondition::Selector { selector })?; + } + if let Some(text) = text { + set(BrowserWaitCondition::Text { text })?; + } + if let Some(pattern) = url_contains { + set(BrowserWaitCondition::UrlMatches { pattern })?; + } + if let Some(state) = load_state { + set(BrowserWaitCondition::LoadState { + state: state.into(), + })?; + } + if let Some(script) = script { + set(BrowserWaitCondition::Function { script })?; + } + if let Some(duration_ms) = delay_ms { + set(BrowserWaitCondition::Delay { duration_ms })?; + } + + condition.context( + "browser wait requires one of --selector, --text, --url-contains, --load-state, --script, or --delay-ms", + ) +} + async fn emit_agent_hook( socket: Option, workspace: Option, @@ -1852,16 +2620,12 @@ async fn emit_agent_hook( .await?; } CliSignalKind::Completed => { - let _ = send_control_command( - &client, - ControlCommand::AgentClearStatus { workspace_id }, - ) - .await?; - let _ = send_control_command( - &client, - ControlCommand::AgentClearProgress { workspace_id }, - ) - .await?; + let _ = + send_control_command(&client, ControlCommand::AgentClearStatus { workspace_id }) + .await?; + let _ = + send_control_command(&client, ControlCommand::AgentClearProgress { workspace_id }) + .await?; } CliSignalKind::Metadata | CliSignalKind::Error => {} } @@ -1909,7 +2673,12 @@ fn infer_agent_kind(value: &str) -> Option { mod tests { use std::sync::Mutex; - use super::{env_pane_id, env_surface_id, env_workspace_id, infer_agent_kind}; + use taskers_control::{BrowserTarget, BrowserWaitCondition}; + + use super::{ + CliBrowserLoadState, env_pane_id, env_surface_id, env_workspace_id, infer_agent_kind, + resolve_browser_target, resolve_wait_condition, + }; static ENV_LOCK: Mutex<()> = Mutex::new(()); @@ -1943,4 +2712,62 @@ mod tests { std::env::remove_var("TASKERS_SURFACE_ID"); } } + + #[test] + fn browser_targets_require_exactly_one_selector_or_ref() { + let target = resolve_browser_target(Some("@e1".into()), None, true).expect("target"); + assert_eq!( + target, + Some(BrowserTarget::Ref { + value: "@e1".into() + }) + ); + assert!(resolve_browser_target(None, None, true).is_err()); + assert!(resolve_browser_target(Some("@e1".into()), Some("a".into()), true).is_err()); + assert_eq!( + resolve_browser_target(None, None, false).expect("optional target"), + None + ); + } + + #[test] + fn browser_wait_conditions_require_one_clause() { + let wait = resolve_wait_condition(None, Some("hello".into()), None, None, None, None) + .expect("wait"); + assert_eq!( + wait, + BrowserWaitCondition::Text { + text: "hello".into() + } + ); + + let wait = resolve_wait_condition( + None, + None, + None, + Some(CliBrowserLoadState::Committed), + None, + None, + ) + .expect("load state"); + assert_eq!( + wait, + BrowserWaitCondition::LoadState { + state: taskers_control::BrowserLoadState::Committed + } + ); + + assert!( + resolve_wait_condition( + Some("body".into()), + Some("hello".into()), + None, + None, + None, + None, + ) + .is_err() + ); + assert!(resolve_wait_condition(None, None, None, None, None, None).is_err()); + } } diff --git a/crates/taskers-control/src/controller.rs b/crates/taskers-control/src/controller.rs index 2d69403..3c7f82d 100644 --- a/crates/taskers-control/src/controller.rs +++ b/crates/taskers-control/src/controller.rs @@ -539,6 +539,11 @@ impl InMemoryController { true, ) } + ControlCommand::Browser { .. } => { + return Err(DomainError::InvalidOperation( + "browser automation commands require a live GTK host", + )); + } ControlCommand::QueryStatus { query } => match query { ControlQuery::ActiveWindow | ControlQuery::All => ( ControlResponse::Status { diff --git a/crates/taskers-control/src/lib.rs b/crates/taskers-control/src/lib.rs index 096ad68..7a4f661 100644 --- a/crates/taskers-control/src/lib.rs +++ b/crates/taskers-control/src/lib.rs @@ -7,5 +7,9 @@ pub mod socket; pub use client::ControlClient; pub use controller::{ControllerSnapshot, InMemoryController}; pub use paths::default_socket_path; -pub use protocol::{ControlCommand, ControlQuery, ControlResponse, RequestFrame, ResponseFrame}; +pub use protocol::{ + BrowserControlCommand, BrowserGetCommand, BrowserLoadState, BrowserPredicateCommand, + BrowserTarget, BrowserWaitCondition, ControlCommand, ControlError, ControlErrorCode, + ControlQuery, ControlResponse, RequestFrame, ResponseFrame, +}; pub use socket::{bind_socket, serve, serve_with_handler}; diff --git a/crates/taskers-control/src/protocol.rs b/crates/taskers-control/src/protocol.rs index e6863d4..8cf36dd 100644 --- a/crates/taskers-control/src/protocol.rs +++ b/crates/taskers-control/src/protocol.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; use uuid::Uuid; use taskers_domain::{ @@ -192,11 +193,265 @@ pub enum ControlCommand { AgentFocusLatestUnread { window_id: Option, }, + Browser { + browser_command: BrowserControlCommand, + }, QueryStatus { query: ControlQuery, }, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ControlErrorCode { + InvalidParams, + NotFound, + Timeout, + InvalidState, + NotSupported, + Internal, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ControlError { + pub code: ControlErrorCode, + pub message: String, +} + +impl ControlError { + pub fn new(code: ControlErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } + + pub fn invalid_params(message: impl Into) -> Self { + Self::new(ControlErrorCode::InvalidParams, message) + } + + pub fn not_found(message: impl Into) -> Self { + Self::new(ControlErrorCode::NotFound, message) + } + + pub fn timeout(message: impl Into) -> Self { + Self::new(ControlErrorCode::Timeout, message) + } + + pub fn invalid_state(message: impl Into) -> Self { + Self::new(ControlErrorCode::InvalidState, message) + } + + pub fn not_supported(message: impl Into) -> Self { + Self::new(ControlErrorCode::NotSupported, message) + } + + pub fn internal(message: impl Into) -> Self { + Self::new(ControlErrorCode::Internal, message) + } +} + +impl std::fmt::Display for ControlError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}: {}", self.code, self.message) + } +} + +impl std::error::Error for ControlError {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "browser_command", rename_all = "snake_case")] +pub enum BrowserControlCommand { + Navigate { + surface_id: SurfaceId, + url: String, + }, + Back { + surface_id: SurfaceId, + }, + Forward { + surface_id: SurfaceId, + }, + Reload { + surface_id: SurfaceId, + }, + FocusWebview { + surface_id: SurfaceId, + }, + IsWebviewFocused { + surface_id: SurfaceId, + }, + Snapshot { + surface_id: SurfaceId, + }, + Eval { + surface_id: SurfaceId, + script: String, + }, + Wait { + surface_id: SurfaceId, + condition: BrowserWaitCondition, + timeout_ms: u64, + poll_interval_ms: u64, + }, + Click { + surface_id: SurfaceId, + target: BrowserTarget, + snapshot_after: bool, + }, + Dblclick { + surface_id: SurfaceId, + target: BrowserTarget, + snapshot_after: bool, + }, + Type { + surface_id: SurfaceId, + target: BrowserTarget, + text: String, + snapshot_after: bool, + }, + Fill { + surface_id: SurfaceId, + target: BrowserTarget, + text: String, + snapshot_after: bool, + }, + Press { + surface_id: SurfaceId, + target: Option, + key: String, + snapshot_after: bool, + }, + Keydown { + surface_id: SurfaceId, + target: Option, + key: String, + snapshot_after: bool, + }, + Keyup { + surface_id: SurfaceId, + target: Option, + key: String, + snapshot_after: bool, + }, + Hover { + surface_id: SurfaceId, + target: BrowserTarget, + snapshot_after: bool, + }, + Focus { + surface_id: SurfaceId, + target: BrowserTarget, + snapshot_after: bool, + }, + Check { + surface_id: SurfaceId, + target: BrowserTarget, + snapshot_after: bool, + }, + Uncheck { + surface_id: SurfaceId, + target: BrowserTarget, + snapshot_after: bool, + }, + Select { + surface_id: SurfaceId, + target: BrowserTarget, + values: Vec, + snapshot_after: bool, + }, + Scroll { + surface_id: SurfaceId, + target: Option, + dx: i32, + dy: i32, + snapshot_after: bool, + }, + ScrollIntoView { + surface_id: SurfaceId, + target: BrowserTarget, + snapshot_after: bool, + }, + Get { + surface_id: SurfaceId, + query: BrowserGetCommand, + }, + Is { + surface_id: SurfaceId, + query: BrowserPredicateCommand, + }, + Screenshot { + surface_id: SurfaceId, + path: Option, + full_document: bool, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "target", rename_all = "snake_case")] +pub enum BrowserTarget { + Ref { value: String }, + Selector { value: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "condition", rename_all = "snake_case")] +pub enum BrowserWaitCondition { + Selector { selector: String }, + Text { text: String }, + UrlMatches { pattern: String }, + LoadState { state: BrowserLoadState }, + Function { script: String }, + Delay { duration_ms: u64 }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BrowserLoadState { + Started, + Redirected, + Committed, + Finished, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "query", rename_all = "snake_case")] +pub enum BrowserGetCommand { + Url, + Title, + Text { + target: BrowserTarget, + }, + Html { + target: BrowserTarget, + }, + Value { + target: BrowserTarget, + }, + Attr { + target: BrowserTarget, + name: String, + }, + Count { + selector: String, + }, + Box { + target: BrowserTarget, + }, + Styles { + target: BrowserTarget, + properties: Vec, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "query", rename_all = "snake_case")] +pub enum BrowserPredicateCommand { + Visible { target: BrowserTarget }, + Enabled { target: BrowserTarget }, + Checked { target: BrowserTarget }, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "scope", rename_all = "snake_case")] pub enum ControlQuery { @@ -237,6 +492,9 @@ pub enum ControlResponse { workspace_id: WorkspaceId, session: PersistedSession, }, + Browser { + result: JsonValue, + }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -248,7 +506,7 @@ pub struct RequestFrame { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ResponseFrame { pub request_id: Uuid, - pub response: Result, + pub response: Result, } impl RequestFrame { diff --git a/crates/taskers-control/src/socket.rs b/crates/taskers-control/src/socket.rs index 0e1290c..aad07ed 100644 --- a/crates/taskers-control/src/socket.rs +++ b/crates/taskers-control/src/socket.rs @@ -9,7 +9,7 @@ use tokio::{ use crate::{ RequestFrame, controller::InMemoryController, - protocol::{ControlCommand, ControlResponse, ResponseFrame}, + protocol::{ControlCommand, ControlError, ControlResponse, ResponseFrame}, }; pub fn bind_socket(path: impl AsRef) -> io::Result { @@ -30,17 +30,30 @@ pub async fn serve( where S: Future + Send, { - serve_with_handler(listener, move |command| controller.handle(command).map_err(|error| error.to_string()), shutdown).await + serve_with_handler( + listener, + move |command| { + let controller = controller.clone(); + async move { + controller + .handle(command) + .map_err(|error| ControlError::internal(error.to_string())) + } + }, + shutdown, + ) + .await } -pub async fn serve_with_handler( +pub async fn serve_with_handler( listener: UnixListener, handler: H, shutdown: S, ) -> io::Result<()> where S: Future + Send, - H: Fn(ControlCommand) -> Result + Clone + Send + Sync + 'static, + H: Fn(ControlCommand) -> F + Clone + Send + Sync + 'static, + F: Future> + Send + 'static, { tokio::pin!(shutdown); @@ -60,9 +73,10 @@ where Ok(()) } -async fn handle_connection_with_handler(stream: UnixStream, handler: H) -> io::Result<()> +async fn handle_connection_with_handler(stream: UnixStream, handler: H) -> io::Result<()> where - H: Fn(ControlCommand) -> Result + Clone + Send + Sync + 'static, + H: Fn(ControlCommand) -> F + Clone + Send + Sync + 'static, + F: Future> + Send + 'static, { let (read_half, mut write_half) = stream.into_split(); let mut reader = BufReader::new(read_half); @@ -70,9 +84,10 @@ where reader.read_line(&mut line).await?; let request: RequestFrame = from_slice(line.trim_end().as_bytes()).map_err(invalid_data)?; + let result = handler(request.command).await; let response = ResponseFrame { request_id: request.request_id, - response: handler(request.command), + response: result, }; let payload = to_vec(&response).map_err(invalid_data)?; write_half.write_all(&payload).await?; diff --git a/crates/taskers-host/Cargo.toml b/crates/taskers-host/Cargo.toml index c55c461..992cc69 100644 --- a/crates/taskers-host/Cargo.toml +++ b/crates/taskers-host/Cargo.toml @@ -12,6 +12,8 @@ publish = false [dependencies] anyhow.workspace = true gtk.workspace = true +serde_json.workspace = true +taskers-control.workspace = true taskers-domain.workspace = true taskers-ghostty.workspace = true taskers-shell-core.workspace = true diff --git a/crates/taskers-host/src/browser_automation.rs b/crates/taskers-host/src/browser_automation.rs new file mode 100644 index 0000000..4864e1c --- /dev/null +++ b/crates/taskers-host/src/browser_automation.rs @@ -0,0 +1,996 @@ +use std::{ + path::PathBuf, + time::{Duration, Instant}, +}; + +use gtk::{glib, prelude::*}; +use serde_json::{Value as JsonValue, json}; +use taskers_control::{ + BrowserControlCommand, BrowserGetCommand, BrowserPredicateCommand, BrowserTarget, + BrowserWaitCondition, ControlError, +}; +use webkit6::{SnapshotOptions, SnapshotRegion, prelude::*}; + +use crate::BrowserSurfaceHandle; + +const HELPER_SOURCE_URI: &str = "taskers://browser-helper"; + +impl BrowserSurfaceHandle { + pub async fn execute(&self, command: BrowserControlCommand) -> Result { + match command { + BrowserControlCommand::Navigate { url, .. } => { + self.navigate(&url); + Ok(json!({ + "surface_id": self.surface_id().to_string(), + "status": "navigating", + "url": url, + })) + } + BrowserControlCommand::Back { .. } => { + self.go_back(); + Ok(json!({ + "surface_id": self.surface_id().to_string(), + "status": "navigating_back", + })) + } + BrowserControlCommand::Forward { .. } => { + self.go_forward(); + Ok(json!({ + "surface_id": self.surface_id().to_string(), + "status": "navigating_forward", + })) + } + BrowserControlCommand::Reload { .. } => { + self.reload(); + Ok(json!({ + "surface_id": self.surface_id().to_string(), + "status": "reloading", + })) + } + BrowserControlCommand::FocusWebview { .. } => { + self.focus_webview(); + Ok(json!({ + "surface_id": self.surface_id().to_string(), + "focused": true, + })) + } + BrowserControlCommand::IsWebviewFocused { .. } => Ok(json!({ + "surface_id": self.surface_id().to_string(), + "focused": self.is_webview_focused(), + })), + BrowserControlCommand::Snapshot { .. } => self.snapshot_payload().await, + BrowserControlCommand::Eval { script, .. } => self.eval_script(&script).await, + BrowserControlCommand::Wait { + condition, + timeout_ms, + poll_interval_ms, + .. + } => self.wait_for(condition, timeout_ms, poll_interval_ms).await, + BrowserControlCommand::Click { + target, + snapshot_after, + .. + } => { + self.run_helper_action("click", Some(target), json!({}), snapshot_after) + .await + } + BrowserControlCommand::Dblclick { + target, + snapshot_after, + .. + } => { + self.run_helper_action("dblclick", Some(target), json!({}), snapshot_after) + .await + } + BrowserControlCommand::Type { + target, + text, + snapshot_after, + .. + } => { + self.run_helper_action( + "type", + Some(target), + json!({ "text": text }), + snapshot_after, + ) + .await + } + BrowserControlCommand::Fill { + target, + text, + snapshot_after, + .. + } => { + self.run_helper_action( + "fill", + Some(target), + json!({ "text": text }), + snapshot_after, + ) + .await + } + BrowserControlCommand::Press { + target, + key, + snapshot_after, + .. + } => { + self.run_helper_action("press", target, json!({ "key": key }), snapshot_after) + .await + } + BrowserControlCommand::Keydown { + target, + key, + snapshot_after, + .. + } => { + self.run_helper_action("keydown", target, json!({ "key": key }), snapshot_after) + .await + } + BrowserControlCommand::Keyup { + target, + key, + snapshot_after, + .. + } => { + self.run_helper_action("keyup", target, json!({ "key": key }), snapshot_after) + .await + } + BrowserControlCommand::Hover { + target, + snapshot_after, + .. + } => { + self.run_helper_action("hover", Some(target), json!({}), snapshot_after) + .await + } + BrowserControlCommand::Focus { + target, + snapshot_after, + .. + } => { + self.run_helper_action("focus", Some(target), json!({}), snapshot_after) + .await + } + BrowserControlCommand::Check { + target, + snapshot_after, + .. + } => { + self.run_helper_action("check", Some(target), json!({}), snapshot_after) + .await + } + BrowserControlCommand::Uncheck { + target, + snapshot_after, + .. + } => { + self.run_helper_action("uncheck", Some(target), json!({}), snapshot_after) + .await + } + BrowserControlCommand::Select { + target, + values, + snapshot_after, + .. + } => { + self.run_helper_action( + "select", + Some(target), + json!({ "values": values }), + snapshot_after, + ) + .await + } + BrowserControlCommand::Scroll { + target, + dx, + dy, + snapshot_after, + .. + } => { + self.run_helper_action( + "scroll", + target, + json!({ "dx": dx, "dy": dy }), + snapshot_after, + ) + .await + } + BrowserControlCommand::ScrollIntoView { + target, + snapshot_after, + .. + } => { + self.run_helper_action("scroll_into_view", Some(target), json!({}), snapshot_after) + .await + } + BrowserControlCommand::Get { query, .. } => self.run_get(query).await, + BrowserControlCommand::Is { query, .. } => self.run_predicate(query).await, + BrowserControlCommand::Screenshot { + path, + full_document, + .. + } => self.screenshot(path, full_document).await, + } + } + + async fn snapshot_payload(&self) -> Result { + let snapshot = self + .run_helper(json!({ + "action": "snapshot", + })) + .await?; + Ok(json!({ + "surface_id": self.surface_id().to_string(), + "url": self.url(), + "title": self.title(), + "loading": self.is_loading(), + "snapshot": snapshot, + })) + } + + async fn eval_script(&self, script: &str) -> Result { + let body = format!( + r#" +const __taskersEval = {}; +return await Promise.resolve((0, eval)(__taskersEval)); +"#, + serde_json::to_string(script) + .map_err(|error| ControlError::invalid_params(error.to_string()))? + ); + let value = self + .webview() + .call_async_javascript_function_future( + &body, + None::<&glib::Variant>, + None, + Some(HELPER_SOURCE_URI), + ) + .await + .map_err(map_webkit_error)?; + Ok(json!({ + "surface_id": self.surface_id().to_string(), + "result": jsc_value_to_json(&value)?, + })) + } + + async fn wait_for( + &self, + condition: BrowserWaitCondition, + timeout_ms: u64, + poll_interval_ms: u64, + ) -> Result { + let timeout = Duration::from_millis(timeout_ms.max(1)); + let poll = Duration::from_millis(poll_interval_ms.max(10)); + let deadline = Instant::now() + timeout; + + loop { + if self.wait_condition_satisfied(&condition).await? { + return Ok(json!({ + "surface_id": self.surface_id().to_string(), + "status": "matched", + "condition": encode_wait_condition(&condition), + })); + } + + if Instant::now() >= deadline { + return Err(ControlError::timeout(format!( + "browser wait timed out after {} ms", + timeout_ms.max(1) + ))); + } + + glib::timeout_future(poll).await; + } + } + + async fn wait_condition_satisfied( + &self, + condition: &BrowserWaitCondition, + ) -> Result { + match condition { + BrowserWaitCondition::Delay { duration_ms } => { + glib::timeout_future(Duration::from_millis(*duration_ms)).await; + Ok(true) + } + BrowserWaitCondition::Selector { selector } => { + let result = self + .run_helper(json!({ + "action": "probe_selector", + "selector": selector, + })) + .await?; + Ok(result + .get("matched") + .and_then(JsonValue::as_bool) + .unwrap_or(false)) + } + BrowserWaitCondition::Text { text } => { + let result = self + .run_helper(json!({ + "action": "probe_text", + "text": text, + })) + .await?; + Ok(result + .get("matched") + .and_then(JsonValue::as_bool) + .unwrap_or(false)) + } + BrowserWaitCondition::UrlMatches { pattern } => Ok(self.url().contains(pattern)), + BrowserWaitCondition::LoadState { state } => Ok(self.load_state() == Some(*state)), + BrowserWaitCondition::Function { script } => { + let result = self + .run_helper(json!({ + "action": "probe_function", + "script": script, + })) + .await?; + Ok(result + .get("matched") + .and_then(JsonValue::as_bool) + .unwrap_or(false)) + } + } + } + + async fn run_helper_action( + &self, + action: &'static str, + target: Option, + extra: JsonValue, + snapshot_after: bool, + ) -> Result { + let mut command = json!({ + "action": action, + "snapshot_after": snapshot_after, + }); + if let Some(target) = target { + command["target"] = encode_target(target); + } + merge_json_object(&mut command, extra); + self.run_helper(command).await + } + + async fn run_get(&self, query: BrowserGetCommand) -> Result { + if matches!( + &query, + BrowserGetCommand::Styles { properties, .. } if properties.is_empty() + ) { + return Err(ControlError::invalid_params( + "browser get styles requires at least one property", + )); + } + let command = match query { + BrowserGetCommand::Url => { + return Ok(json!({ + "surface_id": self.surface_id().to_string(), + "kind": "url", + "value": self.url(), + })); + } + BrowserGetCommand::Title => { + return Ok(json!({ + "surface_id": self.surface_id().to_string(), + "kind": "title", + "value": self.title(), + })); + } + BrowserGetCommand::Text { target } => { + json!({"action": "get_text", "target": encode_target(target)}) + } + BrowserGetCommand::Html { target } => { + json!({"action": "get_html", "target": encode_target(target)}) + } + BrowserGetCommand::Value { target } => { + json!({"action": "get_value", "target": encode_target(target)}) + } + BrowserGetCommand::Attr { target, name } => { + json!({"action": "get_attr", "target": encode_target(target), "name": name}) + } + BrowserGetCommand::Count { selector } => { + json!({"action": "get_count", "selector": selector}) + } + BrowserGetCommand::Box { target } => { + json!({"action": "get_box", "target": encode_target(target)}) + } + BrowserGetCommand::Styles { target, properties } => { + json!({ + "action": "get_styles", + "target": encode_target(target), + "properties": properties, + }) + } + }; + self.run_helper(command).await + } + + async fn run_predicate( + &self, + query: BrowserPredicateCommand, + ) -> Result { + let command = match query { + BrowserPredicateCommand::Visible { target } => { + json!({"action": "is_visible", "target": encode_target(target)}) + } + BrowserPredicateCommand::Enabled { target } => { + json!({"action": "is_enabled", "target": encode_target(target)}) + } + BrowserPredicateCommand::Checked { target } => { + json!({"action": "is_checked", "target": encode_target(target)}) + } + }; + self.run_helper(command).await + } + + async fn screenshot( + &self, + path: Option, + full_document: bool, + ) -> Result { + let region = if full_document { + SnapshotRegion::FullDocument + } else { + SnapshotRegion::Visible + }; + let texture = self + .webview() + .snapshot_future(region, SnapshotOptions::NONE) + .await + .map_err(map_webkit_error)?; + let output_path = screenshot_path(path)?; + texture + .save_to_png(&output_path) + .map_err(|error| ControlError::internal(error.to_string()))?; + Ok(json!({ + "surface_id": self.surface_id().to_string(), + "path": output_path.display().to_string(), + "width": texture.width(), + "height": texture.height(), + "full_document": full_document, + })) + } + + async fn run_helper(&self, command: JsonValue) -> Result { + let body = format!( + "{}\nreturn await globalThis.__taskersBrowserHelper.run({});", + helper_bootstrap_source(), + serde_json::to_string(&command) + .map_err(|error| ControlError::invalid_params(error.to_string()))?, + ); + let value = self + .webview() + .call_async_javascript_function_future( + &body, + None::<&glib::Variant>, + None, + Some(HELPER_SOURCE_URI), + ) + .await + .map_err(map_webkit_error)?; + let payload = jsc_value_to_json(&value)?; + decode_helper_response(payload) + } +} + +fn encode_target(target: BrowserTarget) -> JsonValue { + match target { + BrowserTarget::Ref { value } => json!({ "kind": "ref", "value": value }), + BrowserTarget::Selector { value } => json!({ "kind": "selector", "value": value }), + } +} + +fn encode_wait_condition(condition: &BrowserWaitCondition) -> JsonValue { + match condition { + BrowserWaitCondition::Selector { selector } => { + json!({ "kind": "selector", "selector": selector }) + } + BrowserWaitCondition::Text { text } => json!({ "kind": "text", "text": text }), + BrowserWaitCondition::UrlMatches { pattern } => { + json!({ "kind": "url_matches", "pattern": pattern }) + } + BrowserWaitCondition::LoadState { state } => { + json!({ "kind": "load_state", "state": format!("{state:?}").to_lowercase() }) + } + BrowserWaitCondition::Function { script } => { + json!({ "kind": "function", "script": script }) + } + BrowserWaitCondition::Delay { duration_ms } => { + json!({ "kind": "delay", "duration_ms": duration_ms }) + } + } +} + +fn decode_helper_response(payload: JsonValue) -> Result { + let Some(ok) = payload.get("ok").and_then(JsonValue::as_bool) else { + return Err(ControlError::internal( + "browser helper returned an invalid response", + )); + }; + if ok { + Ok(payload.get("result").cloned().unwrap_or(JsonValue::Null)) + } else { + let code = payload + .get("code") + .and_then(JsonValue::as_str) + .unwrap_or("internal"); + let message = payload + .get("message") + .and_then(JsonValue::as_str) + .unwrap_or("browser helper failed"); + Err(match code { + "invalid_params" => ControlError::invalid_params(message), + "not_found" => ControlError::not_found(message), + "timeout" => ControlError::timeout(message), + "invalid_state" => ControlError::invalid_state(message), + "not_supported" => ControlError::not_supported(message), + _ => ControlError::internal(message), + }) + } +} + +fn jsc_value_to_json(value: &webkit6::javascriptcore::Value) -> Result { + if let Some(payload) = value.to_json(0) { + serde_json::from_str(payload.as_str()) + .map_err(|error| ControlError::internal(error.to_string())) + } else if value.is_boolean() { + Ok(json!(value.to_boolean())) + } else if value.is_number() { + Ok(json!(value.to_double())) + } else if value.is_string() { + Ok(json!(value.to_str().to_string())) + } else { + Ok(json!({ + "result_type": "string", + "text": value.to_str().to_string(), + })) + } +} + +fn map_webkit_error(error: glib::Error) -> ControlError { + ControlError::invalid_state(error.to_string()) +} + +fn merge_json_object(target: &mut JsonValue, extra: JsonValue) { + let Some(target_object) = target.as_object_mut() else { + return; + }; + let Some(extra_object) = extra.as_object() else { + return; + }; + for (key, value) in extra_object { + target_object.insert(key.clone(), value.clone()); + } +} + +fn screenshot_path(path: Option) -> Result { + match path { + Some(path) => Ok(PathBuf::from(path)), + None => { + let timestamp = glib::DateTime::now_local() + .map_err(|error| ControlError::internal(error.to_string()))? + .format("%Y%m%d-%H%M%S") + .map_err(|error| ControlError::internal(error.to_string()))?; + Ok(std::env::temp_dir().join(format!("taskers-browser-{}.png", timestamp))) + } + } +} + +fn helper_bootstrap_source() -> &'static str { + r#" +if (!globalThis.__taskersBrowserHelper) { + globalThis.__taskersBrowserHelper = (() => { + let refCounter = 0; + let refTable = new Map(); + + function ok(result) { + return { ok: true, result }; + } + + function fail(code, message) { + return { ok: false, code, message }; + } + + function resetRefs() { + refCounter = 0; + refTable = new Map(); + } + + function nextRef(element) { + const ref = `@e${++refCounter}`; + refTable.set(ref, element); + return ref; + } + + function roleFor(element) { + if (!(element instanceof Element)) { + return "node"; + } + const explicit = element.getAttribute("role"); + if (explicit) { + return explicit; + } + const tag = element.tagName.toLowerCase(); + if (tag === "a" && element.hasAttribute("href")) return "link"; + if (tag === "button") return "button"; + if (tag === "input") { + const type = (element.getAttribute("type") || "text").toLowerCase(); + if (type === "checkbox") return "checkbox"; + if (type === "radio") return "radio"; + if (type === "submit" || type === "button") return "button"; + return "textbox"; + } + if (tag === "textarea") return "textbox"; + if (tag === "select") return "combobox"; + if (tag === "option") return "option"; + if (tag === "img") return "img"; + if (tag === "form") return "form"; + return tag; + } + + function isVisible(element) { + if (!(element instanceof Element)) { + return false; + } + const style = window.getComputedStyle(element); + if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) { + return false; + } + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + } + + function isEnabled(element) { + return !(element instanceof HTMLButtonElement + || element instanceof HTMLInputElement + || element instanceof HTMLSelectElement + || element instanceof HTMLTextAreaElement) + ? element.getAttribute("aria-disabled") !== "true" + : !element.disabled; + } + + function elementName(element) { + if (!(element instanceof Element)) { + return ""; + } + const labelledBy = element.getAttribute("aria-labelledby"); + if (labelledBy) { + const label = labelledBy + .split(/\s+/) + .map((id) => document.getElementById(id)) + .filter(Boolean) + .map((node) => node.textContent || "") + .join(" ") + .trim(); + if (label) return label; + } + const ariaLabel = element.getAttribute("aria-label"); + if (ariaLabel) return ariaLabel.trim(); + if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement) { + if (element.labels && element.labels.length > 0) { + const labelText = Array.from(element.labels) + .map((label) => label.textContent || "") + .join(" ") + .trim(); + if (labelText) return labelText; + } + } + return ( + element.getAttribute("title") || + element.getAttribute("alt") || + element.getAttribute("placeholder") || + element.textContent || + "" + ).trim(); + } + + function nodeValue(element) { + if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement) { + return element.value; + } + return null; + } + + function nodeText(element) { + if (!(element instanceof Element)) { + return ""; + } + return (element.innerText || element.textContent || "").trim(); + } + + function bounds(element) { + const rect = element.getBoundingClientRect(); + return { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.round(rect.width), + height: Math.round(rect.height), + }; + } + + function snapshotNode(element) { + const ref = nextRef(element); + return { + ref, + role: roleFor(element), + name: elementName(element) || null, + text: nodeText(element) || null, + value: nodeValue(element), + checked: "checked" in element ? Boolean(element.checked) : null, + selected: "selected" in element ? Boolean(element.selected) : null, + disabled: !isEnabled(element), + visible: isVisible(element), + bounds: bounds(element), + children: Array.from(element.children).map(snapshotNode), + }; + } + + function resolveTarget(target) { + if (!target || typeof target !== "object") { + throw fail("invalid_params", "browser action requires a target"); + } + if (target.kind === "ref") { + const element = refTable.get(target.value); + if (!element) { + throw fail("invalid_state", `browser ref ${target.value} is no longer valid`); + } + return element; + } + if (target.kind === "selector") { + const element = document.querySelector(target.value); + if (!element) { + throw fail("not_found", `selector not found: ${target.value}`); + } + return element; + } + throw fail("invalid_params", "unknown browser target"); + } + + function resolveOptionalTarget(target) { + if (!target) { + return document.activeElement || document.body || document.documentElement; + } + return resolveTarget(target); + } + + function ensureEditable(element) { + if ( + element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement || + element instanceof HTMLSelectElement || + element.isContentEditable + ) { + return element; + } + throw fail("invalid_state", "target element is not editable"); + } + + function dispatchInput(element) { + element.dispatchEvent(new Event("input", { bubbles: true, cancelable: true })); + element.dispatchEvent(new Event("change", { bubbles: true, cancelable: true })); + } + + function writeText(element, text, append) { + ensureEditable(element); + element.focus(); + if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { + element.value = append ? `${element.value}${text}` : text; + dispatchInput(element); + return; + } + if (element instanceof HTMLSelectElement) { + element.value = text; + dispatchInput(element); + return; + } + if (element.isContentEditable) { + element.textContent = append ? `${element.textContent || ""}${text}` : text; + dispatchInput(element); + return; + } + throw fail("invalid_state", "target element cannot accept text"); + } + + function dispatchKeyboard(element, type, key) { + element.dispatchEvent(new KeyboardEvent(type, { + key, + bubbles: true, + cancelable: true, + })); + } + + function maybeInsertKey(element, key) { + if (key.length !== 1) { + return; + } + if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { + element.value = `${element.value}${key}`; + dispatchInput(element); + return; + } + if (element.isContentEditable) { + element.textContent = `${element.textContent || ""}${key}`; + dispatchInput(element); + } + } + + function actionResult(snapshotAfter) { + if (!snapshotAfter) { + return {}; + } + return { post_action_snapshot: snapshot() }; + } + + function snapshot() { + resetRefs(); + const root = document.body || document.documentElement; + return snapshotNode(root); + } + + async function run(command) { + try { + switch (command.action) { + case "snapshot": + return ok(snapshot()); + case "probe_selector": + return ok({ matched: Boolean(document.querySelector(command.selector)) }); + case "probe_text": { + const haystack = (document.body?.innerText || document.documentElement?.innerText || ""); + return ok({ matched: haystack.includes(command.text) }); + } + case "probe_function": { + const result = await Promise.resolve((0, eval)(command.script)); + return ok({ matched: Boolean(result) }); + } + case "click": { + const element = resolveTarget(command.target); + element.click(); + return ok(actionResult(command.snapshot_after)); + } + case "dblclick": { + const element = resolveTarget(command.target); + element.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true })); + return ok(actionResult(command.snapshot_after)); + } + case "hover": { + const element = resolveTarget(command.target); + element.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, cancelable: true })); + element.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, cancelable: true })); + return ok(actionResult(command.snapshot_after)); + } + case "focus": { + const element = resolveTarget(command.target); + element.focus(); + return ok(actionResult(command.snapshot_after)); + } + case "type": { + const element = resolveTarget(command.target); + writeText(element, command.text, true); + return ok(actionResult(command.snapshot_after)); + } + case "fill": { + const element = resolveTarget(command.target); + writeText(element, command.text, false); + return ok(actionResult(command.snapshot_after)); + } + case "press": + case "keydown": + case "keyup": { + const element = resolveOptionalTarget(command.target); + element.focus(); + if (command.action === "press" || command.action === "keydown") { + dispatchKeyboard(element, "keydown", command.key); + } + if (command.action === "press") { + maybeInsertKey(element, command.key); + dispatchKeyboard(element, "keyup", command.key); + } + if (command.action === "keyup") { + dispatchKeyboard(element, "keyup", command.key); + } + return ok(actionResult(command.snapshot_after)); + } + case "check": + case "uncheck": { + const element = resolveTarget(command.target); + if (!(element instanceof HTMLInputElement) || (element.type !== "checkbox" && element.type !== "radio")) { + throw fail("invalid_state", "target element is not a checkbox or radio input"); + } + element.checked = command.action === "check"; + dispatchInput(element); + return ok(actionResult(command.snapshot_after)); + } + case "select": { + const element = resolveTarget(command.target); + if (!(element instanceof HTMLSelectElement)) { + throw fail("invalid_state", "target element is not a select"); + } + const wanted = new Set(command.values || []); + let matched = false; + for (const option of Array.from(element.options)) { + const shouldSelect = wanted.has(option.value) || wanted.has(option.text); + option.selected = shouldSelect; + matched = matched || shouldSelect; + } + if (!matched && wanted.size > 0) { + throw fail("not_found", "no matching select option found"); + } + dispatchInput(element); + return ok(actionResult(command.snapshot_after)); + } + case "scroll": { + if (command.target) { + const element = resolveTarget(command.target); + element.scrollBy(command.dx || 0, command.dy || 0); + } else { + window.scrollBy(command.dx || 0, command.dy || 0); + } + return ok(actionResult(command.snapshot_after)); + } + case "scroll_into_view": { + const element = resolveTarget(command.target); + element.scrollIntoView({ block: "center", inline: "center" }); + return ok(actionResult(command.snapshot_after)); + } + case "get_text": { + const element = resolveTarget(command.target); + return ok({ value: nodeText(element) }); + } + case "get_html": { + const element = resolveTarget(command.target); + return ok({ value: element.outerHTML }); + } + case "get_value": { + const element = resolveTarget(command.target); + return ok({ value: nodeValue(element) }); + } + case "get_attr": { + const element = resolveTarget(command.target); + return ok({ value: element.getAttribute(command.name) }); + } + case "get_count": + return ok({ value: document.querySelectorAll(command.selector).length }); + case "get_box": { + const element = resolveTarget(command.target); + return ok({ value: bounds(element) }); + } + case "get_styles": { + const element = resolveTarget(command.target); + const computed = window.getComputedStyle(element); + const styles = {}; + for (const property of command.properties || []) { + styles[property] = computed.getPropertyValue(property); + } + return ok({ value: styles }); + } + case "is_visible": { + const element = resolveTarget(command.target); + return ok({ value: isVisible(element) }); + } + case "is_enabled": { + const element = resolveTarget(command.target); + return ok({ value: isEnabled(element) }); + } + case "is_checked": { + const element = resolveTarget(command.target); + return ok({ value: Boolean("checked" in element ? element.checked : false) }); + } + default: + return fail("not_supported", `unsupported browser helper action: ${command.action}`); + } + } catch (error) { + if (error && typeof error === "object" && "ok" in error && error.ok === false) { + return error; + } + const message = error instanceof Error ? error.message : String(error); + return fail("internal", message); + } + } + + return { run }; + })(); +} +"# +} diff --git a/crates/taskers-host/src/lib.rs b/crates/taskers-host/src/lib.rs index f8d1a9c..cd6a459 100644 --- a/crates/taskers-host/src/lib.rs +++ b/crates/taskers-host/src/lib.rs @@ -1,3 +1,5 @@ +mod browser_automation; + use anyhow::{Result, anyhow, bail}; use gtk::{ Align, Box as GtkBox, CssProvider, EventControllerFocus, EventControllerScroll, @@ -11,14 +13,15 @@ use std::{ sync::Arc, time::{SystemTime, UNIX_EPOCH}, }; -use taskers_shell_core as taskers_core; +use taskers_control::{BrowserControlCommand, BrowserLoadState, ControlError}; use taskers_core::{ - BrowserMountSpec, HostCommand, HostEvent, PortalSurfacePlan, ShellDragMode, ShellSnapshot, - SurfaceId, SurfaceMountSpec, SurfacePortalPlan, TerminalMountSpec, + BrowserSurfaceCatalogEntry, HostCommand, HostEvent, PaneId, PortalSurfacePlan, ShellDragMode, + ShellSnapshot, SurfaceId, SurfaceMountSpec, SurfacePortalPlan, TerminalMountSpec, WorkspaceId, }; use taskers_domain::PaneKind; use taskers_ghostty::{GhosttyHost, SurfaceDescriptor}; -use webkit6::{Settings as WebKitSettings, WebView, prelude::*}; +use taskers_shell_core as taskers_core; +use webkit6::{LoadEvent, Settings as WebKitSettings, WebView, prelude::*}; pub type HostEventSink = Rc; pub type DiagnosticsSink = Arc; @@ -100,6 +103,83 @@ pub struct TaskersHost { terminal_surfaces: HashMap, } +#[derive(Clone)] +pub struct BrowserSurfaceHandle { + surface_id: SurfaceId, + workspace_id: Rc>, + pane_id: Rc>, + webview: WebView, + last_load_state: Rc>>, +} + +impl BrowserSurfaceHandle { + pub fn surface_id(&self) -> SurfaceId { + self.surface_id + } + + pub fn workspace_id(&self) -> WorkspaceId { + self.workspace_id.get() + } + + pub fn pane_id(&self) -> PaneId { + self.pane_id.get() + } + + pub(crate) fn webview(&self) -> &WebView { + &self.webview + } + + pub(crate) fn url(&self) -> String { + self.webview + .uri() + .map(|uri| uri.to_string()) + .unwrap_or_default() + } + + pub(crate) fn title(&self) -> String { + self.webview + .title() + .map(|title| title.to_string()) + .unwrap_or_default() + } + + pub(crate) fn is_loading(&self) -> bool { + self.webview.is_loading() + } + + pub(crate) fn load_state(&self) -> Option { + self.last_load_state.get() + } + + pub(crate) fn focus_webview(&self) { + self.webview.grab_focus(); + } + + pub(crate) fn is_webview_focused(&self) -> bool { + self.webview.has_focus() + } + + pub(crate) fn navigate(&self, url: &str) { + self.webview.load_uri(url); + } + + pub(crate) fn go_back(&self) { + if self.webview.can_go_back() { + self.webview.go_back(); + } + } + + pub(crate) fn go_forward(&self) { + if self.webview.can_go_forward() { + self.webview.go_forward(); + } + } + + pub(crate) fn reload(&self) { + self.webview.reload(); + } +} + impl TaskersHost { pub fn new( shell_widget: &impl IsA, @@ -167,7 +247,7 @@ impl TaskersHost { format!("host sync start panes={}", snapshot.portal.panes.len()), ), ); - self.sync_browser_surfaces(&snapshot.portal, snapshot.revision, interactive)?; + self.sync_browser_surfaces(snapshot, interactive)?; self.sync_terminal_surfaces(&snapshot.portal, snapshot.revision, interactive)?; Ok(()) } @@ -204,6 +284,15 @@ impl TaskersHost { } } + pub async fn execute_browser_command( + &self, + command: BrowserControlCommand, + ) -> Result { + let surface_id = browser_command_surface_id(&command); + let handle = self.browser_surface_handle(surface_id)?; + handle.execute(command).await + } + fn with_browser_surface( &mut self, surface_id: SurfaceId, @@ -226,17 +315,30 @@ impl TaskersHost { Ok(()) } - fn sync_browser_surfaces( - &mut self, - portal: &SurfacePortalPlan, - revision: u64, - interactive: bool, - ) -> Result<()> { - let desired = browser_plans(portal); - let desired_ids = desired + pub fn browser_surface_handle( + &self, + surface_id: SurfaceId, + ) -> Result { + self.browser_surfaces + .get(&surface_id) + .map(BrowserSurface::handle) + .ok_or_else(|| { + ControlError::not_found(format!("browser surface {surface_id} not found")) + }) + } + + fn sync_browser_surfaces(&mut self, snapshot: &ShellSnapshot, interactive: bool) -> Result<()> { + let desired = browser_plans(&snapshot.portal); + let desired_by_id = desired + .into_iter() + .map(|plan| (plan.surface_id, plan)) + .collect::>(); + let catalog_by_id = snapshot + .browser_catalog .iter() - .map(|plan| plan.surface_id) - .collect::>(); + .map(|entry| (entry.surface_id, entry)) + .collect::>(); + let desired_ids = catalog_by_id.keys().copied().collect::>(); let stale = self .browser_surfaces @@ -252,7 +354,7 @@ impl TaskersHost { self.diagnostics.as_ref(), DiagnosticRecord::new( DiagnosticCategory::SurfaceLifecycle, - Some(revision), + Some(snapshot.revision), "browser surface removed", ) .with_surface(surface_id), @@ -260,25 +362,28 @@ impl TaskersHost { } } - for plan in desired { - match self.browser_surfaces.get_mut(&plan.surface_id) { + for entry in snapshot.browser_catalog.iter() { + let visible_plan = desired_by_id.get(&entry.surface_id); + match self.browser_surfaces.get_mut(&entry.surface_id) { Some(surface) => surface.sync( &self.root, - &plan, - revision, + entry, + visible_plan, + snapshot.revision, interactive, self.diagnostics.as_ref(), )?, None => { let surface = BrowserSurface::new( &self.root, - &plan, - revision, + entry, + visible_plan, + snapshot.revision, interactive, self.event_sink.clone(), self.diagnostics.clone(), )?; - self.browser_surfaces.insert(plan.surface_id, surface); + self.browser_surfaces.insert(entry.surface_id, surface); } } } @@ -357,11 +462,15 @@ impl TaskersHost { struct BrowserSurface { shell: NativeSurfaceShell, surface_id: SurfaceId, + workspace_id: Rc>, + pane_id: Rc>, webview: WebView, url: String, active: bool, interactive: bool, + visible: bool, devtools_open: Rc>, + last_load_state: Rc>>, event_sink: HostEventSink, diagnostics: Option, } @@ -369,13 +478,14 @@ struct BrowserSurface { impl BrowserSurface { fn new( overlay: &Overlay, - plan: &PortalSurfacePlan, + entry: &BrowserSurfaceCatalogEntry, + visible_plan: Option<&PortalSurfacePlan>, revision: u64, interactive: bool, event_sink: HostEventSink, diagnostics: Option, ) -> Result { - let BrowserMountSpec { url } = browser_spec(plan)?.clone(); + let url = entry.url.clone(); let settings = WebKitSettings::builder() .enable_back_forward_navigation_gestures(true) @@ -390,23 +500,30 @@ impl BrowserSurface { let (shell_class, widget_class) = native_surface_classes(PaneKind::Browser); webview.add_css_class("native-surface-widget"); webview.add_css_class(widget_class); - webview.set_can_target(interactive); + webview.set_can_target(visible_plan.is_some() && interactive); webview.load_uri(&url); (event_sink)(HostEvent::SurfaceUrlChanged { - surface_id: plan.surface_id, + surface_id: entry.surface_id, url: url.clone(), }); - let shell = NativeSurfaceShell::new(shell_class, interactive); + let shell = NativeSurfaceShell::new(shell_class, visible_plan.is_some() && interactive); shell.mount_child(webview.upcast_ref()); - shell.position(overlay, plan.frame); + match visible_plan { + Some(plan) => shell.show_at(overlay, plan.frame), + None => shell.park_hidden(overlay), + } let devtools_open = Rc::new(Cell::new(false)); + let workspace_id = Rc::new(Cell::new(entry.workspace_id)); + let pane_id = Rc::new(Cell::new(entry.pane_id)); + let last_load_state = Rc::new(Cell::new(None)); - let pane_id = plan.pane_id; - let surface_id = plan.surface_id; + let focus_pane_id = pane_id.clone(); + let surface_id = entry.surface_id; let focus_sink = event_sink.clone(); let focus_diagnostics = diagnostics.clone(); let focus = EventControllerFocus::new(); focus.connect_enter(move |_| { + let pane_id = focus_pane_id.get(); emit_diagnostic( focus_diagnostics.as_ref(), DiagnosticRecord::new( @@ -421,10 +538,12 @@ impl BrowserSurface { }); webview.add_controller(focus); + let click_pane_id = pane_id.clone(); let click_sink = event_sink.clone(); let click_diagnostics = diagnostics.clone(); let click = GestureClick::new(); click.connect_pressed(move |_, _, _, _| { + let pane_id = click_pane_id.get(); emit_diagnostic( click_diagnostics.as_ref(), DiagnosticRecord::new( @@ -439,7 +558,7 @@ impl BrowserSurface { }); webview.add_controller(click); - let surface_id = plan.surface_id; + let surface_id = entry.surface_id; let title_sink = event_sink.clone(); let title_diagnostics = diagnostics.clone(); webview.connect_title_notify(move |web_view| { @@ -460,11 +579,13 @@ impl BrowserSurface { } }); - let surface_id = plan.surface_id; + let surface_id = entry.surface_id; let navigation_sink = event_sink.clone(); let navigation_diagnostics = diagnostics.clone(); let navigation_devtools = devtools_open.clone(); - webview.connect_load_changed(move |web_view, _| { + let load_state_cell = last_load_state.clone(); + webview.connect_load_changed(move |web_view, load_event| { + load_state_cell.set(Some(browser_load_state_from_webkit(load_event))); emit_browser_navigation_state( web_view, surface_id, @@ -474,7 +595,7 @@ impl BrowserSurface { ); }); - let url_surface_id = plan.surface_id; + let url_surface_id = entry.surface_id; let url_sink = event_sink; let uri_sink = url_sink.clone(); let url_diagnostics = diagnostics.clone(); @@ -511,7 +632,7 @@ impl BrowserSurface { let navigation_sink = url_sink.clone(); let navigation_diagnostics = diagnostics.clone(); let navigation_devtools = devtools_open.clone(); - let inspector_surface_id = plan.surface_id; + let inspector_surface_id = entry.surface_id; inspector.connect_closed(move |_| { navigation_devtools.set(false); emit_browser_navigation_state( @@ -524,7 +645,7 @@ impl BrowserSurface { }); } - if plan.active && interactive { + if visible_plan.is_some_and(|plan| plan.active) && interactive { webview.grab_focus(); } @@ -535,13 +656,13 @@ impl BrowserSurface { Some(revision), "browser surface created", ) - .with_pane(plan.pane_id) - .with_surface(plan.surface_id), + .with_pane(entry.pane_id) + .with_surface(entry.surface_id), ); emit_browser_navigation_state( &webview, - plan.surface_id, + entry.surface_id, devtools_open.get(), &url_sink, diagnostics.as_ref(), @@ -549,12 +670,16 @@ impl BrowserSurface { Ok(Self { shell, - surface_id: plan.surface_id, + surface_id: entry.surface_id, + workspace_id, + pane_id, webview, url, - active: plan.active, - interactive, + active: visible_plan.is_some_and(|plan| plan.active), + interactive: visible_plan.is_some() && interactive, + visible: visible_plan.is_some(), devtools_open, + last_load_state, event_sink: url_sink, diagnostics, }) @@ -563,25 +688,36 @@ impl BrowserSurface { fn sync( &mut self, overlay: &Overlay, - plan: &PortalSurfacePlan, + entry: &BrowserSurfaceCatalogEntry, + visible_plan: Option<&PortalSurfacePlan>, revision: u64, interactive: bool, diagnostics: Option<&DiagnosticsSink>, ) -> Result<()> { - self.shell.position(overlay, plan.frame); - self.shell.set_interactive(interactive); - self.webview.set_can_target(interactive); + self.workspace_id.set(entry.workspace_id); + self.pane_id.set(entry.pane_id); + let visible = visible_plan.is_some(); + let effective_interactive = visible && interactive; + self.shell.set_interactive(effective_interactive); + self.webview.set_can_target(effective_interactive); + match visible_plan { + Some(plan) => self.shell.show_at(overlay, plan.frame), + None => self.shell.park_hidden(overlay), + } - let BrowserMountSpec { url } = browser_spec(plan)?; - if self.url != *url { - self.webview.load_uri(url); - self.url = url.clone(); + if self.url != entry.url { + self.webview.load_uri(&entry.url); + self.url = entry.url.clone(); } - if plan.active && interactive && (!self.active || !self.interactive) { + if visible_plan.is_some_and(|plan| plan.active) + && effective_interactive + && (!self.active || !self.interactive || !self.visible) + { self.webview.grab_focus(); } - self.active = plan.active; - self.interactive = interactive; + self.active = visible_plan.is_some_and(|plan| plan.active); + self.interactive = effective_interactive; + self.visible = visible; emit_diagnostic( diagnostics, @@ -590,8 +726,8 @@ impl BrowserSurface { Some(revision), "browser surface updated", ) - .with_pane(plan.pane_id) - .with_surface(plan.surface_id), + .with_pane(entry.pane_id) + .with_surface(entry.surface_id), ); Ok(()) @@ -602,7 +738,6 @@ impl BrowserSurface { self.webview.load_uri(url); self.url = url.to_string(); } - self.webview.grab_focus(); self.emit_navigation_state(); } @@ -610,7 +745,6 @@ impl BrowserSurface { if self.webview.can_go_back() { self.webview.go_back(); } - self.webview.grab_focus(); self.emit_navigation_state(); } @@ -618,13 +752,11 @@ impl BrowserSurface { if self.webview.can_go_forward() { self.webview.go_forward(); } - self.webview.grab_focus(); self.emit_navigation_state(); } fn reload(&mut self) { self.webview.reload(); - self.webview.grab_focus(); self.emit_navigation_state(); } @@ -641,7 +773,6 @@ impl BrowserSurface { inspector.show(); self.devtools_open.set(true); } - self.webview.grab_focus(); self.emit_navigation_state(); } @@ -654,6 +785,16 @@ impl BrowserSurface { self.diagnostics.as_ref(), ); } + + fn handle(&self) -> BrowserSurfaceHandle { + BrowserSurfaceHandle { + surface_id: self.surface_id, + workspace_id: self.workspace_id.clone(), + pane_id: self.pane_id.clone(), + webview: self.webview.clone(), + last_load_state: self.last_load_state.clone(), + } + } } struct TerminalSurface { @@ -780,6 +921,16 @@ impl NativeSurfaceShell { position_widget(overlay, self.root.upcast_ref(), frame); } + fn show_at(&self, overlay: &Overlay, frame: taskers_core::Frame) { + self.root.set_opacity(1.0); + self.position(overlay, frame); + } + + fn park_hidden(&self, overlay: &Overlay) { + self.root.set_opacity(0.0); + self.position(overlay, self.hidden_frame()); + } + fn set_interactive(&self, interactive: bool) { self.root.set_can_target(interactive); } @@ -787,6 +938,10 @@ impl NativeSurfaceShell { fn detach(&self, overlay: &Overlay) { detach_from_overlay(overlay, self.root.upcast_ref()); } + + fn hidden_frame(&self) -> taskers_core::Frame { + taskers_core::Frame::new(100_000, 100_000, 1, 1) + } } fn install_native_surface_css() { @@ -972,6 +1127,47 @@ fn emit_browser_navigation_state( }); } +fn browser_load_state_from_webkit(load_event: LoadEvent) -> BrowserLoadState { + match load_event { + LoadEvent::Started => BrowserLoadState::Started, + LoadEvent::Redirected => BrowserLoadState::Redirected, + LoadEvent::Committed => BrowserLoadState::Committed, + LoadEvent::Finished => BrowserLoadState::Finished, + _ => BrowserLoadState::Finished, + } +} + +fn browser_command_surface_id(command: &BrowserControlCommand) -> SurfaceId { + match command { + BrowserControlCommand::Navigate { surface_id, .. } + | BrowserControlCommand::Back { surface_id } + | BrowserControlCommand::Forward { surface_id } + | BrowserControlCommand::Reload { surface_id } + | BrowserControlCommand::FocusWebview { surface_id } + | BrowserControlCommand::IsWebviewFocused { surface_id } + | BrowserControlCommand::Snapshot { surface_id } + | BrowserControlCommand::Eval { surface_id, .. } + | BrowserControlCommand::Wait { surface_id, .. } + | BrowserControlCommand::Click { surface_id, .. } + | BrowserControlCommand::Dblclick { surface_id, .. } + | BrowserControlCommand::Type { surface_id, .. } + | BrowserControlCommand::Fill { surface_id, .. } + | BrowserControlCommand::Press { surface_id, .. } + | BrowserControlCommand::Keydown { surface_id, .. } + | BrowserControlCommand::Keyup { surface_id, .. } + | BrowserControlCommand::Hover { surface_id, .. } + | BrowserControlCommand::Focus { surface_id, .. } + | BrowserControlCommand::Check { surface_id, .. } + | BrowserControlCommand::Uncheck { surface_id, .. } + | BrowserControlCommand::Select { surface_id, .. } + | BrowserControlCommand::Scroll { surface_id, .. } + | BrowserControlCommand::ScrollIntoView { surface_id, .. } + | BrowserControlCommand::Get { surface_id, .. } + | BrowserControlCommand::Is { surface_id, .. } + | BrowserControlCommand::Screenshot { surface_id, .. } => *surface_id, + } +} + fn surface_descriptor_from(spec: &TerminalMountSpec) -> SurfaceDescriptor { SurfaceDescriptor { cols: spec.cols, @@ -988,13 +1184,6 @@ fn surface_descriptor_from(spec: &TerminalMountSpec) -> SurfaceDescriptor { } } -fn browser_spec(plan: &PortalSurfacePlan) -> Result<&BrowserMountSpec> { - match &plan.mount { - SurfaceMountSpec::Browser(spec) => Ok(spec), - SurfaceMountSpec::Terminal(_) => bail!("surface {} is not a browser", plan.surface_id), - } -} - fn terminal_spec(plan: &PortalSurfacePlan) -> Result<&TerminalMountSpec> { match &plan.mount { SurfaceMountSpec::Terminal(spec) => Ok(spec), @@ -1102,8 +1291,8 @@ mod tests { browser_plans, native_surface_classes, native_surface_css, native_surfaces_interactive, terminal_plans, workspace_pan_delta, }; - use taskers_shell_core::{BootstrapModel, SharedCore, ShellDragMode, SurfaceMountSpec}; use taskers_domain::PaneKind; + use taskers_shell_core::{BootstrapModel, SharedCore, ShellDragMode, SurfaceMountSpec}; #[test] fn partitions_portal_plans_by_surface_kind() { diff --git a/crates/taskers-shell-core/src/lib.rs b/crates/taskers-shell-core/src/lib.rs index 6bd103d..d8dd72a 100644 --- a/crates/taskers-shell-core/src/lib.rs +++ b/crates/taskers-shell-core/src/lib.rs @@ -5,8 +5,8 @@ use std::{ path::PathBuf, sync::Arc, }; -use taskers_core::{AppState, default_session_path}; use taskers_control::{ControlCommand, ControlResponse}; +use taskers_core::{AppState, default_session_path}; use taskers_domain::{ ActivityItem, AppModel, DEFAULT_WORKSPACE_WINDOW_GAP, KEYBOARD_RESIZE_STEP, MIN_WORKSPACE_WINDOW_HEIGHT, MIN_WORKSPACE_WINDOW_WIDTH, PaneKind, PaneMetadata, @@ -727,6 +727,14 @@ pub struct WorkspaceLogEntrySnapshot { pub timestamp: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BrowserSurfaceCatalogEntry { + pub workspace_id: WorkspaceId, + pub pane_id: PaneId, + pub surface_id: SurfaceId, + pub url: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct PullRequestSnapshot { pub number: u32, @@ -912,6 +920,7 @@ pub struct ShellSnapshot { pub current_workspace_status: Option, pub current_workspace_progress: Option, pub current_workspace_log: Vec, + pub browser_catalog: Vec, pub portal: SurfacePortalPlan, pub metrics: LayoutMetrics, pub runtime_status: RuntimeStatus, @@ -1243,6 +1252,7 @@ impl TaskersCore { current_workspace_status: workspace.status_text.clone(), current_workspace_progress, current_workspace_log, + browser_catalog: self.browser_catalog_snapshot(&model), portal: SurfacePortalPlan { window: Frame::new(0, 0, self.ui.window_size.width, self.ui.window_size.height), content: viewport, @@ -1566,8 +1576,7 @@ impl TaskersCore { pane_id: pane.id, surface_id: surface.id, title: display_surface_title(surface), - url: normalized_surface_url(surface) - .unwrap_or_else(|| DEFAULT_BROWSER_HOME.into()), + url: normalized_surface_url(surface).unwrap_or_else(|| DEFAULT_BROWSER_HOME.into()), can_go_back: self .browser_navigation .get(&surface.id) @@ -1586,6 +1595,31 @@ impl TaskersCore { }) } + fn browser_catalog_snapshot(&self, model: &AppModel) -> Vec { + let mut catalog = Vec::new(); + for (workspace_id, workspace) in &model.workspaces { + for pane in workspace.panes.values() { + for surface in pane.surfaces.values() { + if surface.kind != PaneKind::Browser { + continue; + } + let descriptor = fallback_surface_descriptor(surface); + let mount = mount_spec_from_descriptor(surface, descriptor); + let SurfaceMountSpec::Browser(BrowserMountSpec { url }) = mount else { + continue; + }; + catalog.push(BrowserSurfaceCatalogEntry { + workspace_id: *workspace_id, + pane_id: pane.id, + surface_id: surface.id, + url, + }); + } + } + } + catalog + } + fn collect_workspace_surface_plans( &self, workspace_id: WorkspaceId, @@ -3463,21 +3497,29 @@ fn next_workspace_label(model: &AppModel) -> String { } fn workspace_progress_snapshot(workspace: &Workspace) -> Option { - workspace.progress.as_ref().map(|progress| ProgressSnapshot { - fraction: f32::from(progress.value.min(1000)) / 1000.0, - label: progress.label.clone(), - }).or_else(|| { - workspace - .panes - .values() - .flat_map(|pane| pane.surfaces.values()) - .find_map(|surface| { - surface.metadata.progress.as_ref().map(|progress| ProgressSnapshot { - fraction: f32::from(progress.value.min(1000)) / 1000.0, - label: progress.label.clone(), + workspace + .progress + .as_ref() + .map(|progress| ProgressSnapshot { + fraction: f32::from(progress.value.min(1000)) / 1000.0, + label: progress.label.clone(), + }) + .or_else(|| { + workspace + .panes + .values() + .flat_map(|pane| pane.surfaces.values()) + .find_map(|surface| { + surface + .metadata + .progress + .as_ref() + .map(|progress| ProgressSnapshot { + fraction: f32::from(progress.value.min(1000)) / 1000.0, + label: progress.label.clone(), + }) }) - }) - }) + }) } fn attention_panel_visible(model: &AppModel) -> bool { @@ -3489,10 +3531,7 @@ fn attention_panel_visible(model: &AppModel) -> bool { .any(|summary| !summary.agent_summaries.is_empty() || summary.status_text.is_some()) }) .unwrap_or(false) - || model - .workspaces - .values() - .any(|workspace| { + || model.workspaces.values().any(|workspace| { !workspace.notifications.is_empty() || !workspace.log_entries.is_empty() || workspace.progress.is_some() @@ -3603,8 +3642,8 @@ fn is_local_browser_target(value: &str) -> bool { #[cfg(test)] mod tests { - use taskers_core::AppState; use taskers_control::ControlCommand; + use taskers_core::AppState; use taskers_domain::{ AppModel, AttentionState as DomainAttentionState, NotificationItem, SignalKind, }; @@ -3910,6 +3949,37 @@ mod tests { ); } + #[test] + fn browser_catalog_keeps_background_browser_surfaces() { + let core = SharedCore::bootstrap(bootstrap()); + let first_workspace_id = core.snapshot().current_workspace.id; + let first_pane_id = core.snapshot().current_workspace.active_pane; + core.dispatch_shell_action(ShellAction::SplitBrowser { + pane_id: Some(first_pane_id), + }); + + core.dispatch_shell_action(ShellAction::CreateWorkspace); + let second_workspace_id = core.snapshot().current_workspace.id; + let second_pane_id = core.snapshot().current_workspace.active_pane; + core.dispatch_shell_action(ShellAction::SplitBrowser { + pane_id: Some(second_pane_id), + }); + + let catalog = core.snapshot().browser_catalog; + assert!(catalog.len() >= 2); + assert!( + catalog + .iter() + .any(|entry| entry.workspace_id == first_workspace_id) + ); + assert!( + catalog + .iter() + .any(|entry| entry.workspace_id == second_workspace_id) + ); + assert!(catalog.iter().all(|entry| !entry.url.is_empty())); + } + #[test] fn browser_navigation_host_events_update_browser_chrome_snapshot() { let core = SharedCore::bootstrap(bootstrap()); @@ -3942,13 +4012,19 @@ mod tests { let core = SharedCore::bootstrap(bootstrap()); core.dispatch_shell_action(ShellAction::SplitBrowser { pane_id: None }); - let browser = core.snapshot().browser_chrome.expect("active browser chrome"); + let browser = core + .snapshot() + .browser_chrome + .expect("active browser chrome"); core.apply_host_event(HostEvent::SurfaceUrlChanged { surface_id: browser.surface_id, url: "about:blank".into(), }); - let browser = core.snapshot().browser_chrome.expect("active browser chrome"); + let browser = core + .snapshot() + .browser_chrome + .expect("active browser chrome"); assert_eq!(browser.url, "about:blank"); } @@ -4362,9 +4438,10 @@ mod tests { core.dispatch_shell_action(ShellAction::CreateWorkspace); let second_workspace_id = core.snapshot().current_workspace.id; let second_pane_id = core.snapshot().current_workspace.active_pane; - let second_surface_id = find_pane(&core.snapshot().current_workspace.layout, second_pane_id) - .map(|pane| pane.active_surface) - .expect("second surface"); + let second_surface_id = + find_pane(&core.snapshot().current_workspace.layout, second_pane_id) + .map(|pane| pane.active_surface) + .expect("second surface"); let first_workspace_id = core .snapshot() .workspaces From 0c8a15823aa011d3df642d6660f5fb5b1f951d5b Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sat, 21 Mar 2026 15:07:10 +0100 Subject: [PATCH 04/63] feat: add terminal debug and identify parity --- crates/taskers-app/src/main.rs | 113 ++++++-- crates/taskers-cli/src/main.rs | 195 +++++++++++++- crates/taskers-control/src/controller.rs | 211 ++++++++++++++- crates/taskers-control/src/lib.rs | 3 +- crates/taskers-control/src/protocol.rs | 82 +++++- crates/taskers-ghostty/src/bridge.rs | 84 +++++- crates/taskers-host/src/lib.rs | 251 ++++++++++++++---- crates/taskers-shell-core/src/lib.rs | 59 ++++ .../ghostty/include/taskers_ghostty_bridge.h | 8 + vendor/ghostty/src/taskers_bridge.zig | 53 ++++ 10 files changed, 980 insertions(+), 79 deletions(-) diff --git a/crates/taskers-app/src/main.rs b/crates/taskers-app/src/main.rs index 68efa3b..8748b3f 100644 --- a/crates/taskers-app/src/main.rs +++ b/crates/taskers-app/src/main.rs @@ -20,8 +20,8 @@ use std::{ time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use taskers_control::{ - BrowserControlCommand, ControlCommand, ControlError, ControlResponse, bind_socket, - default_socket_path, serve_with_handler, + BrowserControlCommand, ControlCommand, ControlError, ControlResponse, TerminalDebugCommand, + bind_socket, default_socket_path, serve_with_handler, }; use taskers_core::{AppState, default_session_path, load_or_bootstrap}; use taskers_domain::AppModel; @@ -87,8 +87,13 @@ struct RuntimeBootstrap { startup_notes: Vec, } -struct BrowserAutomationRequest { - command: BrowserControlCommand, +enum HostAutomationCommand { + Browser(BrowserControlCommand), + TerminalDebug(TerminalDebugCommand), +} + +struct HostAutomationRequest { + command: HostAutomationCommand, response_tx: tokio::sync::oneshot::Sender>, } @@ -196,14 +201,14 @@ fn build_ui_result( connect_navigation_shortcuts(&window, &shell_view, &core); let last_revision = Rc::new(Cell::new(0_u64)); let last_size = Rc::new(Cell::new((0_i32, 0_i32))); - let (browser_request_tx, browser_request_rx) = mpsc::channel::(); + let (host_request_tx, host_request_rx) = mpsc::channel::(); let control_server_note = spawn_control_server( bootstrap.app_state.clone(), bootstrap.socket_path, - browser_request_tx, + host_request_tx, ); - install_browser_bridge( - browser_request_rx, + install_host_bridge( + host_request_rx, &window, &core, &host, @@ -796,8 +801,8 @@ fn sync_window( host.borrow().tick(); } -fn install_browser_bridge( - receiver: Receiver, +fn install_host_bridge( + receiver: Receiver, window: &adw::ApplicationWindow, core: &SharedCore, host: &Rc>, @@ -825,7 +830,7 @@ fn install_browser_bridge( let request_size = bridge_size.clone(); let request_diagnostics = diagnostics.clone(); glib::MainContext::default().spawn_local(async move { - let response = handle_browser_request( + let response = handle_host_request( &request_window, &request_core, &request_host, @@ -842,6 +847,40 @@ fn install_browser_bridge( }); } +async fn handle_host_request( + window: &adw::ApplicationWindow, + core: &SharedCore, + host: &Rc>, + last_revision: &Rc>, + last_size: &Rc>, + diagnostics: Option<&DiagnosticsWriter>, + command: HostAutomationCommand, +) -> Result { + match command { + HostAutomationCommand::Browser(command) => { + handle_browser_request( + window, + core, + host, + last_revision, + last_size, + diagnostics, + command, + ) + .await + } + HostAutomationCommand::TerminalDebug(command) => handle_terminal_debug_request( + window, + core, + host, + last_revision, + last_size, + diagnostics, + command, + ), + } +} + async fn handle_browser_request( window: &adw::ApplicationWindow, core: &SharedCore, @@ -880,6 +919,21 @@ async fn handle_browser_request( Ok(ControlResponse::Browser { result }) } +fn handle_terminal_debug_request( + window: &adw::ApplicationWindow, + core: &SharedCore, + host: &Rc>, + last_revision: &Rc>, + last_size: &Rc>, + diagnostics: Option<&DiagnosticsWriter>, + command: TerminalDebugCommand, +) -> Result { + sync_window(window, core, host, last_revision, last_size, diagnostics); + let result = host.borrow().execute_terminal_debug(command)?; + sync_window(window, core, host, last_revision, last_size, diagnostics); + Ok(ControlResponse::TerminalDebug { result }) +} + fn browser_surface_id(command: &BrowserControlCommand) -> taskers_shell_core::SurfaceId { match command { BrowserControlCommand::Navigate { surface_id, .. } @@ -914,7 +968,7 @@ fn browser_surface_id(command: &BrowserControlCommand) -> taskers_shell_core::Su fn spawn_control_server( app_state: AppState, socket_path: PathBuf, - browser_tx: Sender, + host_tx: Sender, ) -> String { if let Some(parent) = socket_path.parent() && let Err(error) = std::fs::create_dir_all(parent) @@ -936,25 +990,48 @@ fn spawn_control_server( Ok(listener) => { let handler = move |command| { let app_state = app_state.clone(); - let browser_tx = browser_tx.clone(); + let host_tx = host_tx.clone(); async move { match command { ControlCommand::Browser { browser_command } => { let (response_tx, response_rx) = tokio::sync::oneshot::channel(); - browser_tx - .send(BrowserAutomationRequest { - command: browser_command, + host_tx + .send(HostAutomationRequest { + command: HostAutomationCommand::Browser( + browser_command, + ), + response_tx, + }) + .map_err(|_| { + ControlError::internal( + "host automation bridge is unavailable", + ) + })?; + response_rx.await.map_err(|_| { + ControlError::internal( + "host automation bridge dropped the response", + ) + })? + } + ControlCommand::TerminalDebug { debug_command } => { + let (response_tx, response_rx) = + tokio::sync::oneshot::channel(); + host_tx + .send(HostAutomationRequest { + command: HostAutomationCommand::TerminalDebug( + debug_command, + ), response_tx, }) .map_err(|_| { ControlError::internal( - "browser automation bridge is unavailable", + "host automation bridge is unavailable", ) })?; response_rx.await.map_err(|_| { ControlError::internal( - "browser automation bridge dropped the response", + "host automation bridge dropped the response", ) })? } diff --git a/crates/taskers-cli/src/main.rs b/crates/taskers-cli/src/main.rs index 27da2e2..bb7e81d 100644 --- a/crates/taskers-cli/src/main.rs +++ b/crates/taskers-cli/src/main.rs @@ -5,7 +5,8 @@ use clap::{Args, Parser, Subcommand, ValueEnum}; use taskers_control::{ BrowserControlCommand, BrowserGetCommand, BrowserLoadState, BrowserPredicateCommand, BrowserTarget, BrowserWaitCondition, ControlClient, ControlCommand, ControlQuery, - ControlResponse, InMemoryController, bind_socket, default_socket_path, serve, + ControlResponse, InMemoryController, TerminalDebugCommand, bind_socket, default_socket_path, + serve, }; use taskers_domain::{ AgentTarget, AppModel, AttentionState, Direction, KEYBOARD_RESIZE_STEP, PaneId, PaneKind, @@ -94,6 +95,20 @@ enum Command { #[command(subcommand)] command: BrowserCommand, }, + Identify { + #[arg(long)] + socket: Option, + #[arg(long)] + workspace: Option, + #[arg(long)] + pane: Option, + #[arg(long)] + surface: Option, + }, + Debug { + #[command(subcommand)] + command: DebugCommand, + }, Pane { #[command(subcommand)] command: PaneCommand, @@ -614,6 +629,32 @@ enum BrowserCommand { }, } +#[derive(Debug, Subcommand)] +enum DebugCommand { + Terminal { + #[command(subcommand)] + command: TerminalDebugCliCommand, + }, +} + +#[derive(Debug, Subcommand)] +enum TerminalDebugCliCommand { + IsFocused { + #[command(flatten)] + terminal: TerminalSurfaceArgs, + }, + ReadText { + #[command(flatten)] + terminal: TerminalSurfaceArgs, + #[arg(long)] + tail_lines: Option, + }, + RenderStats { + #[command(flatten)] + terminal: TerminalSurfaceArgs, + }, +} + #[derive(Debug, Clone, Args)] struct BrowserSurfaceArgs { #[arg(long)] @@ -626,6 +667,18 @@ struct BrowserSurfaceArgs { surface: Option, } +#[derive(Debug, Clone, Args)] +struct TerminalSurfaceArgs { + #[arg(long)] + socket: Option, + #[arg(long)] + workspace: Option, + #[arg(long)] + pane: Option, + #[arg(long)] + surface: Option, +} + #[derive(Debug, Clone, Args)] struct BrowserTargetArgs { #[arg(long = "ref")] @@ -1531,6 +1584,36 @@ async fn main() -> anyhow::Result<()> { Command::Browser { command } => { handle_browser_cli_command(command).await?; } + Command::Identify { + socket, + workspace, + pane, + surface, + } => { + let client = ControlClient::new(resolve_socket_path(socket)); + let response = send_control_command( + &client, + ControlCommand::QueryStatus { + query: ControlQuery::Identify { + workspace_id: workspace.or_else(env_workspace_id), + pane_id: pane.or_else(env_pane_id), + surface_id: surface.or_else(env_surface_id), + }, + }, + ) + .await?; + match response { + ControlResponse::Identify { result } => { + println!("{}", serde_json::to_string_pretty(&result)?); + } + other => bail!("unexpected identify response: {other:?}"), + } + } + Command::Debug { command } => match command { + DebugCommand::Terminal { command } => { + handle_terminal_debug_cli_command(command).await?; + } + }, Command::Pane { command } => match command { PaneCommand::NewWindow { socket, @@ -2371,6 +2454,47 @@ where print_browser_result(&result) } +async fn handle_terminal_debug_cli_command(command: TerminalDebugCliCommand) -> anyhow::Result<()> { + match command { + TerminalDebugCliCommand::IsFocused { terminal } => { + run_terminal_surface_command(&terminal, |surface_id| TerminalDebugCommand::IsFocused { + surface_id, + }) + .await + } + TerminalDebugCliCommand::ReadText { + terminal, + tail_lines, + } => { + run_terminal_surface_command(&terminal, |surface_id| TerminalDebugCommand::ReadText { + surface_id, + tail_lines, + }) + .await + } + TerminalDebugCliCommand::RenderStats { terminal } => { + run_terminal_surface_command(&terminal, |surface_id| { + TerminalDebugCommand::RenderStats { surface_id } + }) + .await + } + } +} + +async fn run_terminal_surface_command( + terminal: &TerminalSurfaceArgs, + build: F, +) -> anyhow::Result<()> +where + F: FnOnce(SurfaceId) -> TerminalDebugCommand, +{ + let client = ControlClient::new(resolve_socket_path(terminal.socket.clone())); + let (_, _, surface_id) = resolve_terminal_surface(&client, terminal).await?; + let result = send_terminal_debug_command(&client, build(surface_id)).await?; + println!("{}", serde_json::to_string_pretty(&result)?); + Ok(()) +} + async fn send_browser_command( client: &ControlClient, browser_command: BrowserControlCommand, @@ -2388,6 +2512,23 @@ fn print_browser_result(result: &serde_json::Value) -> anyhow::Result<()> { Ok(()) } +async fn send_terminal_debug_command( + client: &ControlClient, + command: TerminalDebugCommand, +) -> anyhow::Result { + let response = send_control_command( + client, + ControlCommand::TerminalDebug { + debug_command: command, + }, + ) + .await?; + match response { + ControlResponse::TerminalDebug { result } => Ok(serde_json::to_value(result)?), + other => bail!("unexpected terminal debug response: {other:?}"), + } +} + async fn resolve_browser_surface( client: &ControlClient, browser: &BrowserSurfaceArgs, @@ -2440,6 +2581,58 @@ async fn resolve_browser_surface( Ok((workspace_id, pane_id, surface_id)) } +async fn resolve_terminal_surface( + client: &ControlClient, + terminal: &TerminalSurfaceArgs, +) -> anyhow::Result<(WorkspaceId, PaneId, SurfaceId)> { + let model = query_model(client).await?; + if let Some(surface_id) = terminal.surface.or_else(env_surface_id) { + let (workspace_id, pane_id, kind) = find_surface_location(&model, surface_id) + .ok_or_else(|| anyhow!("surface {surface_id} is not present in the current session"))?; + if kind != PaneKind::Terminal { + bail!("surface {surface_id} is not a terminal"); + } + if let Some(workspace_id_arg) = terminal.workspace + && workspace_id_arg != workspace_id + { + bail!( + "surface {surface_id} belongs to workspace {workspace_id}, not {workspace_id_arg}" + ); + } + if let Some(pane_id_arg) = terminal.pane + && pane_id_arg != pane_id + { + bail!("surface {surface_id} belongs to pane {pane_id}, not {pane_id_arg}"); + } + return Ok((workspace_id, pane_id, surface_id)); + } + + let workspace_id = resolve_workspace_id_from_model(&model, terminal.workspace)?; + let workspace = model + .workspaces + .get(&workspace_id) + .ok_or_else(|| anyhow!("workspace {workspace_id} not found"))?; + let pane_id = terminal + .pane + .or_else(env_pane_id) + .unwrap_or(workspace.active_pane); + let pane = workspace + .panes + .get(&pane_id) + .ok_or_else(|| anyhow!("pane {pane_id} is not present in workspace {workspace_id}"))?; + let surface_id = pane.active_surface; + let surface = pane + .surfaces + .get(&surface_id) + .ok_or_else(|| anyhow!("surface {surface_id} is not present in pane {pane_id}"))?; + if surface.kind != PaneKind::Terminal { + bail!( + "active surface {surface_id} in pane {pane_id} is not a terminal; pass --surface or activate a terminal pane" + ); + } + Ok((workspace_id, pane_id, surface_id)) +} + fn find_surface_location( model: &AppModel, surface_id: SurfaceId, diff --git a/crates/taskers-control/src/controller.rs b/crates/taskers-control/src/controller.rs index 3c7f82d..007b2a7 100644 --- a/crates/taskers-control/src/controller.rs +++ b/crates/taskers-control/src/controller.rs @@ -1,8 +1,12 @@ use std::sync::{Arc, Mutex}; -use taskers_domain::{AppModel, DomainError, WindowId, WorkspaceId}; +use taskers_domain::{ + AppModel, DomainError, PaneId, PaneKind, SurfaceId, SurfaceRecord, WindowId, WorkspaceId, +}; -use crate::protocol::{ControlCommand, ControlQuery, ControlResponse}; +use crate::protocol::{ + ControlCommand, ControlQuery, ControlResponse, IdentifyContext, IdentifyResult, +}; #[derive(Debug, Clone)] pub struct InMemoryController { @@ -544,6 +548,11 @@ impl InMemoryController { "browser automation commands require a live GTK host", )); } + ControlCommand::TerminalDebug { .. } => { + return Err(DomainError::InvalidOperation( + "terminal debug commands require a live GTK host", + )); + } ControlCommand::QueryStatus { query } => match query { ControlQuery::ActiveWindow | ControlQuery::All => ( ControlResponse::Status { @@ -555,6 +564,16 @@ impl InMemoryController { ControlQuery::Workspace { workspace_id } => { (workspace_snapshot(model, workspace_id)?, false) } + ControlQuery::Identify { + workspace_id, + pane_id, + surface_id, + } => ( + ControlResponse::Identify { + result: identify_snapshot(model, workspace_id, pane_id, surface_id)?, + }, + false, + ), }, }; @@ -590,11 +609,148 @@ fn workspace_snapshot( }) } +fn identify_snapshot( + model: &AppModel, + workspace_id: Option, + pane_id: Option, + surface_id: Option, +) -> Result { + let focused = focused_identify_context(model)?; + let caller = if workspace_id.is_some() || pane_id.is_some() || surface_id.is_some() { + Some(resolve_identify_context( + model, + workspace_id, + pane_id, + surface_id, + )?) + } else { + None + }; + + Ok(IdentifyResult { focused, caller }) +} + +fn focused_identify_context(model: &AppModel) -> Result { + let window_id = model.active_window; + let workspace = model + .active_workspace() + .ok_or(DomainError::InvalidOperation("app has no active workspace"))?; + let pane = workspace + .panes + .get(&workspace.active_pane) + .ok_or(DomainError::MissingPane(workspace.active_pane))?; + let surface = pane + .surfaces + .get(&pane.active_surface) + .ok_or(DomainError::MissingSurface(pane.active_surface))?; + + Ok(identify_context_from_parts( + window_id, workspace, pane.id, surface, + )) +} + +fn resolve_identify_context( + model: &AppModel, + workspace_id: Option, + pane_id: Option, + surface_id: Option, +) -> Result { + let window_id = model.active_window; + + if let Some(surface_id) = surface_id { + for (candidate_workspace_id, workspace) in &model.workspaces { + if workspace_id.is_some_and(|expected| expected != *candidate_workspace_id) { + continue; + } + for (candidate_pane_id, pane) in &workspace.panes { + if pane_id.is_some_and(|expected| expected != *candidate_pane_id) { + continue; + } + if let Some(surface) = pane.surfaces.get(&surface_id) { + return Ok(identify_context_from_parts( + window_id, + workspace, + *candidate_pane_id, + surface, + )); + } + } + } + return Err(DomainError::MissingSurface(surface_id)); + } + + if let Some(pane_id) = pane_id { + for (candidate_workspace_id, workspace) in &model.workspaces { + if workspace_id.is_some_and(|expected| expected != *candidate_workspace_id) { + continue; + } + if let Some(pane) = workspace.panes.get(&pane_id) { + let surface = pane + .surfaces + .get(&pane.active_surface) + .ok_or(DomainError::MissingSurface(pane.active_surface))?; + return Ok(identify_context_from_parts( + window_id, workspace, pane_id, surface, + )); + } + } + return Err(DomainError::MissingPane(pane_id)); + } + + if let Some(workspace_id) = workspace_id { + let workspace = model + .workspaces + .get(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + let pane = workspace + .panes + .get(&workspace.active_pane) + .ok_or(DomainError::MissingPane(workspace.active_pane))?; + let surface = pane + .surfaces + .get(&pane.active_surface) + .ok_or(DomainError::MissingSurface(pane.active_surface))?; + return Ok(identify_context_from_parts( + window_id, workspace, pane.id, surface, + )); + } + + focused_identify_context(model) +} + +fn identify_context_from_parts( + window_id: WindowId, + workspace: &taskers_domain::Workspace, + pane_id: PaneId, + surface: &SurfaceRecord, +) -> IdentifyContext { + IdentifyContext { + window_id, + workspace_id: workspace.id, + workspace_label: workspace.label.clone(), + workspace_window_id: workspace.window_for_pane(pane_id), + pane_id, + surface_id: surface.id, + surface_kind: surface.kind.clone(), + title: normalized_value(surface.metadata.title.as_deref()), + cwd: normalized_value(surface.metadata.cwd.as_deref()), + url: normalized_value(surface.metadata.url.as_deref()), + loading: matches!(surface.kind, PaneKind::Browser).then_some(false), + } +} + +fn normalized_value(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) +} + #[cfg(test)] mod tests { - use taskers_domain::{AppModel, SignalEvent, SignalKind}; + use taskers_domain::{AppModel, PaneKind, SignalEvent, SignalKind}; - use crate::{ControlCommand, ControlQuery}; + use crate::{ControlCommand, ControlQuery, ControlResponse}; use super::InMemoryController; @@ -636,4 +792,51 @@ mod tests { assert_eq!(controller.revision(), 1); } + + #[test] + fn identify_returns_focused_context_and_optional_caller() { + let controller = InMemoryController::new(AppModel::new("Main")); + let snapshot = controller.snapshot(); + let workspace = snapshot.model.active_workspace().expect("workspace"); + let pane = workspace + .panes + .get(&workspace.active_pane) + .expect("active pane"); + let surface = pane.active_surface().expect("active surface"); + + let response = controller + .handle(ControlCommand::QueryStatus { + query: ControlQuery::Identify { + workspace_id: None, + pane_id: None, + surface_id: None, + }, + }) + .expect("identify focused"); + let ControlResponse::Identify { result } = response else { + panic!("unexpected identify response"); + }; + assert_eq!(result.focused.workspace_id, workspace.id); + assert_eq!(result.focused.pane_id, workspace.active_pane); + assert_eq!(result.focused.surface_id, surface.id); + assert_eq!(result.focused.surface_kind, PaneKind::Terminal); + assert!(result.caller.is_none()); + + let response = controller + .handle(ControlCommand::QueryStatus { + query: ControlQuery::Identify { + workspace_id: Some(workspace.id), + pane_id: Some(workspace.active_pane), + surface_id: Some(surface.id), + }, + }) + .expect("identify caller"); + let ControlResponse::Identify { result } = response else { + panic!("unexpected identify response"); + }; + let caller = result.caller.expect("caller context"); + assert_eq!(caller.workspace_id, workspace.id); + assert_eq!(caller.pane_id, workspace.active_pane); + assert_eq!(caller.surface_id, surface.id); + } } diff --git a/crates/taskers-control/src/lib.rs b/crates/taskers-control/src/lib.rs index 7a4f661..c560bd6 100644 --- a/crates/taskers-control/src/lib.rs +++ b/crates/taskers-control/src/lib.rs @@ -10,6 +10,7 @@ pub use paths::default_socket_path; pub use protocol::{ BrowserControlCommand, BrowserGetCommand, BrowserLoadState, BrowserPredicateCommand, BrowserTarget, BrowserWaitCondition, ControlCommand, ControlError, ControlErrorCode, - ControlQuery, ControlResponse, RequestFrame, ResponseFrame, + ControlQuery, ControlResponse, IdentifyContext, IdentifyResult, RequestFrame, ResponseFrame, + TerminalDebugCommand, TerminalDebugResult, TerminalRenderStats, }; pub use socket::{bind_socket, serve, serve_with_handler}; diff --git a/crates/taskers-control/src/protocol.rs b/crates/taskers-control/src/protocol.rs index 8cf36dd..e751984 100644 --- a/crates/taskers-control/src/protocol.rs +++ b/crates/taskers-control/src/protocol.rs @@ -196,6 +196,9 @@ pub enum ControlCommand { Browser { browser_command: BrowserControlCommand, }, + TerminalDebug { + debug_command: TerminalDebugCommand, + }, QueryStatus { query: ControlQuery, }, @@ -452,12 +455,81 @@ pub enum BrowserPredicateCommand { Checked { target: BrowserTarget }, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "terminal_command", rename_all = "snake_case")] +pub enum TerminalDebugCommand { + IsFocused { + surface_id: SurfaceId, + }, + ReadText { + surface_id: SurfaceId, + tail_lines: Option, + }, + RenderStats { + surface_id: SurfaceId, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "result", rename_all = "snake_case")] +pub enum TerminalDebugResult { + IsFocused { focused: bool }, + ReadText { text: String }, + RenderStats { stats: TerminalRenderStats }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TerminalRenderStats { + pub surface_id: SurfaceId, + pub workspace_id: WorkspaceId, + pub pane_id: PaneId, + pub mounted: bool, + pub visible: bool, + pub focused: bool, + pub backend: String, + pub cols: u16, + pub rows: u16, + pub width_px: i32, + pub height_px: i32, + pub has_selection: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IdentifyContext { + pub window_id: WindowId, + pub workspace_id: WorkspaceId, + pub workspace_label: String, + pub workspace_window_id: Option, + pub pane_id: PaneId, + pub surface_id: SurfaceId, + pub surface_kind: PaneKind, + pub title: Option, + pub cwd: Option, + pub url: Option, + pub loading: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IdentifyResult { + pub focused: IdentifyContext, + pub caller: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "scope", rename_all = "snake_case")] pub enum ControlQuery { ActiveWindow, - Window { window_id: WindowId }, - Workspace { workspace_id: WorkspaceId }, + Window { + window_id: WindowId, + }, + Workspace { + workspace_id: WorkspaceId, + }, + Identify { + workspace_id: Option, + pane_id: Option, + surface_id: Option, + }, All, } @@ -495,6 +567,12 @@ pub enum ControlResponse { Browser { result: JsonValue, }, + TerminalDebug { + result: TerminalDebugResult, + }, + Identify { + result: IdentifyResult, + }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/taskers-ghostty/src/bridge.rs b/crates/taskers-ghostty/src/bridge.rs index 40cb817..6636d66 100644 --- a/crates/taskers-ghostty/src/bridge.rs +++ b/crates/taskers-ghostty/src/bridge.rs @@ -7,6 +7,7 @@ use std::{ use std::{ ffi::{c_int, c_void}, ptr::NonNull, + slice, }; use gtk::Widget; @@ -31,6 +32,8 @@ pub enum GhosttyError { Tick, #[error("failed to create ghostty surface")] SurfaceInit, + #[error("failed to read text from ghostty surface")] + SurfaceReadText, #[error("surface metadata contains NUL bytes: {0}")] InvalidString(&'static str), #[error("failed to load ghostty bridge library from {path}: {message}")] @@ -60,6 +63,9 @@ struct GhosttyBridgeLibrary { *const taskers_ghostty_surface_options_s, ) -> *mut c_void, surface_grab_focus: unsafe extern "C" fn(*mut c_void) -> c_int, + surface_has_selection: unsafe extern "C" fn(*mut c_void) -> c_int, + surface_read_all_text: unsafe extern "C" fn(*mut c_void, *mut taskers_ghostty_text_s) -> c_int, + surface_free_text: unsafe extern "C" fn(*mut taskers_ghostty_text_s), } impl GhosttyHost { @@ -214,6 +220,45 @@ impl GhosttyHost { Err(GhosttyError::Unavailable) } } + + pub fn surface_has_selection(&self, widget: &Widget) -> Result { + #[cfg(taskers_ghostty_bridge)] + unsafe { + Ok((self.bridge.surface_has_selection)(widget.as_ptr().cast()) != 0) + } + + #[cfg(not(taskers_ghostty_bridge))] + { + let _ = widget; + Err(GhosttyError::Unavailable) + } + } + + pub fn read_surface_text(&self, widget: &Widget) -> Result { + #[cfg(taskers_ghostty_bridge)] + unsafe { + let mut text = taskers_ghostty_text_s::default(); + let ok = (self.bridge.surface_read_all_text)(widget.as_ptr().cast(), &mut text); + if ok == 0 { + return Err(GhosttyError::SurfaceReadText); + } + + let bytes = if text.text.is_null() || text.text_len == 0 { + &[] + } else { + slice::from_raw_parts(text.text.cast::(), text.text_len) + }; + let output = String::from_utf8_lossy(bytes).into_owned(); + (self.bridge.surface_free_text)(&mut text); + Ok(output) + } + + #[cfg(not(taskers_ghostty_bridge))] + { + let _ = widget; + Err(GhosttyError::Unavailable) + } + } } #[cfg(taskers_ghostty_bridge)] @@ -239,9 +284,7 @@ fn load_bridge_library() -> Result { let host_new = *library .get:: *mut taskers_ghostty_host_t>( - b"taskers_ghostty_host_new\0", - ) + ) -> *mut taskers_ghostty_host_t>(b"taskers_ghostty_host_new\0") .map_err(|error| GhosttyError::LibraryLoad { path: path.clone(), message: error.to_string(), @@ -279,6 +322,30 @@ fn load_bridge_library() -> Result { path: path.clone(), message: error.to_string(), })?; + let surface_has_selection = *library + .get:: c_int>( + b"taskers_ghostty_surface_has_selection\0", + ) + .map_err(|error| GhosttyError::LibraryLoad { + path: path.clone(), + message: error.to_string(), + })?; + let surface_read_all_text = *library + .get:: c_int>( + b"taskers_ghostty_surface_read_all_text\0", + ) + .map_err(|error| GhosttyError::LibraryLoad { + path: path.clone(), + message: error.to_string(), + })?; + let surface_free_text = *library + .get::( + b"taskers_ghostty_surface_free_text\0", + ) + .map_err(|error| GhosttyError::LibraryLoad { + path: path.clone(), + message: error.to_string(), + })?; Ok(GhosttyBridgeLibrary { _library: library, @@ -287,6 +354,9 @@ fn load_bridge_library() -> Result { host_tick, surface_new, surface_grab_focus, + surface_has_selection, + surface_read_all_text, + surface_free_text, }) } } @@ -314,3 +384,11 @@ struct taskers_ghostty_surface_options_s { env_entries: *const *const c_char, env_count: usize, } + +#[cfg(taskers_ghostty_bridge)] +#[repr(C)] +#[derive(Default)] +struct taskers_ghostty_text_s { + text: *const c_char, + text_len: usize, +} diff --git a/crates/taskers-host/src/lib.rs b/crates/taskers-host/src/lib.rs index cd6a459..cc59c1b 100644 --- a/crates/taskers-host/src/lib.rs +++ b/crates/taskers-host/src/lib.rs @@ -1,6 +1,6 @@ mod browser_automation; -use anyhow::{Result, anyhow, bail}; +use anyhow::{Result, anyhow}; use gtk::{ Align, Box as GtkBox, CssProvider, EventControllerFocus, EventControllerScroll, EventControllerScrollFlags, GestureClick, Orientation, Overflow, Overlay, @@ -13,10 +13,14 @@ use std::{ sync::Arc, time::{SystemTime, UNIX_EPOCH}, }; -use taskers_control::{BrowserControlCommand, BrowserLoadState, ControlError}; +use taskers_control::{ + BrowserControlCommand, BrowserLoadState, ControlError, TerminalDebugCommand, + TerminalDebugResult, TerminalRenderStats, +}; use taskers_core::{ BrowserSurfaceCatalogEntry, HostCommand, HostEvent, PaneId, PortalSurfacePlan, ShellDragMode, - ShellSnapshot, SurfaceId, SurfaceMountSpec, SurfacePortalPlan, TerminalMountSpec, WorkspaceId, + ShellSnapshot, SurfaceId, SurfaceMountSpec, SurfacePortalPlan, TerminalMountSpec, + TerminalSurfaceCatalogEntry, WorkspaceId, }; use taskers_domain::PaneKind; use taskers_ghostty::{GhosttyHost, SurfaceDescriptor}; @@ -248,7 +252,12 @@ impl TaskersHost { ), ); self.sync_browser_surfaces(snapshot, interactive)?; - self.sync_terminal_surfaces(&snapshot.portal, snapshot.revision, interactive)?; + self.sync_terminal_surfaces( + &snapshot.portal, + &snapshot.terminal_catalog, + snapshot.revision, + interactive, + )?; Ok(()) } @@ -293,6 +302,57 @@ impl TaskersHost { handle.execute(command).await } + pub fn execute_terminal_debug( + &self, + command: TerminalDebugCommand, + ) -> Result { + let Some(host) = self.ghostty_host.as_ref() else { + return Err(ControlError::not_supported( + "terminal debug requires the Ghostty host backend", + )); + }; + + let surface_id = terminal_debug_surface_id(&command); + let surface = self.terminal_surfaces.get(&surface_id).ok_or_else(|| { + ControlError::not_found(format!("terminal surface {surface_id} not found")) + })?; + + match command { + TerminalDebugCommand::IsFocused { .. } => Ok(TerminalDebugResult::IsFocused { + focused: surface.is_focused(), + }), + TerminalDebugCommand::ReadText { tail_lines, .. } => { + let text = host + .read_surface_text(&surface.widget) + .map_err(|error| ControlError::internal(error.to_string()))?; + Ok(TerminalDebugResult::ReadText { + text: trim_terminal_tail(text, tail_lines), + }) + } + TerminalDebugCommand::RenderStats { .. } => { + let has_selection = host + .surface_has_selection(&surface.widget) + .map_err(|error| ControlError::internal(error.to_string()))?; + Ok(TerminalDebugResult::RenderStats { + stats: TerminalRenderStats { + surface_id, + workspace_id: surface.workspace_id.get(), + pane_id: surface.pane_id.get(), + mounted: true, + visible: surface.visible, + focused: surface.is_focused(), + backend: "ghostty".into(), + cols: surface.spec.cols, + rows: surface.spec.rows, + width_px: surface.width_px, + height_px: surface.height_px, + has_selection, + }, + }) + } + } + } + fn with_browser_surface( &mut self, surface_id: SurfaceId, @@ -394,14 +454,20 @@ impl TaskersHost { fn sync_terminal_surfaces( &mut self, portal: &SurfacePortalPlan, + catalog: &[TerminalSurfaceCatalogEntry], revision: u64, interactive: bool, ) -> Result<()> { let desired = terminal_plans(portal); - let desired_ids = desired + let desired_by_id = desired + .into_iter() + .map(|plan| (plan.surface_id, plan)) + .collect::>(); + let catalog_by_id = catalog .iter() - .map(|plan| plan.surface_id) - .collect::>(); + .map(|entry| (entry.surface_id, entry)) + .collect::>(); + let desired_ids = catalog_by_id.keys().copied().collect::>(); let stale = self .terminal_surfaces @@ -429,12 +495,13 @@ impl TaskersHost { return Ok(()); }; - for plan in desired { - match self.terminal_surfaces.get_mut(&plan.surface_id) { + for entry in catalog { + let visible_plan = desired_by_id.get(&entry.surface_id); + match self.terminal_surfaces.get_mut(&entry.surface_id) { Some(surface) => surface.sync( &self.root, - plan.frame, - plan.active, + entry, + visible_plan, revision, interactive, host, @@ -443,14 +510,15 @@ impl TaskersHost { None => { let surface = TerminalSurface::new( &self.root, - &plan, + entry, + visible_plan, revision, interactive, self.event_sink.clone(), self.diagnostics.clone(), host, )?; - self.terminal_surfaces.insert(plan.surface_id, surface); + self.terminal_surfaces.insert(entry.surface_id, surface); } } } @@ -798,23 +866,32 @@ impl BrowserSurface { } struct TerminalSurface { + surface_id: SurfaceId, + workspace_id: Rc>, + pane_id: Rc>, + spec: TerminalMountSpec, shell: NativeSurfaceShell, widget: Widget, + focus_state: Rc>, active: bool, interactive: bool, + visible: bool, + width_px: i32, + height_px: i32, } impl TerminalSurface { fn new( overlay: &Overlay, - plan: &PortalSurfacePlan, + entry: &TerminalSurfaceCatalogEntry, + visible_plan: Option<&PortalSurfacePlan>, revision: u64, interactive: bool, event_sink: HostEventSink, diagnostics: Option, host: &GhosttyHost, ) -> Result { - let spec = terminal_spec(plan)?.clone(); + let spec = entry.spec.clone(); let descriptor = surface_descriptor_from(&spec); let widget = host .create_surface(&descriptor) @@ -828,14 +905,29 @@ impl TerminalSurface { widget.add_css_class("native-surface-widget"); widget.add_css_class(widget_class); widget.add_css_class("terminal-output"); - widget.set_can_target(interactive); - let shell = NativeSurfaceShell::new(shell_class, interactive); + let effective_interactive = visible_plan.is_some() && interactive; + widget.set_can_target(effective_interactive); + let shell = NativeSurfaceShell::new(shell_class, effective_interactive); shell.mount_child(&widget); - shell.position(overlay, plan.frame); + match visible_plan { + Some(plan) => shell.show_at(overlay, plan.frame), + None => shell.park_hidden(overlay), + } - connect_ghostty_widget(host, &widget, plan, event_sink, diagnostics.clone()); + let workspace_id = Rc::new(Cell::new(entry.workspace_id)); + let pane_id = Rc::new(Cell::new(entry.pane_id)); + let focus_state = Rc::new(Cell::new(false)); + connect_ghostty_widget( + host, + &widget, + pane_id.clone(), + entry.surface_id, + event_sink, + diagnostics.clone(), + focus_state.clone(), + ); - if plan.active && interactive { + if visible_plan.is_some_and(|plan| plan.active) && effective_interactive { let _ = host.focus_surface(&widget); } @@ -846,36 +938,61 @@ impl TerminalSurface { Some(revision), "terminal surface created", ) - .with_pane(plan.pane_id) - .with_surface(plan.surface_id), + .with_pane(entry.pane_id) + .with_surface(entry.surface_id), ); Ok(Self { + surface_id: entry.surface_id, + workspace_id, + pane_id, + spec, shell, widget, - active: plan.active, - interactive, + focus_state, + active: visible_plan.is_some_and(|plan| plan.active), + interactive: effective_interactive, + visible: visible_plan.is_some(), + width_px: visible_plan.map_or(0, |plan| plan.frame.width), + height_px: visible_plan.map_or(0, |plan| plan.frame.height), }) } fn sync( &mut self, overlay: &Overlay, - frame: taskers_core::Frame, - active: bool, + entry: &TerminalSurfaceCatalogEntry, + visible_plan: Option<&PortalSurfacePlan>, revision: u64, interactive: bool, host: &GhosttyHost, diagnostics: Option<&DiagnosticsSink>, ) { - self.widget.set_can_target(interactive); - self.shell.set_interactive(interactive); - self.shell.position(overlay, frame); - if active && interactive && (!self.active || !self.interactive) { + self.workspace_id.set(entry.workspace_id); + self.pane_id.set(entry.pane_id); + self.spec = entry.spec.clone(); + let visible = visible_plan.is_some(); + let effective_interactive = visible && interactive; + self.widget.set_can_target(effective_interactive); + self.shell.set_interactive(effective_interactive); + match visible_plan { + Some(plan) => self.shell.show_at(overlay, plan.frame), + None => self.shell.park_hidden(overlay), + } + if visible_plan.is_some_and(|plan| plan.active) + && effective_interactive + && (!self.active || !self.interactive || !self.visible) + { let _ = host.focus_surface(&self.widget); } - self.active = active; - self.interactive = interactive; + if !visible || !effective_interactive { + self.focus_state.set(false); + } + self.active = visible_plan.is_some_and(|plan| plan.active); + self.interactive = effective_interactive; + self.visible = visible; + self.width_px = visible_plan.map_or(0, |plan| plan.frame.width); + self.height_px = visible_plan.map_or(0, |plan| plan.frame.height); emit_diagnostic( diagnostics, @@ -883,9 +1000,15 @@ impl TerminalSurface { DiagnosticCategory::SurfaceLifecycle, Some(revision), "terminal surface updated", - ), + ) + .with_pane(entry.pane_id) + .with_surface(self.surface_id), ); } + + fn is_focused(&self) -> bool { + self.focus_state.get() || self.widget.has_focus() + } } struct NativeSurfaceShell { @@ -990,18 +1113,20 @@ fn native_surface_css() -> &'static str { fn connect_ghostty_widget( host: &GhosttyHost, widget: &Widget, - plan: &PortalSurfacePlan, + pane_id: Rc>, + surface_id: SurfaceId, event_sink: HostEventSink, diagnostics: Option, + focus_state: Rc>, ) { let _ = host; - let pane_id = plan.pane_id; - let surface_id = plan.surface_id; + let click_pane_id = pane_id.clone(); let focus_sink = event_sink.clone(); let focus_diagnostics = diagnostics.clone(); let click = GestureClick::new(); click.connect_pressed(move |_, _, _, _| { + let pane_id = click_pane_id.get(); emit_diagnostic( focus_diagnostics.as_ref(), DiagnosticRecord::new( @@ -1016,12 +1141,14 @@ fn connect_ghostty_widget( }); widget.add_controller(click); - let pane_id = plan.pane_id; - let surface_id = plan.surface_id; + let focus_pane_id = pane_id.clone(); let focus_sink = event_sink.clone(); let focus_diagnostics = diagnostics.clone(); + let focus_enter_state = focus_state.clone(); let focus = EventControllerFocus::new(); focus.connect_enter(move |_| { + let pane_id = focus_pane_id.get(); + focus_enter_state.set(true); emit_diagnostic( focus_diagnostics.as_ref(), DiagnosticRecord::new( @@ -1034,9 +1161,12 @@ fn connect_ghostty_widget( ); (focus_sink)(HostEvent::PaneFocused { pane_id }); }); + let focus_leave_state = focus_state; + focus.connect_leave(move |_| { + focus_leave_state.set(false); + }); widget.add_controller(focus); - let surface_id = plan.surface_id; let title_sink = event_sink.clone(); let title_diagnostics = diagnostics.clone(); widget.connect_notify_local(Some("title"), move |widget, _| { @@ -1057,7 +1187,6 @@ fn connect_ghostty_widget( } }); - let surface_id = plan.surface_id; let cwd_sink = event_sink.clone(); let cwd_diagnostics = diagnostics.clone(); widget.connect_notify_local(Some("pwd"), move |widget, _| { @@ -1078,12 +1207,12 @@ fn connect_ghostty_widget( } }); - let pane_id = plan.pane_id; - let surface_id = plan.surface_id; + let exit_pane_id = pane_id; let exit_sink = event_sink; let exit_diagnostics = diagnostics; widget.connect_notify_local(Some("child-exited"), move |widget, _| { if widget.property::("child-exited") { + let pane_id = exit_pane_id.get(); emit_diagnostic( exit_diagnostics.as_ref(), DiagnosticRecord::new(DiagnosticCategory::HostEvent, None, "terminal child exited") @@ -1168,6 +1297,27 @@ fn browser_command_surface_id(command: &BrowserControlCommand) -> SurfaceId { } } +fn terminal_debug_surface_id(command: &TerminalDebugCommand) -> SurfaceId { + match command { + TerminalDebugCommand::IsFocused { surface_id } + | TerminalDebugCommand::ReadText { surface_id, .. } + | TerminalDebugCommand::RenderStats { surface_id } => *surface_id, + } +} + +fn trim_terminal_tail(text: String, tail_lines: Option) -> String { + let Some(limit) = tail_lines else { + return text; + }; + if limit == 0 { + return String::new(); + } + + let lines = text.lines().collect::>(); + let start = lines.len().saturating_sub(limit); + lines[start..].join("\n") +} + fn surface_descriptor_from(spec: &TerminalMountSpec) -> SurfaceDescriptor { SurfaceDescriptor { cols: spec.cols, @@ -1184,13 +1334,6 @@ fn surface_descriptor_from(spec: &TerminalMountSpec) -> SurfaceDescriptor { } } -fn terminal_spec(plan: &PortalSurfacePlan) -> Result<&TerminalMountSpec> { - match &plan.mount { - SurfaceMountSpec::Terminal(spec) => Ok(spec), - SurfaceMountSpec::Browser(_) => bail!("surface {} is not a terminal", plan.surface_id), - } -} - fn position_widget(overlay: &Overlay, widget: &Widget, frame: taskers_core::Frame) { widget.set_size_request(frame.width.max(1), frame.height.max(1)); widget.set_halign(Align::Start); @@ -1289,7 +1432,7 @@ fn current_timestamp_ms() -> u128 { mod tests { use super::{ browser_plans, native_surface_classes, native_surface_css, native_surfaces_interactive, - terminal_plans, workspace_pan_delta, + terminal_plans, trim_terminal_tail, workspace_pan_delta, }; use taskers_domain::PaneKind; use taskers_shell_core::{BootstrapModel, SharedCore, ShellDragMode, SurfaceMountSpec}; @@ -1343,4 +1486,12 @@ mod tests { assert!(css.contains(".terminal-output")); assert!(css.contains("background: #0f1117;")); } + + #[test] + fn trim_terminal_tail_keeps_requested_suffix() { + let text = "one\ntwo\nthree\nfour".to_string(); + assert_eq!(trim_terminal_tail(text.clone(), None), text); + assert_eq!(trim_terminal_tail(text.clone(), Some(2)), "three\nfour"); + assert_eq!(trim_terminal_tail(text, Some(10)), "one\ntwo\nthree\nfour"); + } } diff --git a/crates/taskers-shell-core/src/lib.rs b/crates/taskers-shell-core/src/lib.rs index d8dd72a..f9738e0 100644 --- a/crates/taskers-shell-core/src/lib.rs +++ b/crates/taskers-shell-core/src/lib.rs @@ -735,6 +735,14 @@ pub struct BrowserSurfaceCatalogEntry { pub url: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TerminalSurfaceCatalogEntry { + pub workspace_id: WorkspaceId, + pub pane_id: PaneId, + pub surface_id: SurfaceId, + pub spec: TerminalMountSpec, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct PullRequestSnapshot { pub number: u32, @@ -921,6 +929,7 @@ pub struct ShellSnapshot { pub current_workspace_progress: Option, pub current_workspace_log: Vec, pub browser_catalog: Vec, + pub terminal_catalog: Vec, pub portal: SurfacePortalPlan, pub metrics: LayoutMetrics, pub runtime_status: RuntimeStatus, @@ -1253,6 +1262,7 @@ impl TaskersCore { current_workspace_progress, current_workspace_log, browser_catalog: self.browser_catalog_snapshot(&model), + terminal_catalog: self.terminal_catalog_snapshot(&model), portal: SurfacePortalPlan { window: Frame::new(0, 0, self.ui.window_size.width, self.ui.window_size.height), content: viewport, @@ -1620,6 +1630,31 @@ impl TaskersCore { catalog } + fn terminal_catalog_snapshot(&self, model: &AppModel) -> Vec { + let mut catalog = Vec::new(); + for (workspace_id, workspace) in &model.workspaces { + for pane in workspace.panes.values() { + for surface in pane.surfaces.values() { + if surface.kind != PaneKind::Terminal { + continue; + } + let descriptor = fallback_surface_descriptor(surface); + let mount = mount_spec_from_descriptor(surface, descriptor); + let SurfaceMountSpec::Terminal(spec) = mount else { + continue; + }; + catalog.push(TerminalSurfaceCatalogEntry { + workspace_id: *workspace_id, + pane_id: pane.id, + surface_id: surface.id, + spec, + }); + } + } + } + catalog + } + fn collect_workspace_surface_plans( &self, workspace_id: WorkspaceId, @@ -3980,6 +4015,30 @@ mod tests { assert!(catalog.iter().all(|entry| !entry.url.is_empty())); } + #[test] + fn terminal_catalog_keeps_background_terminal_surfaces() { + let core = SharedCore::bootstrap(bootstrap()); + let first_workspace_id = core.snapshot().current_workspace.id; + + core.dispatch_shell_action(ShellAction::CreateWorkspace); + let second_workspace_id = core.snapshot().current_workspace.id; + + let catalog = core.snapshot().terminal_catalog; + assert!(catalog.len() >= 2); + assert!( + catalog + .iter() + .any(|entry| entry.workspace_id == first_workspace_id) + ); + assert!( + catalog + .iter() + .any(|entry| entry.workspace_id == second_workspace_id) + ); + assert!(catalog.iter().all(|entry| entry.spec.cols > 0)); + assert!(catalog.iter().all(|entry| entry.spec.rows > 0)); + } + #[test] fn browser_navigation_host_events_update_browser_chrome_snapshot() { let core = SharedCore::bootstrap(bootstrap()); diff --git a/vendor/ghostty/include/taskers_ghostty_bridge.h b/vendor/ghostty/include/taskers_ghostty_bridge.h index f3296b7..aa9be3b 100644 --- a/vendor/ghostty/include/taskers_ghostty_bridge.h +++ b/vendor/ghostty/include/taskers_ghostty_bridge.h @@ -21,6 +21,11 @@ typedef struct { size_t env_count; } taskers_ghostty_surface_options_s; +typedef struct { + const char *text; + size_t text_len; +} taskers_ghostty_text_s; + taskers_ghostty_host_t *taskers_ghostty_host_new( const taskers_ghostty_host_options_s *); void taskers_ghostty_host_free(taskers_ghostty_host_t *); @@ -29,6 +34,9 @@ void *taskers_ghostty_surface_new( taskers_ghostty_host_t *, const taskers_ghostty_surface_options_s *); int taskers_ghostty_surface_grab_focus(void *); +int taskers_ghostty_surface_has_selection(void *); +int taskers_ghostty_surface_read_all_text(void *, taskers_ghostty_text_s *); +void taskers_ghostty_surface_free_text(taskers_ghostty_text_s *); #ifdef __cplusplus } diff --git a/vendor/ghostty/src/taskers_bridge.zig b/vendor/ghostty/src/taskers_bridge.zig index 51d52a7..5f81d5b 100644 --- a/vendor/ghostty/src/taskers_bridge.zig +++ b/vendor/ghostty/src/taskers_bridge.zig @@ -1,5 +1,6 @@ const std = @import("std"); const gtk = @import("gtk"); +const terminal = @import("terminal/main.zig"); const CoreApp = @import("App.zig"); const GtkRuntimeApp = @import("apprt/gtk/App.zig"); @@ -33,6 +34,11 @@ pub const SurfaceOptions = extern struct { env_count: usize = 0, }; +pub const Text = extern struct { + text: ?[*:0]const u8 = null, + text_len: usize = 0, +}; + fn ensureInitialized() !void { if (initialized) return; try state.init(); @@ -141,6 +147,53 @@ pub export fn taskers_ghostty_surface_grab_focus(widget: ?*gtk.Widget) c_int { return 1; } +pub export fn taskers_ghostty_surface_has_selection(widget: ?*gtk.Widget) c_int { + const ptr = widget orelse return 0; + const surface: *Surface = @ptrCast(@alignCast(ptr)); + const core = surface.core() orelse return 0; + return if (core.hasSelection()) 1 else 0; +} + +pub export fn taskers_ghostty_surface_read_all_text( + widget: ?*gtk.Widget, + result: ?*Text, +) c_int { + const ptr = widget orelse return 0; + const text = result orelse return 0; + const surface: *Surface = @ptrCast(@alignCast(ptr)); + const core = surface.core() orelse return 0; + const screen = core.io.terminal.screens.active; + const br = screen.pages.getBottomRight(.screen) orelse { + text.* = .{}; + return 1; + }; + const selection = terminal.Selection.init( + screen.pages.getTopLeft(.screen), + br, + true, + ); + + var dumped = core.dumpText(state.alloc, selection) catch |err| { + std.log.warn("failed to read Ghostty surface text err={}", .{err}); + return 0; + }; + errdefer dumped.deinit(state.alloc); + + text.* = .{ + .text = dumped.text.ptr, + .text_len = dumped.text.len, + }; + return 1; +} + +pub export fn taskers_ghostty_surface_free_text(text: ?*Text) void { + const ptr = text orelse return; + if (ptr.text) |value| { + state.alloc.free(value[0..ptr.text_len :0]); + } + ptr.* = .{}; +} + fn taskersSurfaceConfig(app: anytype, ptr: *const Host, opts: *const SurfaceOptions) !*Config { const alloc = state.alloc; const base = app.getConfig(); From c759869cef073b1a7d31c1f3aff0a69c6d969637 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sat, 21 Mar 2026 15:48:34 +0100 Subject: [PATCH 05/63] test: cover terminal debug and identify flows From 89980eaf7247264afca271c3893daf6fdda9a387 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sat, 21 Mar 2026 16:18:41 +0100 Subject: [PATCH 06/63] refactor: add notification lifecycle state --- crates/taskers-cli/src/main.rs | 132 +++++++- crates/taskers-control/src/controller.rs | 47 ++- crates/taskers-control/src/protocol.rs | 22 +- crates/taskers-domain/src/ids.rs | 1 + crates/taskers-domain/src/lib.rs | 13 +- crates/taskers-domain/src/model.rs | 404 +++++++++++++++++++++-- crates/taskers-shell-core/src/lib.rs | 66 ++-- crates/taskers-shell/src/lib.rs | 42 +-- 8 files changed, 618 insertions(+), 109 deletions(-) diff --git a/crates/taskers-cli/src/main.rs b/crates/taskers-cli/src/main.rs index bb7e81d..b3348d4 100644 --- a/crates/taskers-cli/src/main.rs +++ b/crates/taskers-cli/src/main.rs @@ -75,7 +75,11 @@ enum Command { #[arg(long)] title: String, #[arg(long)] + subtitle: Option, + #[arg(long)] body: Option, + #[arg(long = "notification-id")] + notification_id: Option, #[arg(long)] agent: Option, }, @@ -387,6 +391,10 @@ enum AgentNotifyCommand { #[arg(long)] title: Option, #[arg(long)] + subtitle: Option, + #[arg(long = "notification-id")] + notification_id: Option, + #[arg(long)] message: String, #[arg(long, value_enum, default_value_t = CliAttentionState::Waiting)] state: CliAttentionState, @@ -1149,7 +1157,9 @@ async fn main() -> anyhow::Result<()> { pane, surface, title, + subtitle, body, + notification_id, agent: _agent, } => { let client = ControlClient::new(resolve_socket_path(socket)); @@ -1171,7 +1181,10 @@ async fn main() -> anyhow::Result<()> { let response = client .send(ControlCommand::AgentCreateNotification { target, + kind: SignalKind::Notification, title: Some(normalized_title.to_string()), + subtitle, + external_id: notification_id, message, state: AttentionState::WaitingInput, }) @@ -1293,6 +1306,8 @@ async fn main() -> anyhow::Result<()> { surface, scope, title, + subtitle, + notification_id, message, state, } => { @@ -1303,7 +1318,10 @@ async fn main() -> anyhow::Result<()> { &client, ControlCommand::AgentCreateNotification { target, + kind: SignalKind::Notification, title, + subtitle, + external_id: notification_id, message, state: state.into(), }, @@ -1326,12 +1344,15 @@ async fn main() -> anyhow::Result<()> { serde_json::json!({ "workspace_id": item.workspace_id, "workspace_window_id": item.workspace_window_id, + "notification_id": item.notification_id, "pane_id": item.pane_id, "surface_id": item.surface_id, "kind": format!("{:?}", item.kind).to_lowercase(), "state": format!("{:?}", item.state).to_lowercase(), "title": item.title, + "subtitle": item.subtitle, "message": item.message, + "read_at": item.read_at, "created_at": item.created_at, }) }) @@ -2799,10 +2820,7 @@ async fn emit_agent_hook( } match kind { - CliSignalKind::Started - | CliSignalKind::Progress - | CliSignalKind::WaitingInput - | CliSignalKind::Notification => { + CliSignalKind::Started | CliSignalKind::Progress => { let _ = send_control_command( &client, ControlCommand::AgentSetStatus { @@ -2812,15 +2830,105 @@ async fn emit_agent_hook( ) .await?; } - CliSignalKind::Completed => { - let _ = - send_control_command(&client, ControlCommand::AgentClearStatus { workspace_id }) - .await?; - let _ = - send_control_command(&client, ControlCommand::AgentClearProgress { workspace_id }) - .await?; + CliSignalKind::WaitingInput | CliSignalKind::Notification => { + let _ = send_control_command( + &client, + ControlCommand::AgentSetStatus { + workspace_id, + text: status_text.clone(), + }, + ) + .await?; + if normalized_message.is_none() { + let target_surface_id = + surface_id + .or_else(env_surface_id) + .unwrap_or(active_surface_for_pane( + &query_model(&client).await?, + workspace_id, + pane_id, + )?); + let kind = if matches!(kind, CliSignalKind::WaitingInput) { + SignalKind::WaitingInput + } else { + SignalKind::Notification + }; + let state = if matches!(kind, SignalKind::WaitingInput) { + AttentionState::WaitingInput + } else { + AttentionState::WaitingInput + }; + let _ = send_control_command( + &client, + ControlCommand::AgentCreateNotification { + target: AgentTarget::Surface { + workspace_id, + pane_id, + surface_id: target_surface_id, + }, + kind, + title: Some(normalized_title.clone()), + subtitle: None, + external_id: None, + message: status_text.clone(), + state, + }, + ) + .await?; + } + } + CliSignalKind::Completed | CliSignalKind::Error => { + let signal_kind = if matches!(kind, CliSignalKind::Completed) { + SignalKind::Completed + } else { + SignalKind::Error + }; + let state = if matches!(signal_kind, SignalKind::Completed) { + AttentionState::Completed + } else { + AttentionState::Error + }; + if normalized_message.is_none() { + let target_surface_id = + surface_id + .or_else(env_surface_id) + .unwrap_or(active_surface_for_pane( + &query_model(&client).await?, + workspace_id, + pane_id, + )?); + let _ = send_control_command( + &client, + ControlCommand::AgentCreateNotification { + target: AgentTarget::Surface { + workspace_id, + pane_id, + surface_id: target_surface_id, + }, + kind: signal_kind.clone(), + title: Some(normalized_title.clone()), + subtitle: None, + external_id: None, + message: status_text.clone(), + state, + }, + ) + .await?; + } + if matches!(signal_kind, SignalKind::Completed) { + let _ = send_control_command( + &client, + ControlCommand::AgentClearStatus { workspace_id }, + ) + .await?; + let _ = send_control_command( + &client, + ControlCommand::AgentClearProgress { workspace_id }, + ) + .await?; + } } - CliSignalKind::Metadata | CliSignalKind::Error => {} + CliSignalKind::Metadata => {} } if matches!( diff --git a/crates/taskers-control/src/controller.rs b/crates/taskers-control/src/controller.rs index 007b2a7..4ae11ad 100644 --- a/crates/taskers-control/src/controller.rs +++ b/crates/taskers-control/src/controller.rs @@ -500,11 +500,22 @@ impl InMemoryController { } ControlCommand::AgentCreateNotification { target, + kind, title, + subtitle, + external_id, message, state, } => { - model.create_agent_notification(target, title, message, state)?; + model.create_agent_notification( + target, + kind, + title, + subtitle, + external_id, + message, + state, + )?; ( ControlResponse::Ack { message: "agent notification created".into(), @@ -512,6 +523,40 @@ impl InMemoryController { true, ) } + ControlCommand::OpenNotification { + window_id, + notification_id, + } => { + model + .open_notification(window_id.unwrap_or(model.active_window), notification_id)?; + ( + ControlResponse::Ack { + message: "notification opened".into(), + }, + true, + ) + } + ControlCommand::ClearNotification { notification_id } => { + model.clear_notification(notification_id)?; + ( + ControlResponse::Ack { + message: "notification cleared".into(), + }, + true, + ) + } + ControlCommand::MarkNotificationDelivery { + notification_id, + delivery, + } => { + model.mark_notification_delivery(notification_id, delivery)?; + ( + ControlResponse::Ack { + message: "notification delivery updated".into(), + }, + true, + ) + } ControlCommand::AgentClearNotifications { target } => { model.clear_agent_notifications(target)?; ( diff --git a/crates/taskers-control/src/protocol.rs b/crates/taskers-control/src/protocol.rs index e751984..8cb60b0 100644 --- a/crates/taskers-control/src/protocol.rs +++ b/crates/taskers-control/src/protocol.rs @@ -3,10 +3,10 @@ use serde_json::Value as JsonValue; use uuid::Uuid; use taskers_domain::{ - AgentTarget, AppModel, AttentionState, Direction, PaneId, PaneKind, PaneMetadataPatch, - PersistedSession, ProgressState, SignalEvent, SplitAxis, SurfaceId, WindowId, - WorkspaceColumnId, WorkspaceId, WorkspaceLogEntry, WorkspaceViewport, WorkspaceWindowId, - WorkspaceWindowMoveTarget, + AgentTarget, AppModel, AttentionState, Direction, NotificationDeliveryState, NotificationId, + PaneId, PaneKind, PaneMetadataPatch, PersistedSession, ProgressState, SignalEvent, SignalKind, + SplitAxis, SurfaceId, WindowId, WorkspaceColumnId, WorkspaceId, WorkspaceLogEntry, + WorkspaceViewport, WorkspaceWindowId, WorkspaceWindowMoveTarget, }; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -178,10 +178,24 @@ pub enum ControlCommand { }, AgentCreateNotification { target: AgentTarget, + kind: SignalKind, title: Option, + subtitle: Option, + external_id: Option, message: String, state: AttentionState, }, + OpenNotification { + window_id: Option, + notification_id: NotificationId, + }, + ClearNotification { + notification_id: NotificationId, + }, + MarkNotificationDelivery { + notification_id: NotificationId, + delivery: NotificationDeliveryState, + }, AgentClearNotifications { target: AgentTarget, }, diff --git a/crates/taskers-domain/src/ids.rs b/crates/taskers-domain/src/ids.rs index 81a3f7b..8eac275 100644 --- a/crates/taskers-domain/src/ids.rs +++ b/crates/taskers-domain/src/ids.rs @@ -46,3 +46,4 @@ define_id!(WorkspaceWindowId); define_id!(PaneId); define_id!(SurfaceId); define_id!(SessionId); +define_id!(NotificationId); diff --git a/crates/taskers-domain/src/lib.rs b/crates/taskers-domain/src/lib.rs index c8398e1..f04173e 100644 --- a/crates/taskers-domain/src/lib.rs +++ b/crates/taskers-domain/src/lib.rs @@ -6,17 +6,18 @@ pub mod signal; pub use attention::AttentionState; pub use ids::{ - PaneId, SessionId, SurfaceId, WindowId, WorkspaceColumnId, WorkspaceId, WorkspaceWindowId, + NotificationId, PaneId, SessionId, SurfaceId, WindowId, WorkspaceColumnId, WorkspaceId, + WorkspaceWindowId, }; pub use layout::{Direction, LayoutNode, SplitAxis}; pub use model::{ ActivityItem, AgentTarget, AppModel, DEFAULT_WORKSPACE_WINDOW_GAP, DEFAULT_WORKSPACE_WINDOW_HEIGHT, DEFAULT_WORKSPACE_WINDOW_WIDTH, DomainError, KEYBOARD_RESIZE_STEP, MIN_WORKSPACE_WINDOW_HEIGHT, MIN_WORKSPACE_WINDOW_WIDTH, - NotificationItem, PaneKind, PaneMetadata, PaneMetadataPatch, PaneRecord, - PersistedSession, PrStatus, ProgressState, PullRequestState, SESSION_SCHEMA_VERSION, - SurfaceRecord, WindowFrame, WindowRecord, Workspace, WorkspaceAgentState, - WorkspaceAgentSummary, WorkspaceColumnRecord, WorkspaceLogEntry, WorkspaceSummary, - WorkspaceViewport, WorkspaceWindowMoveTarget, WorkspaceWindowRecord, + NotificationDeliveryState, NotificationItem, PaneKind, PaneMetadata, PaneMetadataPatch, + PaneRecord, PersistedSession, PrStatus, ProgressState, PullRequestState, + SESSION_SCHEMA_VERSION, SurfaceRecord, WindowFrame, WindowRecord, Workspace, + WorkspaceAgentState, WorkspaceAgentSummary, WorkspaceColumnRecord, WorkspaceLogEntry, + WorkspaceSummary, WorkspaceViewport, WorkspaceWindowMoveTarget, WorkspaceWindowRecord, }; pub use signal::{SignalEvent, SignalKind, SignalPaneMetadata}; diff --git a/crates/taskers-domain/src/model.rs b/crates/taskers-domain/src/model.rs index f4d09fd..d2e77d6 100644 --- a/crates/taskers-domain/src/model.rs +++ b/crates/taskers-domain/src/model.rs @@ -6,8 +6,8 @@ use thiserror::Error; use time::{Duration, OffsetDateTime}; use crate::{ - AttentionState, Direction, LayoutNode, PaneId, SessionId, SignalEvent, SignalKind, SplitAxis, - SurfaceId, WindowId, WorkspaceColumnId, WorkspaceId, WorkspaceWindowId, + AttentionState, Direction, LayoutNode, NotificationId, PaneId, SessionId, SignalEvent, + SignalKind, SplitAxis, SurfaceId, WindowId, WorkspaceColumnId, WorkspaceId, WorkspaceWindowId, }; pub const SESSION_SCHEMA_VERSION: u32 = 4; @@ -336,6 +336,7 @@ impl PaneRecord { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct NotificationItem { + pub id: NotificationId, pub pane_id: PaneId, pub surface_id: SurfaceId, #[serde(default = "default_notification_kind")] @@ -343,9 +344,27 @@ pub struct NotificationItem { pub state: AttentionState, #[serde(default)] pub title: Option, + #[serde(default)] + pub subtitle: Option, + #[serde(default)] + pub external_id: Option, pub message: String, pub created_at: OffsetDateTime, + #[serde(default)] + pub read_at: Option, pub cleared_at: Option, + #[serde(default = "default_notification_delivery_state")] + pub desktop_delivery: NotificationDeliveryState, +} + +impl NotificationItem { + pub fn unread(&self) -> bool { + self.cleared_at.is_none() && self.read_at.is_none() + } + + pub fn active(&self) -> bool { + self.cleared_at.is_none() + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -358,6 +377,7 @@ pub struct WorkspaceLogEntry { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ActivityItem { + pub notification_id: NotificationId, pub workspace_id: WorkspaceId, pub workspace_window_id: Option, pub pane_id: PaneId, @@ -365,10 +385,20 @@ pub struct ActivityItem { pub kind: SignalKind, pub state: AttentionState, pub title: Option, + pub subtitle: Option, pub message: String, + pub read_at: Option, pub created_at: OffsetDateTime, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NotificationDeliveryState { + Pending, + Shown, + Suppressed, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum WorkspaceAgentState { @@ -427,6 +457,10 @@ fn default_notification_kind() -> SignalKind { SignalKind::Notification } +fn default_notification_delivery_state() -> NotificationDeliveryState { + NotificationDeliveryState::Shown +} + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct WorkspaceViewport { #[serde(default)] @@ -722,24 +756,66 @@ impl Workspace { fn acknowledge_pane_notifications(&mut self, pane_id: PaneId) { let now = OffsetDateTime::now_utc(); for notification in &mut self.notifications { - if notification.pane_id == pane_id && notification.cleared_at.is_none() { - notification.cleared_at = Some(now); + if notification.pane_id == pane_id + && notification.active() + && notification.read_at.is_none() + { + notification.read_at = Some(now); } } } fn acknowledge_surface_notifications(&mut self, pane_id: PaneId, surface_id: SurfaceId) { + let now = OffsetDateTime::now_utc(); + for notification in &mut self.notifications { + if notification.pane_id == pane_id + && notification.surface_id == surface_id + && notification.active() + && notification.read_at.is_none() + { + notification.read_at = Some(now); + } + } + } + + fn complete_surface_notifications(&mut self, pane_id: PaneId, surface_id: SurfaceId) { let now = OffsetDateTime::now_utc(); for notification in &mut self.notifications { if notification.pane_id == pane_id && notification.surface_id == surface_id && notification.cleared_at.is_none() { + if notification.read_at.is_none() { + notification.read_at = Some(now); + } notification.cleared_at = Some(now); } } } + fn upsert_notification(&mut self, notification: NotificationItem) { + if let Some(external_id) = notification.external_id.as_deref() + && let Some(existing) = self.notifications.iter_mut().rev().find(|existing| { + existing.external_id.as_deref() == Some(external_id) + && existing.surface_id == notification.surface_id + }) + { + existing.pane_id = notification.pane_id; + existing.kind = notification.kind; + existing.state = notification.state; + existing.title = notification.title; + existing.subtitle = notification.subtitle; + existing.message = notification.message; + existing.created_at = notification.created_at; + existing.read_at = None; + existing.cleared_at = None; + existing.desktop_delivery = NotificationDeliveryState::Pending; + return; + } + + self.notifications.push(notification); + } + fn active_surface_for_pane(&self, pane_id: PaneId) -> Option { self.panes.get(&pane_id).map(|pane| pane.active_surface) } @@ -754,12 +830,12 @@ impl Workspace { return Err(DomainError::MissingWorkspace(workspace_id)); } let pane_id = self.active_pane; - let surface_id = self - .active_surface_for_pane(pane_id) - .ok_or(DomainError::PaneNotInWorkspace { + let surface_id = self.active_surface_for_pane(pane_id).ok_or( + DomainError::PaneNotInWorkspace { workspace_id, pane_id, - })?; + }, + )?; Ok((workspace_id, pane_id, surface_id)) } AgentTarget::Pane { @@ -769,12 +845,12 @@ impl Workspace { if workspace_id != self.id { return Err(DomainError::MissingWorkspace(workspace_id)); } - let surface_id = self - .active_surface_for_pane(pane_id) - .ok_or(DomainError::PaneNotInWorkspace { + let surface_id = self.active_surface_for_pane(pane_id).ok_or( + DomainError::PaneNotInWorkspace { workspace_id, pane_id, - })?; + }, + )?; Ok((workspace_id, pane_id, surface_id)) } AgentTarget::Surface { @@ -810,6 +886,9 @@ impl Workspace { } for notification in &mut self.notifications { if notification.cleared_at.is_none() { + if notification.read_at.is_none() { + notification.read_at = Some(now); + } notification.cleared_at = Some(now); } } @@ -829,6 +908,9 @@ impl Workspace { } for notification in &mut self.notifications { if notification.pane_id == pane_id && notification.cleared_at.is_none() { + if notification.read_at.is_none() { + notification.read_at = Some(now); + } notification.cleared_at = Some(now); } } @@ -857,6 +939,9 @@ impl Workspace { && notification.surface_id == surface_id && notification.cleared_at.is_none() { + if notification.read_at.is_none() { + notification.read_at = Some(now); + } notification.cleared_at = Some(now); } } @@ -865,9 +950,64 @@ impl Workspace { Ok(()) } + fn clear_notification(&mut self, notification_id: NotificationId) -> bool { + let now = OffsetDateTime::now_utc(); + if let Some(notification) = self.notifications.iter_mut().find(|notification| { + notification.id == notification_id && notification.cleared_at.is_none() + }) { + if notification.read_at.is_none() { + notification.read_at = Some(now); + } + notification.cleared_at = Some(now); + return true; + } + false + } + + fn notification_target(&self, notification_id: NotificationId) -> Option<(PaneId, SurfaceId)> { + self.notifications + .iter() + .find(|notification| notification.id == notification_id) + .map(|notification| (notification.pane_id, notification.surface_id)) + } + + fn mark_notification_read(&mut self, notification_id: NotificationId) -> bool { + let now = OffsetDateTime::now_utc(); + if let Some(notification) = self + .notifications + .iter_mut() + .find(|notification| notification.id == notification_id && notification.cleared_at.is_none()) + { + if notification.read_at.is_none() { + notification.read_at = Some(now); + } + return true; + } + false + } + + fn set_notification_delivery( + &mut self, + notification_id: NotificationId, + delivery: NotificationDeliveryState, + ) -> bool { + if let Some(notification) = self + .notifications + .iter_mut() + .find(|notification| notification.id == notification_id) + { + notification.desktop_delivery = delivery; + return true; + } + false + } + fn append_log_entry(&mut self, entry: WorkspaceLogEntry) { self.log_entries.push(entry); - let overflow = self.log_entries.len().saturating_sub(WORKSPACE_LOG_RETENTION); + let overflow = self + .log_entries + .len() + .saturating_sub(WORKSPACE_LOG_RETENTION); if overflow > 0 { self.log_entries.drain(0..overflow); } @@ -1474,6 +1614,7 @@ impl AppModel { } workspace.focus_pane(pane_id); + workspace.acknowledge_pane_notifications(pane_id); Ok(()) } @@ -1525,7 +1666,7 @@ impl AppModel { surface.attention = AttentionState::Completed; surface.metadata.agent_active = false; surface.metadata.last_signal_at = Some(OffsetDateTime::now_utc()); - workspace.acknowledge_surface_notifications(pane_id, surface_id); + workspace.complete_surface_notifications(pane_id, surface_id); Ok(()) } @@ -1962,21 +2103,26 @@ impl AppModel { }; if should_acknowledge_surface_notifications { - workspace.acknowledge_surface_notifications(pane_id, surface_id); + workspace.complete_surface_notifications(pane_id, surface_id); } if signal_creates_notification(&event.kind) && let Some(message) = event.message { - workspace.notifications.push(NotificationItem { + workspace.upsert_notification(NotificationItem { + id: NotificationId::new(), pane_id, surface_id, kind: event.kind, state: surface_attention, title: notification_title, + subtitle: None, + external_id: None, message, created_at: event.timestamp, + read_at: None, cleared_at: None, + desktop_delivery: NotificationDeliveryState::Pending, }); } @@ -2024,6 +2170,7 @@ impl AppModel { surface_id, }); } + workspace.acknowledge_surface_notifications(pane_id, surface_id); Ok(()) } @@ -2103,7 +2250,10 @@ impl AppModel { pub fn create_agent_notification( &mut self, target: AgentTarget, + kind: SignalKind, title: Option, + subtitle: Option, + external_id: Option, message: String, state: AttentionState, ) -> Result<(), DomainError> { @@ -2125,15 +2275,26 @@ impl AppModel { surface.attention = state; } - workspace.notifications.push(NotificationItem { + workspace.upsert_notification(NotificationItem { + id: NotificationId::new(), pane_id, surface_id, - kind: SignalKind::Notification, + kind, state, - title: title.map(|value| value.trim().to_owned()).filter(|value| !value.is_empty()), + title: title + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()), + subtitle: subtitle + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()), + external_id: external_id + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()), message, created_at: now, + read_at: None, cleared_at: None, + desktop_delivery: NotificationDeliveryState::Pending, }); Ok(()) } @@ -2151,6 +2312,31 @@ impl AppModel { workspace.clear_notifications_matching(&target) } + pub fn clear_notification( + &mut self, + notification_id: NotificationId, + ) -> Result<(), DomainError> { + for workspace in self.workspaces.values_mut() { + if workspace.clear_notification(notification_id) { + return Ok(()); + } + } + Err(DomainError::InvalidOperation("notification not found")) + } + + pub fn mark_notification_delivery( + &mut self, + notification_id: NotificationId, + delivery: NotificationDeliveryState, + ) -> Result<(), DomainError> { + for workspace in self.workspaces.values_mut() { + if workspace.set_notification_delivery(notification_id, delivery) { + return Ok(()); + } + } + Err(DomainError::InvalidOperation("notification not found")) + } + pub fn trigger_surface_flash( &mut self, workspace_id: WorkspaceId, @@ -2189,21 +2375,45 @@ impl AppModel { workspace .notifications .iter() - .filter(|notification| notification.cleared_at.is_none()) + .filter(|notification| notification.unread()) .map(move |notification| (workspace.id, notification)) }) .max_by_key(|(_, notification)| notification.created_at) - .map(|(workspace_id, notification)| { - (workspace_id, notification.pane_id, notification.surface_id) - }); + .map(|(_, notification)| notification.id); - let Some((workspace_id, pane_id, surface_id)) = target else { + let Some(notification_id) = target else { return Ok(false); }; + self.open_notification(window_id, notification_id)?; + Ok(true) + } + + pub fn open_notification( + &mut self, + window_id: WindowId, + notification_id: NotificationId, + ) -> Result<(), DomainError> { + let mut target = None; + for workspace in self.workspaces.values() { + if let Some((pane_id, surface_id)) = workspace.notification_target(notification_id) { + target = Some((workspace.id, pane_id, surface_id)); + break; + } + } + + let (workspace_id, pane_id, surface_id) = + target.ok_or(DomainError::InvalidOperation("notification not found"))?; self.switch_workspace(window_id, workspace_id)?; self.focus_surface(workspace_id, pane_id, surface_id)?; - Ok(true) + + for workspace in self.workspaces.values_mut() { + if workspace.mark_notification_read(notification_id) { + return Ok(()); + } + } + + Err(DomainError::InvalidOperation("notification not found")) } pub fn close_surface( @@ -2759,7 +2969,7 @@ impl AppModel { let unread = workspace .notifications .iter() - .filter(|notification| notification.cleared_at.is_none()) + .filter(|notification| notification.unread()) .collect::>(); let unread_attention = unread .iter() @@ -2797,8 +3007,9 @@ impl AppModel { workspace .notifications .iter() - .filter(|notification| notification.cleared_at.is_none()) + .filter(|notification| notification.active()) .map(move |notification| ActivityItem { + notification_id: notification.id, workspace_id: workspace.id, workspace_window_id: workspace.window_for_pane(notification.pane_id), pane_id: notification.pane_id, @@ -2806,7 +3017,9 @@ impl AppModel { kind: notification.kind.clone(), state: notification.state, title: notification.title.clone(), + subtitle: notification.subtitle.clone(), message: notification.message.clone(), + read_at: notification.read_at, created_at: notification.created_at, }) }) @@ -4268,10 +4481,25 @@ mod tests { let workspace = model.workspaces.get(&workspace_id).expect("workspace"); assert_eq!(summary.status_text.as_deref(), Some("Running import")); - assert_eq!(workspace.progress.as_ref().map(|progress| progress.value), Some(420)); + assert_eq!( + workspace.progress.as_ref().map(|progress| progress.value), + Some(420) + ); assert_eq!(workspace.log_entries.len(), 200); - assert_eq!(workspace.log_entries.first().map(|entry| entry.message.as_str()), Some("log 5")); - assert_eq!(workspace.log_entries.last().map(|entry| entry.message.as_str()), Some("log 204")); + assert_eq!( + workspace + .log_entries + .first() + .map(|entry| entry.message.as_str()), + Some("log 5") + ); + assert_eq!( + workspace + .log_entries + .last() + .map(|entry| entry.message.as_str()), + Some("log 204") + ); } #[test] @@ -4293,7 +4521,10 @@ mod tests { pane_id, surface_id, }, + SignalKind::Notification, Some("Older".into()), + None, + None, "First".into(), AttentionState::WaitingInput, ) @@ -4304,7 +4535,10 @@ mod tests { AgentTarget::Workspace { workspace_id: second_workspace_id, }, + SignalKind::Notification, Some("Newest".into()), + None, + None, "Second".into(), AttentionState::WaitingInput, ) @@ -4318,6 +4552,116 @@ mod tests { assert_eq!(model.active_workspace_id(), Some(second_workspace_id)); } + #[test] + fn opening_notification_marks_it_read_without_clearing() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let pane_id = model.active_workspace().expect("workspace").active_pane; + let surface_id = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&pane_id)) + .and_then(|pane| pane.active_surface()) + .map(|surface| surface.id) + .expect("surface"); + + model + .create_agent_notification( + AgentTarget::Surface { + workspace_id, + pane_id, + surface_id, + }, + SignalKind::Notification, + Some("Heads up".into()), + None, + None, + "Review needed".into(), + AttentionState::WaitingInput, + ) + .expect("notification"); + + let notification_id = model + .active_workspace() + .and_then(|workspace| workspace.notifications.last()) + .map(|notification| notification.id) + .expect("notification id"); + + model + .open_notification(model.active_window, notification_id) + .expect("open notification"); + + let notification = model + .active_workspace() + .and_then(|workspace| { + workspace + .notifications + .iter() + .find(|notification| notification.id == notification_id) + }) + .expect("notification"); + assert!(notification.read_at.is_some()); + assert!(notification.cleared_at.is_none()); + assert_eq!(model.activity_items().len(), 1); + assert!( + model + .activity_items() + .iter() + .all(|item| item.read_at.is_some()) + ); + } + + #[test] + fn clearing_notification_moves_it_out_of_active_activity() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let pane_id = model.active_workspace().expect("workspace").active_pane; + let surface_id = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&pane_id)) + .and_then(|pane| pane.active_surface()) + .map(|surface| surface.id) + .expect("surface"); + + model + .create_agent_notification( + AgentTarget::Surface { + workspace_id, + pane_id, + surface_id, + }, + SignalKind::Notification, + Some("Heads up".into()), + None, + None, + "Review needed".into(), + AttentionState::WaitingInput, + ) + .expect("notification"); + + let notification_id = model + .active_workspace() + .and_then(|workspace| workspace.notifications.last()) + .map(|notification| notification.id) + .expect("notification id"); + + model + .clear_notification(notification_id) + .expect("clear notification"); + + assert!(model.activity_items().is_empty()); + let notification = model + .active_workspace() + .and_then(|workspace| { + workspace + .notifications + .iter() + .find(|notification| notification.id == notification_id) + }) + .expect("notification"); + assert!(notification.read_at.is_some()); + assert!(notification.cleared_at.is_some()); + } + #[test] fn triggering_surface_flash_advances_workspace_flash_token() { let mut model = AppModel::new("Main"); diff --git a/crates/taskers-shell-core/src/lib.rs b/crates/taskers-shell-core/src/lib.rs index f9738e0..96b67e3 100644 --- a/crates/taskers-shell-core/src/lib.rs +++ b/crates/taskers-shell-core/src/lib.rs @@ -9,9 +9,9 @@ use taskers_control::{ControlCommand, ControlResponse}; use taskers_core::{AppState, default_session_path}; use taskers_domain::{ ActivityItem, AppModel, DEFAULT_WORKSPACE_WINDOW_GAP, KEYBOARD_RESIZE_STEP, - MIN_WORKSPACE_WINDOW_HEIGHT, MIN_WORKSPACE_WINDOW_WIDTH, PaneKind, PaneMetadata, - PaneMetadataPatch, SplitAxis as DomainSplitAxis, SurfaceRecord, WindowFrame, Workspace, - WorkspaceSummary as DomainWorkspaceSummary, + MIN_WORKSPACE_WINDOW_HEIGHT, MIN_WORKSPACE_WINDOW_WIDTH, NotificationId, PaneKind, + PaneMetadata, PaneMetadataPatch, SplitAxis as DomainSplitAxis, SurfaceRecord, WindowFrame, + Workspace, WorkspaceSummary as DomainWorkspaceSummary, }; use taskers_ghostty::{BackendChoice, SurfaceDescriptor}; use taskers_runtime::ShellLaunchSpec; @@ -25,18 +25,12 @@ pub use taskers_domain::{ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ActivityId { - pub workspace_id: WorkspaceId, - pub pane_id: PaneId, - pub surface_id: SurfaceId, + pub notification_id: NotificationId, } impl fmt::Display for ActivityId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "activity-{}-{}-{}", - self.workspace_id, self.pane_id, self.surface_id - ) + write!(f, "activity-{}", self.notification_id) } } @@ -1067,6 +1061,9 @@ pub enum ShellAction { pane_id: PaneId, surface_id: SurfaceId, }, + OpenActivity { + activity_id: ActivityId, + }, DismissActivity { activity_id: ActivityId, }, @@ -1416,7 +1413,7 @@ impl TaskersCore { model .activity_items() .into_iter() - .map(|item| activity_item_snapshot(model, &item, true)) + .map(|item| activity_item_snapshot(model, &item)) .collect() } @@ -1430,6 +1427,7 @@ impl TaskersCore { .iter() .filter(|notification| notification.cleared_at.is_some()) .map(move |notification| ActivityItem { + notification_id: notification.id, workspace_id: workspace.id, workspace_window_id: workspace.window_for_pane(notification.pane_id), pane_id: notification.pane_id, @@ -1437,7 +1435,9 @@ impl TaskersCore { kind: notification.kind.clone(), state: notification.state, title: notification.title.clone(), + subtitle: notification.subtitle.clone(), message: notification.message.clone(), + read_at: notification.read_at, created_at: notification.created_at, }) }) @@ -1445,7 +1445,7 @@ impl TaskersCore { items.sort_by(|left, right| right.created_at.cmp(&left.created_at)); items .into_iter() - .map(|item| activity_item_snapshot(model, &item, false)) + .map(|item| activity_item_snapshot(model, &item)) .collect() } @@ -1912,6 +1912,7 @@ impl TaskersCore { pane_id, surface_id, } => self.close_surface_by_id(pane_id, surface_id), + ShellAction::OpenActivity { activity_id } => self.open_activity(activity_id), ShellAction::DismissActivity { activity_id } => self.dismiss_activity(activity_id), ShellAction::SelectTheme { theme_id } => { if self.ui.selected_theme_id == theme_id { @@ -2445,10 +2446,15 @@ impl TaskersCore { } fn dismiss_activity(&mut self, activity_id: ActivityId) -> bool { - self.dispatch_control(ControlCommand::MarkSurfaceCompleted { - workspace_id: activity_id.workspace_id, - pane_id: activity_id.pane_id, - surface_id: activity_id.surface_id, + self.dispatch_control(ControlCommand::ClearNotification { + notification_id: activity_id.notification_id, + }) + } + + fn open_activity(&mut self, activity_id: ActivityId) -> bool { + self.dispatch_control(ControlCommand::OpenNotification { + window_id: None, + notification_id: activity_id.notification_id, }) } @@ -3461,11 +3467,7 @@ fn activity_title(model: &AppModel, item: &ActivityItem) -> String { .unwrap_or_else(|| "Terminal pane".into()) } -fn activity_item_snapshot( - model: &AppModel, - item: &ActivityItem, - unread: bool, -) -> ActivityItemSnapshot { +fn activity_item_snapshot(model: &AppModel, item: &ActivityItem) -> ActivityItemSnapshot { let title = activity_title(model, item); let body = if item.message.trim() != title.trim() && !item.message.is_empty() { Some(item.message.clone()) @@ -3478,9 +3480,7 @@ fn activity_item_snapshot( .map(|workspace| workspace.label.clone()); ActivityItemSnapshot { id: ActivityId { - workspace_id: item.workspace_id, - pane_id: item.pane_id, - surface_id: item.surface_id, + notification_id: item.notification_id, }, title, preview: compact_preview(&item.message), @@ -3489,7 +3489,7 @@ fn activity_item_snapshot( workspace_id: item.workspace_id, pane_id: Some(item.pane_id), surface_id: Some(item.surface_id), - unread, + unread: item.read_at.is_none(), timestamp: format_relative_time(item.created_at), body, source_workspace_title, @@ -3680,7 +3680,8 @@ mod tests { use taskers_control::ControlCommand; use taskers_core::AppState; use taskers_domain::{ - AppModel, AttentionState as DomainAttentionState, NotificationItem, SignalKind, + AppModel, AttentionState as DomainAttentionState, NotificationId, NotificationItem, + SignalKind, }; use taskers_ghostty::BackendChoice; use taskers_runtime::ShellLaunchSpec; @@ -3730,14 +3731,19 @@ mod tests { .expect("workspace") .notifications .push(NotificationItem { + id: NotificationId::new(), pane_id, surface_id, kind: SignalKind::Notification, state: DomainAttentionState::WaitingInput, title: Some("Heads up".into()), + subtitle: None, + external_id: None, message: "Needs attention".into(), created_at: now, + read_at: cleared.then_some(now), cleared_at: cleared.then_some(now), + desktop_delivery: taskers_domain::NotificationDeliveryState::Shown, }); BootstrapModel { @@ -4524,7 +4530,10 @@ mod tests { pane_id: first_pane_id, surface_id: first_surface_id, }, + kind: SignalKind::Notification, title: Some("Older".into()), + subtitle: None, + external_id: None, message: "Older".into(), state: DomainAttentionState::WaitingInput, }) @@ -4537,7 +4546,10 @@ mod tests { pane_id: second_pane_id, surface_id: second_surface_id, }, + kind: SignalKind::Notification, title: Some("Newest".into()), + subtitle: None, + external_id: None, message: "Newest".into(), state: DomainAttentionState::WaitingInput, }) diff --git a/crates/taskers-shell/src/lib.rs b/crates/taskers-shell/src/lib.rs index 653a5df..f8c63e9 100644 --- a/crates/taskers-shell/src/lib.rs +++ b/crates/taskers-shell/src/lib.rs @@ -1,15 +1,15 @@ mod theme; use dioxus::prelude::*; -use taskers_shell_core as taskers_core; use taskers_core::{ ActivityItemSnapshot, AgentSessionSnapshot, AttentionState, BrowserChromeSnapshot, Direction, LayoutNodeSnapshot, PaneId, PaneSnapshot, ProgressSnapshot, PullRequestSnapshot, RuntimeStatus, SettingsSnapshot, SharedCore, ShellAction, ShellSection, ShellSnapshot, ShortcutAction, - ShortcutBindingSnapshot, SplitAxis, SurfaceId, SurfaceKind, SurfaceSnapshot, - WorkspaceId, WorkspaceLogEntrySnapshot, WorkspaceSummary, WorkspaceViewSnapshot, - WorkspaceWindowMoveTarget, WorkspaceWindowSnapshot, + ShortcutBindingSnapshot, SplitAxis, SurfaceId, SurfaceKind, SurfaceSnapshot, WorkspaceId, + WorkspaceLogEntrySnapshot, WorkspaceSummary, WorkspaceViewSnapshot, WorkspaceWindowMoveTarget, + WorkspaceWindowSnapshot, }; +use taskers_shell_core as taskers_core; #[derive(Clone, Copy, PartialEq, Eq)] struct DraggedSurface { @@ -165,6 +165,7 @@ pub fn TaskersShell(core: SharedCore) -> Element { let _ = revision(); let snapshot = core.snapshot(); + let unread_activity = snapshot.activity.iter().filter(|item| item.unread).count(); let stylesheet = app_css(&snapshot); let show_workspace_nav = { let core = core.clone(); @@ -303,9 +304,9 @@ pub fn TaskersShell(core: SharedCore) -> Element { "{snapshot.agents.len()} agents" } } - if snapshot.activity.len() > 0 { + if unread_activity > 0 { span { class: "notification-count-pill notification-count-unread", - "{snapshot.activity.len()} unread" + "{unread_activity} unread" } button { class: "notification-jump-button", @@ -1478,7 +1479,7 @@ fn render_agent_item( fn render_notification_row( item: &ActivityItemSnapshot, core: SharedCore, - current_workspace: &taskers_core::WorkspaceViewSnapshot, + _current_workspace: &taskers_core::WorkspaceViewSnapshot, ) -> Element { let dot_class = if item.unread { "notification-dot notification-dot-unread" @@ -1495,23 +1496,8 @@ fn render_notification_row( }; let focus_target = { let core = core.clone(); - let workspace_id = item.workspace_id; - let pane_id = item.pane_id; - let surface_id = item.surface_id; - let current_workspace_id = current_workspace.id; move |_| { - if workspace_id != current_workspace_id { - core.dispatch_shell_action(ShellAction::FocusWorkspace { workspace_id }); - } else if let (Some(pane_id), Some(surface_id)) = (pane_id, surface_id) { - core.dispatch_shell_action(ShellAction::FocusSurface { - pane_id, - surface_id, - }); - } else if let Some(pane_id) = pane_id { - core.dispatch_shell_action(ShellAction::FocusPane { pane_id }); - } else { - core.dispatch_shell_action(ShellAction::FocusWorkspace { workspace_id }); - } + core.dispatch_shell_action(ShellAction::OpenActivity { activity_id }); } }; @@ -1531,12 +1517,10 @@ fn render_notification_row( if let Some(source) = &item.source_workspace_title { div { class: "notification-source", "{source}" } } - if item.unread { - button { - class: "notification-clear", - onclick: dismiss, - "×" - } + button { + class: "notification-clear", + onclick: dismiss, + "×" } } } From 30f600b67d7d124187de3bd9f8ed3a8b202f0482 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sat, 21 Mar 2026 16:31:48 +0100 Subject: [PATCH 07/63] feat: deliver desktop notifications --- Cargo.lock | 1 + crates/taskers-app/Cargo.toml | 1 + crates/taskers-app/src/main.rs | 275 ++++++++++++++++++++++++++++++++- 3 files changed, 275 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b7a8ee4..992c3dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2504,6 +2504,7 @@ dependencies = [ "taskers-runtime", "taskers-shell", "taskers-shell-core", + "time", "tokio", "webkit6", ] diff --git a/crates/taskers-app/Cargo.toml b/crates/taskers-app/Cargo.toml index 152e92f..2f8d065 100644 --- a/crates/taskers-app/Cargo.toml +++ b/crates/taskers-app/Cargo.toml @@ -30,5 +30,6 @@ taskers-paths.workspace = true taskers-runtime.workspace = true taskers-shell.workspace = true taskers-shell-core.workspace = true +time.workspace = true tokio.workspace = true webkit6.workspace = true diff --git a/crates/taskers-app/src/main.rs b/crates/taskers-app/src/main.rs index 8748b3f..b12bff9 100644 --- a/crates/taskers-app/src/main.rs +++ b/crates/taskers-app/src/main.rs @@ -2,7 +2,7 @@ use adw::prelude::*; use anyhow::{Context, Result}; use axum::{Router, extract::ws::WebSocketUpgrade, response::Html, routing::get}; use clap::{Parser, ValueEnum}; -use gtk::{EventControllerKey, gdk, glib}; +use gtk::{EventControllerKey, gdk, gio, glib}; use std::{ cell::{Cell, RefCell}, fs::{File, OpenOptions, remove_file}, @@ -24,7 +24,7 @@ use taskers_control::{ bind_socket, default_socket_path, serve_with_handler, }; use taskers_core::{AppState, default_session_path, load_or_bootstrap}; -use taskers_domain::AppModel; +use taskers_domain::{AppModel, NotificationDeliveryState, NotificationId, SignalKind}; use taskers_ghostty::{BackendChoice, GhosttyHost, GhosttyHostOptions, ensure_runtime_installed}; use taskers_host::{DiagnosticCategory, DiagnosticRecord, DiagnosticsSink, TaskersHost}; use taskers_runtime::{ShellLaunchSpec, install_shell_integration, scrub_inherited_terminal_env}; @@ -34,6 +34,8 @@ use taskers_shell_core::{ }; use webkit6::{Settings as WebKitSettings, WebView, prelude::*}; +use glib::variant::ToVariant; + const APP_ID: &str = taskers_paths::APP_ID; #[derive(Debug, Clone, Parser)] @@ -97,6 +99,16 @@ struct HostAutomationRequest { response_tx: tokio::sync::oneshot::Sender>, } +#[derive(Debug, Clone)] +struct PendingDesktopNotification { + id: NotificationId, + workspace_id: taskers_shell_core::WorkspaceId, + pane_id: taskers_shell_core::PaneId, + surface_id: taskers_shell_core::SurfaceId, + title: String, + body: Option, +} + fn main() -> glib::ExitCode { let cli = Cli::parse(); scrub_inherited_terminal_env(); @@ -199,6 +211,7 @@ fn build_ui_result( let host_widget = host.borrow().widget(); window.set_content(Some(&host_widget)); connect_navigation_shortcuts(&window, &shell_view, &core); + install_notification_action(app, &window, &core, &bootstrap.app_state); let last_revision = Rc::new(Cell::new(0_u64)); let last_size = Rc::new(Cell::new((0_i32, 0_i32))); let (host_request_tx, host_request_rx) = mpsc::channel::(); @@ -251,6 +264,8 @@ fn build_ui_result( let tick_revision = last_revision.clone(); let tick_size = last_size.clone(); let tick_diagnostics = diagnostics.clone(); + let tick_app = app.clone(); + let tick_app_state = bootstrap.app_state.clone(); glib::timeout_add_local(Duration::from_millis(16), move || { sync_window( &tick_window, @@ -260,6 +275,13 @@ fn build_ui_result( &tick_size, tick_diagnostics.as_ref(), ); + process_pending_notifications( + &tick_app, + &tick_window, + &tick_core, + &tick_app_state, + tick_diagnostics.as_ref(), + ); glib::ControlFlow::Continue }); @@ -271,6 +293,8 @@ fn build_ui_result( let initial_revision = last_revision.clone(); let initial_size = last_size.clone(); let initial_diagnostics = diagnostics.clone(); + let initial_app = app.clone(); + let initial_app_state = bootstrap.app_state.clone(); glib::timeout_add_local_once(Duration::from_millis(80), move || { sync_window( &initial_window, @@ -280,6 +304,13 @@ fn build_ui_result( &initial_size, initial_diagnostics.as_ref(), ); + process_pending_notifications( + &initial_app, + &initial_window, + &initial_core, + &initial_app_state, + initial_diagnostics.as_ref(), + ); }); if let Some(script) = smoke_script { @@ -299,6 +330,246 @@ fn normalize_shortcut_modifiers(state: gdk::ModifierType) -> gdk::ModifierType { | gdk::ModifierType::HYPER_MASK) } +fn install_notification_action( + app: &adw::Application, + window: &adw::ApplicationWindow, + core: &SharedCore, + app_state: &AppState, +) { + let action = gio::SimpleAction::new("open-notification", Some(&String::static_variant_type())); + let action_window = window.clone(); + let action_core = core.clone(); + let action_state = app_state.clone(); + action.connect_activate(move |_, parameter| { + let Some(notification_id) = parameter + .and_then(|value| value.str()) + .and_then(|value| value.parse::().ok()) + else { + return; + }; + + let _ = action_state.dispatch(ControlCommand::OpenNotification { + window_id: None, + notification_id, + }); + action_window.present(); + action_core.sync_external_changes(); + }); + app.add_action(&action); +} + +fn process_pending_notifications( + app: &adw::Application, + window: &adw::ApplicationWindow, + core: &SharedCore, + app_state: &AppState, + diagnostics: Option<&DiagnosticsWriter>, +) { + let model = app_state.snapshot_model(); + let pending = pending_desktop_notifications(&model); + if pending.is_empty() { + return; + } + + for notification in pending { + let delivery = if notification_target_visible(&model, window, ¬ification) { + NotificationDeliveryState::Suppressed + } else { + let desktop = gio::Notification::new(¬ification.title); + if let Some(body) = ¬ification.body { + desktop.set_body(Some(body)); + } + desktop.set_default_action_and_target_value( + "app.open-notification", + Some(¬ification.id.to_string().to_variant()), + ); + app.send_notification(Some(¬ification.id.to_string()), &desktop); + NotificationDeliveryState::Shown + }; + + if let Err(error) = app_state.dispatch(ControlCommand::MarkNotificationDelivery { + notification_id: notification.id, + delivery, + }) { + log_diagnostic( + diagnostics, + DiagnosticRecord::new( + DiagnosticCategory::Sync, + Some(core.revision()), + format!("notification delivery update failed: {error:?}"), + ) + .with_pane(notification.pane_id) + .with_surface(notification.surface_id), + ); + eprintln!("taskers notification delivery update failed: {error:?}"); + } + } + + core.sync_external_changes(); +} + +fn pending_desktop_notifications(model: &AppModel) -> Vec { + model + .workspaces + .values() + .flat_map(|workspace| { + workspace.notifications.iter().filter_map(move |notification| { + if notification.cleared_at.is_some() + || !matches!(notification.desktop_delivery, NotificationDeliveryState::Pending) + || !notification_alerts_enabled(notification.kind.clone()) + { + return None; + } + + let title = notification + .title + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| notification.message.clone()); + Some(PendingDesktopNotification { + id: notification.id, + workspace_id: workspace.id, + pane_id: notification.pane_id, + surface_id: notification.surface_id, + title, + body: notification_body(notification.subtitle.as_deref(), ¬ification.message), + }) + }) + }) + .collect() +} + +fn notification_alerts_enabled(kind: SignalKind) -> bool { + matches!( + kind, + SignalKind::WaitingInput + | SignalKind::Error + | SignalKind::Completed + | SignalKind::Notification + ) +} + +fn notification_body(subtitle: Option<&str>, message: &str) -> Option { + let subtitle = subtitle + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned); + let message = message.trim(); + + match (subtitle, message.is_empty()) { + (Some(subtitle), false) if subtitle != message => Some(format!("{subtitle}\n{message}")), + (Some(subtitle), _) => Some(subtitle), + (None, false) => Some(message.to_owned()), + (None, true) => None, + } +} + +fn notification_target_visible( + model: &AppModel, + window: &adw::ApplicationWindow, + notification: &PendingDesktopNotification, +) -> bool { + if !window.is_active() || model.active_workspace_id() != Some(notification.workspace_id) { + return false; + } + + model.workspaces + .get(¬ification.workspace_id) + .and_then(|workspace| { + if workspace.active_pane != notification.pane_id { + return None; + } + workspace.panes.get(¬ification.pane_id) + }) + .is_some_and(|pane| pane.active_surface == notification.surface_id) +} + +#[cfg(test)] +mod notification_tests { + use super::{notification_alerts_enabled, notification_body, pending_desktop_notifications}; + use taskers_domain::{ + AppModel, AttentionState, NotificationDeliveryState, NotificationId, NotificationItem, + SignalKind, + }; + use time::OffsetDateTime; + + #[test] + fn desktop_alert_filter_includes_user_facing_notification_kinds() { + assert!(notification_alerts_enabled(SignalKind::WaitingInput)); + assert!(notification_alerts_enabled(SignalKind::Error)); + assert!(notification_alerts_enabled(SignalKind::Completed)); + assert!(notification_alerts_enabled(SignalKind::Notification)); + assert!(!notification_alerts_enabled(SignalKind::Started)); + assert!(!notification_alerts_enabled(SignalKind::Progress)); + } + + #[test] + fn notification_body_prefers_subtitle_then_message() { + assert_eq!( + notification_body(Some("Codex"), "Finished"), + Some("Codex\nFinished".into()) + ); + assert_eq!( + notification_body(Some("Finished"), "Finished"), + Some("Finished".into()) + ); + assert_eq!(notification_body(None, "Done"), Some("Done".into())); + assert_eq!(notification_body(None, " "), None); + } + + #[test] + fn pending_desktop_notifications_only_returns_pending_active_items() { + let mut model = AppModel::new("Main"); + let workspace = model.active_workspace_id().expect("workspace"); + let pane_id = model.active_workspace().expect("workspace").active_pane; + let surface_id = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&pane_id)) + .and_then(|pane| pane.active_surface()) + .map(|surface| surface.id) + .expect("surface"); + let now = OffsetDateTime::now_utc(); + let workspace = model.workspaces.get_mut(&workspace).expect("workspace"); + workspace.notifications.push(NotificationItem { + id: NotificationId::new(), + pane_id, + surface_id, + kind: SignalKind::Notification, + state: AttentionState::WaitingInput, + title: Some("Codex".into()), + subtitle: Some("Waiting".into()), + external_id: None, + message: "Need input".into(), + created_at: now, + read_at: None, + cleared_at: None, + desktop_delivery: NotificationDeliveryState::Pending, + }); + workspace.notifications.push(NotificationItem { + id: NotificationId::new(), + pane_id, + surface_id, + kind: SignalKind::Notification, + state: AttentionState::WaitingInput, + title: Some("Old".into()), + subtitle: None, + external_id: None, + message: "Shown already".into(), + created_at: now, + read_at: None, + cleared_at: None, + desktop_delivery: NotificationDeliveryState::Shown, + }); + + let pending = pending_desktop_notifications(&model); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].title, "Codex"); + assert_eq!(pending[0].body.as_deref(), Some("Waiting\nNeed input")); + } +} + fn is_modifier_key(key: gdk::Key) -> bool { matches!( key, From 078d0e86b54062652062c9754ef4c9d1b7e2bb73 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sat, 21 Mar 2026 16:37:53 +0100 Subject: [PATCH 08/63] feat: persist notification preferences --- Cargo.lock | 2 + crates/taskers-app/Cargo.toml | 2 + crates/taskers-app/src/main.rs | 328 +++++++++++++++++++++++---- crates/taskers-shell-core/src/lib.rs | 68 +++++- crates/taskers-shell/src/lib.rs | 77 ++++++- crates/taskers-shell/src/theme.rs | 47 +++- 6 files changed, 467 insertions(+), 57 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 992c3dd..17881f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2495,6 +2495,8 @@ dependencies = [ "dioxus-liveview", "gtk4", "libadwaita", + "serde", + "serde_json", "taskers-control", "taskers-core", "taskers-domain", diff --git a/crates/taskers-app/Cargo.toml b/crates/taskers-app/Cargo.toml index 2f8d065..c6a10c0 100644 --- a/crates/taskers-app/Cargo.toml +++ b/crates/taskers-app/Cargo.toml @@ -21,6 +21,8 @@ clap.workspace = true dioxus.workspace = true dioxus-liveview.workspace = true gtk.workspace = true +serde.workspace = true +serde_json.workspace = true taskers-control.workspace = true taskers-core.workspace = true taskers-domain.workspace = true diff --git a/crates/taskers-app/src/main.rs b/crates/taskers-app/src/main.rs index b12bff9..760ba98 100644 --- a/crates/taskers-app/src/main.rs +++ b/crates/taskers-app/src/main.rs @@ -3,9 +3,10 @@ use anyhow::{Context, Result}; use axum::{Router, extract::ws::WebSocketUpgrade, response::Html, routing::get}; use clap::{Parser, ValueEnum}; use gtk::{EventControllerKey, gdk, gio, glib}; +use serde::{Deserialize, Serialize}; use std::{ cell::{Cell, RefCell}, - fs::{File, OpenOptions, remove_file}, + fs::{File, OpenOptions, create_dir_all, read_to_string, remove_file, write}, future::pending, io::{self, Write}, net::TcpListener, @@ -29,8 +30,9 @@ use taskers_ghostty::{BackendChoice, GhosttyHost, GhosttyHostOptions, ensure_run use taskers_host::{DiagnosticCategory, DiagnosticRecord, DiagnosticsSink, TaskersHost}; use taskers_runtime::{ShellLaunchSpec, install_shell_integration, scrub_inherited_terminal_env}; use taskers_shell_core::{ - BootstrapModel, LayoutNodeSnapshot, PixelSize, RuntimeCapability, RuntimeStatus, SharedCore, - ShellAction, ShellSection, ShortcutAction, ShortcutPreset, SurfaceKind, + BootstrapModel, LayoutNodeSnapshot, NotificationPreferencesSnapshot, PixelSize, + RuntimeCapability, RuntimeStatus, SharedCore, ShellAction, ShellSection, ShortcutAction, + ShortcutPreset, SurfaceKind, }; use webkit6::{Settings as WebKitSettings, WebView, prelude::*}; @@ -77,6 +79,7 @@ struct BootstrapContext { app_state: AppState, socket_path: PathBuf, ghostty_host: Option, + config: TaskersConfig, startup_notes: Vec, } @@ -109,6 +112,128 @@ struct PendingDesktopNotification { body: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct TaskersConfig { + #[serde(default = "default_theme_id")] + selected_theme_id: String, + #[serde(default = "default_shortcut_preset_id")] + selected_shortcut_preset: String, + #[serde(default)] + notification_preferences: NotificationPreferencesConfig, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +struct NotificationPreferencesConfig { + #[serde(default = "default_true")] + alerts_on_waiting: bool, + #[serde(default = "default_true")] + alerts_on_error: bool, + #[serde(default = "default_true")] + alerts_on_completed: bool, + #[serde(default = "default_true")] + suppress_when_visible: bool, +} + +impl Default for TaskersConfig { + fn default() -> Self { + Self { + selected_theme_id: default_theme_id(), + selected_shortcut_preset: default_shortcut_preset_id(), + notification_preferences: NotificationPreferencesConfig::default(), + } + } +} + +impl Default for NotificationPreferencesConfig { + fn default() -> Self { + Self { + alerts_on_waiting: true, + alerts_on_error: true, + alerts_on_completed: true, + suppress_when_visible: true, + } + } +} + +impl NotificationPreferencesConfig { + fn to_snapshot(self) -> NotificationPreferencesSnapshot { + NotificationPreferencesSnapshot { + alerts_on_waiting: self.alerts_on_waiting, + alerts_on_error: self.alerts_on_error, + alerts_on_completed: self.alerts_on_completed, + suppress_when_visible: self.suppress_when_visible, + } + } + + fn from_snapshot(snapshot: NotificationPreferencesSnapshot) -> Self { + Self { + alerts_on_waiting: snapshot.alerts_on_waiting, + alerts_on_error: snapshot.alerts_on_error, + alerts_on_completed: snapshot.alerts_on_completed, + suppress_when_visible: snapshot.suppress_when_visible, + } + } +} + +fn default_theme_id() -> String { + "dark".into() +} + +fn default_shortcut_preset_id() -> String { + ShortcutPreset::PowerUser.id().into() +} + +fn default_true() -> bool { + true +} + +impl TaskersConfig { + fn load() -> Result { + let path = taskers_paths::default_config_path(); + let data = match read_to_string(&path) { + Ok(data) => data, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Self::default()), + Err(error) => { + return Err(error) + .with_context(|| format!("failed to read config {}", path.display())); + } + }; + let config: Self = serde_json::from_str(&data) + .with_context(|| format!("failed to parse config {}", path.display()))?; + Ok(config) + } + + fn save(&self) -> Result<()> { + let path = taskers_paths::default_config_path(); + if let Some(parent) = path.parent() { + create_dir_all(parent).with_context(|| { + format!("failed to create config directory {}", parent.display()) + })?; + } + let data = serde_json::to_string_pretty(self).context("failed to serialize config")?; + write(&path, data).with_context(|| format!("failed to write config {}", path.display())) + } + + fn shortcut_preset(&self) -> ShortcutPreset { + ShortcutPreset::parse(&self.selected_shortcut_preset).unwrap_or(ShortcutPreset::PowerUser) + } + + fn from_settings(settings: &taskers_shell_core::SettingsSnapshot) -> Self { + Self { + selected_theme_id: settings.selected_theme_id.clone(), + selected_shortcut_preset: settings + .shortcut_presets + .iter() + .find(|preset| preset.active) + .map(|preset| preset.id.clone()) + .unwrap_or_else(default_shortcut_preset_id), + notification_preferences: NotificationPreferencesConfig::from_snapshot( + settings.notification_preferences, + ), + } + } +} + fn main() -> glib::ExitCode { let cli = Cli::parse(); scrub_inherited_terminal_env(); @@ -197,6 +322,7 @@ fn build_ui_result( event_sink, diagnostics_sink, ))); + let persisted_config = Rc::new(RefCell::new(bootstrap.config.clone())); let window = adw::ApplicationWindow::builder() .application(app) @@ -266,6 +392,7 @@ fn build_ui_result( let tick_diagnostics = diagnostics.clone(); let tick_app = app.clone(); let tick_app_state = bootstrap.app_state.clone(); + let tick_config = persisted_config.clone(); glib::timeout_add_local(Duration::from_millis(16), move || { sync_window( &tick_window, @@ -282,6 +409,7 @@ fn build_ui_result( &tick_app_state, tick_diagnostics.as_ref(), ); + persist_settings_if_needed(&tick_core, &tick_config, tick_diagnostics.as_ref()); glib::ControlFlow::Continue }); @@ -366,13 +494,17 @@ fn process_pending_notifications( diagnostics: Option<&DiagnosticsWriter>, ) { let model = app_state.snapshot_model(); - let pending = pending_desktop_notifications(&model); + let settings = core.snapshot().settings; + let prefs = settings.notification_preferences; + let pending = pending_desktop_notifications_with_prefs(&model, prefs); if pending.is_empty() { return; } for notification in pending { - let delivery = if notification_target_visible(&model, window, ¬ification) { + let delivery = if prefs.suppress_when_visible + && notification_target_visible(&model, window, ¬ification) + { NotificationDeliveryState::Suppressed } else { let desktop = gio::Notification::new(¬ification.title); @@ -408,47 +540,59 @@ fn process_pending_notifications( core.sync_external_changes(); } -fn pending_desktop_notifications(model: &AppModel) -> Vec { +fn pending_desktop_notifications_with_prefs( + model: &AppModel, + prefs: NotificationPreferencesSnapshot, +) -> Vec { model .workspaces .values() .flat_map(|workspace| { - workspace.notifications.iter().filter_map(move |notification| { - if notification.cleared_at.is_some() - || !matches!(notification.desktop_delivery, NotificationDeliveryState::Pending) - || !notification_alerts_enabled(notification.kind.clone()) - { - return None; - } + workspace + .notifications + .iter() + .filter_map(move |notification| { + if notification.cleared_at.is_some() + || !matches!( + notification.desktop_delivery, + NotificationDeliveryState::Pending + ) + || !notification_alerts_enabled(¬ification.kind, prefs) + { + return None; + } - let title = notification - .title - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_owned) - .unwrap_or_else(|| notification.message.clone()); - Some(PendingDesktopNotification { - id: notification.id, - workspace_id: workspace.id, - pane_id: notification.pane_id, - surface_id: notification.surface_id, - title, - body: notification_body(notification.subtitle.as_deref(), ¬ification.message), + let title = notification + .title + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| notification.message.clone()); + Some(PendingDesktopNotification { + id: notification.id, + workspace_id: workspace.id, + pane_id: notification.pane_id, + surface_id: notification.surface_id, + title, + body: notification_body( + notification.subtitle.as_deref(), + ¬ification.message, + ), + }) }) - }) }) .collect() } -fn notification_alerts_enabled(kind: SignalKind) -> bool { - matches!( - kind, - SignalKind::WaitingInput - | SignalKind::Error - | SignalKind::Completed - | SignalKind::Notification - ) +fn notification_alerts_enabled(kind: &SignalKind, prefs: NotificationPreferencesSnapshot) -> bool { + match kind { + SignalKind::WaitingInput => prefs.alerts_on_waiting, + SignalKind::Error => prefs.alerts_on_error, + SignalKind::Completed => prefs.alerts_on_completed, + SignalKind::Notification => true, + SignalKind::Started | SignalKind::Progress | SignalKind::Metadata => false, + } } fn notification_body(subtitle: Option<&str>, message: &str) -> Option { @@ -475,7 +619,8 @@ fn notification_target_visible( return false; } - model.workspaces + model + .workspaces .get(¬ification.workspace_id) .and_then(|workspace| { if workspace.active_pane != notification.pane_id { @@ -486,23 +631,60 @@ fn notification_target_visible( .is_some_and(|pane| pane.active_surface == notification.surface_id) } +fn persist_settings_if_needed( + core: &SharedCore, + persisted_config: &Rc>, + diagnostics: Option<&DiagnosticsWriter>, +) { + let snapshot = core.snapshot(); + let next = TaskersConfig::from_settings(&snapshot.settings); + if *persisted_config.borrow() == next { + return; + } + + if let Err(error) = next.save() { + log_diagnostic( + diagnostics, + DiagnosticRecord::new( + DiagnosticCategory::Startup, + Some(core.revision()), + format!("failed to persist config: {error:?}"), + ), + ); + eprintln!("taskers config save failed: {error:?}"); + return; + } + + *persisted_config.borrow_mut() = next; +} + #[cfg(test)] mod notification_tests { - use super::{notification_alerts_enabled, notification_body, pending_desktop_notifications}; + use super::{ + notification_alerts_enabled, notification_body, pending_desktop_notifications_with_prefs, + }; use taskers_domain::{ AppModel, AttentionState, NotificationDeliveryState, NotificationId, NotificationItem, SignalKind, }; + use taskers_shell_core::NotificationPreferencesSnapshot; use time::OffsetDateTime; #[test] fn desktop_alert_filter_includes_user_facing_notification_kinds() { - assert!(notification_alerts_enabled(SignalKind::WaitingInput)); - assert!(notification_alerts_enabled(SignalKind::Error)); - assert!(notification_alerts_enabled(SignalKind::Completed)); - assert!(notification_alerts_enabled(SignalKind::Notification)); - assert!(!notification_alerts_enabled(SignalKind::Started)); - assert!(!notification_alerts_enabled(SignalKind::Progress)); + let prefs = NotificationPreferencesSnapshot::default(); + assert!(notification_alerts_enabled( + &SignalKind::WaitingInput, + prefs + )); + assert!(notification_alerts_enabled(&SignalKind::Error, prefs)); + assert!(notification_alerts_enabled(&SignalKind::Completed, prefs)); + assert!(notification_alerts_enabled( + &SignalKind::Notification, + prefs + )); + assert!(!notification_alerts_enabled(&SignalKind::Started, prefs)); + assert!(!notification_alerts_enabled(&SignalKind::Progress, prefs)); } #[test] @@ -563,11 +745,53 @@ mod notification_tests { desktop_delivery: NotificationDeliveryState::Shown, }); - let pending = pending_desktop_notifications(&model); + let pending = pending_desktop_notifications_with_prefs( + &model, + NotificationPreferencesSnapshot::default(), + ); assert_eq!(pending.len(), 1); assert_eq!(pending[0].title, "Codex"); assert_eq!(pending[0].body.as_deref(), Some("Waiting\nNeed input")); } + + #[test] + fn pending_desktop_notifications_respects_waiting_toggle() { + let mut model = AppModel::new("Main"); + let workspace = model.active_workspace_id().expect("workspace"); + let pane_id = model.active_workspace().expect("workspace").active_pane; + let surface_id = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&pane_id)) + .and_then(|pane| pane.active_surface()) + .map(|surface| surface.id) + .expect("surface"); + let workspace = model.workspaces.get_mut(&workspace).expect("workspace"); + workspace.notifications.push(NotificationItem { + id: NotificationId::new(), + pane_id, + surface_id, + kind: SignalKind::WaitingInput, + state: AttentionState::WaitingInput, + title: Some("Codex".into()), + subtitle: None, + external_id: None, + message: "Need input".into(), + created_at: OffsetDateTime::now_utc(), + read_at: None, + cleared_at: None, + desktop_delivery: NotificationDeliveryState::Pending, + }); + + let pending = pending_desktop_notifications_with_prefs( + &model, + NotificationPreferencesSnapshot { + alerts_on_waiting: false, + ..NotificationPreferencesSnapshot::default() + }, + ); + + assert!(pending.is_empty()); + } } fn is_modifier_key(key: gdk::Key) -> bool { @@ -663,6 +887,13 @@ fn connect_navigation_shortcuts( fn bootstrap_runtime(diagnostics: Option<&DiagnosticsWriter>) -> Result { let runtime = resolve_runtime_bootstrap(); let mut startup_notes = runtime.startup_notes; + let config = match TaskersConfig::load() { + Ok(config) => config, + Err(error) => { + startup_notes.push(format!("Taskers config unavailable: {error}")); + TaskersConfig::default() + } + }; let session_path = default_session_path(); let initial_model = load_or_bootstrap(&session_path, false).with_context(|| { format!( @@ -721,8 +952,9 @@ fn bootstrap_runtime(diagnostics: Option<&DiagnosticsWriter>) -> Result) -> Result Self { + Self { + alerts_on_waiting: true, + alerts_on_error: true, + alerts_on_completed: true, + suppress_when_visible: true, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotificationPreferenceKey { + AlertsOnWaiting, + AlertsOnError, + AlertsOnCompleted, + SuppressWhenVisible, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PixelSize { pub width: i32, @@ -904,6 +933,7 @@ pub struct SettingsSnapshot { pub theme_options: Vec, pub shortcut_presets: Vec, pub shortcuts: Vec, + pub notification_preferences: NotificationPreferencesSnapshot, } #[derive(Debug, Clone, PartialEq)] @@ -1073,6 +1103,10 @@ pub enum ShellAction { SelectShortcutPreset { preset_id: String, }, + SetNotificationPreference { + key: NotificationPreferenceKey, + enabled: bool, + }, } #[derive(Debug, Clone)] @@ -1082,6 +1116,7 @@ struct UiState { drag_mode: ShellDragMode, selected_theme_id: String, selected_shortcut_preset: ShortcutPreset, + notification_preferences: NotificationPreferencesSnapshot, window_size: PixelSize, } @@ -1144,6 +1179,7 @@ impl TaskersCore { drag_mode: ShellDragMode::None, selected_theme_id: bootstrap.selected_theme_id, selected_shortcut_preset: bootstrap.selected_shortcut_preset, + notification_preferences: bootstrap.notification_preferences, window_size: PixelSize::new(1440, 900), }, host_commands: VecDeque::new(), @@ -1307,6 +1343,7 @@ impl TaskersCore { }) .collect(), shortcuts: shortcut_bindings(self.ui.selected_shortcut_preset), + notification_preferences: self.ui.notification_preferences, } } @@ -1933,6 +1970,28 @@ impl TaskersCore { self.bump_local_revision(); true } + ShellAction::SetNotificationPreference { key, enabled } => { + let changed = match key { + NotificationPreferenceKey::AlertsOnWaiting => { + &mut self.ui.notification_preferences.alerts_on_waiting + } + NotificationPreferenceKey::AlertsOnError => { + &mut self.ui.notification_preferences.alerts_on_error + } + NotificationPreferenceKey::AlertsOnCompleted => { + &mut self.ui.notification_preferences.alerts_on_completed + } + NotificationPreferenceKey::SuppressWhenVisible => { + &mut self.ui.notification_preferences.suppress_when_visible + } + }; + if *changed == enabled { + return false; + } + *changed = enabled; + self.bump_local_revision(); + true + } } } @@ -3689,10 +3748,10 @@ mod tests { use super::{ BootstrapModel, BrowserMountSpec, DEFAULT_BROWSER_HOME, Direction, HostCommand, HostEvent, - LayoutMetrics, RuntimeCapability, RuntimeStatus, SharedCore, ShellAction, ShellDragMode, - ShellSection, SurfaceMountSpec, WorkspaceDirection, default_preview_app_state, - default_session_path_for_preview, pane_body_frame, resolved_browser_uri, split_frame, - workspace_window_content_frame, + LayoutMetrics, NotificationPreferencesSnapshot, RuntimeCapability, RuntimeStatus, + SharedCore, ShellAction, ShellDragMode, ShellSection, SurfaceMountSpec, WorkspaceDirection, + default_preview_app_state, default_session_path_for_preview, pane_body_frame, + resolved_browser_uri, split_frame, workspace_window_content_frame, }; fn bootstrap() -> BootstrapModel { @@ -3707,6 +3766,7 @@ mod tests { }, selected_theme_id: "dark".into(), selected_shortcut_preset: super::ShortcutPreset::Balanced, + notification_preferences: NotificationPreferencesSnapshot::default(), } } diff --git a/crates/taskers-shell/src/lib.rs b/crates/taskers-shell/src/lib.rs index f8c63e9..2449e48 100644 --- a/crates/taskers-shell/src/lib.rs +++ b/crates/taskers-shell/src/lib.rs @@ -3,11 +3,11 @@ mod theme; use dioxus::prelude::*; use taskers_core::{ ActivityItemSnapshot, AgentSessionSnapshot, AttentionState, BrowserChromeSnapshot, Direction, - LayoutNodeSnapshot, PaneId, PaneSnapshot, ProgressSnapshot, PullRequestSnapshot, RuntimeStatus, - SettingsSnapshot, SharedCore, ShellAction, ShellSection, ShellSnapshot, ShortcutAction, - ShortcutBindingSnapshot, SplitAxis, SurfaceId, SurfaceKind, SurfaceSnapshot, WorkspaceId, - WorkspaceLogEntrySnapshot, WorkspaceSummary, WorkspaceViewSnapshot, WorkspaceWindowMoveTarget, - WorkspaceWindowSnapshot, + LayoutNodeSnapshot, NotificationPreferenceKey, PaneId, PaneSnapshot, ProgressSnapshot, + PullRequestSnapshot, RuntimeStatus, SettingsSnapshot, SharedCore, ShellAction, ShellSection, + ShellSnapshot, ShortcutAction, ShortcutBindingSnapshot, SplitAxis, SurfaceId, SurfaceKind, + SurfaceSnapshot, WorkspaceId, WorkspaceLogEntrySnapshot, WorkspaceSummary, + WorkspaceViewSnapshot, WorkspaceWindowMoveTarget, WorkspaceWindowSnapshot, }; use taskers_shell_core as taskers_core; @@ -1566,6 +1566,42 @@ fn render_settings(settings: &SettingsSnapshot, core: SharedCore) -> Element { } } } + section { class: "settings-card", + div { class: "sidebar-heading", "Notifications" } + div { class: "settings-copy", + "Desktop alerts follow the active Taskers lifecycle policy. Manual notifications always alert unless the target is already visible." + } + div { class: "settings-toggle-list", + {render_notification_preference( + "Alert on waiting", + "Show a desktop notification when an agent needs input.", + settings.notification_preferences.alerts_on_waiting, + NotificationPreferenceKey::AlertsOnWaiting, + core.clone(), + )} + {render_notification_preference( + "Alert on errors", + "Show a desktop notification when an agent exits with an error.", + settings.notification_preferences.alerts_on_error, + NotificationPreferenceKey::AlertsOnError, + core.clone(), + )} + {render_notification_preference( + "Alert on completion", + "Show a desktop notification when an agent finishes successfully.", + settings.notification_preferences.alerts_on_completed, + NotificationPreferenceKey::AlertsOnCompleted, + core.clone(), + )} + {render_notification_preference( + "Suppress when visible", + "Skip the desktop banner if the target pane is already visible in the focused Taskers window.", + settings.notification_preferences.suppress_when_visible, + NotificationPreferenceKey::SuppressWhenVisible, + core.clone(), + )} + } + } section { class: "settings-card settings-card-span", div { class: "sidebar-heading", "Shortcut Reference" } div { class: "shortcut-groups", @@ -1655,3 +1691,34 @@ fn render_shortcut_group(category: &'static str, bindings: &[ShortcutBindingSnap } } } + +fn render_notification_preference( + label: &'static str, + detail: &'static str, + enabled: bool, + key: NotificationPreferenceKey, + core: SharedCore, +) -> Element { + let toggle = move |_| { + core.dispatch_shell_action(ShellAction::SetNotificationPreference { + key, + enabled: !enabled, + }) + }; + let button_class = if enabled { + "settings-toggle-button settings-toggle-button-active" + } else { + "settings-toggle-button" + }; + let state_label = if enabled { "On" } else { "Off" }; + + rsx! { + button { class: "settings-toggle-row", onclick: toggle, + div { class: "settings-toggle-copy", + div { class: "workspace-label", "{label}" } + div { class: "settings-copy", "{detail}" } + } + span { class: "{button_class}", "{state_label}" } + } + } +} diff --git a/crates/taskers-shell/src/theme.rs b/crates/taskers-shell/src/theme.rs index 091cace..c099bd8 100644 --- a/crates/taskers-shell/src/theme.rs +++ b/crates/taskers-shell/src/theme.rs @@ -1,6 +1,6 @@ use std::fmt::Write as _; -use taskers_shell_core as taskers_core; use taskers_core::LayoutMetrics; +use taskers_shell_core as taskers_core; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Color { @@ -602,6 +602,51 @@ button {{ gap: 4px; }} +.settings-toggle-list {{ + display: flex; + flex-direction: column; + gap: 8px; +}} + +.settings-toggle-row {{ + border: 1px solid {border_06}; + background: {surface}; + padding: 10px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + text-align: left; +}} + +.settings-toggle-row:hover {{ + border-color: {accent_24}; + background: {accent_08}; +}} + +.settings-toggle-copy {{ + display: flex; + flex-direction: column; + gap: 4px; +}} + +.settings-toggle-button {{ + min-width: 42px; + padding: 4px 8px; + border: 1px solid {border_08}; + color: {text_dim}; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +}} + +.settings-toggle-button-active {{ + border-color: {accent_24}; + background: {accent_12}; + color: {text_bright}; +}} + .runtime-status-row {{ display: flex; align-items: center; From e11e9f9172a83ec872e7801e988a17810a9a9c04 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sat, 21 Mar 2026 16:47:32 +0100 Subject: [PATCH 09/63] feat: add cmux notification terminal protocol --- crates/taskers-core/src/pane_runtime.rs | 42 +++- crates/taskers-runtime/src/lib.rs | 5 +- crates/taskers-runtime/src/signals.rs | 281 +++++++++++++++++++++++- 3 files changed, 311 insertions(+), 17 deletions(-) diff --git a/crates/taskers-core/src/pane_runtime.rs b/crates/taskers-core/src/pane_runtime.rs index 8e8ea2b..af6582d 100644 --- a/crates/taskers-core/src/pane_runtime.rs +++ b/crates/taskers-core/src/pane_runtime.rs @@ -7,8 +7,12 @@ use std::{ use anyhow::{Context, Result, anyhow}; use taskers_control::{ControlCommand, InMemoryController}; -use taskers_domain::{AppModel, PaneId, PaneKind, SurfaceId, WorkspaceId}; -use taskers_runtime::{CommandSpec, PtySession, ShellLaunchSpec, SignalStreamParser}; +use taskers_domain::{ + AgentTarget, AppModel, AttentionState, PaneId, PaneKind, SignalKind, SurfaceId, WorkspaceId, +}; +use taskers_runtime::{ + CommandSpec, ParsedTerminalEvent, PtySession, ShellLaunchSpec, SignalStreamParser, +}; const MAX_OUTPUT_CHARS: usize = 24_000; @@ -197,13 +201,33 @@ fn spawn_surface_runtime( append_output(&reader_output, &clean); } - for signal in signal_parser.push(&chunk) { - let _ = controller.handle(ControlCommand::EmitSignal { - workspace_id, - pane_id, - surface_id: Some(surface_id), - event: signal.clone().into_event("pty"), - }); + for event in signal_parser.push_events(&chunk) { + match event { + ParsedTerminalEvent::Signal(signal) => { + let _ = controller.handle(ControlCommand::EmitSignal { + workspace_id, + pane_id, + surface_id: Some(surface_id), + event: signal.into_event("pty"), + }); + } + ParsedTerminalEvent::Notification(notification) => { + let _ = + controller.handle(ControlCommand::AgentCreateNotification { + target: AgentTarget::Surface { + workspace_id, + pane_id, + surface_id, + }, + kind: SignalKind::Notification, + title: notification.title, + subtitle: notification.subtitle, + external_id: notification.external_id, + message: notification.body.unwrap_or_default(), + state: AttentionState::WaitingInput, + }); + } + } } } Err(_) => { diff --git a/crates/taskers-runtime/src/lib.rs b/crates/taskers-runtime/src/lib.rs index 2304dd6..36a9042 100644 --- a/crates/taskers-runtime/src/lib.rs +++ b/crates/taskers-runtime/src/lib.rs @@ -7,4 +7,7 @@ pub use shell::{ ShellIntegration, ShellLaunchSpec, default_shell_program, install_shell_integration, scrub_inherited_terminal_env, validate_shell_program, }; -pub use signals::{ParsedSignal, SignalStreamParser, parse_signal_frames}; +pub use signals::{ + ParsedNotification, ParsedSignal, ParsedTerminalEvent, SignalStreamParser, parse_signal_frames, + parse_terminal_events, +}; diff --git a/crates/taskers-runtime/src/signals.rs b/crates/taskers-runtime/src/signals.rs index 14f3d22..89c4699 100644 --- a/crates/taskers-runtime/src/signals.rs +++ b/crates/taskers-runtime/src/signals.rs @@ -1,7 +1,9 @@ +use std::collections::HashMap; + use base64::{Engine as _, engine::general_purpose::STANDARD}; use taskers_domain::{SignalEvent, SignalKind, SignalPaneMetadata}; -const OSC_PREFIX: &str = "\u{1b}]777;taskers;"; +const OSC_PREFIX: &str = "\u{1b}]"; const BEL: char = '\u{7}'; const ST: &str = "\u{1b}\\"; @@ -12,9 +14,24 @@ pub struct ParsedSignal { pub metadata: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedNotification { + pub title: Option, + pub subtitle: Option, + pub body: Option, + pub external_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParsedTerminalEvent { + Signal(ParsedSignal), + Notification(ParsedNotification), +} + #[derive(Debug, Default, Clone)] pub struct SignalStreamParser { pending: String, + kitty_notification_drafts: HashMap, } impl ParsedSignal { @@ -23,6 +40,18 @@ impl ParsedSignal { } } +#[derive(Debug, Default, Clone)] +struct NotificationDraft { + title: Option, + subtitle: Option, + body: Option, +} + +pub fn parse_terminal_events(buffer: &str) -> Vec { + let mut parser = SignalStreamParser::default(); + parser.push_events(buffer) +} + pub fn parse_signal_frames(buffer: &str) -> Vec { let mut parser = SignalStreamParser::default(); parser.push(buffer) @@ -30,9 +59,19 @@ pub fn parse_signal_frames(buffer: &str) -> Vec { impl SignalStreamParser { pub fn push(&mut self, chunk: &str) -> Vec { + self.push_events(chunk) + .into_iter() + .filter_map(|event| match event { + ParsedTerminalEvent::Signal(signal) => Some(signal), + ParsedTerminalEvent::Notification(_) => None, + }) + .collect() + } + + pub fn push_events(&mut self, chunk: &str) -> Vec { self.pending.push_str(chunk); - let mut frames = Vec::new(); + let mut events = Vec::new(); let mut cursor = 0usize; let mut keep_from = floor_char_boundary( &self.pending, @@ -49,8 +88,9 @@ impl SignalStreamParser { break; }; - if let Some(parsed) = parse_frame(raw_frame) { - frames.push(parsed); + let raw_frame = raw_frame.to_string(); + if let Some(parsed) = self.parse_frame(&raw_frame) { + events.push(parsed); } cursor = content_start + consumed; @@ -58,11 +98,26 @@ impl SignalStreamParser { } self.pending = self.pending[floor_char_boundary(&self.pending, keep_from)..].to_string(); - frames + events + } + + fn parse_frame(&mut self, frame: &str) -> Option { + if let Some(frame) = frame.strip_prefix("777;taskers;") { + return parse_taskers_frame(frame).map(ParsedTerminalEvent::Signal); + } + if let Some(frame) = frame.strip_prefix("777;notify;") { + return parse_rxvt_notification(frame).map(ParsedTerminalEvent::Notification); + } + if let Some(frame) = frame.strip_prefix("99;") { + return self + .parse_kitty_notification(frame) + .map(ParsedTerminalEvent::Notification); + } + None } } -fn parse_frame(frame: &str) -> Option { +fn parse_taskers_frame(frame: &str) -> Option { let mut kind = None; let mut message = None; let mut title = None; @@ -139,6 +194,135 @@ fn parse_frame(frame: &str) -> Option { }) } +fn parse_rxvt_notification(frame: &str) -> Option { + let (title, body) = match frame.split_once(';') { + Some((title, body)) => (title, Some(body)), + None => (frame, None), + }; + + let title = Some(title.to_string()).filter(|value| !value.is_empty()); + let body = body.map(str::to_string).filter(|value| !value.is_empty()); + + if title.is_none() && body.is_none() { + return None; + } + + Some(ParsedNotification { + title, + subtitle: None, + body, + external_id: None, + }) +} + +impl SignalStreamParser { + fn parse_kitty_notification(&mut self, frame: &str) -> Option { + let (param_tokens, payload) = split_kitty_params_and_payload(frame); + let mut external_id = None; + let mut part = None; + let mut done = None; + + for token in param_tokens { + let (key, value) = token.split_once('=')?; + match key { + "i" => { + external_id = Some(value.to_string()).filter(|value| !value.is_empty()); + } + "p" => { + part = Some(value.to_ascii_lowercase()); + } + "d" => { + done = match value { + "0" => Some(false), + "1" => Some(true), + _ => None, + }; + } + "e" => {} + _ => {} + } + } + + let mut draft = external_id + .as_ref() + .and_then(|id| self.kitty_notification_drafts.remove(id)) + .unwrap_or_default(); + + let payload = Some(payload.to_string()).filter(|value| !value.is_empty()); + match part.as_deref() { + Some("title") => draft.title = payload, + Some("subtitle") => draft.subtitle = payload, + Some("body") => draft.body = payload, + Some(_) => {} + None => { + if let Some(payload) = payload { + if draft.title.is_none() { + draft.title = Some(payload); + } else { + draft.body = Some(payload); + } + } + } + } + + let should_defer = matches!(done, Some(false)) && part.is_some(); + if should_defer { + if let Some(external_id) = external_id { + self.kitty_notification_drafts.insert(external_id, draft); + } + return None; + } + + if draft.title.is_none() && draft.subtitle.is_none() && draft.body.is_none() { + return None; + } + + Some(ParsedNotification { + title: draft.title, + subtitle: draft.subtitle, + body: draft.body, + external_id, + }) + } +} + +fn split_kitty_params_and_payload(frame: &str) -> (Vec<&str>, &str) { + let mut params = Vec::new(); + let mut start = 0usize; + + if let Some(stripped) = frame.strip_prefix([';', ':']) { + return (params, stripped); + } + + while start < frame.len() { + let remainder = &frame[start..]; + let Some(separator) = remainder.find([';', ':']) else { + if is_kitty_param_token(remainder) { + params.push(remainder); + return (params, ""); + } + return (params, remainder); + }; + + let token_end = start + separator; + let token = &frame[start..token_end]; + if !is_kitty_param_token(token) { + return (params, &frame[start..]); + } + + params.push(token); + start = token_end + 1; + } + + (params, "") +} + +fn is_kitty_param_token(token: &str) -> bool { + token + .split_once('=') + .is_some_and(|(key, _)| !key.is_empty()) +} + fn parse_ports(value: &str) -> Option> { if value.is_empty() { return Some(Vec::new()); @@ -218,7 +402,9 @@ mod tests { use base64::{Engine as _, engine::general_purpose::STANDARD}; use taskers_domain::SignalKind; - use super::{SignalStreamParser, parse_signal_frames}; + use super::{ + ParsedTerminalEvent, SignalStreamParser, parse_signal_frames, parse_terminal_events, + }; #[test] fn parses_multiple_frames_with_different_terminators() { @@ -243,6 +429,12 @@ mod tests { assert!(parse_signal_frames(output).is_empty()); } + #[test] + fn signal_parser_ignores_notification_only_frames() { + let output = "\u{1b}]777;notify;Taskers;Body\u{7}"; + assert!(parse_signal_frames(output).is_empty()); + } + #[test] fn stream_parser_handles_split_frames() { let mut parser = SignalStreamParser::default(); @@ -299,4 +491,79 @@ mod tests { assert_eq!(metadata.title.as_deref(), Some("codex · taskers")); assert_eq!(metadata.ports, vec![3000, 8080]); } + + #[test] + fn parses_rxvt_notification_frames() { + let frames = parse_terminal_events("\u{1b}]777;notify;OSC777 Title;OSC777 Body\u{7}"); + + assert_eq!( + frames, + vec![ParsedTerminalEvent::Notification( + super::ParsedNotification { + title: Some("OSC777 Title".into()), + subtitle: None, + body: Some("OSC777 Body".into()), + external_id: None, + } + )] + ); + } + + #[test] + fn parses_simple_kitty_notification_frames() { + let frames = parse_terminal_events("\u{1b}]99;;Kitty Simple\u{1b}\\"); + + assert_eq!( + frames, + vec![ParsedTerminalEvent::Notification( + super::ParsedNotification { + title: Some("Kitty Simple".into()), + subtitle: None, + body: None, + external_id: None, + } + )] + ); + } + + #[test] + fn parses_chunked_kitty_notification_frames() { + let mut parser = SignalStreamParser::default(); + + assert!( + parser + .push_events("\u{1b}]99;i=kitty:d=0:p=title;Kitty Title\u{1b}\\") + .is_empty() + ); + + let frames = parser.push_events("\u{1b}]99;i=kitty:p=body;Kitty Body\u{1b}\\"); + assert_eq!( + frames, + vec![ParsedTerminalEvent::Notification( + super::ParsedNotification { + title: Some("Kitty Title".into()), + subtitle: None, + body: Some("Kitty Body".into()), + external_id: Some("kitty".into()), + } + )] + ); + } + + #[test] + fn parses_doc_style_kitty_notification_payloads() { + let frames = parse_terminal_events("\u{1b}]99;i=1;e=1;d=0:Hello World\u{1b}\\"); + + assert_eq!( + frames, + vec![ParsedTerminalEvent::Notification( + super::ParsedNotification { + title: Some("Hello World".into()), + subtitle: None, + body: None, + external_id: Some("1".into()), + } + )] + ); + } } From 35b3cd4f2f1e0a69422e71c059907dc12e50feef Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sat, 21 Mar 2026 16:53:49 +0100 Subject: [PATCH 10/63] docs: rewrite active taskers docs --- README.md | 70 +++++++++++++++++--- docs/notifications.md | 151 ++++++++++++++++++++++++++++++++++++++++++ docs/usage.md | 109 ++++++++++++++++++++++++++++++ 3 files changed, 321 insertions(+), 9 deletions(-) create mode 100644 docs/notifications.md create mode 100644 docs/usage.md diff --git a/README.md b/README.md index 44c5dab..33fae48 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,23 @@ -# taskers +# Taskers -Taskers is a Linux-first terminal workspace for agent-heavy work. It provides -Niri-style top-level windows, local pane splits, tabs inside panes, and an -attention rail for active and completed work. +Taskers is a Linux-first terminal workspace for agent-heavy work. It combines: + +- top-level workspace windows that behave like a tiling canvas +- panes inside each window +- tabs inside each pane +- an attention rail for unread, waiting, error, and completed work +- a local control CLI for notifications, agent state, browser automation, and debugging The active product lives at the repo root. Archived pre-cutover GTK/AppKit code is kept under `taskers-old/` for reference only. -## Try it +## Documentation + +- [Daily usage](docs/usage.md) +- [Notifications and attention](docs/notifications.md) +- [Release checklist](docs/release.md) + +## Install Linux (`x86_64-unknown-linux-gnu`): @@ -16,12 +26,54 @@ cargo install taskers --locked taskers ``` -The first launch downloads the exact version-matched Linux bundle from the tagged -GitHub release. The Linux app requires GTK4/libadwaita plus the host WebKitGTK -6.0 runtime. +The first launch downloads the exact version-matched Linux bundle from the +tagged GitHub release. The Linux app requires GTK4/libadwaita plus the host +WebKitGTK 6.0 runtime. Mainline macOS support is currently not shipped from this repo root. +## First 5 Minutes + +Start Taskers: + +```bash +taskers +``` + +Keep the workspace model straight: + +- A workspace contains top-level workspace windows. +- A workspace window contains panes. +- A pane contains tabs. +- Scrolling/panning is a workspace-window concern. Splits and tabs stay local to the current window. + +Open a terminal in Taskers and emit a test notification: + +```bash +taskersctl notify --title "Taskers" --body "Hello from the current pane" +``` + +You should see: + +- an unread badge in the sidebar +- a notification row in the attention rail +- a desktop banner unless the target is already visible and notification suppression is enabled + +Try the built-in shell helpers from a Taskers terminal: + +```bash +taskers_waiting "Need review" +taskers_done "Finished" +taskers_error "Build failed" +``` + +Open a browser pane from the pane controls, then inspect it from the same Taskers terminal: + +```bash +taskersctl browser snapshot +taskersctl browser get title +``` + ## Develop On Ubuntu 24.04, install the Linux UI dependencies first: @@ -53,4 +105,4 @@ bash scripts/headless-smoke.sh \ --quit-after-ms 5000 ``` -Release checklist: [docs/release.md](docs/release.md) +For publishing and release prep, use [docs/release.md](docs/release.md). diff --git a/docs/notifications.md b/docs/notifications.md new file mode 100644 index 0000000..45a5354 --- /dev/null +++ b/docs/notifications.md @@ -0,0 +1,151 @@ +# Notifications And Attention + +Taskers supports both in-app attention and desktop notifications. This page describes what creates notifications, how unread state works, and which integration paths are available to agents and shell scripts. + +## How Notification State Works + +A Taskers notification moves through three distinct states: + +- Unread: the item exists and has not been opened yet. +- Read: you opened or jumped to the target, but the item is still kept in history. +- Cleared: you explicitly dismissed it, so it no longer appears in active attention. + +Opening a notification does not clear it. It only marks it read and focuses the target. + +Desktop delivery is separate: + +- Each notification is delivered to the desktop at most once. +- If desktop suppression is enabled and the target is already visible, Taskers marks the notification as processed without showing a banner. + +## Where Notifications Appear + +Notifications surface in three places: + +- The sidebar shows unread counts and a short preview for each workspace. +- The attention rail shows active unread items, workspace status, progress, and recent log entries. +- Desktop banners appear through the GTK app when notification delivery is enabled. + +## Notification Settings + +Taskers exposes notification settings in the in-app Settings page. + +Current toggles: + +- Alert on waiting +- Alert on errors +- Alert on completion +- Suppress when visible + +Default behavior: + +- Waiting, error, and completion alerts are enabled +- Desktop banners are suppressed when the current app view is already showing the exact target + +## Fastest Manual Path + +From a Taskers terminal: + +```bash +taskersctl notify --title "Build Complete" --subtitle "Project X" --body "All tests passed" +``` + +That creates a notification targeted at the current surface. Because the terminal already carries `TASKERS_*` ids, no extra target flags are usually needed. + +Outside Taskers, pass explicit ids: + +```bash +taskersctl notify \ + --workspace \ + --pane \ + --surface \ + --title "Build Complete" \ + --body "All tests passed" +``` + +## Agent-Facing Paths + +For richer workspace and agent state, use the `agent` command family: + +```bash +taskersctl agent status set --text "Running sync" +taskersctl agent progress set --value 650 --label "65%" +taskersctl agent log append --source codex --message "Applied patch" +taskersctl agent notify create --title "Codex" --message "Need review" +taskersctl agent focus-unread +``` + +Practical split: + +- `status` is the live one-line workspace summary +- `progress` is the live progress indicator +- `log append` is non-unread history +- `notify create` is an attention item +- `focus-unread` jumps to the newest unread notification + +## Shell Helpers + +The shipped shell integration exposes three helpers in supported shells: + +```bash +taskers_waiting "Need review" +taskers_done "Finished" +taskers_error "Build failed" +``` + +Those helpers emit Taskers signals from the current terminal. They are the easiest path for shell scripts and ad hoc workflows. + +## Agent Hook Integration + +For tool or wrapper integrations, Taskers also supports explicit hook-style commands: + +```bash +taskersctl agent-hook waiting --message "Need review" +taskersctl agent-hook notification --title "Codex" --message "Turn complete" +taskersctl agent-hook stop --message "Finished" +``` + +Use this path when you already have structured lifecycle hooks and want to translate them into Taskers attention items directly. + +## CMUX-Compatible Terminal Escapes + +Taskers understands both the Taskers-native signal frames and the CMUX-compatible notification sequences. + +Simple `OSC 777` notification: + +```bash +printf '\e]777;notify;OSC777 Title;OSC777 Body\a' +``` + +Rich `OSC 99` notification with an id, subtitle, and final body chunk: + +```bash +printf '\e]99;i=build:d=0:p=title;Build Complete\e\\' +printf '\e]99;i=build:d=0:p=subtitle;Project X\e\\' +printf '\e]99;i=build:d=1:p=body;All tests passed\e\\' +``` + +Simple `OSC 99` payload: + +```bash +printf '\e]99;;Hello World\e\\' +``` + +Use `OSC 777` when you only need a title and body. Use `OSC 99` when you need a notification id or subtitle. + +## Verifying The Flow + +From a Taskers terminal, this is a good end-to-end check: + +```bash +taskersctl agent status set --text "Running sync" +taskersctl notify --title "Taskers" --body "Hello from the current pane" +taskersctl query notifications +taskersctl agent focus-unread +``` + +Expected behavior: + +- the workspace row shows an unread badge +- the attention rail shows the notification +- a desktop banner appears unless the target is already visible and suppression is enabled +- focusing the unread item marks it read but leaves it in history until you clear it diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..1465140 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,109 @@ +# Daily Usage + +This guide is the quickest way to get oriented in the active Taskers app. + +## Mental Model + +Taskers has three layers of layout: + +- A workspace contains top-level workspace windows. +- A workspace window contains panes. +- A pane contains tabs. + +That distinction matters: + +- Workspace navigation and panning operate on top-level windows. +- Pane splits stay local to the current workspace window. +- Tabs stay local to the current pane until you move them. + +If something feels like “Niri behavior,” it should usually happen at the workspace-window layer, not at the pane or tab layer. + +## Core Surfaces + +Taskers currently ships two live surface kinds: + +- Terminal surfaces backed by embedded Ghostty +- Browser surfaces backed by embedded WebKit + +Each pane can hold one or more tabs of either kind. The active tab supplies the live content for that pane. + +## A Typical Session + +Start Taskers: + +```bash +taskers +``` + +Build out the workspace: + +- Create or switch to a workspace from the sidebar. +- Use the pane controls to add a new terminal tab or browser tab. +- Split the active pane when you want another local work area inside the current workspace window. +- Create or move top-level workspace windows when you want side-by-side tiled regions. + +The important boundary is: + +- New split inside the current window: pane operation +- New top-level tile in the scrolling workspace: workspace-window operation + +## Working Inside A Taskers Terminal + +Taskers terminals export runtime context for the current pane and surface. That means `taskersctl` can usually infer the current target without extra flags when you run it inside an embedded terminal. + +For example: + +```bash +taskersctl identify +taskersctl agent status set --text "Running sync" +taskersctl browser snapshot +``` + +When you run the same commands outside Taskers, pass explicit ids such as `--workspace`, `--pane`, or `--surface`. + +## Attention And Status + +Taskers keeps active agent state visible in three places: + +- the workspace row in the sidebar +- the attention rail on the right +- the current pane or surface when a flash or unread item targets it + +Use that split intentionally: + +- Status text is for “what this workspace is doing right now” +- Progress is for bounded ongoing work +- Notifications are for unread events that need follow-up +- Log entries are for recent history that should not become unread noise + +For the full notification lifecycle, see [Notifications and attention](notifications.md). + +## Browser And Terminal Introspection + +The built-in CLI can inspect both live browser and terminal surfaces. + +Browser examples: + +```bash +taskersctl browser snapshot +taskersctl browser get title +taskersctl browser wait --text DuckDuckGo +``` + +Terminal examples: + +```bash +taskersctl debug terminal is-focused +taskersctl debug terminal read-text --tail-lines 40 +taskersctl debug terminal render-stats +``` + +## Desktop Launcher For Development + +If you are testing repo-local changes from your desktop environment, repoint the launcher to the local checkout: + +```bash +bash scripts/install-dev-desktop-entry.sh +``` + +That writes a dev desktop entry that launches `cargo run` against the repo root instead of a previously installed release bundle. From 36831604d42cec3fbd57924d51165ac45f3453f6 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sun, 22 Mar 2026 12:46:28 +0100 Subject: [PATCH 11/63] docs: add operator and release guides --- README.md | 1 + docs/release.md | 17 +++++ docs/taskersctl.md | 157 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+) create mode 100644 docs/taskersctl.md diff --git a/README.md b/README.md index 33fae48..434915d 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ is kept under `taskers-old/` for reference only. - [Daily usage](docs/usage.md) - [Notifications and attention](docs/notifications.md) +- [Taskersctl operator guide](docs/taskersctl.md) - [Release checklist](docs/release.md) ## Install diff --git a/docs/release.md b/docs/release.md index ac0f6d2..f8eec19 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,5 +1,9 @@ # Release Prep +This guide is for release maintainers. For everyday usage and operator flows, +start with the root [README](../README.md), [daily usage](usage.md), and +[notifications guide](notifications.md). + Use this checklist before publishing a new `taskers` Linux release. ## 1. Finalize The Repo State @@ -68,6 +72,13 @@ cargo publish --dry-run -p taskers-paths - `taskers-cli` - `taskers` +Before publishing, also verify the operator path still works: + +```bash +cargo run -p taskers-cli -- --help +cargo run -p taskers-cli -- notify --help +``` + ## 3. Publish - Push the release tag so GitHub Actions can assemble the assets and attach them to a draft GitHub release. @@ -99,3 +110,9 @@ taskers - Confirm the published Linux launcher downloads the exact version-matched bundle on first launch. - Confirm `cargo install taskers-cli --bin taskersctl --locked` still works as the standalone helper path. - Confirm `cargo install taskers --locked` on macOS fails with the Linux-only guidance from the launcher crate. + +For dev-desktop testing against the local checkout after a release pass: + +```bash +bash scripts/install-dev-desktop-entry.sh +``` diff --git a/docs/taskersctl.md b/docs/taskersctl.md new file mode 100644 index 0000000..8ea8b89 --- /dev/null +++ b/docs/taskersctl.md @@ -0,0 +1,157 @@ +# Taskersctl Operator Guide + +`taskersctl` is the local control CLI for Taskers. It is the shortest path for: + +- inspecting the current workspace state +- creating notifications and agent status +- driving embedded browsers +- identifying the current runtime target +- reading terminal debug state + +## Target Resolution + +When you run `taskersctl` inside a Taskers terminal, it usually infers the current target from the exported `TASKERS_*` environment. + +That means commands like these usually work without extra flags from an embedded terminal: + +```bash +taskersctl identify +taskersctl notify --title "Hello" --body "Current pane" +taskersctl browser snapshot +taskersctl debug terminal read-text --tail-lines 40 +``` + +When you run `taskersctl` outside Taskers, pass explicit ids such as `--workspace`, `--pane`, or `--surface`. + +To discover ids: + +```bash +taskersctl query tree +taskersctl query status +``` + +## Command Families + +Top-level command groups: + +- `query`: inspect workspaces, agents, notifications, and the full tree +- `notify`: create a notification directly +- `agent`: manage workspace status, progress, logs, notifications, flash, and unread navigation +- `agent-hook`: hook-oriented lifecycle commands for external tools +- `browser`: inspect and automate embedded browser surfaces +- `identify`: print the current resolved workspace, pane, and surface context +- `debug`: inspect terminal focus, text, and render stats +- `workspace`, `pane`, `surface`: create and manipulate Taskers structure directly + +## Common Recipes + +### Inspect The Current Context + +```bash +taskersctl identify +taskersctl query status +taskersctl query notifications +``` + +Use this first when you want to understand where a script or agent is currently operating. + +### Create User-Facing Notifications + +```bash +taskersctl notify --title "Build Complete" --subtitle "Project X" --body "All tests passed" +taskersctl agent focus-unread +``` + +For more lifecycle detail, see [Notifications and attention](notifications.md). + +### Publish Agent State + +```bash +taskersctl agent status set --text "Running sync" +taskersctl agent progress set --value 650 --label "65%" +taskersctl agent log append --source codex --message "Applied patch" +taskersctl agent notify create --title "Codex" --message "Need review" +``` + +Clear paths: + +```bash +taskersctl agent status clear +taskersctl agent progress clear +taskersctl agent log clear +taskersctl agent notify clear +``` + +### Drive A Browser Surface + +Open a browser in a known pane: + +```bash +taskersctl surface new --workspace --pane --kind browser --url https://duckduckgo.com/ +``` + +Inspect the active browser from a Taskers terminal: + +```bash +taskersctl browser snapshot +taskersctl browser get title +taskersctl browser wait --text DuckDuckGo +``` + +Interact with DOM targets: + +```bash +taskersctl browser click --selector 'a[href]' +taskersctl browser click --ref @e1 +taskersctl browser screenshot +``` + +Use `--selector` when you already know a CSS target. Use `--ref` after a `snapshot` when you want to act on the exact node Taskers returned. + +### Read Terminal State + +```bash +taskersctl debug terminal is-focused +taskersctl debug terminal read-text --tail-lines 40 +taskersctl debug terminal render-stats +``` + +This is useful for agent integrations, test harnesses, and debugging embedded terminal behavior. + +### Create And Reshape Structure + +Create a workspace: + +```bash +taskersctl workspace new --label "Workspace 2" +``` + +Split a pane in a specific workspace: + +```bash +taskersctl pane split --workspace --pane --axis vertical --kind terminal +``` + +Create a new browser surface in a pane: + +```bash +taskersctl surface new --workspace --pane --kind browser --url https://duckduckgo.com/ +``` + +## Hook And Script Integration + +If your tool already emits lifecycle events, use `agent-hook` instead of rebuilding the state model yourself: + +```bash +taskersctl agent-hook waiting --title "Codex" --message "Need review" +taskersctl agent-hook notification --title "Codex" --message "Turn complete" +taskersctl agent-hook stop --message "Finished" +``` + +This is the best fit for wrappers around coding agents, CI helpers, and long-running scripts. + +## Advanced Notes + +- Use `--socket` if you need to target a non-default Taskers control socket. +- `browser` commands are strict about selectors and refs. Resolve a target with `snapshot` first when in doubt. +- The browser automation surface is intentionally broader than the rest of the CLI. Start with `snapshot`, `get`, `wait`, and `click`, then expand only as needed. From dca4397063d8a02f00e6d0fe44e12b7a3458d829 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sun, 22 Mar 2026 12:48:14 +0100 Subject: [PATCH 12/63] feat: polish cross-workspace tab transfers --- crates/taskers-control/src/controller.rs | 12 +- crates/taskers-control/src/protocol.rs | 6 +- crates/taskers-domain/src/model.rs | 519 ++++++++++++++++------- crates/taskers-shell-core/src/lib.rs | 124 +++++- crates/taskers-shell/src/lib.rs | 172 +++++++- crates/taskers-shell/src/theme.rs | 30 ++ 6 files changed, 668 insertions(+), 195 deletions(-) diff --git a/crates/taskers-control/src/controller.rs b/crates/taskers-control/src/controller.rs index 4ae11ad..ed2076a 100644 --- a/crates/taskers-control/src/controller.rs +++ b/crates/taskers-control/src/controller.rs @@ -315,16 +315,18 @@ impl InMemoryController { ) } ControlCommand::TransferSurface { - workspace_id, + source_workspace_id, source_pane_id, surface_id, + target_workspace_id, target_pane_id, to_index, } => { model.transfer_surface( - workspace_id, + source_workspace_id, source_pane_id, surface_id, + target_workspace_id, target_pane_id, to_index, )?; @@ -336,16 +338,18 @@ impl InMemoryController { ) } ControlCommand::MoveSurfaceToSplit { - workspace_id, + source_workspace_id, source_pane_id, surface_id, + target_workspace_id, target_pane_id, direction, } => { let new_pane_id = model.move_surface_to_split( - workspace_id, + source_workspace_id, source_pane_id, surface_id, + target_workspace_id, target_pane_id, direction, )?; diff --git a/crates/taskers-control/src/protocol.rs b/crates/taskers-control/src/protocol.rs index 8cb60b0..9f5197e 100644 --- a/crates/taskers-control/src/protocol.rs +++ b/crates/taskers-control/src/protocol.rs @@ -115,16 +115,18 @@ pub enum ControlCommand { to_index: usize, }, TransferSurface { - workspace_id: WorkspaceId, + source_workspace_id: WorkspaceId, source_pane_id: PaneId, surface_id: SurfaceId, + target_workspace_id: WorkspaceId, target_pane_id: PaneId, to_index: usize, }, MoveSurfaceToSplit { - workspace_id: WorkspaceId, + source_workspace_id: WorkspaceId, source_pane_id: PaneId, surface_id: SurfaceId, + target_workspace_id: WorkspaceId, target_pane_id: PaneId, direction: Direction, }, diff --git a/crates/taskers-domain/src/model.rs b/crates/taskers-domain/src/model.rs index d2e77d6..6515170 100644 --- a/crates/taskers-domain/src/model.rs +++ b/crates/taskers-domain/src/model.rs @@ -973,11 +973,9 @@ impl Workspace { fn mark_notification_read(&mut self, notification_id: NotificationId) -> bool { let now = OffsetDateTime::now_utc(); - if let Some(notification) = self - .notifications - .iter_mut() - .find(|notification| notification.id == notification_id && notification.cleared_at.is_none()) - { + if let Some(notification) = self.notifications.iter_mut().find(|notification| { + notification.id == notification_id && notification.cleared_at.is_none() + }) { if notification.read_at.is_none() { notification.read_at = Some(now); } @@ -2494,83 +2492,155 @@ impl AppModel { Ok(()) } - pub fn transfer_surface( + fn take_surface_from_pane( &mut self, workspace_id: WorkspaceId, + pane_id: PaneId, + surface_id: SurfaceId, + ) -> Result { + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + let source_pane = + workspace + .panes + .get_mut(&pane_id) + .ok_or(DomainError::PaneNotInWorkspace { + workspace_id, + pane_id, + })?; + source_pane + .surfaces + .shift_remove(&surface_id) + .ok_or(DomainError::SurfaceNotInPane { + workspace_id, + pane_id, + surface_id, + }) + } + + fn should_close_source_pane(&self, workspace_id: WorkspaceId, pane_id: PaneId) -> bool { + self.workspaces + .get(&workspace_id) + .and_then(|workspace| workspace.panes.get(&pane_id)) + .is_some_and(|pane| pane.surfaces.is_empty()) + } + + fn retarget_surface_state( + &mut self, + source_workspace_id: WorkspaceId, + target_workspace_id: WorkspaceId, + surface_id: SurfaceId, + target_pane_id: PaneId, + ) -> Result<(), DomainError> { + if source_workspace_id == target_workspace_id { + let workspace = self + .workspaces + .get_mut(&source_workspace_id) + .ok_or(DomainError::MissingWorkspace(source_workspace_id))?; + for notification in &mut workspace.notifications { + if notification.surface_id == surface_id { + notification.pane_id = target_pane_id; + } + } + return Ok(()); + } + + let (mut moved_notifications, moved_flash_token) = { + let source_workspace = self + .workspaces + .get_mut(&source_workspace_id) + .ok_or(DomainError::MissingWorkspace(source_workspace_id))?; + let moved_flash_token = source_workspace.surface_flash_tokens.remove(&surface_id); + let mut moved_notifications = Vec::new(); + source_workspace.notifications.retain(|notification| { + if notification.surface_id == surface_id { + moved_notifications.push(notification.clone()); + false + } else { + true + } + }); + (moved_notifications, moved_flash_token) + }; + + let target_workspace = self + .workspaces + .get_mut(&target_workspace_id) + .ok_or(DomainError::MissingWorkspace(target_workspace_id))?; + for notification in &mut moved_notifications { + notification.pane_id = target_pane_id; + } + target_workspace.notifications.extend(moved_notifications); + if let Some(token) = moved_flash_token { + target_workspace + .surface_flash_tokens + .insert(surface_id, token); + } + Ok(()) + } + + pub fn transfer_surface( + &mut self, + source_workspace_id: WorkspaceId, source_pane_id: PaneId, surface_id: SurfaceId, + target_workspace_id: WorkspaceId, target_pane_id: PaneId, to_index: usize, ) -> Result<(), DomainError> { - if source_pane_id == target_pane_id { - return self.move_surface(workspace_id, source_pane_id, surface_id, to_index); + if source_workspace_id == target_workspace_id && source_pane_id == target_pane_id { + return self.move_surface(source_workspace_id, source_pane_id, surface_id, to_index); } { - let workspace = self + let source_workspace = self .workspaces - .get(&workspace_id) - .ok_or(DomainError::MissingWorkspace(workspace_id))?; - if !workspace.panes.contains_key(&source_pane_id) { + .get(&source_workspace_id) + .ok_or(DomainError::MissingWorkspace(source_workspace_id))?; + let target_workspace = self + .workspaces + .get(&target_workspace_id) + .ok_or(DomainError::MissingWorkspace(target_workspace_id))?; + if !source_workspace.panes.contains_key(&source_pane_id) { return Err(DomainError::PaneNotInWorkspace { - workspace_id, + workspace_id: source_workspace_id, pane_id: source_pane_id, }); } - if !workspace.panes.contains_key(&target_pane_id) { + if !target_workspace.panes.contains_key(&target_pane_id) { return Err(DomainError::PaneNotInWorkspace { - workspace_id, + workspace_id: target_workspace_id, pane_id: target_pane_id, }); } - if !workspace + if !source_workspace .panes .get(&source_pane_id) .is_some_and(|pane| pane.surfaces.contains_key(&surface_id)) { return Err(DomainError::SurfaceNotInPane { - workspace_id, + workspace_id: source_workspace_id, pane_id: source_pane_id, surface_id, }); } } - let moved_surface = { - let workspace = self - .workspaces - .get_mut(&workspace_id) - .ok_or(DomainError::MissingWorkspace(workspace_id))?; - let source_pane = workspace.panes.get_mut(&source_pane_id).ok_or( - DomainError::PaneNotInWorkspace { - workspace_id, - pane_id: source_pane_id, - }, - )?; - source_pane - .surfaces - .shift_remove(&surface_id) - .ok_or(DomainError::SurfaceNotInPane { - workspace_id, - pane_id: source_pane_id, - surface_id, - })? - }; - - let should_close_source_pane = self - .workspaces - .get(&workspace_id) - .and_then(|workspace| workspace.panes.get(&source_pane_id)) - .is_some_and(|pane| pane.surfaces.is_empty()); + let moved_surface = + self.take_surface_from_pane(source_workspace_id, source_pane_id, surface_id)?; + let should_close_source_pane = + self.should_close_source_pane(source_workspace_id, source_pane_id); { let workspace = self .workspaces - .get_mut(&workspace_id) - .ok_or(DomainError::MissingWorkspace(workspace_id))?; + .get_mut(&target_workspace_id) + .ok_or(DomainError::MissingWorkspace(target_workspace_id))?; let target_pane = workspace.panes.get_mut(&target_pane_id).ok_or( DomainError::PaneNotInWorkspace { - workspace_id, + workspace_id: target_workspace_id, pane_id: target_pane_id, }, )?; @@ -2581,61 +2651,73 @@ impl AppModel { let _ = target_pane.move_surface(surface_id, target_index); } target_pane.active_surface = surface_id; - for notification in &mut workspace.notifications { - if notification.surface_id == surface_id { - notification.pane_id = target_pane_id; - } - } let _ = workspace.focus_surface(target_pane_id, surface_id); } + self.retarget_surface_state( + source_workspace_id, + target_workspace_id, + surface_id, + target_pane_id, + )?; if should_close_source_pane { - self.close_pane(workspace_id, source_pane_id)?; - let workspace = self - .workspaces - .get_mut(&workspace_id) - .ok_or(DomainError::MissingWorkspace(workspace_id))?; - let _ = workspace.focus_surface(target_pane_id, surface_id); + self.close_pane(source_workspace_id, source_pane_id)?; + } + + if source_workspace_id != target_workspace_id { + self.switch_workspace(self.active_window, target_workspace_id)?; } + let workspace = self + .workspaces + .get_mut(&target_workspace_id) + .ok_or(DomainError::MissingWorkspace(target_workspace_id))?; + let _ = workspace.focus_surface(target_pane_id, surface_id); + Ok(()) } pub fn move_surface_to_split( &mut self, - workspace_id: WorkspaceId, + source_workspace_id: WorkspaceId, source_pane_id: PaneId, surface_id: SurfaceId, + target_workspace_id: WorkspaceId, target_pane_id: PaneId, direction: Direction, ) -> Result { { - let workspace = self + let source_workspace = self .workspaces - .get(&workspace_id) - .ok_or(DomainError::MissingWorkspace(workspace_id))?; - let source_pane = - workspace - .panes - .get(&source_pane_id) - .ok_or(DomainError::PaneNotInWorkspace { - workspace_id, - pane_id: source_pane_id, - })?; - if !workspace.panes.contains_key(&target_pane_id) { + .get(&source_workspace_id) + .ok_or(DomainError::MissingWorkspace(source_workspace_id))?; + let source_pane = source_workspace.panes.get(&source_pane_id).ok_or( + DomainError::PaneNotInWorkspace { + workspace_id: source_workspace_id, + pane_id: source_pane_id, + }, + )?; + let target_workspace = self + .workspaces + .get(&target_workspace_id) + .ok_or(DomainError::MissingWorkspace(target_workspace_id))?; + if !target_workspace.panes.contains_key(&target_pane_id) { return Err(DomainError::PaneNotInWorkspace { - workspace_id, + workspace_id: target_workspace_id, pane_id: target_pane_id, }); } if !source_pane.surfaces.contains_key(&surface_id) { return Err(DomainError::SurfaceNotInPane { - workspace_id, + workspace_id: source_workspace_id, pane_id: source_pane_id, surface_id, }); } - if source_pane_id == target_pane_id && source_pane.surfaces.len() <= 1 { + if source_workspace_id == target_workspace_id + && source_pane_id == target_pane_id + && source_pane.surfaces.len() <= 1 + { return Err(DomainError::InvalidOperation( "cannot split a pane from its only surface", )); @@ -2644,44 +2726,23 @@ impl AppModel { let target_window_id = self .workspaces - .get(&workspace_id) + .get(&target_workspace_id) .and_then(|workspace| workspace.window_for_pane(target_pane_id)) .ok_or(DomainError::MissingPane(target_pane_id))?; - let moved_surface = { - let workspace = self - .workspaces - .get_mut(&workspace_id) - .ok_or(DomainError::MissingWorkspace(workspace_id))?; - let source_pane = workspace.panes.get_mut(&source_pane_id).ok_or( - DomainError::PaneNotInWorkspace { - workspace_id, - pane_id: source_pane_id, - }, - )?; - source_pane - .surfaces - .shift_remove(&surface_id) - .ok_or(DomainError::SurfaceNotInPane { - workspace_id, - pane_id: source_pane_id, - surface_id, - })? - }; + let moved_surface = + self.take_surface_from_pane(source_workspace_id, source_pane_id, surface_id)?; let new_pane = PaneRecord::from_surface(moved_surface); let new_pane_id = new_pane.id; - let should_close_source_pane = self - .workspaces - .get(&workspace_id) - .and_then(|workspace| workspace.panes.get(&source_pane_id)) - .is_some_and(|pane| pane.surfaces.is_empty()); + let should_close_source_pane = + self.should_close_source_pane(source_workspace_id, source_pane_id); { let workspace = self .workspaces - .get_mut(&workspace_id) - .ok_or(DomainError::MissingWorkspace(workspace_id))?; + .get_mut(&target_workspace_id) + .ok_or(DomainError::MissingWorkspace(target_workspace_id))?; workspace.panes.insert(new_pane_id, new_pane); let target_window = workspace @@ -2695,24 +2756,30 @@ impl AppModel { 500, ); target_window.active_pane = new_pane_id; - for notification in &mut workspace.notifications { - if notification.surface_id == surface_id { - notification.pane_id = new_pane_id; - } - } workspace.sync_active_from_window(target_window_id); let _ = workspace.focus_surface(new_pane_id, surface_id); } + self.retarget_surface_state( + source_workspace_id, + target_workspace_id, + surface_id, + new_pane_id, + )?; if should_close_source_pane { - self.close_pane(workspace_id, source_pane_id)?; - let workspace = self - .workspaces - .get_mut(&workspace_id) - .ok_or(DomainError::MissingWorkspace(workspace_id))?; - let _ = workspace.focus_surface(new_pane_id, surface_id); + self.close_pane(source_workspace_id, source_pane_id)?; } + if source_workspace_id != target_workspace_id { + self.switch_workspace(self.active_window, target_workspace_id)?; + } + + let workspace = self + .workspaces + .get_mut(&target_workspace_id) + .ok_or(DomainError::MissingWorkspace(target_workspace_id))?; + let _ = workspace.focus_surface(new_pane_id, surface_id); + Ok(new_pane_id) } @@ -2752,48 +2819,11 @@ impl AppModel { } } - let moved_surface = { - let source_workspace = self - .workspaces - .get_mut(&source_workspace_id) - .ok_or(DomainError::MissingWorkspace(source_workspace_id))?; - let source_pane = source_workspace.panes.get_mut(&source_pane_id).ok_or( - DomainError::PaneNotInWorkspace { - workspace_id: source_workspace_id, - pane_id: source_pane_id, - }, - )?; - source_pane - .surfaces - .shift_remove(&surface_id) - .ok_or(DomainError::SurfaceNotInPane { - workspace_id: source_workspace_id, - pane_id: source_pane_id, - surface_id, - })? - }; + let moved_surface = + self.take_surface_from_pane(source_workspace_id, source_pane_id, surface_id)?; - let should_close_source_pane = self - .workspaces - .get(&source_workspace_id) - .and_then(|workspace| workspace.panes.get(&source_pane_id)) - .is_some_and(|pane| pane.surfaces.is_empty()); - - let mut moved_notifications = Vec::new(); - { - let source_workspace = self - .workspaces - .get_mut(&source_workspace_id) - .ok_or(DomainError::MissingWorkspace(source_workspace_id))?; - source_workspace.notifications.retain(|notification| { - if notification.surface_id == surface_id { - moved_notifications.push(notification.clone()); - false - } else { - true - } - }); - } + let should_close_source_pane = + self.should_close_source_pane(source_workspace_id, source_pane_id); let new_pane = PaneRecord::from_surface(moved_surface); let new_pane_id = new_pane.id; @@ -2808,13 +2838,15 @@ impl AppModel { target_workspace.panes.insert(new_pane_id, new_pane); target_workspace.windows.insert(new_window_id, new_window); insert_window_relative_to_active(target_workspace, new_window_id, Direction::Right)?; - for notification in &mut moved_notifications { - notification.pane_id = new_pane_id; - } - target_workspace.notifications.extend(moved_notifications); target_workspace.sync_active_from_window(new_window_id); let _ = target_workspace.focus_surface(new_pane_id, surface_id); } + self.retarget_surface_state( + source_workspace_id, + target_workspace_id, + surface_id, + new_pane_id, + )?; if should_close_source_pane { self.close_pane(source_workspace_id, source_pane_id)?; @@ -3711,6 +3743,7 @@ mod tests { workspace_id, source_pane_id, second_surface_id, + workspace_id, target_pane_id, 0, ) @@ -3751,6 +3784,7 @@ mod tests { workspace_id, source_pane_id, moved_surface_id, + workspace_id, source_pane_id, Direction::Right, ) @@ -3798,6 +3832,7 @@ mod tests { workspace_id, source_pane_id, moved_surface_id, + workspace_id, target_pane_id, Direction::Left, ) @@ -3837,7 +3872,14 @@ mod tests { .expect("surface"); let error = model - .move_surface_to_split(workspace_id, pane_id, surface_id, pane_id, Direction::Right) + .move_surface_to_split( + workspace_id, + pane_id, + surface_id, + workspace_id, + pane_id, + Direction::Right, + ) .expect_err("reject self split of only surface"); assert!(matches!( @@ -3905,6 +3947,166 @@ mod tests { assert_eq!(target_workspace.active_pane, new_pane_id); } + #[test] + fn transferring_surface_to_existing_pane_in_another_workspace_moves_notifications_and_flash() { + let mut model = AppModel::new("Main"); + let source_workspace_id = model.active_workspace_id().expect("workspace"); + let source_pane_id = model.active_workspace().expect("workspace").active_pane; + let _first_surface_id = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&source_pane_id)) + .map(|pane| pane.active_surface) + .expect("first surface"); + let moved_surface_id = model + .create_surface(source_workspace_id, source_pane_id, PaneKind::Browser) + .expect("second surface"); + model + .create_agent_notification( + AgentTarget::Surface { + workspace_id: source_workspace_id, + pane_id: source_pane_id, + surface_id: moved_surface_id, + }, + SignalKind::Notification, + Some("Needs review".into()), + None, + Some("review-1".into()), + "Check this browser tab".into(), + AttentionState::WaitingInput, + ) + .expect("notification"); + model + .trigger_surface_flash(source_workspace_id, source_pane_id, moved_surface_id) + .expect("flash"); + + let target_workspace_id = model.create_workspace("Secondary"); + let target_pane_id = model.active_workspace().expect("workspace").active_pane; + + model + .transfer_surface( + source_workspace_id, + source_pane_id, + moved_surface_id, + target_workspace_id, + target_pane_id, + usize::MAX, + ) + .expect("transfer"); + + let source_workspace = model + .workspaces + .get(&source_workspace_id) + .expect("source workspace"); + let target_workspace = model + .workspaces + .get(&target_workspace_id) + .expect("target workspace"); + let target_pane = target_workspace + .panes + .get(&target_pane_id) + .expect("target pane"); + + assert_eq!(model.active_workspace_id(), Some(target_workspace_id)); + assert!( + !source_workspace + .notifications + .iter() + .any(|notification| notification.surface_id == moved_surface_id) + ); + assert!( + source_workspace + .surface_flash_tokens + .get(&moved_surface_id) + .is_none() + ); + assert!( + target_pane + .surface_ids() + .collect::>() + .contains(&moved_surface_id) + ); + assert_eq!(target_pane.active_surface, moved_surface_id); + assert!(target_workspace.notifications.iter().any(|notification| { + notification.surface_id == moved_surface_id && notification.pane_id == target_pane_id + })); + assert!( + target_workspace + .surface_flash_tokens + .get(&moved_surface_id) + .is_some() + ); + } + + #[test] + fn moving_surface_to_split_in_another_workspace_closes_empty_source_pane() { + let mut model = AppModel::new("Main"); + let source_workspace_id = model.active_workspace_id().expect("workspace"); + let source_pane_id = model.active_workspace().expect("workspace").active_pane; + let anchor_pane_id = model + .split_pane( + source_workspace_id, + Some(source_pane_id), + SplitAxis::Horizontal, + ) + .expect("split source workspace"); + model + .focus_pane(source_workspace_id, source_pane_id) + .expect("focus source pane"); + let moved_surface_id = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&source_pane_id)) + .map(|pane| pane.active_surface) + .expect("moved surface"); + + let target_workspace_id = model.create_workspace("Secondary"); + let target_pane_id = model.active_workspace().expect("workspace").active_pane; + + let new_pane_id = model + .move_surface_to_split( + source_workspace_id, + source_pane_id, + moved_surface_id, + target_workspace_id, + target_pane_id, + Direction::Left, + ) + .expect("move to split"); + + let source_workspace = model + .workspaces + .get(&source_workspace_id) + .expect("source workspace"); + let target_workspace = model + .workspaces + .get(&target_workspace_id) + .expect("target workspace"); + let target_window_id = target_workspace + .window_for_pane(target_pane_id) + .expect("target window"); + let target_window = target_workspace + .windows + .get(&target_window_id) + .expect("target window record"); + + assert_eq!(model.active_workspace_id(), Some(target_workspace_id)); + assert!(!source_workspace.panes.contains_key(&source_pane_id)); + assert!(source_workspace.panes.contains_key(&anchor_pane_id)); + assert_eq!( + target_window.layout.leaves(), + vec![new_pane_id, target_pane_id] + ); + assert_eq!(target_workspace.active_pane, new_pane_id); + assert_eq!( + target_workspace + .panes + .get(&new_pane_id) + .expect("new pane") + .surface_ids() + .collect::>(), + vec![moved_surface_id] + ); + } + #[test] fn transferring_last_surface_closes_the_source_pane() { let mut model = AppModel::new("Main"); @@ -3924,6 +4126,7 @@ mod tests { workspace_id, source_pane_id, moved_surface_id, + workspace_id, target_pane_id, usize::MAX, ) diff --git a/crates/taskers-shell-core/src/lib.rs b/crates/taskers-shell-core/src/lib.rs index f46ed97..d44d0ab 100644 --- a/crates/taskers-shell-core/src/lib.rs +++ b/crates/taskers-shell-core/src/lib.rs @@ -2393,9 +2393,6 @@ impl TaskersCore { else { return false; }; - if workspace_id != target_workspace_id { - return false; - } if source_pane_id == target_pane_id { return self.dispatch_control(ControlCommand::MoveSurface { workspace_id, @@ -2404,13 +2401,20 @@ impl TaskersCore { to_index: target_index, }); } - self.dispatch_control(ControlCommand::TransferSurface { - workspace_id, - source_pane_id, - surface_id, - target_pane_id, - to_index: target_index, - }) + let changed = self + .dispatch_control_with_response(ControlCommand::TransferSurface { + source_workspace_id: workspace_id, + source_pane_id, + surface_id, + target_workspace_id, + target_pane_id, + to_index: target_index, + }) + .is_some(); + if changed && workspace_id != target_workspace_id { + return self.ensure_active_window_visible() || changed; + } + changed } fn move_surface_to_split_by_id( @@ -2421,7 +2425,7 @@ impl TaskersCore { direction: Direction, ) -> bool { let model = self.app_state.snapshot_model(); - let Some((workspace_id, located_source_pane_id)) = + let Some((source_workspace_id, located_source_pane_id)) = self.resolve_surface_location(&model, surface_id) else { return false; @@ -2430,14 +2434,15 @@ impl TaskersCore { else { return false; }; - if workspace_id != target_workspace_id || located_source_pane_id != source_pane_id { + if located_source_pane_id != source_pane_id { return false; } let Some(response) = self.dispatch_control_with_response(ControlCommand::MoveSurfaceToSplit { - workspace_id, + source_workspace_id, source_pane_id, surface_id, + target_workspace_id, target_pane_id, direction, }) @@ -4397,6 +4402,48 @@ mod tests { assert_eq!(snapshot.current_workspace.active_pane, target_pane_id); } + #[test] + fn move_surface_shell_action_transfers_surface_between_workspaces() { + let core = SharedCore::bootstrap(bootstrap()); + let source_workspace_id = core.snapshot().current_workspace.id; + let source_pane_id = core.snapshot().current_workspace.active_pane; + core.dispatch_shell_action(ShellAction::AddBrowserSurface { + pane_id: Some(source_pane_id), + }); + + let snapshot = core.snapshot(); + let moved_surface_id = find_pane(&snapshot.current_workspace.layout, source_pane_id) + .map(|pane| pane.active_surface) + .expect("added surface"); + + core.dispatch_shell_action(ShellAction::CreateWorkspace); + let target_workspace_id = core.snapshot().current_workspace.id; + let target_pane_id = core.snapshot().current_workspace.active_pane; + + core.dispatch_shell_action(ShellAction::FocusWorkspace { + workspace_id: source_workspace_id, + }); + core.dispatch_shell_action(ShellAction::MoveSurface { + surface_id: moved_surface_id, + target_pane_id, + target_index: usize::MAX, + }); + + let snapshot = core.snapshot(); + assert_eq!(snapshot.current_workspace.id, target_workspace_id); + let target_pane = + find_pane(&snapshot.current_workspace.layout, target_pane_id).expect("target pane"); + + assert!( + target_pane + .surfaces + .iter() + .any(|surface| surface.id == moved_surface_id) + ); + assert_eq!(target_pane.active_surface, moved_surface_id); + assert_eq!(snapshot.current_workspace.active_pane, target_pane_id); + } + #[test] fn move_surface_to_split_shell_action_creates_neighbor_pane() { let core = SharedCore::bootstrap(bootstrap()); @@ -4446,6 +4493,57 @@ mod tests { assert_eq!(snapshot.current_workspace.active_pane, new_pane_id); } + #[test] + fn move_surface_to_split_shell_action_moves_surface_into_other_workspace() { + let core = SharedCore::bootstrap(bootstrap()); + let source_workspace_id = core.snapshot().current_workspace.id; + let source_pane_id = core.snapshot().current_workspace.active_pane; + core.dispatch_shell_action(ShellAction::AddBrowserSurface { + pane_id: Some(source_pane_id), + }); + + let snapshot = core.snapshot(); + let moved_surface_id = find_pane(&snapshot.current_workspace.layout, source_pane_id) + .map(|pane| pane.active_surface) + .expect("added surface"); + + core.dispatch_shell_action(ShellAction::CreateWorkspace); + let target_workspace_id = core.snapshot().current_workspace.id; + let target_pane_id = core.snapshot().current_workspace.active_pane; + + core.dispatch_shell_action(ShellAction::FocusWorkspace { + workspace_id: source_workspace_id, + }); + core.dispatch_shell_action(ShellAction::MoveSurfaceToSplit { + source_pane_id, + surface_id: moved_surface_id, + target_pane_id, + direction: Direction::Left, + }); + + let snapshot = core.snapshot(); + assert_eq!(snapshot.current_workspace.id, target_workspace_id); + + let mut pane_ids = Vec::new(); + collect_pane_ids(&snapshot.current_workspace.layout, &mut pane_ids); + let new_pane_id = pane_ids + .into_iter() + .find(|pane_id| { + *pane_id != target_pane_id + && find_pane(&snapshot.current_workspace.layout, *pane_id) + .is_some_and(|pane| pane.active_surface == moved_surface_id) + }) + .expect("new pane"); + let new_pane = + find_pane(&snapshot.current_workspace.layout, new_pane_id).expect("new pane"); + + assert_eq!( + new_pane.surfaces.first().map(|surface| surface.id), + Some(moved_surface_id) + ); + assert_eq!(snapshot.current_workspace.active_pane, new_pane_id); + } + #[test] fn move_surface_to_workspace_shell_action_switches_workspace_and_keeps_surface() { let core = SharedCore::bootstrap(bootstrap()); diff --git a/crates/taskers-shell/src/lib.rs b/crates/taskers-shell/src/lib.rs index 2449e48..df6707b 100644 --- a/crates/taskers-shell/src/lib.rs +++ b/crates/taskers-shell/src/lib.rs @@ -13,6 +13,7 @@ use taskers_shell_core as taskers_core; #[derive(Clone, Copy, PartialEq, Eq)] struct DraggedSurface { + workspace_id: WorkspaceId, pane_id: PaneId, surface_id: SurfaceId, } @@ -200,9 +201,9 @@ pub fn TaskersShell(core: SharedCore) -> Element { }; let drag_source = use_signal(|| None::); let drag_target = use_signal(|| None::); - let surface_drag_source = use_signal(|| None::); - let surface_drop_target = use_signal(|| None::); - let surface_workspace_target = use_signal(|| None::); + let mut surface_drag_source = use_signal(|| None::); + let mut surface_drop_target = use_signal(|| None::); + let mut surface_workspace_target = use_signal(|| None::); let window_drag_source = use_signal(|| None::); let window_drop_target = use_signal(|| None::); let workspace_ids: Vec = snapshot.workspaces.iter().map(|ws| ws.id).collect(); @@ -275,6 +276,24 @@ pub fn TaskersShell(core: SharedCore) -> Element { if matches!(snapshot.section, ShellSection::Workspace) { div { class: if snapshot.overview_mode { "workspace-canvas workspace-canvas-overview" } else { "workspace-canvas" }, + ondragend: { + let core = core.clone(); + move |_: Event| { + let dragged = *surface_drag_source.read(); + surface_drag_source.set(None); + surface_drop_target.set(None); + surface_workspace_target.set(None); + let Some(dragged) = dragged else { + return; + }; + if core.snapshot().current_workspace.id != dragged.workspace_id { + core.dispatch_shell_action(ShellAction::FocusWorkspace { + workspace_id: dragged.workspace_id, + }); + } + core.dispatch_shell_action(ShellAction::EndDrag); + } + }, {render_workspace_strip( &snapshot.current_workspace, snapshot.overview_mode, @@ -283,6 +302,7 @@ pub fn TaskersShell(core: SharedCore) -> Element { &snapshot.runtime_status, surface_drag_source, surface_drop_target, + surface_workspace_target, window_drag_source, window_drop_target, )} @@ -454,14 +474,20 @@ fn render_workspace_item( let on_dragstart = move |_: Event| { drag_source.set(Some(workspace_id)); }; - let on_dragover = move |event: Event| { - event.prevent_default(); - if surface_drag_source.read().is_some() { - surface_workspace_target.set(Some(workspace_id)); - drag_target.set(None); - } else { - drag_target.set(Some(workspace_id)); - surface_workspace_target.set(None); + let on_dragover = { + let core = core.clone(); + move |event: Event| { + event.prevent_default(); + if let Some(dragged_surface) = *surface_drag_source.read() { + surface_workspace_target.set(Some(workspace_id)); + drag_target.set(None); + if dragged_surface.workspace_id != workspace_id { + core.dispatch_shell_action(ShellAction::FocusWorkspace { workspace_id }); + } + } else { + drag_target.set(Some(workspace_id)); + surface_workspace_target.set(None); + } } }; let on_dragleave = move |_: Event| { @@ -484,11 +510,13 @@ fn render_workspace_item( surface_workspace_target.set(None); if let Some(dragged_surface) = dragged_surface { surface_drag_source.set(None); - core.dispatch_shell_action(ShellAction::MoveSurfaceToWorkspace { - source_pane_id: dragged_surface.pane_id, - surface_id: dragged_surface.surface_id, - target_workspace_id: workspace_id, - }); + if dragged_surface.workspace_id != workspace_id { + core.dispatch_shell_action(ShellAction::MoveSurfaceToWorkspace { + source_pane_id: dragged_surface.pane_id, + surface_id: dragged_surface.surface_id, + target_workspace_id: workspace_id, + }); + } core.dispatch_shell_action(ShellAction::EndDrag); return; } @@ -603,7 +631,67 @@ fn render_workspace_log_entry(entry: &WorkspaceLogEntrySnapshot) -> Element { } } +fn render_surface_workspace_fallback_drop( + target_workspace_id: WorkspaceId, + core: SharedCore, + mut surface_drag_source: Signal>, + mut surface_drop_target: Signal>, + mut surface_workspace_target: Signal>, +) -> Element { + let active = *surface_workspace_target.read() == Some(target_workspace_id); + let class = if active { + "workspace-surface-fallback-drop workspace-surface-fallback-drop-active" + } else { + "workspace-surface-fallback-drop" + }; + let on_dragover = move |event: Event| { + let Some(dragged) = *surface_drag_source.read() else { + return; + }; + if dragged.workspace_id == target_workspace_id { + return; + } + event.prevent_default(); + surface_workspace_target.set(Some(target_workspace_id)); + surface_drop_target.set(None); + }; + let on_dragleave = move |_: Event| { + if *surface_workspace_target.read() == Some(target_workspace_id) { + surface_workspace_target.set(None); + } + }; + let on_drop = move |event: Event| { + event.prevent_default(); + let dragged = *surface_drag_source.read(); + surface_drag_source.set(None); + surface_drop_target.set(None); + surface_workspace_target.set(None); + let Some(dragged) = dragged else { + return; + }; + if dragged.workspace_id != target_workspace_id { + core.dispatch_shell_action(ShellAction::MoveSurfaceToWorkspace { + source_pane_id: dragged.pane_id, + surface_id: dragged.surface_id, + target_workspace_id, + }); + } + core.dispatch_shell_action(ShellAction::EndDrag); + }; + + rsx! { + div { + class: "{class}", + ondragover: on_dragover, + ondragleave: on_dragleave, + ondrop: on_drop, + div { class: "workspace-surface-fallback-label", "Drop to create a new window" } + } + } +} + fn render_layout( + workspace_id: WorkspaceId, node: &LayoutNodeSnapshot, overview_mode: bool, browser_chrome: Option<&BrowserChromeSnapshot>, @@ -611,6 +699,7 @@ fn render_layout( runtime_status: &RuntimeStatus, surface_drag_source: Signal>, surface_drop_target: Signal>, + surface_workspace_target: Signal>, ) -> Element { match node { LayoutNodeSnapshot::Split { @@ -631,15 +720,16 @@ fn render_layout( rsx! { div { class: "split-container", style: "flex-direction: {direction};", div { class: "split-child", style: "{first_style}", - {render_layout(first, overview_mode, browser_chrome, core.clone(), runtime_status, surface_drag_source, surface_drop_target)} + {render_layout(workspace_id, first, overview_mode, browser_chrome, core.clone(), runtime_status, surface_drag_source, surface_drop_target, surface_workspace_target)} } div { class: "split-child", style: "{second_style}", - {render_layout(second, overview_mode, browser_chrome, core.clone(), runtime_status, surface_drag_source, surface_drop_target)} + {render_layout(workspace_id, second, overview_mode, browser_chrome, core.clone(), runtime_status, surface_drag_source, surface_drop_target, surface_workspace_target)} } } } } LayoutNodeSnapshot::Pane(pane) => render_pane( + workspace_id, pane, overview_mode, browser_chrome, @@ -647,6 +737,7 @@ fn render_layout( runtime_status, surface_drag_source, surface_drop_target, + surface_workspace_target, ), } } @@ -659,6 +750,7 @@ fn render_workspace_strip( runtime_status: &RuntimeStatus, surface_drag_source: Signal>, surface_drop_target: Signal>, + surface_workspace_target: Signal>, window_drag_source: Signal>, window_drop_target: Signal>, ) -> Element { @@ -694,6 +786,18 @@ fn render_workspace_strip( rsx! { div { class: "{viewport_class}", onwheel: scroll_viewport, div { class: "workspace-strip-canvas", style: "{canvas_style}", + if surface_drag_source + .read() + .is_some_and(|dragged| dragged.workspace_id != workspace.id) + { + {render_surface_workspace_fallback_drop( + workspace.id, + core.clone(), + surface_drag_source, + surface_drop_target, + surface_workspace_target, + )} + } for column in &workspace.columns { for window in &column.windows { {render_workspace_window( @@ -705,6 +809,7 @@ fn render_workspace_strip( runtime_status, surface_drag_source, surface_drop_target, + surface_workspace_target, window_drag_source, window_drop_target, )} @@ -724,6 +829,7 @@ fn render_workspace_window( runtime_status: &RuntimeStatus, mut surface_drag_source: Signal>, mut surface_drop_target: Signal>, + surface_workspace_target: Signal>, mut window_drag_source: Signal>, window_drop_target: Signal>, ) -> Element { @@ -819,6 +925,7 @@ fn render_workspace_window( } div { class: "workspace-window-body", {render_layout( + workspace.id, &window.layout, overview_mode, browser_chrome, @@ -826,6 +933,7 @@ fn render_workspace_window( runtime_status, surface_drag_source, surface_drop_target, + surface_workspace_target, )} } } @@ -888,6 +996,7 @@ fn render_window_drop_zone( } fn render_pane( + workspace_id: WorkspaceId, pane: &PaneSnapshot, overview_mode: bool, browser_chrome: Option<&BrowserChromeSnapshot>, @@ -895,6 +1004,7 @@ fn render_pane( runtime_status: &RuntimeStatus, surface_drag_source: Signal>, surface_drop_target: Signal>, + surface_workspace_target: Signal>, ) -> Element { let pane_id = pane.id; let dragged_surface = *surface_drag_source.read(); @@ -1035,12 +1145,14 @@ fn render_pane( div { class: "surface-tabs", for surface in &pane.surfaces { {render_surface_tab( + workspace_id, pane.id, pane.active_surface, surface, core.clone(), surface_drag_source, surface_drop_target, + surface_workspace_target, &ordered_surface_ids, )} } @@ -1052,6 +1164,7 @@ fn render_pane( core.clone(), surface_drag_source, surface_drop_target, + surface_workspace_target, )} } } @@ -1077,6 +1190,7 @@ fn render_pane( core.clone(), surface_drag_source, surface_drop_target, + surface_workspace_target, )} if pane_allows_split { {render_surface_pane_drop_target( @@ -1089,6 +1203,7 @@ fn render_pane( core.clone(), surface_drag_source, surface_drop_target, + surface_workspace_target, )} {render_surface_pane_drop_target( "pane-drop-target pane-drop-target-edge pane-drop-target-right", @@ -1100,6 +1215,7 @@ fn render_pane( core.clone(), surface_drag_source, surface_drop_target, + surface_workspace_target, )} {render_surface_pane_drop_target( "pane-drop-target pane-drop-target-edge pane-drop-target-top", @@ -1111,6 +1227,7 @@ fn render_pane( core.clone(), surface_drag_source, surface_drop_target, + surface_workspace_target, )} {render_surface_pane_drop_target( "pane-drop-target pane-drop-target-edge pane-drop-target-bottom", @@ -1122,6 +1239,7 @@ fn render_pane( core.clone(), surface_drag_source, surface_drop_target, + surface_workspace_target, )} } } @@ -1139,6 +1257,7 @@ fn render_surface_pane_drop_target( core: SharedCore, mut surface_drag_source: Signal>, mut surface_drop_target: Signal>, + mut surface_workspace_target: Signal>, ) -> Element { let class = if *surface_drop_target.read() == Some(target) { format!("{base_class} pane-drop-target-active") @@ -1150,6 +1269,7 @@ fn render_surface_pane_drop_target( return; } event.prevent_default(); + surface_workspace_target.set(None); surface_drop_target.set(Some(target)); }; let clear_drop_target = move |_: Event| { @@ -1164,6 +1284,7 @@ fn render_surface_pane_drop_target( let dragged = *surface_drag_source.read(); surface_drag_source.set(None); surface_drop_target.set(None); + surface_workspace_target.set(None); let Some(dragged) = dragged else { return; }; @@ -1184,12 +1305,14 @@ fn render_surface_pane_drop_target( } fn render_surface_tab( + workspace_id: WorkspaceId, pane_id: PaneId, active_surface_id: SurfaceId, surface: &SurfaceSnapshot, core: SharedCore, mut surface_drag_source: Signal>, mut surface_drop_target: Signal>, + mut surface_workspace_target: Signal>, ordered_surface_ids: &[SurfaceId], ) -> Element { let kind_label = match surface.kind { @@ -1234,17 +1357,28 @@ fn render_surface_tab( let core = core.clone(); move |_: Event| { surface_drag_source.set(Some(DraggedSurface { + workspace_id, pane_id, surface_id, })); + surface_workspace_target.set(None); core.dispatch_shell_action(ShellAction::BeginSurfaceDrag); } }; let clear_drag = { let core = core.clone(); move |_: Event| { + let dragged = *surface_drag_source.read(); surface_drag_source.set(None); surface_drop_target.set(None); + surface_workspace_target.set(None); + if let Some(dragged) = dragged + && core.snapshot().current_workspace.id != dragged.workspace_id + { + core.dispatch_shell_action(ShellAction::FocusWorkspace { + workspace_id: dragged.workspace_id, + }); + } core.dispatch_shell_action(ShellAction::EndDrag); } }; @@ -1253,6 +1387,7 @@ fn render_surface_tab( return; } event.prevent_default(); + surface_workspace_target.set(None); surface_drop_target.set(Some(SurfaceDropTarget::BeforeSurface { pane_id, surface_id, @@ -1276,6 +1411,7 @@ fn render_surface_tab( let dragged = *surface_drag_source.read(); surface_drag_source.set(None); surface_drop_target.set(None); + surface_workspace_target.set(None); let Some(dragged) = dragged else { return; }; diff --git a/crates/taskers-shell/src/theme.rs b/crates/taskers-shell/src/theme.rs index c099bd8..e909ef5 100644 --- a/crates/taskers-shell/src/theme.rs +++ b/crates/taskers-shell/src/theme.rs @@ -874,6 +874,36 @@ button {{ transform-origin: top left; }} +.workspace-surface-fallback-drop {{ + position: absolute; + inset: 0; + display: flex; + align-items: flex-end; + justify-content: flex-end; + padding: 16px; + border: 1px dashed transparent; + background: transparent; +}} + +.workspace-surface-fallback-drop-active {{ + border-color: {accent_20}; + background: {accent_08}; +}} + +.workspace-surface-fallback-label {{ + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 28px; + padding: 0 10px; + border: 1px solid {border_10}; + background: {surface}; + color: {text_dim}; + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; +}} + .workspace-viewport-overview .workspace-strip-canvas {{ position: relative; inset: auto; From 1b21dc45b9734015010e7d30a57a0f2cb57067f7 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sun, 22 Mar 2026 15:01:19 +0100 Subject: [PATCH 13/63] refactor: add shell-core surface drag sessions --- crates/taskers-shell-core/src/lib.rs | 261 +++++++++++++++++++++++++-- 1 file changed, 244 insertions(+), 17 deletions(-) diff --git a/crates/taskers-shell-core/src/lib.rs b/crates/taskers-shell-core/src/lib.rs index d44d0ab..a900bb2 100644 --- a/crates/taskers-shell-core/src/lib.rs +++ b/crates/taskers-shell-core/src/lib.rs @@ -856,6 +856,14 @@ pub enum ShellDragMode { Surface, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SurfaceDragSessionSnapshot { + pub workspace_id: WorkspaceId, + pub pane_id: PaneId, + pub surface_id: SurfaceId, + pub preview_workspace_id: WorkspaceId, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AgentStateSnapshot { Working, @@ -942,6 +950,7 @@ pub struct ShellSnapshot { pub section: ShellSection, pub overview_mode: bool, pub drag_mode: ShellDragMode, + pub surface_drag: Option, pub attention_panel_visible: bool, pub workspaces: Vec, pub current_workspace: WorkspaceViewSnapshot, @@ -1068,7 +1077,15 @@ pub enum ShellAction { target_workspace_id: WorkspaceId, }, BeginWindowDrag, - BeginSurfaceDrag, + BeginSurfaceDrag { + workspace_id: WorkspaceId, + pane_id: PaneId, + surface_id: SurfaceId, + }, + PreviewSurfaceDragWorkspace { + workspace_id: WorkspaceId, + }, + CancelSurfaceDrag, EndDrag, NavigateBrowser { surface_id: SurfaceId, @@ -1114,6 +1131,7 @@ struct UiState { section: ShellSection, overview_mode: bool, drag_mode: ShellDragMode, + surface_drag: Option, selected_theme_id: String, selected_shortcut_preset: ShortcutPreset, notification_preferences: NotificationPreferencesSnapshot, @@ -1177,6 +1195,7 @@ impl TaskersCore { section: ShellSection::Workspace, overview_mode: false, drag_mode: ShellDragMode::None, + surface_drag: None, selected_theme_id: bootstrap.selected_theme_id, selected_shortcut_preset: bootstrap.selected_shortcut_preset, notification_preferences: bootstrap.notification_preferences, @@ -1261,6 +1280,7 @@ impl TaskersCore { section: self.ui.section, overview_mode: self.ui.overview_mode, drag_mode: self.ui.drag_mode, + surface_drag: self.ui.surface_drag, attention_panel_visible, workspaces: self.workspace_summaries(&model), current_workspace: WorkspaceViewSnapshot { @@ -1924,9 +1944,17 @@ impl TaskersCore { surface_id, target_workspace_id, ), - ShellAction::BeginWindowDrag => self.set_drag_mode(ShellDragMode::Window), - ShellAction::BeginSurfaceDrag => self.set_drag_mode(ShellDragMode::Surface), - ShellAction::EndDrag => self.set_drag_mode(ShellDragMode::None), + ShellAction::BeginWindowDrag => self.begin_window_drag(), + ShellAction::BeginSurfaceDrag { + workspace_id, + pane_id, + surface_id, + } => self.begin_surface_drag(workspace_id, pane_id, surface_id), + ShellAction::PreviewSurfaceDragWorkspace { workspace_id } => { + self.preview_surface_drag_workspace(workspace_id) + } + ShellAction::CancelSurfaceDrag => self.clear_surface_drag(true), + ShellAction::EndDrag => self.clear_surface_drag(false), ShellAction::NavigateBrowser { surface_id, url } => { self.navigate_browser_surface(surface_id, &url) } @@ -2633,6 +2661,93 @@ impl TaskersCore { changed } + fn begin_window_drag(&mut self) -> bool { + let mut changed = false; + if self.ui.surface_drag.is_some() { + self.ui.surface_drag = None; + changed = true; + } + if self.ui.drag_mode != ShellDragMode::Window { + self.ui.drag_mode = ShellDragMode::Window; + changed = true; + } + if changed { + self.bump_local_revision(); + } + changed + } + + fn begin_surface_drag( + &mut self, + workspace_id: WorkspaceId, + pane_id: PaneId, + surface_id: SurfaceId, + ) -> bool { + let model = self.app_state.snapshot_model(); + if self.resolve_surface_location(&model, surface_id) != Some((workspace_id, pane_id)) { + return false; + } + let next = SurfaceDragSessionSnapshot { + workspace_id, + pane_id, + surface_id, + preview_workspace_id: workspace_id, + }; + if self.ui.drag_mode == ShellDragMode::Surface && self.ui.surface_drag == Some(next) { + return false; + } + self.ui.drag_mode = ShellDragMode::Surface; + self.ui.surface_drag = Some(next); + self.bump_local_revision(); + true + } + + fn preview_surface_drag_workspace(&mut self, workspace_id: WorkspaceId) -> bool { + let Some(mut session) = self.ui.surface_drag else { + return false; + }; + let mut changed = false; + if session.preview_workspace_id != workspace_id { + session.preview_workspace_id = workspace_id; + self.ui.surface_drag = Some(session); + self.bump_local_revision(); + changed = true; + } + if self.app_state.snapshot_model().active_workspace_id() != Some(workspace_id) { + changed |= self.dispatch_control(ControlCommand::SwitchWorkspace { + window_id: None, + workspace_id, + }); + } + changed + } + + fn clear_surface_drag(&mut self, restore_source_workspace: bool) -> bool { + let source_workspace_id = self.ui.surface_drag.map(|session| session.workspace_id); + let mut changed = false; + if self.ui.surface_drag.is_some() { + self.ui.surface_drag = None; + changed = true; + } + if self.ui.drag_mode != ShellDragMode::None { + self.ui.drag_mode = ShellDragMode::None; + changed = true; + } + if changed { + self.bump_local_revision(); + } + if restore_source_workspace + && let Some(workspace_id) = source_workspace_id + && self.app_state.snapshot_model().active_workspace_id() != Some(workspace_id) + { + changed |= self.dispatch_control(ControlCommand::SwitchWorkspace { + window_id: None, + workspace_id, + }); + } + changed + } + fn prepare_workspace_interaction(&mut self) -> Option { let mut changed = false; if self.ui.section != ShellSection::Workspace { @@ -2647,21 +2762,16 @@ impl TaskersCore { self.ui.drag_mode = ShellDragMode::None; changed = true; } + if self.ui.surface_drag.is_some() { + self.ui.surface_drag = None; + changed = true; + } if changed { self.bump_local_revision(); } self.app_state.snapshot_model().active_workspace_id() } - fn set_drag_mode(&mut self, drag_mode: ShellDragMode) -> bool { - if self.ui.drag_mode == drag_mode { - return false; - } - self.ui.drag_mode = drag_mode; - self.bump_local_revision(); - true - } - fn ensure_active_window_visible(&mut self) -> bool { let model = self.app_state.snapshot_model(); let Some(workspace_id) = model.active_workspace_id() else { @@ -3754,9 +3864,10 @@ mod tests { use super::{ BootstrapModel, BrowserMountSpec, DEFAULT_BROWSER_HOME, Direction, HostCommand, HostEvent, LayoutMetrics, NotificationPreferencesSnapshot, RuntimeCapability, RuntimeStatus, - SharedCore, ShellAction, ShellDragMode, ShellSection, SurfaceMountSpec, WorkspaceDirection, - default_preview_app_state, default_session_path_for_preview, pane_body_frame, - resolved_browser_uri, split_frame, workspace_window_content_frame, + SharedCore, ShellAction, ShellDragMode, ShellSection, SurfaceDragSessionSnapshot, + SurfaceMountSpec, WorkspaceDirection, default_preview_app_state, + default_session_path_for_preview, pane_body_frame, resolved_browser_uri, split_frame, + workspace_window_content_frame, }; fn bootstrap() -> BootstrapModel { @@ -4174,15 +4285,131 @@ mod tests { #[test] fn shell_drag_actions_update_snapshot_drag_mode() { let core = SharedCore::bootstrap(bootstrap()); + let snapshot = core.snapshot(); + let workspace_id = snapshot.current_workspace.id; + let pane_id = snapshot.current_workspace.active_pane; + let surface_id = snapshot + .portal + .panes + .iter() + .find(|plan| plan.pane_id == pane_id) + .map(|plan| plan.surface_id) + .expect("active surface"); - core.dispatch_shell_action(ShellAction::BeginSurfaceDrag); + core.dispatch_shell_action(ShellAction::BeginSurfaceDrag { + workspace_id, + pane_id, + surface_id, + }); assert_eq!(core.snapshot().drag_mode, ShellDragMode::Surface); + assert_eq!( + core.snapshot().surface_drag, + Some(SurfaceDragSessionSnapshot { + workspace_id, + pane_id, + surface_id, + preview_workspace_id: workspace_id, + }) + ); core.dispatch_shell_action(ShellAction::BeginWindowDrag); assert_eq!(core.snapshot().drag_mode, ShellDragMode::Window); + assert_eq!(core.snapshot().surface_drag, None); core.dispatch_shell_action(ShellAction::EndDrag); assert_eq!(core.snapshot().drag_mode, ShellDragMode::None); + assert_eq!(core.snapshot().surface_drag, None); + } + + #[test] + fn surface_drag_preview_switches_workspace_and_cancel_restores_source() { + let core = SharedCore::bootstrap(bootstrap()); + let source_snapshot = core.snapshot(); + let source_workspace_id = source_snapshot.current_workspace.id; + let source_pane_id = source_snapshot.current_workspace.active_pane; + let source_surface_id = source_snapshot + .portal + .panes + .iter() + .find(|plan| plan.pane_id == source_pane_id) + .map(|plan| plan.surface_id) + .expect("active surface"); + + core.dispatch_shell_action(ShellAction::CreateWorkspace); + let target_workspace_id = core + .snapshot() + .workspaces + .iter() + .find(|workspace| workspace.id != source_workspace_id) + .map(|workspace| workspace.id) + .expect("target workspace"); + + core.dispatch_shell_action(ShellAction::BeginSurfaceDrag { + workspace_id: source_workspace_id, + pane_id: source_pane_id, + surface_id: source_surface_id, + }); + core.dispatch_shell_action(ShellAction::PreviewSurfaceDragWorkspace { + workspace_id: target_workspace_id, + }); + + let preview_snapshot = core.snapshot(); + assert_eq!(preview_snapshot.current_workspace.id, target_workspace_id); + assert_eq!( + preview_snapshot.surface_drag, + Some(SurfaceDragSessionSnapshot { + workspace_id: source_workspace_id, + pane_id: source_pane_id, + surface_id: source_surface_id, + preview_workspace_id: target_workspace_id, + }) + ); + + core.dispatch_shell_action(ShellAction::CancelSurfaceDrag); + + let canceled_snapshot = core.snapshot(); + assert_eq!(canceled_snapshot.current_workspace.id, source_workspace_id); + assert_eq!(canceled_snapshot.drag_mode, ShellDragMode::None); + assert_eq!(canceled_snapshot.surface_drag, None); + } + + #[test] + fn end_drag_clears_surface_drag_without_restoring_source_workspace() { + let core = SharedCore::bootstrap(bootstrap()); + let source_snapshot = core.snapshot(); + let source_workspace_id = source_snapshot.current_workspace.id; + let source_pane_id = source_snapshot.current_workspace.active_pane; + let source_surface_id = source_snapshot + .portal + .panes + .iter() + .find(|plan| plan.pane_id == source_pane_id) + .map(|plan| plan.surface_id) + .expect("active surface"); + + core.dispatch_shell_action(ShellAction::CreateWorkspace); + let target_workspace_id = core + .snapshot() + .workspaces + .iter() + .find(|workspace| workspace.id != source_workspace_id) + .map(|workspace| workspace.id) + .expect("target workspace"); + + core.dispatch_shell_action(ShellAction::BeginSurfaceDrag { + workspace_id: source_workspace_id, + pane_id: source_pane_id, + surface_id: source_surface_id, + }); + core.dispatch_shell_action(ShellAction::PreviewSurfaceDragWorkspace { + workspace_id: target_workspace_id, + }); + core.dispatch_shell_action(ShellAction::EndDrag); + + let ended_snapshot = core.snapshot(); + assert_eq!(ended_snapshot.current_workspace.id, target_workspace_id); + assert_eq!(ended_snapshot.drag_mode, ShellDragMode::None); + assert_eq!(ended_snapshot.surface_drag, None); } #[test] From bf4aaba37808a3149d96d8a41affb8f7872bfc69 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sun, 22 Mar 2026 15:21:24 +0100 Subject: [PATCH 14/63] feat: fix tab drag and tab-strip add controls --- crates/taskers-shell/src/lib.rs | 303 +++++++++++++++--------------- crates/taskers-shell/src/theme.rs | 12 ++ 2 files changed, 163 insertions(+), 152 deletions(-) diff --git a/crates/taskers-shell/src/lib.rs b/crates/taskers-shell/src/lib.rs index df6707b..182a070 100644 --- a/crates/taskers-shell/src/lib.rs +++ b/crates/taskers-shell/src/lib.rs @@ -5,18 +5,13 @@ use taskers_core::{ ActivityItemSnapshot, AgentSessionSnapshot, AttentionState, BrowserChromeSnapshot, Direction, LayoutNodeSnapshot, NotificationPreferenceKey, PaneId, PaneSnapshot, ProgressSnapshot, PullRequestSnapshot, RuntimeStatus, SettingsSnapshot, SharedCore, ShellAction, ShellSection, - ShellSnapshot, ShortcutAction, ShortcutBindingSnapshot, SplitAxis, SurfaceId, SurfaceKind, - SurfaceSnapshot, WorkspaceId, WorkspaceLogEntrySnapshot, WorkspaceSummary, - WorkspaceViewSnapshot, WorkspaceWindowMoveTarget, WorkspaceWindowSnapshot, + ShellSnapshot, ShortcutAction, ShortcutBindingSnapshot, SplitAxis, SurfaceDragSessionSnapshot, + SurfaceId, SurfaceKind, SurfaceSnapshot, WorkspaceId, WorkspaceLogEntrySnapshot, + WorkspaceSummary, WorkspaceViewSnapshot, WorkspaceWindowMoveTarget, WorkspaceWindowSnapshot, }; use taskers_shell_core as taskers_core; -#[derive(Clone, Copy, PartialEq, Eq)] -struct DraggedSurface { - workspace_id: WorkspaceId, - pane_id: PaneId, - surface_id: SurfaceId, -} +type DraggedSurface = SurfaceDragSessionSnapshot; #[derive(Clone, Copy, PartialEq, Eq)] struct DraggedWindow { @@ -96,6 +91,24 @@ fn show_live_surface_backdrop(surface_kind: SurfaceKind, overview_mode: bool) -> overview_mode || !matches!(surface_kind, SurfaceKind::Browser) } +fn same_kind_add_surface_action(surface_kind: SurfaceKind, pane_id: PaneId) -> ShellAction { + match surface_kind { + SurfaceKind::Terminal => ShellAction::AddTerminalSurface { + pane_id: Some(pane_id), + }, + SurfaceKind::Browser => ShellAction::AddBrowserSurface { + pane_id: Some(pane_id), + }, + } +} + +fn same_kind_add_surface_title(surface_kind: SurfaceKind) -> &'static str { + match surface_kind { + SurfaceKind::Terminal => "New terminal tab", + SurfaceKind::Browser => "New browser tab", + } +} + fn apply_surface_drop( core: &SharedCore, dragged: DraggedSurface, @@ -201,12 +214,11 @@ pub fn TaskersShell(core: SharedCore) -> Element { }; let drag_source = use_signal(|| None::); let drag_target = use_signal(|| None::); - let mut surface_drag_source = use_signal(|| None::); let mut surface_drop_target = use_signal(|| None::); - let mut surface_workspace_target = use_signal(|| None::); let window_drag_source = use_signal(|| None::); let window_drop_target = use_signal(|| None::); let workspace_ids: Vec = snapshot.workspaces.iter().map(|ws| ws.id).collect(); + let dragged_surface = snapshot.surface_drag; let main_class = match snapshot.section { ShellSection::Workspace => { @@ -249,8 +261,8 @@ pub fn TaskersShell(core: SharedCore) -> Element { core.clone(), drag_source, drag_target, - surface_drag_source, - surface_workspace_target, + dragged_surface, + surface_drop_target, &workspace_ids, )} } @@ -279,19 +291,10 @@ pub fn TaskersShell(core: SharedCore) -> Element { ondragend: { let core = core.clone(); move |_: Event| { - let dragged = *surface_drag_source.read(); - surface_drag_source.set(None); surface_drop_target.set(None); - surface_workspace_target.set(None); - let Some(dragged) = dragged else { - return; - }; - if core.snapshot().current_workspace.id != dragged.workspace_id { - core.dispatch_shell_action(ShellAction::FocusWorkspace { - workspace_id: dragged.workspace_id, - }); + if core.snapshot().surface_drag.is_some() { + core.dispatch_shell_action(ShellAction::CancelSurfaceDrag); } - core.dispatch_shell_action(ShellAction::EndDrag); } }, {render_workspace_strip( @@ -300,9 +303,8 @@ pub fn TaskersShell(core: SharedCore) -> Element { snapshot.browser_chrome.as_ref(), core.clone(), &snapshot.runtime_status, - surface_drag_source, surface_drop_target, - surface_workspace_target, + dragged_surface, window_drag_source, window_drop_target, )} @@ -389,8 +391,8 @@ fn render_workspace_item( core: SharedCore, mut drag_source: Signal>, mut drag_target: Signal>, - mut surface_drag_source: Signal>, - mut surface_workspace_target: Signal>, + dragged_surface: Option, + mut surface_drop_target: Signal>, all_ids: &[WorkspaceId], ) -> Element { let tab_class = if workspace.active { @@ -461,7 +463,8 @@ fn render_workspace_item( .unwrap_or_default(); let is_workspace_drag_target = *drag_target.read() == Some(workspace_id); - let is_surface_drag_target = *surface_workspace_target.read() == Some(workspace_id); + let is_surface_drag_target = + dragged_surface.is_some_and(|dragged| dragged.preview_workspace_id == workspace_id); let outer_class = if is_surface_drag_target { "workspace-button workspace-button-surface-drop" } else if is_workspace_drag_target { @@ -478,15 +481,14 @@ fn render_workspace_item( let core = core.clone(); move |event: Event| { event.prevent_default(); - if let Some(dragged_surface) = *surface_drag_source.read() { - surface_workspace_target.set(Some(workspace_id)); + if core.snapshot().surface_drag.is_some() { drag_target.set(None); - if dragged_surface.workspace_id != workspace_id { - core.dispatch_shell_action(ShellAction::FocusWorkspace { workspace_id }); - } + surface_drop_target.set(None); + core.dispatch_shell_action(ShellAction::PreviewSurfaceDragWorkspace { + workspace_id, + }); } else { drag_target.set(Some(workspace_id)); - surface_workspace_target.set(None); } } }; @@ -494,22 +496,18 @@ fn render_workspace_item( if *drag_target.read() == Some(workspace_id) { drag_target.set(None); } - if *surface_workspace_target.read() == Some(workspace_id) { - surface_workspace_target.set(None); - } }; let on_drop = { let core = core.clone(); let all_ids = all_ids.clone(); move |event: Event| { event.prevent_default(); - let dragged_surface = *surface_drag_source.read(); + let dragged_surface = core.snapshot().surface_drag; let source = *drag_source.read(); drag_source.set(None); drag_target.set(None); - surface_workspace_target.set(None); + surface_drop_target.set(None); if let Some(dragged_surface) = dragged_surface { - surface_drag_source.set(None); if dragged_surface.workspace_id != workspace_id { core.dispatch_shell_action(ShellAction::MoveSurfaceToWorkspace { source_pane_id: dragged_surface.pane_id, @@ -634,49 +632,51 @@ fn render_workspace_log_entry(entry: &WorkspaceLogEntrySnapshot) -> Element { fn render_surface_workspace_fallback_drop( target_workspace_id: WorkspaceId, core: SharedCore, - mut surface_drag_source: Signal>, mut surface_drop_target: Signal>, - mut surface_workspace_target: Signal>, + dragged_surface: Option, ) -> Element { - let active = *surface_workspace_target.read() == Some(target_workspace_id); + let active = + dragged_surface.is_some_and(|dragged| dragged.preview_workspace_id == target_workspace_id); let class = if active { "workspace-surface-fallback-drop workspace-surface-fallback-drop-active" } else { "workspace-surface-fallback-drop" }; - let on_dragover = move |event: Event| { - let Some(dragged) = *surface_drag_source.read() else { - return; - }; - if dragged.workspace_id == target_workspace_id { - return; - } - event.prevent_default(); - surface_workspace_target.set(Some(target_workspace_id)); - surface_drop_target.set(None); - }; - let on_dragleave = move |_: Event| { - if *surface_workspace_target.read() == Some(target_workspace_id) { - surface_workspace_target.set(None); + let on_dragover = { + let core = core.clone(); + move |event: Event| { + let Some(dragged) = core.snapshot().surface_drag else { + return; + }; + if dragged.workspace_id == target_workspace_id { + return; + } + event.prevent_default(); + surface_drop_target.set(None); + core.dispatch_shell_action(ShellAction::PreviewSurfaceDragWorkspace { + workspace_id: target_workspace_id, + }); } }; - let on_drop = move |event: Event| { - event.prevent_default(); - let dragged = *surface_drag_source.read(); - surface_drag_source.set(None); - surface_drop_target.set(None); - surface_workspace_target.set(None); - let Some(dragged) = dragged else { - return; - }; - if dragged.workspace_id != target_workspace_id { - core.dispatch_shell_action(ShellAction::MoveSurfaceToWorkspace { - source_pane_id: dragged.pane_id, - surface_id: dragged.surface_id, - target_workspace_id, - }); + let on_dragleave = move |_: Event| {}; + let on_drop = { + let core = core.clone(); + move |event: Event| { + event.prevent_default(); + let dragged = core.snapshot().surface_drag; + surface_drop_target.set(None); + let Some(dragged) = dragged else { + return; + }; + if dragged.workspace_id != target_workspace_id { + core.dispatch_shell_action(ShellAction::MoveSurfaceToWorkspace { + source_pane_id: dragged.pane_id, + surface_id: dragged.surface_id, + target_workspace_id, + }); + } + core.dispatch_shell_action(ShellAction::EndDrag); } - core.dispatch_shell_action(ShellAction::EndDrag); }; rsx! { @@ -697,9 +697,8 @@ fn render_layout( browser_chrome: Option<&BrowserChromeSnapshot>, core: SharedCore, runtime_status: &RuntimeStatus, - surface_drag_source: Signal>, surface_drop_target: Signal>, - surface_workspace_target: Signal>, + dragged_surface: Option, ) -> Element { match node { LayoutNodeSnapshot::Split { @@ -720,10 +719,10 @@ fn render_layout( rsx! { div { class: "split-container", style: "flex-direction: {direction};", div { class: "split-child", style: "{first_style}", - {render_layout(workspace_id, first, overview_mode, browser_chrome, core.clone(), runtime_status, surface_drag_source, surface_drop_target, surface_workspace_target)} + {render_layout(workspace_id, first, overview_mode, browser_chrome, core.clone(), runtime_status, surface_drop_target, dragged_surface)} } div { class: "split-child", style: "{second_style}", - {render_layout(workspace_id, second, overview_mode, browser_chrome, core.clone(), runtime_status, surface_drag_source, surface_drop_target, surface_workspace_target)} + {render_layout(workspace_id, second, overview_mode, browser_chrome, core.clone(), runtime_status, surface_drop_target, dragged_surface)} } } } @@ -735,9 +734,8 @@ fn render_layout( browser_chrome, core, runtime_status, - surface_drag_source, surface_drop_target, - surface_workspace_target, + dragged_surface, ), } } @@ -748,9 +746,8 @@ fn render_workspace_strip( browser_chrome: Option<&BrowserChromeSnapshot>, core: SharedCore, runtime_status: &RuntimeStatus, - surface_drag_source: Signal>, surface_drop_target: Signal>, - surface_workspace_target: Signal>, + dragged_surface: Option, window_drag_source: Signal>, window_drop_target: Signal>, ) -> Element { @@ -786,16 +783,13 @@ fn render_workspace_strip( rsx! { div { class: "{viewport_class}", onwheel: scroll_viewport, div { class: "workspace-strip-canvas", style: "{canvas_style}", - if surface_drag_source - .read() - .is_some_and(|dragged| dragged.workspace_id != workspace.id) + if dragged_surface.is_some_and(|dragged| dragged.workspace_id != workspace.id) { {render_surface_workspace_fallback_drop( workspace.id, core.clone(), - surface_drag_source, surface_drop_target, - surface_workspace_target, + dragged_surface, )} } for column in &workspace.columns { @@ -807,9 +801,8 @@ fn render_workspace_strip( browser_chrome, core.clone(), runtime_status, - surface_drag_source, surface_drop_target, - surface_workspace_target, + dragged_surface, window_drag_source, window_drop_target, )} @@ -827,9 +820,8 @@ fn render_workspace_window( browser_chrome: Option<&BrowserChromeSnapshot>, core: SharedCore, runtime_status: &RuntimeStatus, - mut surface_drag_source: Signal>, mut surface_drop_target: Signal>, - surface_workspace_target: Signal>, + dragged_surface: Option, mut window_drag_source: Signal>, window_drop_target: Signal>, ) -> Element { @@ -852,7 +844,6 @@ fn render_workspace_window( let start_window_drag = { let core = core.clone(); move |_: Event| { - surface_drag_source.set(None); surface_drop_target.set(None); window_drag_source.set(Some(DraggedWindow { window_id })); core.dispatch_shell_action(ShellAction::BeginWindowDrag); @@ -931,9 +922,8 @@ fn render_workspace_window( browser_chrome, core.clone(), runtime_status, - surface_drag_source, surface_drop_target, - surface_workspace_target, + dragged_surface, )} } } @@ -1002,12 +992,10 @@ fn render_pane( browser_chrome: Option<&BrowserChromeSnapshot>, core: SharedCore, runtime_status: &RuntimeStatus, - surface_drag_source: Signal>, surface_drop_target: Signal>, - surface_workspace_target: Signal>, + dragged_surface: Option, ) -> Element { let pane_id = pane.id; - let dragged_surface = *surface_drag_source.read(); let pane_is_drop_target = pane_has_surface_drop_target(*surface_drop_target.read(), pane_id); let pane_class = if pane.active { format!( @@ -1068,11 +1056,17 @@ fn render_pane( let pane_allows_split = pane_allows_surface_split(dragged_surface, pane_id, pane.surfaces.len()); let surface_drag_active = dragged_surface.is_some(); + let add_same_kind_label = same_kind_add_surface_title(active_surface.kind); let focus_pane = { let core = core.clone(); move |_| core.dispatch_shell_action(ShellAction::FocusPane { pane_id }) }; + let add_same_kind_surface = { + let core = core.clone(); + let add_action = same_kind_add_surface_action(active_surface.kind, pane_id); + move |_| core.dispatch_shell_action(add_action.clone()) + }; let add_browser_surface = { let core = core.clone(); move |_| { @@ -1150,9 +1144,7 @@ fn render_pane( pane.active_surface, surface, core.clone(), - surface_drag_source, surface_drop_target, - surface_workspace_target, &ordered_surface_ids, )} } @@ -1162,10 +1154,15 @@ fn render_pane( "+", SurfaceDropTarget::AppendToPane { pane_id }, core.clone(), - surface_drag_source, surface_drop_target, - surface_workspace_target, )} + } else { + button { + class: "surface-tab surface-tab-add-button", + title: "{add_same_kind_label}", + onclick: add_same_kind_surface, + "+" + } } } } @@ -1188,9 +1185,7 @@ fn render_pane( "append", SurfaceDropTarget::AppendToPane { pane_id }, core.clone(), - surface_drag_source, surface_drop_target, - surface_workspace_target, )} if pane_allows_split { {render_surface_pane_drop_target( @@ -1201,9 +1196,7 @@ fn render_pane( direction: Direction::Left, }, core.clone(), - surface_drag_source, surface_drop_target, - surface_workspace_target, )} {render_surface_pane_drop_target( "pane-drop-target pane-drop-target-edge pane-drop-target-right", @@ -1213,9 +1206,7 @@ fn render_pane( direction: Direction::Right, }, core.clone(), - surface_drag_source, surface_drop_target, - surface_workspace_target, )} {render_surface_pane_drop_target( "pane-drop-target pane-drop-target-edge pane-drop-target-top", @@ -1225,9 +1216,7 @@ fn render_pane( direction: Direction::Up, }, core.clone(), - surface_drag_source, surface_drop_target, - surface_workspace_target, )} {render_surface_pane_drop_target( "pane-drop-target pane-drop-target-edge pane-drop-target-bottom", @@ -1237,9 +1226,7 @@ fn render_pane( direction: Direction::Down, }, core.clone(), - surface_drag_source, surface_drop_target, - surface_workspace_target, )} } } @@ -1255,22 +1242,22 @@ fn render_surface_pane_drop_target( label: &'static str, target: SurfaceDropTarget, core: SharedCore, - mut surface_drag_source: Signal>, mut surface_drop_target: Signal>, - mut surface_workspace_target: Signal>, ) -> Element { let class = if *surface_drop_target.read() == Some(target) { format!("{base_class} pane-drop-target-active") } else { base_class.to_string() }; - let set_drop_target = move |event: Event| { - if surface_drag_source.read().is_none() { - return; + let set_drop_target = { + let core = core.clone(); + move |event: Event| { + if core.snapshot().surface_drag.is_none() { + return; + } + event.prevent_default(); + surface_drop_target.set(Some(target)); } - event.prevent_default(); - surface_workspace_target.set(None); - surface_drop_target.set(Some(target)); }; let clear_drop_target = move |_: Event| { if *surface_drop_target.read() == Some(target) { @@ -1281,10 +1268,8 @@ fn render_surface_pane_drop_target( let core = core.clone(); move |event: Event| { event.prevent_default(); - let dragged = *surface_drag_source.read(); - surface_drag_source.set(None); + let dragged = core.snapshot().surface_drag; surface_drop_target.set(None); - surface_workspace_target.set(None); let Some(dragged) = dragged else { return; }; @@ -1310,9 +1295,7 @@ fn render_surface_tab( active_surface_id: SurfaceId, surface: &SurfaceSnapshot, core: SharedCore, - mut surface_drag_source: Signal>, mut surface_drop_target: Signal>, - mut surface_workspace_target: Signal>, ordered_surface_ids: &[SurfaceId], ) -> Element { let kind_label = match surface.kind { @@ -1356,42 +1339,32 @@ fn render_surface_tab( let start_drag = { let core = core.clone(); move |_: Event| { - surface_drag_source.set(Some(DraggedSurface { + core.dispatch_shell_action(ShellAction::BeginSurfaceDrag { workspace_id, pane_id, surface_id, - })); - surface_workspace_target.set(None); - core.dispatch_shell_action(ShellAction::BeginSurfaceDrag); + }); } }; let clear_drag = { let core = core.clone(); move |_: Event| { - let dragged = *surface_drag_source.read(); - surface_drag_source.set(None); surface_drop_target.set(None); - surface_workspace_target.set(None); - if let Some(dragged) = dragged - && core.snapshot().current_workspace.id != dragged.workspace_id - { - core.dispatch_shell_action(ShellAction::FocusWorkspace { - workspace_id: dragged.workspace_id, - }); - } - core.dispatch_shell_action(ShellAction::EndDrag); + core.dispatch_shell_action(ShellAction::CancelSurfaceDrag); } }; - let set_surface_drop_target = move |event: Event| { - if surface_drag_source.read().is_none() { - return; + let set_surface_drop_target = { + let core = core.clone(); + move |event: Event| { + if core.snapshot().surface_drag.is_none() { + return; + } + event.prevent_default(); + surface_drop_target.set(Some(SurfaceDropTarget::BeforeSurface { + pane_id, + surface_id, + })); } - event.prevent_default(); - surface_workspace_target.set(None); - surface_drop_target.set(Some(SurfaceDropTarget::BeforeSurface { - pane_id, - surface_id, - })); }; let clear_surface_drop_target = move |_: Event| { if *surface_drop_target.read() @@ -1408,10 +1381,8 @@ fn render_surface_tab( let ordered_surface_ids = ordered_surface_ids.to_vec(); move |event: Event| { event.prevent_default(); - let dragged = *surface_drag_source.read(); - surface_drag_source.set(None); + let dragged = core.snapshot().surface_drag; surface_drop_target.set(None); - surface_workspace_target.set(None); let Some(dragged) = dragged else { return; }; @@ -1667,7 +1638,10 @@ fn render_notification_row( #[cfg(test)] mod tests { - use super::{SurfaceKind, show_live_surface_backdrop}; + use super::{ + PaneId, ShellAction, SurfaceKind, same_kind_add_surface_action, + same_kind_add_surface_title, show_live_surface_backdrop, + }; #[test] fn live_browser_panes_skip_decorative_backdrop_outside_overview() { @@ -1675,6 +1649,31 @@ mod tests { assert!(show_live_surface_backdrop(SurfaceKind::Browser, true)); assert!(show_live_surface_backdrop(SurfaceKind::Terminal, false)); } + + #[test] + fn same_kind_add_surface_helpers_match_active_surface_kind() { + let pane_id = PaneId::new(); + assert_eq!( + same_kind_add_surface_title(SurfaceKind::Terminal), + "New terminal tab" + ); + assert_eq!( + same_kind_add_surface_action(SurfaceKind::Terminal, pane_id), + ShellAction::AddTerminalSurface { + pane_id: Some(pane_id), + } + ); + assert_eq!( + same_kind_add_surface_title(SurfaceKind::Browser), + "New browser tab" + ); + assert_eq!( + same_kind_add_surface_action(SurfaceKind::Browser, pane_id), + ShellAction::AddBrowserSurface { + pane_id: Some(pane_id), + } + ); + } } fn render_settings(settings: &SettingsSnapshot, core: SharedCore) -> Element { diff --git a/crates/taskers-shell/src/theme.rs b/crates/taskers-shell/src/theme.rs index e909ef5..24cdc5a 100644 --- a/crates/taskers-shell/src/theme.rs +++ b/crates/taskers-shell/src/theme.rs @@ -1106,6 +1106,18 @@ button {{ border-color: {border_10}; }} +.surface-tab-add-button {{ + min-width: 28px; + justify-content: center; + color: {text_subtle}; +}} + +.surface-tab-add-button:hover {{ + color: {text_bright}; + background: {overlay_16}; + border-color: {accent_20}; +}} + .surface-tab-append-target {{ min-width: 28px; justify-content: center; From 76e51879b769604cae1be0f022a97e8df038a872 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sun, 22 Mar 2026 15:27:55 +0100 Subject: [PATCH 15/63] fix: restore tab drag and drop behavior --- crates/taskers-shell/src/lib.rs | 36 +++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/crates/taskers-shell/src/lib.rs b/crates/taskers-shell/src/lib.rs index 182a070..b3dbed7 100644 --- a/crates/taskers-shell/src/lib.rs +++ b/crates/taskers-shell/src/lib.rs @@ -13,6 +13,10 @@ use taskers_shell_core as taskers_core; type DraggedSurface = SurfaceDragSessionSnapshot; +const WORKSPACE_DRAG_MIME: &str = "application/x-taskers-workspace"; +const WINDOW_DRAG_MIME: &str = "application/x-taskers-window"; +const SURFACE_DRAG_MIME: &str = "application/x-taskers-surface"; + #[derive(Clone, Copy, PartialEq, Eq)] struct DraggedWindow { window_id: taskers_core::WorkspaceWindowId, @@ -109,6 +113,19 @@ fn same_kind_add_surface_title(surface_kind: SurfaceKind) -> &'static str { } } +fn prime_drag_transfer(event: &Event, mime: &str, payload: &str) { + let transfer = event.data().data_transfer(); + let _ = transfer.set_data(mime, payload); + let _ = transfer.set_data("text/plain", payload); + transfer.set_effect_allowed("move"); + transfer.set_drop_effect("move"); +} + +fn mark_move_drop(event: &Event) { + event.prevent_default(); + event.data().data_transfer().set_drop_effect("move"); +} + fn apply_surface_drop( core: &SharedCore, dragged: DraggedSurface, @@ -474,13 +491,14 @@ fn render_workspace_item( }; let all_ids = all_ids.to_vec(); - let on_dragstart = move |_: Event| { + let on_dragstart = move |event: Event| { + prime_drag_transfer(&event, WORKSPACE_DRAG_MIME, &workspace_id.to_string()); drag_source.set(Some(workspace_id)); }; let on_dragover = { let core = core.clone(); move |event: Event| { - event.prevent_default(); + mark_move_drop(&event); if core.snapshot().surface_drag.is_some() { drag_target.set(None); surface_drop_target.set(None); @@ -645,13 +663,13 @@ fn render_surface_workspace_fallback_drop( let on_dragover = { let core = core.clone(); move |event: Event| { + mark_move_drop(&event); let Some(dragged) = core.snapshot().surface_drag else { return; }; if dragged.workspace_id == target_workspace_id { return; } - event.prevent_default(); surface_drop_target.set(None); core.dispatch_shell_action(ShellAction::PreviewSurfaceDragWorkspace { workspace_id: target_workspace_id, @@ -843,7 +861,8 @@ fn render_workspace_window( }; let start_window_drag = { let core = core.clone(); - move |_: Event| { + move |event: Event| { + prime_drag_transfer(&event, WINDOW_DRAG_MIME, &window_id.to_string()); surface_drop_target.set(None); window_drag_source.set(Some(DraggedWindow { window_id })); core.dispatch_shell_action(ShellAction::BeginWindowDrag); @@ -949,7 +968,7 @@ fn render_window_drop_zone( if window_drag_source.read().is_none() { return; } - event.prevent_default(); + mark_move_drop(&event); window_drop_target.set(Some(target)); }; let clear_drop_target = move |_: Event| { @@ -1255,7 +1274,7 @@ fn render_surface_pane_drop_target( if core.snapshot().surface_drag.is_none() { return; } - event.prevent_default(); + mark_move_drop(&event); surface_drop_target.set(Some(target)); } }; @@ -1338,7 +1357,8 @@ fn render_surface_tab( }; let start_drag = { let core = core.clone(); - move |_: Event| { + move |event: Event| { + prime_drag_transfer(&event, SURFACE_DRAG_MIME, &surface_id.to_string()); core.dispatch_shell_action(ShellAction::BeginSurfaceDrag { workspace_id, pane_id, @@ -1359,7 +1379,7 @@ fn render_surface_tab( if core.snapshot().surface_drag.is_none() { return; } - event.prevent_default(); + mark_move_drop(&event); surface_drop_target.set(Some(SurfaceDropTarget::BeforeSurface { pane_id, surface_id, From 98ab2ebf9355922c602587793328e7ad14bba6ee Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sun, 22 Mar 2026 15:38:47 +0100 Subject: [PATCH 16/63] fix: keep native panes out of tab chrome --- crates/taskers-host/src/lib.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/taskers-host/src/lib.rs b/crates/taskers-host/src/lib.rs index cc59c1b..bcda65e 100644 --- a/crates/taskers-host/src/lib.rs +++ b/crates/taskers-host/src/lib.rs @@ -1018,10 +1018,10 @@ struct NativeSurfaceShell { impl NativeSurfaceShell { fn new(kind_class: &'static str, interactive: bool) -> Self { let root = GtkBox::new(Orientation::Vertical, 0); - root.set_hexpand(true); - root.set_vexpand(true); - root.set_halign(Align::Fill); - root.set_valign(Align::Fill); + root.set_hexpand(false); + root.set_vexpand(false); + root.set_halign(Align::Start); + root.set_valign(Align::Start); root.set_overflow(Overflow::Hidden); root.set_focusable(false); root.set_can_target(interactive); @@ -1336,6 +1336,8 @@ fn surface_descriptor_from(spec: &TerminalMountSpec) -> SurfaceDescriptor { fn position_widget(overlay: &Overlay, widget: &Widget, frame: taskers_core::Frame) { widget.set_size_request(frame.width.max(1), frame.height.max(1)); + widget.set_hexpand(false); + widget.set_vexpand(false); widget.set_halign(Align::Start); widget.set_valign(Align::Start); widget.set_margin_start(frame.x.max(0)); From 23eb314fa2566225d5935ba32101175ec9eb9f53 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sun, 22 Mar 2026 15:44:41 +0100 Subject: [PATCH 17/63] fix: replace broken tab drag with pointer interactions --- crates/taskers-shell/src/lib.rs | 386 ++++++++++++++++++++---------- crates/taskers-shell/src/theme.rs | 14 +- 2 files changed, 267 insertions(+), 133 deletions(-) diff --git a/crates/taskers-shell/src/lib.rs b/crates/taskers-shell/src/lib.rs index b3dbed7..a464345 100644 --- a/crates/taskers-shell/src/lib.rs +++ b/crates/taskers-shell/src/lib.rs @@ -1,5 +1,10 @@ mod theme; +use dioxus::html::{ + PointerData, + input_data::MouseButton, + point_interaction::{InteractionLocation, PointerInteraction}, +}; use dioxus::prelude::*; use taskers_core::{ ActivityItemSnapshot, AgentSessionSnapshot, AttentionState, BrowserChromeSnapshot, Direction, @@ -15,13 +20,22 @@ type DraggedSurface = SurfaceDragSessionSnapshot; const WORKSPACE_DRAG_MIME: &str = "application/x-taskers-workspace"; const WINDOW_DRAG_MIME: &str = "application/x-taskers-window"; -const SURFACE_DRAG_MIME: &str = "application/x-taskers-surface"; +const SURFACE_DRAG_THRESHOLD_PX: f64 = 6.0; #[derive(Clone, Copy, PartialEq, Eq)] struct DraggedWindow { window_id: taskers_core::WorkspaceWindowId, } +#[derive(Clone, Copy, PartialEq)] +struct SurfaceDragCandidate { + workspace_id: WorkspaceId, + pane_id: PaneId, + surface_id: SurfaceId, + start_x: f64, + start_y: f64, +} + #[derive(Clone, Copy, PartialEq, Eq)] enum SurfaceDropTarget { AppendToPane { @@ -95,24 +109,6 @@ fn show_live_surface_backdrop(surface_kind: SurfaceKind, overview_mode: bool) -> overview_mode || !matches!(surface_kind, SurfaceKind::Browser) } -fn same_kind_add_surface_action(surface_kind: SurfaceKind, pane_id: PaneId) -> ShellAction { - match surface_kind { - SurfaceKind::Terminal => ShellAction::AddTerminalSurface { - pane_id: Some(pane_id), - }, - SurfaceKind::Browser => ShellAction::AddBrowserSurface { - pane_id: Some(pane_id), - }, - } -} - -fn same_kind_add_surface_title(surface_kind: SurfaceKind) -> &'static str { - match surface_kind { - SurfaceKind::Terminal => "New terminal tab", - SurfaceKind::Browser => "New browser tab", - } -} - fn prime_drag_transfer(event: &Event, mime: &str, payload: &str) { let transfer = event.data().data_transfer(); let _ = transfer.set_data(mime, payload); @@ -126,6 +122,21 @@ fn mark_move_drop(event: &Event) { event.data().data_transfer().set_drop_effect("move"); } +fn pointer_client_position(event: &Event) -> (f64, f64) { + let position = event.data().client_coordinates(); + (position.x, position.y) +} + +fn surface_drag_threshold_reached( + candidate: SurfaceDragCandidate, + current_x: f64, + current_y: f64, +) -> bool { + let dx = current_x - candidate.start_x; + let dy = current_y - candidate.start_y; + dx.hypot(dy) >= SURFACE_DRAG_THRESHOLD_PX +} + fn apply_surface_drop( core: &SharedCore, dragged: DraggedSurface, @@ -232,10 +243,67 @@ pub fn TaskersShell(core: SharedCore) -> Element { let drag_source = use_signal(|| None::); let drag_target = use_signal(|| None::); let mut surface_drop_target = use_signal(|| None::); + let mut surface_drag_candidate = use_signal(|| None::); let window_drag_source = use_signal(|| None::); let window_drop_target = use_signal(|| None::); let workspace_ids: Vec = snapshot.workspaces.iter().map(|ws| ws.id).collect(); let dragged_surface = snapshot.surface_drag; + let track_surface_drag = { + let core = core.clone(); + move |event: Event| { + let candidate = *surface_drag_candidate.read(); + if let Some(candidate) = candidate + && event.data().held_buttons().contains(MouseButton::Primary) + { + let (current_x, current_y) = pointer_client_position(&event); + if surface_drag_threshold_reached(candidate, current_x, current_y) { + surface_drag_candidate.set(None); + surface_drop_target.set(None); + core.dispatch_shell_action(ShellAction::BeginSurfaceDrag { + workspace_id: candidate.workspace_id, + pane_id: candidate.pane_id, + surface_id: candidate.surface_id, + }); + event.stop_propagation(); + } + } + + if core.snapshot().surface_drag.is_some() + && !event.data().held_buttons().contains(MouseButton::Primary) + { + surface_drag_candidate.set(None); + surface_drop_target.set(None); + core.dispatch_shell_action(ShellAction::CancelSurfaceDrag); + event.stop_propagation(); + } + } + }; + let finish_surface_drag_up = { + let core = core.clone(); + move |event: Event| { + if surface_drag_candidate.read().is_some() { + surface_drag_candidate.set(None); + } + if core.snapshot().surface_drag.is_some() { + surface_drop_target.set(None); + core.dispatch_shell_action(ShellAction::CancelSurfaceDrag); + event.stop_propagation(); + } + } + }; + let finish_surface_drag_cancel = { + let core = core.clone(); + move |event: Event| { + if surface_drag_candidate.read().is_some() { + surface_drag_candidate.set(None); + } + if core.snapshot().surface_drag.is_some() { + surface_drop_target.set(None); + core.dispatch_shell_action(ShellAction::CancelSurfaceDrag); + event.stop_propagation(); + } + } + }; let main_class = match snapshot.section { ShellSection::Workspace => { @@ -250,7 +318,11 @@ pub fn TaskersShell(core: SharedCore) -> Element { rsx! { style { "{stylesheet}" } - div { class: "app-shell", + div { + class: "app-shell", + onpointermove: track_surface_drag, + onpointerup: finish_surface_drag_up, + onpointercancel: finish_surface_drag_cancel, aside { class: "workspace-sidebar", div { class: "sidebar-brand", h1 { "Taskers" } @@ -305,15 +377,6 @@ pub fn TaskersShell(core: SharedCore) -> Element { if matches!(snapshot.section, ShellSection::Workspace) { div { class: if snapshot.overview_mode { "workspace-canvas workspace-canvas-overview" } else { "workspace-canvas" }, - ondragend: { - let core = core.clone(); - move |_: Event| { - surface_drop_target.set(None); - if core.snapshot().surface_drag.is_some() { - core.dispatch_shell_action(ShellAction::CancelSurfaceDrag); - } - } - }, {render_workspace_strip( &snapshot.current_workspace, snapshot.overview_mode, @@ -321,6 +384,7 @@ pub fn TaskersShell(core: SharedCore) -> Element { core.clone(), &snapshot.runtime_status, surface_drop_target, + surface_drag_candidate, dragged_surface, window_drag_source, window_drop_target, @@ -498,7 +562,8 @@ fn render_workspace_item( let on_dragover = { let core = core.clone(); move |event: Event| { - mark_move_drop(&event); + event.prevent_default(); + event.data().data_transfer().set_drop_effect("move"); if core.snapshot().surface_drag.is_some() { drag_target.set(None); surface_drop_target.set(None); @@ -510,6 +575,50 @@ fn render_workspace_item( } } }; + let preview_surface_workspace_enter = { + let core = core.clone(); + move |event: Event| { + if core.snapshot().surface_drag.is_none() { + return; + } + event.stop_propagation(); + drag_target.set(None); + surface_drop_target.set(None); + core.dispatch_shell_action(ShellAction::PreviewSurfaceDragWorkspace { workspace_id }); + } + }; + let preview_surface_workspace_move = { + let core = core.clone(); + move |event: Event| { + if core.snapshot().surface_drag.is_none() { + return; + } + event.stop_propagation(); + drag_target.set(None); + surface_drop_target.set(None); + core.dispatch_shell_action(ShellAction::PreviewSurfaceDragWorkspace { workspace_id }); + } + }; + let drop_surface_on_workspace = { + let core = core.clone(); + move |event: Event| { + let Some(dragged_surface) = core.snapshot().surface_drag else { + return; + }; + event.stop_propagation(); + drag_source.set(None); + drag_target.set(None); + surface_drop_target.set(None); + if dragged_surface.workspace_id != workspace_id { + core.dispatch_shell_action(ShellAction::MoveSurfaceToWorkspace { + source_pane_id: dragged_surface.pane_id, + surface_id: dragged_surface.surface_id, + target_workspace_id: workspace_id, + }); + } + core.dispatch_shell_action(ShellAction::EndDrag); + } + }; let on_dragleave = move |_: Event| { if *drag_target.read() == Some(workspace_id) { drag_target.set(None); @@ -564,6 +673,9 @@ fn render_workspace_item( ondragover: on_dragover, ondragleave: on_dragleave, ondrop: on_drop, + onpointerenter: preview_surface_workspace_enter, + onpointermove: preview_surface_workspace_move, + onpointerup: drop_surface_on_workspace, div { class: "{tab_class}", style: "{tab_style}", if workspace.active { div { class: "workspace-tab-rail" } @@ -660,32 +772,47 @@ fn render_surface_workspace_fallback_drop( } else { "workspace-surface-fallback-drop" }; - let on_dragover = { + let preview_workspace_enter = { let core = core.clone(); - move |event: Event| { - mark_move_drop(&event); + move |event: Event| { let Some(dragged) = core.snapshot().surface_drag else { return; }; if dragged.workspace_id == target_workspace_id { return; } + event.stop_propagation(); surface_drop_target.set(None); core.dispatch_shell_action(ShellAction::PreviewSurfaceDragWorkspace { workspace_id: target_workspace_id, }); } }; - let on_dragleave = move |_: Event| {}; - let on_drop = { + let preview_workspace_move = { let core = core.clone(); - move |event: Event| { - event.prevent_default(); + move |event: Event| { + let Some(dragged) = core.snapshot().surface_drag else { + return; + }; + if dragged.workspace_id == target_workspace_id { + return; + } + event.stop_propagation(); + surface_drop_target.set(None); + core.dispatch_shell_action(ShellAction::PreviewSurfaceDragWorkspace { + workspace_id: target_workspace_id, + }); + } + }; + let move_surface_to_workspace = { + let core = core.clone(); + move |event: Event| { let dragged = core.snapshot().surface_drag; surface_drop_target.set(None); let Some(dragged) = dragged else { return; }; + event.stop_propagation(); if dragged.workspace_id != target_workspace_id { core.dispatch_shell_action(ShellAction::MoveSurfaceToWorkspace { source_pane_id: dragged.pane_id, @@ -700,9 +827,9 @@ fn render_surface_workspace_fallback_drop( rsx! { div { class: "{class}", - ondragover: on_dragover, - ondragleave: on_dragleave, - ondrop: on_drop, + onpointerenter: preview_workspace_enter, + onpointermove: preview_workspace_move, + onpointerup: move_surface_to_workspace, div { class: "workspace-surface-fallback-label", "Drop to create a new window" } } } @@ -716,6 +843,7 @@ fn render_layout( core: SharedCore, runtime_status: &RuntimeStatus, surface_drop_target: Signal>, + surface_drag_candidate: Signal>, dragged_surface: Option, ) -> Element { match node { @@ -737,10 +865,10 @@ fn render_layout( rsx! { div { class: "split-container", style: "flex-direction: {direction};", div { class: "split-child", style: "{first_style}", - {render_layout(workspace_id, first, overview_mode, browser_chrome, core.clone(), runtime_status, surface_drop_target, dragged_surface)} + {render_layout(workspace_id, first, overview_mode, browser_chrome, core.clone(), runtime_status, surface_drop_target, surface_drag_candidate, dragged_surface)} } div { class: "split-child", style: "{second_style}", - {render_layout(workspace_id, second, overview_mode, browser_chrome, core.clone(), runtime_status, surface_drop_target, dragged_surface)} + {render_layout(workspace_id, second, overview_mode, browser_chrome, core.clone(), runtime_status, surface_drop_target, surface_drag_candidate, dragged_surface)} } } } @@ -753,6 +881,7 @@ fn render_layout( core, runtime_status, surface_drop_target, + surface_drag_candidate, dragged_surface, ), } @@ -765,6 +894,7 @@ fn render_workspace_strip( core: SharedCore, runtime_status: &RuntimeStatus, surface_drop_target: Signal>, + surface_drag_candidate: Signal>, dragged_surface: Option, window_drag_source: Signal>, window_drop_target: Signal>, @@ -820,6 +950,7 @@ fn render_workspace_strip( core.clone(), runtime_status, surface_drop_target, + surface_drag_candidate, dragged_surface, window_drag_source, window_drop_target, @@ -839,6 +970,7 @@ fn render_workspace_window( core: SharedCore, runtime_status: &RuntimeStatus, mut surface_drop_target: Signal>, + surface_drag_candidate: Signal>, dragged_surface: Option, mut window_drag_source: Signal>, window_drop_target: Signal>, @@ -942,6 +1074,7 @@ fn render_workspace_window( core.clone(), runtime_status, surface_drop_target, + surface_drag_candidate, dragged_surface, )} } @@ -1012,6 +1145,7 @@ fn render_pane( core: SharedCore, runtime_status: &RuntimeStatus, surface_drop_target: Signal>, + surface_drag_candidate: Signal>, dragged_surface: Option, ) -> Element { let pane_id = pane.id; @@ -1075,20 +1209,15 @@ fn render_pane( let pane_allows_split = pane_allows_surface_split(dragged_surface, pane_id, pane.surfaces.len()); let surface_drag_active = dragged_surface.is_some(); - let add_same_kind_label = same_kind_add_surface_title(active_surface.kind); let focus_pane = { let core = core.clone(); move |_| core.dispatch_shell_action(ShellAction::FocusPane { pane_id }) }; - let add_same_kind_surface = { - let core = core.clone(); - let add_action = same_kind_add_surface_action(active_surface.kind, pane_id); - move |_| core.dispatch_shell_action(add_action.clone()) - }; let add_browser_surface = { let core = core.clone(); - move |_| { + move |event: Event| { + event.stop_propagation(); core.dispatch_shell_action(ShellAction::AddBrowserSurface { pane_id: Some(pane_id), }) @@ -1096,7 +1225,8 @@ fn render_pane( }; let add_terminal_surface = { let core = core.clone(); - move |_| { + move |event: Event| { + event.stop_propagation(); core.dispatch_shell_action(ShellAction::AddTerminalSurface { pane_id: Some(pane_id), }) @@ -1104,7 +1234,8 @@ fn render_pane( }; let split_terminal = { let core = core.clone(); - move |_| { + move |event: Event| { + event.stop_propagation(); core.dispatch_shell_action(ShellAction::SplitTerminal { pane_id: Some(pane_id), }) @@ -1112,14 +1243,16 @@ fn render_pane( }; let split_down = { let core = core.clone(); - move |_| { + move |event: Event| { + event.stop_propagation(); core.dispatch_shell_action(ShellAction::FocusPane { pane_id }); core.dispatch_shortcut_action(ShortcutAction::SplitDown); } }; let close_surface = { let core = core.clone(); - move |_| { + move |event: Event| { + event.stop_propagation(); core.dispatch_shell_action(ShellAction::CloseSurface { pane_id, surface_id: active_surface_id, @@ -1164,6 +1297,8 @@ fn render_pane( surface, core.clone(), surface_drop_target, + surface_drag_candidate, + dragged_surface, &ordered_surface_ids, )} } @@ -1175,13 +1310,6 @@ fn render_pane( core.clone(), surface_drop_target, )} - } else { - button { - class: "surface-tab surface-tab-add-button", - title: "{add_same_kind_label}", - onclick: add_same_kind_surface, - "+" - } } } } @@ -1268,30 +1396,40 @@ fn render_surface_pane_drop_target( } else { base_class.to_string() }; - let set_drop_target = { + let set_drop_target_enter = { let core = core.clone(); - move |event: Event| { + move |event: Event| { if core.snapshot().surface_drag.is_none() { return; } - mark_move_drop(&event); + event.stop_propagation(); surface_drop_target.set(Some(target)); } }; - let clear_drop_target = move |_: Event| { + let set_drop_target_move = { + let core = core.clone(); + move |event: Event| { + if core.snapshot().surface_drag.is_none() { + return; + } + event.stop_propagation(); + surface_drop_target.set(Some(target)); + } + }; + let clear_drop_target = move |_: Event| { if *surface_drop_target.read() == Some(target) { surface_drop_target.set(None); } }; let drop_surface = { let core = core.clone(); - move |event: Event| { - event.prevent_default(); + move |event: Event| { let dragged = core.snapshot().surface_drag; surface_drop_target.set(None); let Some(dragged) = dragged else { return; }; + event.stop_propagation(); apply_surface_drop(&core, dragged, target, &[]); core.dispatch_shell_action(ShellAction::EndDrag); } @@ -1300,9 +1438,10 @@ fn render_surface_pane_drop_target( rsx! { div { class: "{class}", - ondragover: set_drop_target, - ondragleave: clear_drop_target, - ondrop: drop_surface, + onpointerenter: set_drop_target_enter, + onpointermove: set_drop_target_move, + onpointerleave: clear_drop_target, + onpointerup: drop_surface, "{label}" } } @@ -1315,6 +1454,8 @@ fn render_surface_tab( surface: &SurfaceSnapshot, core: SharedCore, mut surface_drop_target: Signal>, + mut surface_drag_candidate: Signal>, + dragged_surface: Option, ordered_surface_ids: &[SurfaceId], ) -> Element { let kind_label = match surface.kind { @@ -1349,44 +1490,60 @@ fn render_surface_tab( ) }; let focus_core = core.clone(); - let focus_surface = move |_| { + let focus_surface = move |event: Event| { + event.stop_propagation(); focus_core.dispatch_shell_action(ShellAction::FocusSurface { pane_id, surface_id, }); }; - let start_drag = { - let core = core.clone(); - move |event: Event| { - prime_drag_transfer(&event, SURFACE_DRAG_MIME, &surface_id.to_string()); - core.dispatch_shell_action(ShellAction::BeginSurfaceDrag { - workspace_id, - pane_id, - surface_id, - }); + let begin_surface_drag_candidate = move |event: Event| { + if event.data().trigger_button() != Some(MouseButton::Primary) { + return; } + let (start_x, start_y) = pointer_client_position(&event); + event.stop_propagation(); + surface_drag_candidate.set(Some(SurfaceDragCandidate { + workspace_id, + pane_id, + surface_id, + start_x, + start_y, + })); }; - let clear_drag = { + let set_surface_drop_target_enter = { let core = core.clone(); - move |_: Event| { - surface_drop_target.set(None); - core.dispatch_shell_action(ShellAction::CancelSurfaceDrag); + move |event: Event| { + if dragged_surface.is_none() && core.snapshot().surface_drag.is_none() { + return; + } + if core.snapshot().surface_drag.is_none() { + return; + } + event.stop_propagation(); + surface_drop_target.set(Some(SurfaceDropTarget::BeforeSurface { + pane_id, + surface_id, + })); } }; - let set_surface_drop_target = { + let set_surface_drop_target_move = { let core = core.clone(); - move |event: Event| { + move |event: Event| { + if dragged_surface.is_none() && core.snapshot().surface_drag.is_none() { + return; + } if core.snapshot().surface_drag.is_none() { return; } - mark_move_drop(&event); + event.stop_propagation(); surface_drop_target.set(Some(SurfaceDropTarget::BeforeSurface { pane_id, surface_id, })); } }; - let clear_surface_drop_target = move |_: Event| { + let clear_surface_drop_target = move |_: Event| { if *surface_drop_target.read() == Some(SurfaceDropTarget::BeforeSurface { pane_id, @@ -1399,13 +1556,13 @@ fn render_surface_tab( let drop_surface = { let core = core.clone(); let ordered_surface_ids = ordered_surface_ids.to_vec(); - move |event: Event| { - event.prevent_default(); + move |event: Event| { let dragged = core.snapshot().surface_drag; surface_drop_target.set(None); let Some(dragged) = dragged else { return; }; + event.stop_propagation(); apply_surface_drop( &core, dragged, @@ -1421,14 +1578,13 @@ fn render_surface_tab( rsx! { button { - class: "{tab_class}", - draggable: "true", + class: "{tab_class} surface-tab-draggable", onclick: focus_surface, - ondragstart: start_drag, - ondragend: clear_drag, - ondragover: set_surface_drop_target, - ondragleave: clear_surface_drop_target, - ondrop: drop_surface, + onpointerdown: begin_surface_drag_candidate, + onpointerenter: set_surface_drop_target_enter, + onpointermove: set_surface_drop_target_move, + onpointerleave: clear_surface_drop_target, + onpointerup: drop_surface, span { class: "surface-tab-label", "{kind_label}" } span { class: "surface-tab-title", "{surface.title}" } } @@ -1659,9 +1815,10 @@ fn render_notification_row( #[cfg(test)] mod tests { use super::{ - PaneId, ShellAction, SurfaceKind, same_kind_add_surface_action, - same_kind_add_surface_title, show_live_surface_backdrop, + SurfaceDragCandidate, SurfaceKind, show_live_surface_backdrop, + surface_drag_threshold_reached, }; + use crate::taskers_core::{PaneId, SurfaceId, WorkspaceId}; #[test] fn live_browser_panes_skip_decorative_backdrop_outside_overview() { @@ -1671,28 +1828,17 @@ mod tests { } #[test] - fn same_kind_add_surface_helpers_match_active_surface_kind() { - let pane_id = PaneId::new(); - assert_eq!( - same_kind_add_surface_title(SurfaceKind::Terminal), - "New terminal tab" - ); - assert_eq!( - same_kind_add_surface_action(SurfaceKind::Terminal, pane_id), - ShellAction::AddTerminalSurface { - pane_id: Some(pane_id), - } - ); - assert_eq!( - same_kind_add_surface_title(SurfaceKind::Browser), - "New browser tab" - ); - assert_eq!( - same_kind_add_surface_action(SurfaceKind::Browser, pane_id), - ShellAction::AddBrowserSurface { - pane_id: Some(pane_id), - } - ); + fn surface_drag_threshold_requires_real_pointer_motion() { + let candidate = SurfaceDragCandidate { + workspace_id: WorkspaceId::new(), + pane_id: PaneId::new(), + surface_id: SurfaceId::new(), + start_x: 100.0, + start_y: 120.0, + }; + + assert!(!surface_drag_threshold_reached(candidate, 104.0, 123.0)); + assert!(surface_drag_threshold_reached(candidate, 106.0, 120.0)); } } diff --git a/crates/taskers-shell/src/theme.rs b/crates/taskers-shell/src/theme.rs index 24cdc5a..0b46a5d 100644 --- a/crates/taskers-shell/src/theme.rs +++ b/crates/taskers-shell/src/theme.rs @@ -1106,18 +1106,6 @@ button {{ border-color: {border_10}; }} -.surface-tab-add-button {{ - min-width: 28px; - justify-content: center; - color: {text_subtle}; -}} - -.surface-tab-add-button:hover {{ - color: {text_bright}; - background: {overlay_16}; - border-color: {accent_20}; -}} - .surface-tab-append-target {{ min-width: 28px; justify-content: center; @@ -1125,7 +1113,7 @@ button {{ color: {text_dim}; }} -.surface-tab[draggable] {{ +.surface-tab-draggable {{ cursor: grab; }} From baa18b7ee5a415bfa6bfb45930b0f1a0bb95244b Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sun, 22 Mar 2026 16:51:11 +0100 Subject: [PATCH 18/63] feat: add SVG icon module for shell UI --- crates/taskers-shell/src/icons.rs | 410 ++++++++++++++++++++++++++++++ crates/taskers-shell/src/lib.rs | 1 + 2 files changed, 411 insertions(+) create mode 100644 crates/taskers-shell/src/icons.rs diff --git a/crates/taskers-shell/src/icons.rs b/crates/taskers-shell/src/icons.rs new file mode 100644 index 0000000..4da00d0 --- /dev/null +++ b/crates/taskers-shell/src/icons.rs @@ -0,0 +1,410 @@ +use dioxus::prelude::*; + +/// Terminal prompt icon (>_) +pub fn terminal(size: u32, class: &str) -> Element { + rsx! { + svg { + class: "{class}", + width: "{size}", + height: "{size}", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + polyline { points: "4 17 10 11 4 5" } + line { x1: "12", y1: "19", x2: "20", y2: "19" } + } + } +} + +/// Globe icon for browser surfaces +pub fn globe(size: u32, class: &str) -> Element { + rsx! { + svg { + class: "{class}", + width: "{size}", + height: "{size}", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + circle { cx: "12", cy: "12", r: "10" } + path { d: "M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" } + path { d: "M2 12h20" } + } + } +} + +/// Plus icon for add actions +pub fn plus(size: u32, class: &str) -> Element { + rsx! { + svg { + class: "{class}", + width: "{size}", + height: "{size}", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + line { x1: "12", y1: "5", x2: "12", y2: "19" } + line { x1: "5", y1: "12", x2: "19", y2: "12" } + } + } +} + +/// Split horizontal (columns) icon for split-right +pub fn split_horizontal(size: u32, class: &str) -> Element { + rsx! { + svg { + class: "{class}", + width: "{size}", + height: "{size}", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + rect { x: "3", y: "3", width: "18", height: "18", rx: "2" } + line { x1: "12", y1: "3", x2: "12", y2: "21" } + } + } +} + +/// Split vertical (rows) icon for split-down +pub fn split_vertical(size: u32, class: &str) -> Element { + rsx! { + svg { + class: "{class}", + width: "{size}", + height: "{size}", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + rect { x: "3", y: "3", width: "18", height: "18", rx: "2" } + line { x1: "3", y1: "12", x2: "21", y2: "12" } + } + } +} + +/// X mark icon for close actions +pub fn close(size: u32, class: &str) -> Element { + rsx! { + svg { + class: "{class}", + width: "{size}", + height: "{size}", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + line { x1: "18", y1: "6", x2: "6", y2: "18" } + line { x1: "6", y1: "6", x2: "18", y2: "18" } + } + } +} + +/// Left arrow for browser back +pub fn arrow_left(size: u32, class: &str) -> Element { + rsx! { + svg { + class: "{class}", + width: "{size}", + height: "{size}", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + line { x1: "19", y1: "12", x2: "5", y2: "12" } + polyline { points: "12 19 5 12 12 5" } + } + } +} + +/// Right arrow for browser forward +pub fn arrow_right(size: u32, class: &str) -> Element { + rsx! { + svg { + class: "{class}", + width: "{size}", + height: "{size}", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + line { x1: "5", y1: "12", x2: "19", y2: "12" } + polyline { points: "12 5 19 12 12 19" } + } + } +} + +/// Arrow in circle for browser navigate/go +pub fn arrow_right_circle(size: u32, class: &str) -> Element { + rsx! { + svg { + class: "{class}", + width: "{size}", + height: "{size}", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + circle { cx: "12", cy: "12", r: "10" } + polyline { points: "12 16 16 12 12 8" } + line { x1: "8", y1: "12", x2: "16", y2: "12" } + } + } +} + +/// Circular arrow for browser reload +pub fn refresh(size: u32, class: &str) -> Element { + rsx! { + svg { + class: "{class}", + width: "{size}", + height: "{size}", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + polyline { points: "23 4 23 10 17 10" } + path { d: "M20.49 15a9 9 0 1 1-2.12-9.36L23 10" } + } + } +} + +/// Gear icon for settings +pub fn settings(size: u32, class: &str) -> Element { + rsx! { + svg { + class: "{class}", + width: "{size}", + height: "{size}", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + circle { cx: "12", cy: "12", r: "3" } + path { d: "M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" } + } + } +} + +/// Stacked layers icon for workspaces nav +pub fn layers(size: u32, class: &str) -> Element { + rsx! { + svg { + class: "{class}", + width: "{size}", + height: "{size}", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + polygon { points: "12 2 2 7 12 12 22 7 12 2" } + polyline { points: "2 17 12 22 22 17" } + polyline { points: "2 12 12 17 22 12" } + } + } +} + +/// Git branch fork icon +pub fn git_branch(size: u32, class: &str) -> Element { + rsx! { + svg { + class: "{class}", + width: "{size}", + height: "{size}", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + line { x1: "6", y1: "3", x2: "6", y2: "15" } + circle { cx: "18", cy: "6", r: "3" } + circle { cx: "6", cy: "18", r: "3" } + path { d: "M18 9a9 9 0 0 1-9 9" } + } + } +} + +/// Bell icon for notifications +pub fn bell(size: u32, class: &str) -> Element { + rsx! { + svg { + class: "{class}", + width: "{size}", + height: "{size}", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + path { d: "M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" } + path { d: "M13.73 21a2 2 0 0 1-3.46 0" } + } + } +} + +/// Open eye icon for devtools show +pub fn eye(size: u32, class: &str) -> Element { + rsx! { + svg { + class: "{class}", + width: "{size}", + height: "{size}", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + path { d: "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" } + circle { cx: "12", cy: "12", r: "3" } + } + } +} + +/// Struck eye icon for devtools hide +pub fn eye_off(size: u32, class: &str) -> Element { + rsx! { + svg { + class: "{class}", + width: "{size}", + height: "{size}", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + path { d: "M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" } + path { d: "M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" } + path { d: "M14.12 14.12a3 3 0 1 1-4.24-4.24" } + line { x1: "1", y1: "1", x2: "23", y2: "23" } + } + } +} + +/// Checkmark icon for completed state +pub fn check(size: u32, class: &str) -> Element { + rsx! { + svg { + class: "{class}", + width: "{size}", + height: "{size}", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + polyline { points: "20 6 9 17 4 12" } + } + } +} + +/// Warning triangle icon for error state +pub fn alert_triangle(size: u32, class: &str) -> Element { + rsx! { + svg { + class: "{class}", + width: "{size}", + height: "{size}", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + path { d: "M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" } + line { x1: "12", y1: "9", x2: "12", y2: "13" } + line { x1: "12", y1: "17", x2: "12.01", y2: "17" } + } + } +} + +/// External link icon (box with arrow) +pub fn external_link(size: u32, class: &str) -> Element { + rsx! { + svg { + class: "{class}", + width: "{size}", + height: "{size}", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + path { d: "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" } + polyline { points: "15 3 21 3 21 9" } + line { x1: "10", y1: "14", x2: "21", y2: "3" } + } + } +} + +/// Right chevron for breadcrumb/navigation +pub fn chevron_right(size: u32, class: &str) -> Element { + rsx! { + svg { + class: "{class}", + width: "{size}", + height: "{size}", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + polyline { points: "9 18 15 12 9 6" } + } + } +} + +/// Network nodes icon for listening ports +pub fn network(size: u32, class: &str) -> Element { + rsx! { + svg { + class: "{class}", + width: "{size}", + height: "{size}", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + rect { x: "9", y: "2", width: "6", height: "6", rx: "1" } + rect { x: "16", y: "16", width: "6", height: "6", rx: "1" } + rect { x: "2", y: "16", width: "6", height: "6", rx: "1" } + path { d: "M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3" } + line { x1: "12", y1: "12", x2: "12", y2: "8" } + } + } +} diff --git a/crates/taskers-shell/src/lib.rs b/crates/taskers-shell/src/lib.rs index a464345..0a74c49 100644 --- a/crates/taskers-shell/src/lib.rs +++ b/crates/taskers-shell/src/lib.rs @@ -1,3 +1,4 @@ +mod icons; mod theme; use dioxus::html::{ From 1b024de3a7496b7a8556662af03451e3d64d4661 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sun, 22 Mar 2026 17:19:13 +0100 Subject: [PATCH 19/63] style: add border-radius, shadows, scrollbars, and backdrop polish --- crates/taskers-shell/src/theme.rs | 86 ++++++++++++++++++++++++++++--- 1 file changed, 79 insertions(+), 7 deletions(-) diff --git a/crates/taskers-shell/src/theme.rs b/crates/taskers-shell/src/theme.rs index 0b46a5d..ca06ff8 100644 --- a/crates/taskers-shell/src/theme.rs +++ b/crates/taskers-shell/src/theme.rs @@ -192,7 +192,7 @@ pub fn generate_css( } else { format!("{sidebar_width}px minmax(0, 1fr)") }; - let mut css = String::with_capacity(18_000); + let mut css = String::with_capacity(22_000); let _ = write!( css, r#" @@ -213,6 +213,17 @@ button {{ font: inherit; }} +button:focus-visible, +input:focus-visible {{ + outline: 2px solid {accent}; + outline-offset: 1px; +}} + +::-webkit-scrollbar {{ width: 6px; }} +::-webkit-scrollbar-track {{ background: transparent; }} +::-webkit-scrollbar-thumb {{ background: {border_10}; border-radius: 3px; }} +::-webkit-scrollbar-thumb:hover {{ background: {border_12}; }} + .app-shell {{ width: 100vw; height: 100vh; @@ -238,12 +249,14 @@ button {{ border-right: 1px solid {border_04}; padding: 8px; gap: 10px; + backdrop-filter: blur(12px) saturate(1.4); }} .attention-panel {{ border-left: 1px solid {border_04}; padding: 10px 12px; gap: 8px; + backdrop-filter: blur(12px) saturate(1.4); }} .sidebar-brand {{ @@ -298,12 +311,14 @@ button {{ .sidebar-nav-button {{ padding: 8px 10px; color: {text_subtle}; + border-radius: 6px; }} .sidebar-nav-button:hover, .sidebar-nav-button-active {{ background: {border_06}; color: {text_bright}; + border-radius: 6px; }} .sidebar-section-header {{ @@ -322,6 +337,10 @@ button {{ min-height: 24px; padding: 0; font-size: 16px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; }} .workspace-add:hover {{ @@ -347,6 +366,7 @@ button {{ position: relative; padding: 8px 10px 8px 14px; border: 1px solid transparent; + border-radius: 6px; display: flex; align-items: stretch; gap: 0; @@ -436,6 +456,7 @@ button {{ align-items: center; justify-content: center; visibility: hidden; + border-radius: 4px; transition: background 0.14s ease-in-out, color 0.14s ease-in-out; }} @@ -459,6 +480,7 @@ button {{ font-weight: 700; background: var(--workspace-accent, {accent}); color: {base}; + border-radius: 9999px; }} .workspace-unread-badge-error {{ @@ -512,12 +534,14 @@ button {{ flex: 1; height: 3px; background: {border_08}; + border-radius: 2px; }} .workspace-progress-fill {{ height: 100%; background: var(--workspace-accent, {accent}); transition: width 0.3s ease; + border-radius: 2px; }} .workspace-progress-label {{ @@ -593,6 +617,8 @@ button {{ display: flex; flex-direction: column; gap: 8px; + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0,0,0,0.24), inset 0 1px 0 rgba(255,255,255,0.03); }} .runtime-row, @@ -617,6 +643,7 @@ button {{ justify-content: space-between; gap: 10px; text-align: left; + border-radius: 6px; }} .settings-toggle-row:hover {{ @@ -639,6 +666,7 @@ button {{ font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; + border-radius: 12px; }} .settings-toggle-button-active {{ @@ -662,6 +690,7 @@ button {{ font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; + border-radius: 4px; }} .status-pill-inline {{ @@ -736,6 +765,7 @@ button {{ gap: 2px; background: {border_04}; padding: 2px; + border-radius: 6px; }} .workspace-header-group .workspace-header-action {{ @@ -801,6 +831,7 @@ button {{ .shortcut-pill {{ border: 1px solid {border_10}; background: transparent; + border-radius: 4px; }} .workspace-header-action, @@ -809,12 +840,14 @@ button {{ min-height: 28px; padding: 0 10px; color: {text_subtle}; + border-radius: 4px; }} .workspace-header-action:hover, .pane-action:hover {{ background: {border_06}; color: {text_bright}; + box-shadow: 0 1px 2px rgba(0,0,0,0.20); }} .activity-action-passive {{ @@ -881,13 +914,15 @@ button {{ align-items: flex-end; justify-content: flex-end; padding: 16px; - border: 1px dashed transparent; + border: 1px solid transparent; background: transparent; + border-radius: 8px; }} .workspace-surface-fallback-drop-active {{ border-color: {accent_20}; background: {accent_08}; + border-radius: 8px; }} .workspace-surface-fallback-label {{ @@ -902,6 +937,7 @@ button {{ font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; + border-radius: 4px; }} .workspace-viewport-overview .workspace-strip-canvas {{ @@ -916,10 +952,13 @@ button {{ background: {surface}; border: {window_border_width}px solid {border_07}; overflow: hidden; + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0,0,0,0.32); }} .workspace-window-shell-active {{ border-color: {accent_24}; + box-shadow: 0 4px 20px rgba(0,0,0,0.40); }} .workspace-window-toolbar {{ @@ -934,6 +973,7 @@ button {{ gap: 6px; cursor: grab; user-select: none; + border-radius: 8px 8px 0 0; }} .workspace-window-title {{ @@ -979,6 +1019,7 @@ button {{ background: {elevated}; border: {pane_border_width}px solid {border_10}; overflow: hidden; + border-radius: 6px; }} .pane-card-active {{ @@ -1004,6 +1045,7 @@ button {{ pointer-events: none; opacity: 0; z-index: 10; + border-radius: 6px; }} .pane-flash-ring-active {{ @@ -1020,6 +1062,7 @@ button {{ justify-content: space-between; gap: 8px; background: {surface}; + border-radius: 6px 6px 0 0; }} .pane-toolbar-meta, @@ -1099,6 +1142,7 @@ button {{ padding: 0 6px; color: {text_muted}; white-space: nowrap; + border-radius: 4px; }} .surface-tab:hover {{ @@ -1158,11 +1202,16 @@ button {{ font-size: 10px; font-family: "IBM Plex Mono", ui-monospace, monospace; line-height: 1; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; }} .pane-utility:hover {{ background: {border_06}; color: {text_bright}; + box-shadow: 0 1px 2px rgba(0,0,0,0.20); }} .pane-utility-split {{ @@ -1248,11 +1297,22 @@ button {{ color: {text_subtle}; font-size: 11px; font-weight: 600; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; }} .browser-toolbar-button:hover {{ background: {overlay_16}; color: {text_bright}; + box-shadow: 0 1px 2px rgba(0,0,0,0.20); +}} + +.browser-toolbar-button:disabled {{ + opacity: 0.35; + cursor: default; + box-shadow: none; }} .browser-toolbar-button-primary {{ @@ -1265,10 +1325,11 @@ button {{ min-width: 0; height: 26px; border: 1px solid {border_10}; - padding: 0 10px; + padding: 0 14px; background: {overlay_05}; color: {text_bright}; font-size: 12px; + border-radius: 16px; }} .browser-address:focus {{ @@ -1298,13 +1359,14 @@ button {{ display: flex; align-items: center; justify-content: center; - border: 1px dashed {accent_24}; + border: 1px solid {accent_24}; background: {accent_12}; color: {text_bright}; font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; pointer-events: auto; + border-radius: 6px; }} .pane-drop-target-active {{ @@ -1358,9 +1420,10 @@ button {{ flex-direction: column; justify-content: space-between; gap: 12px; - border: 1px dashed {border_10}; + border: 1px solid {border_06}; padding: 12px; - background: {overlay_03}; + background: linear-gradient(180deg, {overlay_05} 0%, {overlay_03} 100%); + border-radius: 6px; }} .workspace-main-overview .workspace-window-shell {{ @@ -1460,6 +1523,7 @@ button {{ padding: 4px 8px; color: {text_muted}; font-size: 11px; + border-radius: 4px; }} .shortcut-pill-muted {{ @@ -1491,10 +1555,11 @@ button {{ }} .empty-state {{ - border: 1px dashed {border_10}; + border: 1px solid {border_10}; padding: 12px; color: {text_dim}; font-size: 12px; + border-radius: 6px; }} .activity-item {{ @@ -1502,6 +1567,7 @@ button {{ display: flex; flex-direction: column; gap: 3px; + border-radius: 6px; }} .activity-item-button {{ @@ -1536,6 +1602,7 @@ button {{ color: {text_bright}; font-size: 10px; padding: 3px 7px; + border-radius: 4px; }} .notification-count-pill {{ @@ -1544,6 +1611,7 @@ button {{ color: {text_dim}; padding: 2px 6px; background: {border_06}; + border-radius: 12px; }} .notification-count-unread {{ @@ -1600,6 +1668,7 @@ button {{ padding: 10px; background: {border_03}; transition: background 0.14s ease-in-out; + border-radius: 6px; }} .workspace-log-list {{ @@ -1616,6 +1685,7 @@ button {{ display: flex; flex-direction: column; gap: 4px; + border-radius: 6px; }} .workspace-log-entry-header {{ @@ -1646,6 +1716,7 @@ button {{ width: 8px; height: 8px; margin-top: 4px; + border-radius: 9999px; }} .notification-dot-unread {{ @@ -1775,6 +1846,7 @@ button {{ .preset-card {{ border: 1px solid {border_08}; padding: 10px; + border-radius: 6px; }} .theme-card:hover, From 7b81338610e441a8de928e967b84fbdd217f4f87 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sun, 22 Mar 2026 17:23:55 +0100 Subject: [PATCH 20/63] style: redesign sidebar with icons and compact brand --- crates/taskers-shell/src/lib.rs | 24 +++++++++++++------ crates/taskers-shell/src/theme.rs | 40 +++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/crates/taskers-shell/src/lib.rs b/crates/taskers-shell/src/lib.rs index 0a74c49..f6d33d5 100644 --- a/crates/taskers-shell/src/lib.rs +++ b/crates/taskers-shell/src/lib.rs @@ -326,23 +326,27 @@ pub fn TaskersShell(core: SharedCore) -> Element { onpointercancel: finish_surface_drag_cancel, aside { class: "workspace-sidebar", div { class: "sidebar-brand", - h1 { "Taskers" } + div { class: "sidebar-brand-wordmark", "TASKERS" } } div { class: "sidebar-nav", button { class: if matches!(snapshot.section, ShellSection::Workspace) { "sidebar-nav-button sidebar-nav-button-active" } else { "sidebar-nav-button" }, onclick: show_workspace_nav, - "Workspaces" + {icons::layers(16, "sidebar-nav-icon")} + span { "Workspaces" } } button { class: if matches!(snapshot.section, ShellSection::Settings) { "sidebar-nav-button sidebar-nav-button-active" } else { "sidebar-nav-button" }, onclick: show_settings_nav, - "Settings" + {icons::settings(16, "sidebar-nav-icon")} + span { "Settings" } } } div { class: "sidebar-section-header", div { class: "sidebar-heading", "Workspaces" } - button { class: "workspace-add", onclick: create_workspace, "+" } + button { class: "workspace-add", onclick: create_workspace, + {icons::plus(14, "workspace-add-icon")} + } } div { class: "workspace-list", for workspace in &snapshot.workspaces { @@ -691,7 +695,7 @@ fn render_workspace_item( button { class: "workspace-tab-close", onclick: close_workspace, - "×" + {icons::close(12, "workspace-tab-close-icon")} } } } @@ -701,10 +705,16 @@ fn render_workspace_item( div { class: "workspace-notification", "{notification}" } } if let Some(branch) = &branch_row { - div { class: "workspace-branch-row", "{branch}" } + div { class: "workspace-branch-row", + {icons::git_branch(10, "workspace-branch-icon")} + span { "{branch}" } + } } if let Some(ports) = &ports_row { - div { class: "workspace-ports-row", "{ports}" } + div { class: "workspace-ports-row", + {icons::network(10, "workspace-ports-icon")} + span { "{ports}" } + } } {render_workspace_progress(&workspace.progress)} {render_workspace_pull_requests(&workspace.pull_requests)} diff --git a/crates/taskers-shell/src/theme.rs b/crates/taskers-shell/src/theme.rs index ca06ff8..6536f3d 100644 --- a/crates/taskers-shell/src/theme.rs +++ b/crates/taskers-shell/src/theme.rs @@ -273,6 +273,15 @@ input:focus-visible {{ color: {text_bright}; }} +.sidebar-brand-wordmark {{ + margin: 0; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.18em; + color: {text_muted}; + line-height: 1; +}} + .sidebar-heading {{ font-weight: 600; font-size: 11px; @@ -312,6 +321,9 @@ input:focus-visible {{ padding: 8px 10px; color: {text_subtle}; border-radius: 6px; + display: flex; + align-items: center; + gap: 8px; }} .sidebar-nav-button:hover, @@ -516,12 +528,40 @@ input:focus-visible {{ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + display: flex; + align-items: center; + gap: 4px; }} .workspace-ports-row {{ color: {text_dim}; font-size: 10px; font-family: "IBM Plex Mono", ui-monospace, monospace; + display: flex; + align-items: center; + gap: 4px; +}} + +.sidebar-nav-icon {{ + flex: 0 0 auto; + opacity: 0.6; +}} + +.sidebar-nav-button-active .sidebar-nav-icon {{ + opacity: 1.0; +}} + +.workspace-add-icon, +.workspace-tab-close-icon, +.workspace-branch-icon, +.workspace-ports-icon {{ + flex: 0 0 auto; + display: block; +}} + +.workspace-branch-icon, +.workspace-ports-icon {{ + opacity: 0.5; }} .workspace-progress {{ From 488dbcb144161d16e3a12ef9296897b080b67335 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sun, 22 Mar 2026 17:24:58 +0100 Subject: [PATCH 21/63] style: redesign pane toolbar and tab strip with icons --- crates/taskers-shell/src/lib.rs | 48 ++++++++++++++++------------ crates/taskers-shell/src/theme.rs | 53 +++++++++++++++++++++++-------- 2 files changed, 66 insertions(+), 35 deletions(-) diff --git a/crates/taskers-shell/src/lib.rs b/crates/taskers-shell/src/lib.rs index f6d33d5..f2dee56 100644 --- a/crates/taskers-shell/src/lib.rs +++ b/crates/taskers-shell/src/lib.rs @@ -1208,15 +1208,6 @@ fn render_pane( .map(|url| format!("{}-{}", active_surface.id, url)) }) .unwrap_or_else(|| active_surface.id.to_string()); - let pane_kind = match active_surface.kind { - SurfaceKind::Terminal => "terminal", - SurfaceKind::Browser => "browser", - }; - let tab_count_label = if pane.surfaces.len() == 1 { - "1 tab".to_string() - } else { - format!("{} tabs", pane.surfaces.len()) - }; let pane_allows_split = pane_allows_surface_split(dragged_surface, pane_id, pane.surfaces.len()); let surface_drag_active = dragged_surface.is_some(); @@ -1287,15 +1278,30 @@ fn render_pane( section { class: "{pane_class}", onclick: focus_pane, div { class: "pane-toolbar", div { class: "pane-toolbar-meta", - span { class: "pane-toolbar-eyebrow", "pane" } - span { class: "pane-toolbar-detail", "{pane_kind} · {tab_count_label}" } + {match active_surface.kind { + SurfaceKind::Terminal => icons::terminal(14, "pane-toolbar-kind-icon"), + SurfaceKind::Browser => icons::globe(14, "pane-toolbar-kind-icon"), + }} + span { class: "pane-toolbar-title", "{active_surface.title}" } } div { class: "pane-action-cluster", - button { class: "pane-utility pane-utility-tab", title: "New terminal tab", onclick: add_terminal_surface, "+t" } - button { class: "pane-utility pane-utility-tab", title: "New browser tab", onclick: add_browser_surface, "+w" } - button { class: "pane-utility pane-utility-split", title: "Split right", onclick: split_terminal, "|r" } - button { class: "pane-utility pane-utility-split", title: "Split down", onclick: split_down, "|d" } - button { class: "pane-utility pane-utility-close", title: "{close_label}", onclick: close_surface, "x" } + button { class: "pane-utility", title: "New terminal tab", onclick: add_terminal_surface, + {icons::terminal(14, "pane-utility-icon")} + } + button { class: "pane-utility", title: "New browser tab", onclick: add_browser_surface, + {icons::globe(14, "pane-utility-icon")} + } + div { class: "pane-action-separator" } + button { class: "pane-utility", title: "Split right", onclick: split_terminal, + {icons::split_horizontal(14, "pane-utility-icon")} + } + button { class: "pane-utility", title: "Split down", onclick: split_down, + {icons::split_vertical(14, "pane-utility-icon")} + } + div { class: "pane-action-separator" } + button { class: "pane-utility pane-utility-close", title: "{close_label}", onclick: close_surface, + {icons::close(12, "pane-utility-icon")} + } } } div { class: "pane-tabs", @@ -1469,11 +1475,8 @@ fn render_surface_tab( dragged_surface: Option, ordered_surface_ids: &[SurfaceId], ) -> Element { - let kind_label = match surface.kind { - SurfaceKind::Terminal => "term", - SurfaceKind::Browser => "web", - }; let surface_id = surface.id; + let surface_kind = surface.kind; let is_drop_target = matches!( *surface_drop_target.read(), Some(SurfaceDropTarget::BeforeSurface { @@ -1596,7 +1599,10 @@ fn render_surface_tab( onpointermove: set_surface_drop_target_move, onpointerleave: clear_surface_drop_target, onpointerup: drop_surface, - span { class: "surface-tab-label", "{kind_label}" } + {match surface_kind { + SurfaceKind::Terminal => icons::terminal(10, "surface-tab-kind-icon"), + SurfaceKind::Browser => icons::globe(10, "surface-tab-kind-icon"), + }} span { class: "surface-tab-title", "{surface.title}" } } } diff --git a/crates/taskers-shell/src/theme.rs b/crates/taskers-shell/src/theme.rs index 6536f3d..ed1f978 100644 --- a/crates/taskers-shell/src/theme.rs +++ b/crates/taskers-shell/src/theme.rs @@ -1106,7 +1106,6 @@ input:focus-visible {{ }} .pane-toolbar-meta, -.surface-tab-label, .shortcut-label {{ color: {text_subtle}; font-size: 11px; @@ -1119,18 +1118,30 @@ input:focus-visible {{ min-width: 0; }} -.pane-toolbar-eyebrow {{ - text-transform: uppercase; - letter-spacing: 0.1em; - font-size: 10px; +.pane-toolbar-kind-icon {{ + flex: 0 0 auto; color: {text_dim}; - font-family: "IBM Plex Mono", ui-monospace, monospace; }} -.pane-toolbar-detail {{ - color: {text_subtle}; - font-size: 11px; +.pane-toolbar-title {{ + font-size: 12px; + font-weight: 500; + color: {text_bright}; white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +}} + +.pane-action-separator {{ + width: 1px; + height: 14px; + background: {border_10}; + margin: 0 2px; +}} + +.pane-utility-icon {{ + display: block; }} .pane-action-cluster {{ @@ -1210,6 +1221,18 @@ input:focus-visible {{ background: {overlay_16}; border-color: {accent_20}; color: {text_bright}; + position: relative; +}} + +.surface-tab-active::after {{ + content: ''; + position: absolute; + bottom: -1px; + left: 4px; + right: 4px; + height: 2px; + background: {accent}; + border-radius: 1px; }} .surface-tab-title {{ @@ -1225,11 +1248,13 @@ input:focus-visible {{ color: {text_bright}; }} -.surface-tab-label {{ - text-transform: uppercase; - letter-spacing: 0.08em; - font-size: 9px; - color: {text_dim}; +.surface-tab-kind-icon {{ + flex: 0 0 auto; + opacity: 0.5; +}} + +.surface-tab-active .surface-tab-kind-icon {{ + opacity: 0.8; }} .pane-utility {{ From c8f7a5bc99c81e778c1dc60bd042bebd115cc1d5 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sun, 22 Mar 2026 17:26:21 +0100 Subject: [PATCH 22/63] style: polish browser toolbar and surface backdrop --- crates/taskers-shell/src/lib.rs | 49 +++++++++++++++++++++---------- crates/taskers-shell/src/theme.rs | 12 ++++---- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/crates/taskers-shell/src/lib.rs b/crates/taskers-shell/src/lib.rs index f2dee56..35e0284 100644 --- a/crates/taskers-shell/src/lib.rs +++ b/crates/taskers-shell/src/lib.rs @@ -1676,28 +1676,47 @@ fn BrowserToolbar( class: "browser-toolbar-button", disabled: !can_go_back, onclick: go_back, - "←" + title: "Go back", + {icons::arrow_left(14, "browser-toolbar-icon")} } button { r#type: "button", class: "browser-toolbar-button", disabled: !can_go_forward, onclick: go_forward, - "→" + title: "Go forward", + {icons::arrow_right(14, "browser-toolbar-icon")} + } + button { + r#type: "button", + class: "browser-toolbar-button", + onclick: reload, + title: "Reload", + {icons::refresh(14, "browser-toolbar-icon")} } - button { r#type: "button", class: "browser-toolbar-button", onclick: reload, "↻" } input { class: "browser-address", r#type: "text", value: "{address}", + placeholder: "Enter URL...", oninput: move |event| address.set(event.value()), } - button { r#type: "submit", class: "browser-toolbar-button browser-toolbar-button-primary", "Go" } + button { + r#type: "submit", + class: "browser-toolbar-button browser-toolbar-button-primary", + title: "Navigate", + {icons::arrow_right_circle(14, "browser-toolbar-icon")} + } button { r#type: "button", class: "browser-toolbar-button", onclick: toggle_devtools, - "{devtools_label}" + title: "{devtools_label}", + if devtools_open { + {icons::eye_off(14, "browser-toolbar-icon")} + } else { + {icons::eye(14, "browser-toolbar-icon")} + } } } } @@ -1706,18 +1725,16 @@ fn BrowserToolbar( fn render_surface_backdrop(surface: &SurfaceSnapshot, runtime_status: &RuntimeStatus) -> Element { match surface.kind { SurfaceKind::Browser => { - let url = surface - .url - .clone() - .unwrap_or_else(|| taskers_core::DEFAULT_BROWSER_HOME.into()); rsx! { div { class: "surface-backdrop", div { class: "surface-backdrop-copy", - div { class: "surface-backdrop-eyebrow", "browser" } + {icons::globe(18, "surface-backdrop-icon")} div { class: "surface-backdrop-title", "{surface.title}" } } - div { class: "surface-meta", - span { class: "surface-chip", "URL: {url}" } + if let Some(url) = &surface.url { + div { class: "surface-meta", + span { class: "surface-chip", "{url}" } + } } } } @@ -1726,15 +1743,15 @@ fn render_surface_backdrop(surface: &SurfaceSnapshot, runtime_status: &RuntimeSt rsx! { div { class: "surface-backdrop", div { class: "surface-backdrop-copy", - div { class: "surface-backdrop-eyebrow", "terminal" } + {icons::terminal(18, "surface-backdrop-icon")} div { class: "surface-backdrop-title", "{surface.title}" } if let Some(message) = runtime_status.terminal_host.message() { div { class: "surface-backdrop-note", "{message}" } } } - div { class: "surface-meta", - if let Some(cwd) = &surface.cwd { - span { class: "surface-chip", "cwd: {cwd}" } + if let Some(cwd) = &surface.cwd { + div { class: "surface-meta", + span { class: "surface-chip", "{cwd}" } } } } diff --git a/crates/taskers-shell/src/theme.rs b/crates/taskers-shell/src/theme.rs index ed1f978..ead9973 100644 --- a/crates/taskers-shell/src/theme.rs +++ b/crates/taskers-shell/src/theme.rs @@ -1558,12 +1558,14 @@ input:focus-visible {{ gap: 6px; }} -.surface-backdrop-eyebrow {{ - font-size: 11px; - font-weight: 700; - letter-spacing: 0.10em; - text-transform: uppercase; +.surface-backdrop-icon {{ color: {text_dim}; + opacity: 0.6; + margin-bottom: 4px; +}} + +.browser-toolbar-icon {{ + display: block; }} .surface-backdrop-title {{ From e919e9bbd805c88d1bf27c8bfdfd1ba2b1eeb705 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sun, 22 Mar 2026 17:27:21 +0100 Subject: [PATCH 23/63] style: polish settings toggles and notification panel --- crates/taskers-shell/src/lib.rs | 16 +++++--- crates/taskers-shell/src/theme.rs | 66 ++++++++++++++++++++++++------- 2 files changed, 61 insertions(+), 21 deletions(-) diff --git a/crates/taskers-shell/src/lib.rs b/crates/taskers-shell/src/lib.rs index 35e0284..9e7bb89 100644 --- a/crates/taskers-shell/src/lib.rs +++ b/crates/taskers-shell/src/lib.rs @@ -445,7 +445,9 @@ pub fn TaskersShell(core: SharedCore) -> Element { div { class: "notification-timeline", if snapshot.activity.is_empty() && snapshot.done_activity.is_empty() { div { class: "notification-empty", + {icons::bell(24, "notification-empty-icon")} div { class: "notification-empty-title", "No notifications" } + div { class: "notification-empty-subtitle", "Activity and alerts will appear here." } } } else { for item in &snapshot.activity { @@ -1784,6 +1786,7 @@ fn render_agent_item( button { class: "activity-item-button", onclick: focus_target, div { class: "{row_class}", div { class: "activity-header", + {icons::terminal(12, "agent-kind-icon")} div { class: "workspace-label", "{agent.title}" } div { class: "activity-time", "{agent.state.label()}" } } @@ -1837,7 +1840,7 @@ fn render_notification_row( button { class: "notification-clear", onclick: dismiss, - "×" + {icons::close(12, "notification-clear-icon")} } } } @@ -2040,12 +2043,11 @@ fn render_notification_preference( enabled: !enabled, }) }; - let button_class = if enabled { - "settings-toggle-button settings-toggle-button-active" + let track_class = if enabled { + "toggle-track toggle-track-active" } else { - "settings-toggle-button" + "toggle-track" }; - let state_label = if enabled { "On" } else { "Off" }; rsx! { button { class: "settings-toggle-row", onclick: toggle, @@ -2053,7 +2055,9 @@ fn render_notification_preference( div { class: "workspace-label", "{label}" } div { class: "settings-copy", "{detail}" } } - span { class: "{button_class}", "{state_label}" } + div { class: "{track_class}", + div { class: "toggle-thumb" } + } } } } diff --git a/crates/taskers-shell/src/theme.rs b/crates/taskers-shell/src/theme.rs index ead9973..3c88954 100644 --- a/crates/taskers-shell/src/theme.rs +++ b/crates/taskers-shell/src/theme.rs @@ -697,22 +697,33 @@ input:focus-visible {{ gap: 4px; }} -.settings-toggle-button {{ - min-width: 42px; - padding: 4px 8px; - border: 1px solid {border_08}; - color: {text_dim}; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; - border-radius: 12px; +.toggle-track {{ + width: 36px; + height: 20px; + border-radius: 10px; + background: {border_10}; + position: relative; + transition: background 0.14s ease-in-out; + flex: 0 0 auto; }} -.settings-toggle-button-active {{ - border-color: {accent_24}; - background: {accent_12}; - color: {text_bright}; +.toggle-track-active {{ + background: {accent}; +}} + +.toggle-thumb {{ + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 9999px; + background: {text_bright}; + transition: left 0.14s ease-in-out; +}} + +.toggle-track-active .toggle-thumb {{ + left: 18px; }} .runtime-status-row {{ @@ -1585,12 +1596,22 @@ input:focus-visible {{ flex-wrap: wrap; }} -.surface-chip, +.surface-chip {{ + padding: 4px 8px; + color: {text_muted}; + font-size: 11px; + border-radius: 4px; +}} + .shortcut-pill {{ padding: 4px 8px; color: {text_muted}; font-size: 11px; border-radius: 4px; + background: {border_06}; + border: 1px solid {border_10}; + box-shadow: 0 1px 0 {border_08}; + font-family: "IBM Plex Mono", ui-monospace, monospace; }} .shortcut-pill-muted {{ @@ -1861,9 +1882,24 @@ input:focus-visible {{ display: flex; align-items: center; justify-content: center; + border-radius: 4px; transition: background 0.14s ease-in-out, color 0.14s ease-in-out; }} +.notification-clear-icon {{ + display: block; +}} + +.notification-empty-icon {{ + color: {text_dim}; + opacity: 0.4; +}} + +.agent-kind-icon {{ + flex: 0 0 auto; + color: {text_dim}; +}} + .notification-clear:hover {{ background: {error_16}; color: {error}; From 92019a9c9a7b0ddef74be89b998cb2f2144ca054 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sun, 22 Mar 2026 17:29:21 +0100 Subject: [PATCH 24/63] chore: remove unused icons and dead CSS cleanup --- crates/taskers-shell/src/icons.rs | 76 ------------------------------- crates/taskers-shell/src/theme.rs | 14 +----- 2 files changed, 1 insertion(+), 89 deletions(-) diff --git a/crates/taskers-shell/src/icons.rs b/crates/taskers-shell/src/icons.rs index 4da00d0..daba526 100644 --- a/crates/taskers-shell/src/icons.rs +++ b/crates/taskers-shell/src/icons.rs @@ -311,82 +311,6 @@ pub fn eye_off(size: u32, class: &str) -> Element { } } -/// Checkmark icon for completed state -pub fn check(size: u32, class: &str) -> Element { - rsx! { - svg { - class: "{class}", - width: "{size}", - height: "{size}", - view_box: "0 0 24 24", - fill: "none", - stroke: "currentColor", - stroke_width: "2", - stroke_linecap: "round", - stroke_linejoin: "round", - polyline { points: "20 6 9 17 4 12" } - } - } -} - -/// Warning triangle icon for error state -pub fn alert_triangle(size: u32, class: &str) -> Element { - rsx! { - svg { - class: "{class}", - width: "{size}", - height: "{size}", - view_box: "0 0 24 24", - fill: "none", - stroke: "currentColor", - stroke_width: "2", - stroke_linecap: "round", - stroke_linejoin: "round", - path { d: "M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" } - line { x1: "12", y1: "9", x2: "12", y2: "13" } - line { x1: "12", y1: "17", x2: "12.01", y2: "17" } - } - } -} - -/// External link icon (box with arrow) -pub fn external_link(size: u32, class: &str) -> Element { - rsx! { - svg { - class: "{class}", - width: "{size}", - height: "{size}", - view_box: "0 0 24 24", - fill: "none", - stroke: "currentColor", - stroke_width: "2", - stroke_linecap: "round", - stroke_linejoin: "round", - path { d: "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" } - polyline { points: "15 3 21 3 21 9" } - line { x1: "10", y1: "14", x2: "21", y2: "3" } - } - } -} - -/// Right chevron for breadcrumb/navigation -pub fn chevron_right(size: u32, class: &str) -> Element { - rsx! { - svg { - class: "{class}", - width: "{size}", - height: "{size}", - view_box: "0 0 24 24", - fill: "none", - stroke: "currentColor", - stroke_width: "2", - stroke_linecap: "round", - stroke_linejoin: "round", - polyline { points: "9 18 15 12 9 6" } - } - } -} - /// Network nodes icon for listening ports pub fn network(size: u32, class: &str) -> Element { rsx! { diff --git a/crates/taskers-shell/src/theme.rs b/crates/taskers-shell/src/theme.rs index 3c88954..c906ee2 100644 --- a/crates/taskers-shell/src/theme.rs +++ b/crates/taskers-shell/src/theme.rs @@ -266,13 +266,6 @@ input:focus-visible {{ gap: 4px; }} -.sidebar-brand h1 {{ - margin: 0; - font-size: 24px; - line-height: 1; - color: {text_bright}; -}} - .sidebar-brand-wordmark {{ margin: 0; font-size: 13px; @@ -1290,10 +1283,6 @@ input:focus-visible {{ box-shadow: 0 1px 2px rgba(0,0,0,0.20); }} -.pane-utility-split {{ - color: {text_subtle}; -}} - .workspace-window-drop-zone {{ position: absolute; z-index: 12; @@ -1541,8 +1530,7 @@ input:focus-visible {{ padding: 0 6px; }} -.workspace-main-overview .surface-tab-title, -.workspace-main-overview .surface-tab-label {{ +.workspace-main-overview .surface-tab-title {{ font-size: 10px; }} From 2e3914c032b8ba24426a8950e1781ad18f401b9c Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sun, 22 Mar 2026 17:56:42 +0100 Subject: [PATCH 25/63] feat: simplify pane and window chrome --- crates/taskers-shell-core/src/lib.rs | 311 ++++++++++++++++++++++++--- crates/taskers-shell/src/lib.rs | 125 +++++++---- crates/taskers-shell/src/theme.rs | 47 ++-- 3 files changed, 401 insertions(+), 82 deletions(-) diff --git a/crates/taskers-shell-core/src/lib.rs b/crates/taskers-shell-core/src/lib.rs index a900bb2..27fcf7e 100644 --- a/crates/taskers-shell-core/src/lib.rs +++ b/crates/taskers-shell-core/src/lib.rs @@ -627,7 +627,7 @@ impl Default for LayoutMetrics { toolbar_height: 42, workspace_padding: 16, window_border_width: 2, - window_toolbar_height: 28, + window_toolbar_height: 20, window_body_padding: 0, split_gap: 8, pane_border_width: 1, @@ -1751,7 +1751,12 @@ impl TaskersCore { pane_id: pane.id, surface_id: active_surface.id, active: workspace.active_pane == pane.id, - frame: pane_body_frame(frame, self.metrics, &active_surface.kind), + frame: pane_body_frame( + frame, + self.metrics, + &active_surface.kind, + pane_shows_tab_strip_for_surface_count(pane.surfaces.len()), + ), mount: self.mount_spec_for_active_surface( workspace_id, pane, @@ -3460,14 +3465,24 @@ fn split_frame(frame: Frame, axis: SplitAxis, ratio: u16, gap: i32) -> (Frame, F } } -fn pane_body_frame(frame: Frame, metrics: LayoutMetrics, kind: &PaneKind) -> Frame { +fn pane_body_frame( + frame: Frame, + metrics: LayoutMetrics, + kind: &PaneKind, + show_tab_strip: bool, +) -> Frame { let browser_toolbar_height = match kind { PaneKind::Terminal => 0, PaneKind::Browser => metrics.browser_toolbar_height, }; + let tab_strip_height = if show_tab_strip { + metrics.surface_tab_height + } else { + 0 + }; frame .inset(metrics.pane_border_width) - .inset_top(metrics.pane_header_height + metrics.surface_tab_height + browser_toolbar_height) + .inset_top(metrics.pane_header_height + tab_strip_height + browser_toolbar_height) } fn workspace_window_content_frame(frame: Frame, metrics: LayoutMetrics) -> Frame { @@ -3539,31 +3554,89 @@ fn window_primary_title( } fn display_surface_title(surface: &SurfaceRecord) -> String { - if let Some(title) = surface - .metadata + match surface.kind { + PaneKind::Terminal => display_terminal_title(&surface.metadata), + PaneKind::Browser => display_browser_title(&surface.metadata), + } +} + +fn display_terminal_title(metadata: &PaneMetadata) -> String { + let agent_title = metadata + .agent_title + .as_deref() + .map(str::trim) + .filter(|title| !title.is_empty()); + let context = terminal_context_label(metadata); + + if let Some(agent_title) = agent_title { + if let Some(context) = context.as_deref() { + return format!("{agent_title} · {context}"); + } + return agent_title.to_string(); + } + + if let Some(context) = context { + return context; + } + + if let Some(title) = metadata .title .as_deref() .map(str::trim) .filter(|title| !title.is_empty()) + .filter(|title| !is_generic_terminal_title(title)) { return title.to_string(); } - if matches!(surface.kind, PaneKind::Browser) - && let Some(url) = surface - .metadata - .url - .as_deref() - .map(str::trim) - .filter(|url| !url.is_empty()) + "Terminal".into() +} + +fn display_browser_title(metadata: &PaneMetadata) -> String { + if let Some(title) = metadata + .title + .as_deref() + .map(str::trim) + .filter(|title| !title.is_empty()) + { + return title.to_string(); + } + + if let Some(url) = metadata + .url + .as_deref() + .map(str::trim) + .filter(|url| !url.is_empty()) { return url.to_string(); } - match surface.kind { - PaneKind::Terminal => "Terminal".into(), - PaneKind::Browser => "Browser".into(), + "Browser".into() +} + +fn terminal_context_label(metadata: &PaneMetadata) -> Option { + let repo_name = metadata + .repo_name + .as_deref() + .map(str::trim) + .filter(|repo| !repo.is_empty()); + let git_branch = metadata + .git_branch + .as_deref() + .map(str::trim) + .filter(|branch| !branch.is_empty()); + + if let Some(repo_name) = repo_name { + return Some(match git_branch { + Some(git_branch) => format!("{repo_name}/{git_branch}"), + None => repo_name.to_string(), + }); } + + normalized_cwd(metadata) + .as_deref() + .and_then(path_basename) + .map(str::to_string) } fn normalized_surface_url(surface: &SurfaceRecord) -> Option { @@ -3585,6 +3658,60 @@ fn normalized_cwd(metadata: &PaneMetadata) -> Option { .map(str::to_string) } +fn path_basename(path: &str) -> Option<&str> { + let trimmed = path.trim().trim_end_matches(|ch| matches!(ch, '/' | '\\')); + if trimmed.is_empty() { + return None; + } + + trimmed + .rsplit(|ch| matches!(ch, '/' | '\\')) + .find(|segment| !segment.is_empty()) +} + +fn is_generic_terminal_title(title: &str) -> bool { + let trimmed = title.trim(); + if trimmed.is_empty() { + return true; + } + + let mut parts = trimmed.split_whitespace(); + let command = parts.next().unwrap_or_default(); + if !parts.all(|part| part.starts_with('-')) { + return false; + } + + let basename = command + .rsplit(|ch| matches!(ch, '/' | '\\')) + .next() + .unwrap_or(command) + .trim() + .to_ascii_lowercase(); + + matches!( + basename.as_str(), + "sh" | "bash" + | "zsh" + | "fish" + | "nu" + | "nushell" + | "dash" + | "ash" + | "ksh" + | "mksh" + | "pwsh" + | "powershell" + | "cmd" + | "cmd.exe" + | "xonsh" + | "elvish" + ) +} + +fn pane_shows_tab_strip_for_surface_count(surface_count: usize) -> bool { + surface_count > 1 +} + fn format_relative_time(timestamp: OffsetDateTime) -> String { let now = OffsetDateTime::now_utc(); let delta = now - timestamp; @@ -3779,9 +3906,7 @@ fn mount_spec_from_descriptor( .unwrap_or_else(|| DEFAULT_BROWSER_HOME.into()), }), PaneKind::Terminal => SurfaceMountSpec::Terminal(TerminalMountSpec { - title: descriptor - .title - .unwrap_or_else(|| display_surface_title(surface)), + title: display_surface_title(surface), cwd: descriptor.cwd, cols: descriptor.cols, rows: descriptor.rows, @@ -3866,7 +3991,8 @@ mod tests { LayoutMetrics, NotificationPreferencesSnapshot, RuntimeCapability, RuntimeStatus, SharedCore, ShellAction, ShellDragMode, ShellSection, SurfaceDragSessionSnapshot, SurfaceMountSpec, WorkspaceDirection, default_preview_app_state, - default_session_path_for_preview, pane_body_frame, resolved_browser_uri, split_frame, + default_session_path_for_preview, display_surface_title, pane_body_frame, + pane_shows_tab_strip_for_surface_count, resolved_browser_uri, split_frame, workspace_window_content_frame, }; @@ -3985,6 +4111,87 @@ mod tests { } } + fn surface_with_metadata( + kind: taskers_domain::PaneKind, + metadata: taskers_domain::PaneMetadata, + ) -> taskers_domain::SurfaceRecord { + let mut surface = taskers_domain::SurfaceRecord::new(kind); + surface.metadata = metadata; + surface + } + + #[test] + fn terminal_surface_titles_prefer_agent_and_repo_context() { + let surface = surface_with_metadata( + taskers_domain::PaneKind::Terminal, + taskers_domain::PaneMetadata { + title: Some("/usr/bin/zsh".into()), + agent_title: Some("Codex".into()), + repo_name: Some("taskers".into()), + git_branch: Some("main".into()), + ..taskers_domain::PaneMetadata::default() + }, + ); + + assert_eq!(display_surface_title(&surface), "Codex · taskers/main"); + } + + #[test] + fn terminal_surface_titles_prefer_repo_context_over_generic_shell_names() { + let surface = surface_with_metadata( + taskers_domain::PaneKind::Terminal, + taskers_domain::PaneMetadata { + title: Some("/usr/bin/zsh".into()), + repo_name: Some("taskers".into()), + git_branch: Some("main".into()), + ..taskers_domain::PaneMetadata::default() + }, + ); + + assert_eq!(display_surface_title(&surface), "taskers/main"); + } + + #[test] + fn terminal_surface_titles_fall_back_to_cwd_basename_before_generic_shell_names() { + let surface = surface_with_metadata( + taskers_domain::PaneKind::Terminal, + taskers_domain::PaneMetadata { + title: Some("zsh".into()), + cwd: Some("/home/notes/Projects/taskers".into()), + ..taskers_domain::PaneMetadata::default() + }, + ); + + assert_eq!(display_surface_title(&surface), "taskers"); + } + + #[test] + fn terminal_surface_titles_keep_non_generic_host_titles_as_fallback() { + let surface = surface_with_metadata( + taskers_domain::PaneKind::Terminal, + taskers_domain::PaneMetadata { + title: Some("OpenAI Codex".into()), + ..taskers_domain::PaneMetadata::default() + }, + ); + + assert_eq!(display_surface_title(&surface), "OpenAI Codex"); + } + + #[test] + fn browser_surface_titles_stay_page_title_first() { + let surface = surface_with_metadata( + taskers_domain::PaneKind::Browser, + taskers_domain::PaneMetadata { + title: Some("Taskers Docs".into()), + url: Some("https://example.com/docs".into()), + ..taskers_domain::PaneMetadata::default() + }, + ); + + assert_eq!(display_surface_title(&surface), "Taskers Docs"); + } + #[test] fn default_bootstrap_projects_browser_and_terminal_portal_plans() { let core = SharedCore::bootstrap(bootstrap()); @@ -4012,10 +4219,8 @@ mod tests { let core = SharedCore::bootstrap(bootstrap()); let snapshot = core.snapshot(); let metrics = LayoutMetrics::default(); - let min_content_y = snapshot.portal.content.y - + metrics.window_toolbar_height - + metrics.pane_header_height - + metrics.surface_tab_height; + let min_content_y = + snapshot.portal.content.y + metrics.window_toolbar_height + metrics.pane_header_height; assert!( snapshot @@ -4056,14 +4261,70 @@ mod tests { SurfaceMountSpec::Browser(_) => taskers_domain::PaneKind::Browser, SurfaceMountSpec::Terminal(_) => taskers_domain::PaneKind::Terminal, }; + let pane = find_pane(&workspace.layout, workspace.active_pane).expect("active pane"); assert_eq!( active_plan.frame, - pane_body_frame(pane_frame, metrics, &pane_kind), + pane_body_frame( + pane_frame, + metrics, + &pane_kind, + pane_shows_tab_strip_for_surface_count(pane.surfaces.len()), + ), "expected native surface frame to match shell pane-body insets" ); } + #[test] + fn multi_surface_pane_frames_include_tab_strip_height() { + let core = SharedCore::bootstrap(bootstrap()); + let pane_id = core.snapshot().current_workspace.active_pane; + + core.dispatch_shell_action(ShellAction::AddTerminalSurface { + pane_id: Some(pane_id), + }); + + let snapshot = core.snapshot(); + let workspace = &snapshot.current_workspace; + let metrics = LayoutMetrics::default(); + let active_window = workspace + .columns + .iter() + .flat_map(|column| column.windows.iter()) + .find(|window| window.id == workspace.active_window_id) + .expect("active window"); + let active_plan = snapshot + .portal + .panes + .iter() + .find(|plan| plan.pane_id == pane_id) + .expect("active portal plan"); + let pane_frame = find_pane_frame( + &active_window.layout, + pane_id, + workspace_window_content_frame(active_window.frame, metrics), + metrics.split_gap, + ) + .expect("active pane frame"); + let pane_kind = match &active_plan.mount { + SurfaceMountSpec::Browser(_) => taskers_domain::PaneKind::Browser, + SurfaceMountSpec::Terminal(_) => taskers_domain::PaneKind::Terminal, + }; + let pane = find_pane(&workspace.layout, pane_id).expect("active pane"); + + assert_eq!(pane.surfaces.len(), 2); + assert_eq!( + active_plan.frame, + pane_body_frame( + pane_frame, + metrics, + &pane_kind, + pane_shows_tab_strip_for_surface_count(pane.surfaces.len()), + ), + "expected multi-surface panes to reserve tab-strip height" + ); + } + #[test] fn split_browser_creates_real_browser_pane() { let core = SharedCore::bootstrap(bootstrap()); diff --git a/crates/taskers-shell/src/lib.rs b/crates/taskers-shell/src/lib.rs index 9e7bb89..e587eef 100644 --- a/crates/taskers-shell/src/lib.rs +++ b/crates/taskers-shell/src/lib.rs @@ -106,6 +106,10 @@ fn pane_allows_surface_split( dragged.is_some_and(|dragged| dragged.pane_id != pane_id || surface_count > 1) } +fn pane_shows_tab_strip(surface_count: usize) -> bool { + surface_count > 1 +} + fn show_live_surface_backdrop(surface_kind: SurfaceKind, overview_mode: bool) -> bool { overview_mode || !matches!(surface_kind, SurfaceKind::Browser) } @@ -128,6 +132,27 @@ fn pointer_client_position(event: &Event) -> (f64, f64) { (position.x, position.y) } +fn surface_drag_candidate_from_event( + event: &Event, + workspace_id: WorkspaceId, + pane_id: PaneId, + surface_id: SurfaceId, +) -> Option { + if event.data().trigger_button() != Some(MouseButton::Primary) { + return None; + } + + let (start_x, start_y) = pointer_client_position(event); + event.stop_propagation(); + Some(SurfaceDragCandidate { + workspace_id, + pane_id, + surface_id, + start_x, + start_y, + }) +} + fn surface_drag_threshold_reached( candidate: SurfaceDragCandidate, current_x: f64, @@ -1074,9 +1099,7 @@ fn render_workspace_window( onclick: focus_window, ondragstart: start_window_drag, ondragend: clear_window_drag, - div { class: "workspace-window-title", - span { class: "workspace-label", "{window.title}" } - } + div { class: "workspace-window-grip" } } div { class: "workspace-window-body", {render_layout( @@ -1158,7 +1181,7 @@ fn render_pane( core: SharedCore, runtime_status: &RuntimeStatus, surface_drop_target: Signal>, - surface_drag_candidate: Signal>, + mut surface_drag_candidate: Signal>, dragged_surface: Option, ) -> Element { let pane_id = pane.id; @@ -1210,6 +1233,7 @@ fn render_pane( .map(|url| format!("{}-{}", active_surface.id, url)) }) .unwrap_or_else(|| active_surface.id.to_string()); + let show_tab_strip = pane_shows_tab_strip(pane.surfaces.len()); let pane_allows_split = pane_allows_surface_split(dragged_surface, pane_id, pane.surfaces.len()); let surface_drag_active = dragged_surface.is_some(); @@ -1263,6 +1287,13 @@ fn render_pane( }) } }; + let begin_active_surface_drag_candidate = move |event: Event| { + if let Some(candidate) = + surface_drag_candidate_from_event(&event, workspace_id, pane_id, active_surface_id) + { + surface_drag_candidate.set(Some(candidate)); + } + }; let flash_key = pane.focus_flash_token; let flash_class = if flash_key > 0 { @@ -1279,12 +1310,23 @@ fn render_pane( rsx! { section { class: "{pane_class}", onclick: focus_pane, div { class: "pane-toolbar", - div { class: "pane-toolbar-meta", - {match active_surface.kind { - SurfaceKind::Terminal => icons::terminal(14, "pane-toolbar-kind-icon"), - SurfaceKind::Browser => icons::globe(14, "pane-toolbar-kind-icon"), - }} - span { class: "pane-toolbar-title", "{active_surface.title}" } + if show_tab_strip { + div { class: "pane-toolbar-meta", + {match active_surface.kind { + SurfaceKind::Terminal => icons::terminal(14, "pane-toolbar-kind-icon"), + SurfaceKind::Browser => icons::globe(14, "pane-toolbar-kind-icon"), + }} + } + } else { + div { + class: "pane-toolbar-meta pane-toolbar-meta-draggable", + title: "Drag surface", + onpointerdown: begin_active_surface_drag_candidate, + {match active_surface.kind { + SurfaceKind::Terminal => icons::terminal(14, "pane-toolbar-kind-icon"), + SurfaceKind::Browser => icons::globe(14, "pane-toolbar-kind-icon"), + }} + } } div { class: "pane-action-cluster", button { class: "pane-utility", title: "New terminal tab", onclick: add_terminal_surface, @@ -1306,29 +1348,31 @@ fn render_pane( } } } - div { class: "pane-tabs", - div { class: "surface-tabs", - for surface in &pane.surfaces { - {render_surface_tab( - workspace_id, - pane.id, - pane.active_surface, - surface, - core.clone(), - surface_drop_target, - surface_drag_candidate, - dragged_surface, - &ordered_surface_ids, - )} - } - if surface_drag_active { - {render_surface_pane_drop_target( - "surface-tab surface-tab-append-target", - "+", - SurfaceDropTarget::AppendToPane { pane_id }, - core.clone(), - surface_drop_target, - )} + if show_tab_strip { + div { class: "pane-tabs", + div { class: "surface-tabs", + for surface in &pane.surfaces { + {render_surface_tab( + workspace_id, + pane.id, + pane.active_surface, + surface, + core.clone(), + surface_drop_target, + surface_drag_candidate, + dragged_surface, + &ordered_surface_ids, + )} + } + if surface_drag_active { + {render_surface_pane_drop_target( + "surface-tab surface-tab-append-target", + "+", + SurfaceDropTarget::AppendToPane { pane_id }, + core.clone(), + surface_drop_target, + )} + } } } } @@ -1514,18 +1558,11 @@ fn render_surface_tab( }); }; let begin_surface_drag_candidate = move |event: Event| { - if event.data().trigger_button() != Some(MouseButton::Primary) { - return; + if let Some(candidate) = + surface_drag_candidate_from_event(&event, workspace_id, pane_id, surface_id) + { + surface_drag_candidate.set(Some(candidate)); } - let (start_x, start_y) = pointer_client_position(&event); - event.stop_propagation(); - surface_drag_candidate.set(Some(SurfaceDragCandidate { - workspace_id, - pane_id, - surface_id, - start_x, - start_y, - })); }; let set_surface_drop_target_enter = { let core = core.clone(); diff --git a/crates/taskers-shell/src/theme.rs b/crates/taskers-shell/src/theme.rs index c906ee2..edbf140 100644 --- a/crates/taskers-shell/src/theme.rs +++ b/crates/taskers-shell/src/theme.rs @@ -1008,23 +1008,31 @@ input:focus-visible {{ .workspace-window-toolbar {{ height: {window_toolbar_height}px; min-height: {window_toolbar_height}px; - border-bottom: 1px solid {border_10}; - background: {surface}; - padding: 0 10px; + border-bottom: 1px solid {border_06}; + background: linear-gradient(180deg, {overlay_05} 0%, {overlay_03} 100%); + padding: 0 8px; display: flex; align-items: center; - justify-content: flex-start; - gap: 6px; + justify-content: center; cursor: grab; user-select: none; border-radius: 8px 8px 0 0; }} -.workspace-window-title {{ - color: {text_bright}; - min-width: 0; - font-size: 12px; - font-weight: 600; +.workspace-window-grip {{ + width: 38px; + height: 4px; + border-radius: 999px; + background: {border_10}; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.04); +}} + +.workspace-window-toolbar:hover .workspace-window-grip {{ + background: {text_dim}; +}} + +.workspace-window-shell-active .workspace-window-grip {{ + background: {accent_24}; }} .workspace-window-toolbar:active {{ @@ -1118,10 +1126,19 @@ input:focus-visible {{ .pane-toolbar-meta {{ display: flex; align-items: center; - gap: 8px; + gap: 6px; min-width: 0; }} +.pane-toolbar-meta-draggable {{ + cursor: grab; + padding: 0 2px; +}} + +.pane-toolbar-meta-draggable:active {{ + cursor: grabbing; +}} + .pane-toolbar-kind-icon {{ flex: 0 0 auto; color: {text_dim}; @@ -1496,11 +1513,15 @@ input:focus-visible {{ }} .workspace-main-overview .workspace-window-toolbar {{ - min-height: 32px; - padding: 0 8px; + min-height: {window_toolbar_height}px; + padding: 0 6px; background: {surface_85}; }} +.workspace-main-overview .workspace-window-grip {{ + width: 28px; +}} + .workspace-main-overview .workspace-window-body {{ padding: 8px; background: {border_03}; From 4e6eb5474bc3072d2b0117e31ac4ac4aa0a8cdc3 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sun, 22 Mar 2026 19:18:21 +0100 Subject: [PATCH 26/63] fix: restore settings scrolling --- crates/taskers-shell/src/theme.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/taskers-shell/src/theme.rs b/crates/taskers-shell/src/theme.rs index edbf140..2649516 100644 --- a/crates/taskers-shell/src/theme.rs +++ b/crates/taskers-shell/src/theme.rs @@ -929,6 +929,9 @@ input:focus-visible {{ .settings-canvas {{ padding: 16px; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; }} .workspace-viewport {{ From 65a46e8d3be4f6d389fa5a8e67eb91a56572a15c Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sun, 22 Mar 2026 20:32:13 +0100 Subject: [PATCH 27/63] refactor(domain): separate agent-hook notification context from terminal completion alerts --- crates/taskers-cli/src/main.rs | 80 +------ crates/taskers-domain/src/model.rs | 348 +++++++++++++++++++++++++---- 2 files changed, 312 insertions(+), 116 deletions(-) diff --git a/crates/taskers-cli/src/main.rs b/crates/taskers-cli/src/main.rs index b3348d4..ecc97b5 100644 --- a/crates/taskers-cli/src/main.rs +++ b/crates/taskers-cli/src/main.rs @@ -2839,86 +2839,26 @@ async fn emit_agent_hook( }, ) .await?; - if normalized_message.is_none() { - let target_surface_id = - surface_id - .or_else(env_surface_id) - .unwrap_or(active_surface_for_pane( - &query_model(&client).await?, - workspace_id, - pane_id, - )?); - let kind = if matches!(kind, CliSignalKind::WaitingInput) { - SignalKind::WaitingInput - } else { - SignalKind::Notification - }; - let state = if matches!(kind, SignalKind::WaitingInput) { - AttentionState::WaitingInput - } else { - AttentionState::WaitingInput - }; + } + CliSignalKind::Completed | CliSignalKind::Error => { + if matches!(kind, CliSignalKind::Completed) { let _ = send_control_command( &client, - ControlCommand::AgentCreateNotification { - target: AgentTarget::Surface { - workspace_id, - pane_id, - surface_id: target_surface_id, - }, - kind, - title: Some(normalized_title.clone()), - subtitle: None, - external_id: None, - message: status_text.clone(), - state, - }, + ControlCommand::AgentClearStatus { workspace_id }, ) .await?; - } - } - CliSignalKind::Completed | CliSignalKind::Error => { - let signal_kind = if matches!(kind, CliSignalKind::Completed) { - SignalKind::Completed - } else { - SignalKind::Error - }; - let state = if matches!(signal_kind, SignalKind::Completed) { - AttentionState::Completed - } else { - AttentionState::Error - }; - if normalized_message.is_none() { - let target_surface_id = - surface_id - .or_else(env_surface_id) - .unwrap_or(active_surface_for_pane( - &query_model(&client).await?, - workspace_id, - pane_id, - )?); let _ = send_control_command( &client, - ControlCommand::AgentCreateNotification { - target: AgentTarget::Surface { - workspace_id, - pane_id, - surface_id: target_surface_id, - }, - kind: signal_kind.clone(), - title: Some(normalized_title.clone()), - subtitle: None, - external_id: None, - message: status_text.clone(), - state, - }, + ControlCommand::AgentClearProgress { workspace_id }, ) .await?; - } - if matches!(signal_kind, SignalKind::Completed) { + } else { let _ = send_control_command( &client, - ControlCommand::AgentClearStatus { workspace_id }, + ControlCommand::AgentSetStatus { + workspace_id, + text: status_text, + }, ) .await?; let _ = send_control_command( diff --git a/crates/taskers-domain/src/model.rs b/crates/taskers-domain/src/model.rs index 6515170..cd75ffe 100644 --- a/crates/taskers-domain/src/model.rs +++ b/crates/taskers-domain/src/model.rs @@ -7,7 +7,8 @@ use time::{Duration, OffsetDateTime}; use crate::{ AttentionState, Direction, LayoutNode, NotificationId, PaneId, SessionId, SignalEvent, - SignalKind, SplitAxis, SurfaceId, WindowId, WorkspaceColumnId, WorkspaceId, WorkspaceWindowId, + SignalKind, SignalPaneMetadata, SplitAxis, SurfaceId, WindowId, WorkspaceColumnId, + WorkspaceId, WorkspaceWindowId, }; pub const SESSION_SCHEMA_VERSION: u32 = 4; @@ -174,6 +175,10 @@ pub struct PaneMetadata { pub agent_kind: Option, #[serde(default)] pub agent_active: bool, + #[serde(default)] + pub agent_state: Option, + #[serde(default)] + pub latest_agent_message: Option, pub last_signal_at: Option, #[serde(default)] pub progress: Option, @@ -404,7 +409,8 @@ pub enum NotificationDeliveryState { pub enum WorkspaceAgentState { Working, Waiting, - Inactive, + Completed, + Failed, } impl WorkspaceAgentState { @@ -412,7 +418,8 @@ impl WorkspaceAgentState { match self { Self::Working => "Working", Self::Waiting => "Waiting", - Self::Inactive => "Inactive", + Self::Completed => "Completed", + Self::Failed => "Failed", } } @@ -420,7 +427,8 @@ impl WorkspaceAgentState { match self { Self::Waiting => 0, Self::Working => 1, - Self::Inactive => 2, + Self::Failed => 2, + Self::Completed => 3, } } } @@ -1663,6 +1671,7 @@ impl AppModel { surface.attention = AttentionState::Completed; surface.metadata.agent_active = false; + surface.metadata.agent_state = Some(WorkspaceAgentState::Completed); surface.metadata.last_signal_at = Some(OffsetDateTime::now_utc()); workspace.complete_surface_notifications(pane_id, surface_id); Ok(()) @@ -2033,6 +2042,13 @@ impl AppModel { surface_id: SurfaceId, event: SignalEvent, ) -> Result<(), DomainError> { + let SignalEvent { + source, + kind, + message, + metadata, + timestamp, + } = event; let workspace = self .workspaces .get_mut(&workspace_id) @@ -2053,20 +2069,18 @@ impl AppModel { surface_id, })?; - let notification_title = event.metadata.as_ref().and_then(|metadata| { - metadata - .agent_title - .clone() - .or_else(|| metadata.title.clone()) - }); - let metadata_reported_inactive = event - .metadata + let agent_signal = is_agent_signal(surface, &source, metadata.as_ref()); + let normalized_message = normalized_signal_message(message.as_deref()); + let metadata_reported_inactive = metadata .as_ref() .and_then(|metadata| metadata.agent_active) .is_some_and(|active| !active); let (surface_attention, should_acknowledge_surface_notifications) = { let mut acknowledged_inactive_resolution = false; - if let Some(metadata) = event.metadata { + if agent_signal && matches!(kind, SignalKind::Started) { + surface.metadata.latest_agent_message = None; + } + if let Some(metadata) = metadata { surface.metadata.title = metadata.title; if metadata.agent_title.is_some() { surface.metadata.agent_title = metadata.agent_title; @@ -2080,44 +2094,57 @@ impl AppModel { surface.metadata.agent_active = agent_active; } } - if !matches!(event.kind, SignalKind::Metadata) { - surface.metadata.last_signal_at = Some(event.timestamp); - surface.attention = map_signal_to_attention(&event.kind); - if let Some(agent_active) = signal_agent_active(&event.kind) { + if agent_signal { + if let Some(agent_state) = signal_agent_state(&kind) { + surface.metadata.agent_state = Some(agent_state); + } + if let Some(message) = normalized_message.as_ref() { + surface.metadata.latest_agent_message = Some(message.clone()); + } + } + if !matches!(kind, SignalKind::Metadata) { + surface.metadata.last_signal_at = Some(timestamp); + surface.attention = map_signal_to_attention(&kind); + if let Some(agent_active) = signal_agent_active(&kind) { surface.metadata.agent_active = agent_active; } } else if metadata_reported_inactive && matches!( - surface.attention, - AttentionState::Busy | AttentionState::WaitingInput + surface.metadata.agent_state, + Some(WorkspaceAgentState::Working | WorkspaceAgentState::Waiting) ) { surface.attention = AttentionState::Completed; - surface.metadata.last_signal_at = Some(event.timestamp); + surface.metadata.agent_state = Some(WorkspaceAgentState::Completed); + surface.metadata.last_signal_at = Some(timestamp); acknowledged_inactive_resolution = true; } (surface.attention, acknowledged_inactive_resolution) }; + let notification_title = surface_notification_title(surface); + let notification_message = if signal_creates_notification(&source, &kind) { + notification_message_for_signal(&kind, normalized_message, ¬ification_title, surface) + } else { + None + }; if should_acknowledge_surface_notifications { workspace.complete_surface_notifications(pane_id, surface_id); } - if signal_creates_notification(&event.kind) - && let Some(message) = event.message - { + if let Some(message) = notification_message { workspace.upsert_notification(NotificationItem { id: NotificationId::new(), pane_id, surface_id, - kind: event.kind, + kind, state: surface_attention, title: notification_title, subtitle: None, external_id: None, message, - created_at: event.timestamp, + created_at: timestamp, read_at: None, cleared_at: None, desktop_delivery: NotificationDeliveryState::Pending, @@ -3123,14 +3150,10 @@ impl CurrentWorkspaceSerde { const RECENT_INACTIVE_AGENT_RETENTION: Duration = Duration::minutes(15); -fn signal_creates_notification(kind: &SignalKind) -> bool { +fn signal_kind_creates_notification(kind: &SignalKind) -> bool { matches!( kind, - SignalKind::Started - | SignalKind::Completed - | SignalKind::WaitingInput - | SignalKind::Error - | SignalKind::Notification + SignalKind::Completed | SignalKind::WaitingInput | SignalKind::Error ) } @@ -3140,6 +3163,61 @@ fn is_agent_kind(agent_kind: Option<&str>) -> bool { .is_some_and(|agent| !agent.is_empty() && agent != "shell") } +fn is_agent_hook_source(source: &str) -> bool { + source.trim().starts_with("agent-hook:") +} + +fn is_agent_signal( + surface: &SurfaceRecord, + source: &str, + metadata: Option<&SignalPaneMetadata>, +) -> bool { + is_agent_hook_source(source) + || is_agent_kind(metadata.and_then(|metadata| metadata.agent_kind.as_deref())) + || is_agent_kind(surface.metadata.agent_kind.as_deref()) +} + +fn normalized_signal_message(message: Option<&str>) -> Option { + message + .map(str::trim) + .filter(|message| !message.is_empty()) + .map(str::to_owned) +} + +fn surface_notification_title(surface: &SurfaceRecord) -> Option { + surface + .metadata + .agent_title + .as_deref() + .or(surface.metadata.title.as_deref()) + .map(str::trim) + .filter(|title| !title.is_empty()) + .map(str::to_owned) +} + +fn notification_message_for_signal( + kind: &SignalKind, + explicit_message: Option, + notification_title: &Option, + surface: &SurfaceRecord, +) -> Option { + match kind { + SignalKind::Metadata | SignalKind::Started | SignalKind::Progress => None, + SignalKind::Notification => explicit_message.or_else(|| notification_title.clone()), + SignalKind::WaitingInput => explicit_message.or_else(|| notification_title.clone()), + SignalKind::Completed | SignalKind::Error => explicit_message + .or_else(|| surface.metadata.latest_agent_message.clone()) + .or_else(|| notification_title.clone()), + } +} + +fn signal_creates_notification(source: &str, kind: &SignalKind) -> bool { + match kind { + SignalKind::Notification => !is_agent_hook_source(source), + _ => signal_kind_creates_notification(kind), + } +} + fn recent_inactive_cutoff(now: OffsetDateTime) -> OffsetDateTime { now - RECENT_INACTIVE_AGENT_RETENTION } @@ -3152,22 +3230,34 @@ fn workspace_agent_state( return None; } - if !surface.metadata.agent_active { - return surface - .metadata - .last_signal_at - .filter(|timestamp| *timestamp >= recent_inactive_cutoff(now)) - .map(|_| WorkspaceAgentState::Inactive); - } + let state = surface.metadata.agent_state.or_else(|| { + if surface.metadata.agent_active { + match surface.attention { + AttentionState::Busy => Some(WorkspaceAgentState::Working), + AttentionState::WaitingInput => Some(WorkspaceAgentState::Waiting), + AttentionState::Completed => Some(WorkspaceAgentState::Completed), + AttentionState::Error => Some(WorkspaceAgentState::Failed), + AttentionState::Normal => None, + } + } else { + match surface.attention { + AttentionState::Completed => Some(WorkspaceAgentState::Completed), + AttentionState::Error => Some(WorkspaceAgentState::Failed), + AttentionState::Busy | AttentionState::WaitingInput => { + Some(WorkspaceAgentState::Completed) + } + AttentionState::Normal => None, + } + } + })?; - match surface.attention { - AttentionState::Busy => Some(WorkspaceAgentState::Working), - AttentionState::WaitingInput => Some(WorkspaceAgentState::Waiting), - AttentionState::Completed | AttentionState::Error | AttentionState::Normal => surface + match state { + WorkspaceAgentState::Working | WorkspaceAgentState::Waiting => Some(state), + WorkspaceAgentState::Completed | WorkspaceAgentState::Failed => surface .metadata .last_signal_at .filter(|timestamp| *timestamp >= recent_inactive_cutoff(now)) - .map(|_| WorkspaceAgentState::Inactive), + .map(|_| state), } } @@ -3241,6 +3331,16 @@ fn signal_agent_active(kind: &SignalKind) -> Option { } } +fn signal_agent_state(kind: &SignalKind) -> Option { + match kind { + SignalKind::Metadata => None, + SignalKind::Started | SignalKind::Progress => Some(WorkspaceAgentState::Working), + SignalKind::WaitingInput | SignalKind::Notification => Some(WorkspaceAgentState::Waiting), + SignalKind::Completed => Some(WorkspaceAgentState::Completed), + SignalKind::Error => Some(WorkspaceAgentState::Failed), + } +} + #[cfg(test)] mod tests { use serde_json::json; @@ -4337,6 +4437,124 @@ mod tests { assert_eq!(surface.attention, AttentionState::WaitingInput); } + #[test] + fn agent_hook_notification_updates_context_without_creating_attention_item() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let pane_id = model.active_workspace().expect("workspace").active_pane; + + model + .apply_signal( + workspace_id, + pane_id, + SignalEvent::with_metadata( + "agent-hook:codex", + SignalKind::Notification, + Some("Turn complete".into()), + Some(SignalPaneMetadata { + title: None, + agent_title: Some("Codex".into()), + cwd: None, + repo_name: None, + git_branch: None, + ports: Vec::new(), + agent_kind: Some("codex".into()), + agent_active: Some(true), + }), + ), + ) + .expect("notification applied"); + + let workspace = model.active_workspace().expect("workspace"); + let surface = workspace + .panes + .get(&pane_id) + .and_then(PaneRecord::active_surface) + .expect("surface"); + assert_eq!(surface.attention, AttentionState::WaitingInput); + assert_eq!( + surface.metadata.latest_agent_message.as_deref(), + Some("Turn complete") + ); + assert_eq!( + surface.metadata.agent_state, + Some(WorkspaceAgentState::Waiting) + ); + assert!(workspace.notifications.is_empty()); + } + + #[test] + fn stop_signal_uses_cached_agent_message_for_final_notification() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let pane_id = model.active_workspace().expect("workspace").active_pane; + + model + .apply_signal( + workspace_id, + pane_id, + SignalEvent::with_metadata( + "agent-hook:codex", + SignalKind::Notification, + Some("Turn complete".into()), + Some(SignalPaneMetadata { + title: None, + agent_title: Some("Codex".into()), + cwd: None, + repo_name: None, + git_branch: None, + ports: Vec::new(), + agent_kind: Some("codex".into()), + agent_active: Some(true), + }), + ), + ) + .expect("notification applied"); + + model + .apply_signal( + workspace_id, + pane_id, + SignalEvent::with_metadata( + "agent-hook:codex", + SignalKind::Completed, + None, + Some(SignalPaneMetadata { + title: None, + agent_title: Some("Codex".into()), + cwd: None, + repo_name: None, + git_branch: None, + ports: Vec::new(), + agent_kind: Some("codex".into()), + agent_active: Some(false), + }), + ), + ) + .expect("completed applied"); + + let workspace = model.active_workspace().expect("workspace"); + let notification = workspace + .notifications + .last() + .expect("completion notification"); + assert_eq!(notification.kind, SignalKind::Completed); + assert_eq!(notification.state, AttentionState::Completed); + assert_eq!(notification.message, "Turn complete"); + assert_eq!(notification.title.as_deref(), Some("Codex")); + + let surface = workspace + .panes + .get(&pane_id) + .and_then(PaneRecord::active_surface) + .expect("surface"); + assert_eq!( + surface.metadata.agent_state, + Some(WorkspaceAgentState::Completed) + ); + assert!(!surface.metadata.agent_active); + } + #[test] fn progress_signals_update_attention_without_creating_activity_items() { let mut model = AppModel::new("Main"); @@ -4375,6 +4593,44 @@ mod tests { assert!(model.activity_items().is_empty()); } + #[test] + fn started_signals_do_not_create_attention_items() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let pane_id = model.active_workspace().expect("workspace").active_pane; + + model + .apply_signal( + workspace_id, + pane_id, + SignalEvent::with_metadata( + "agent-hook:codex", + SignalKind::Started, + None, + Some(SignalPaneMetadata { + title: None, + agent_title: Some("Codex".into()), + cwd: None, + repo_name: None, + git_branch: None, + ports: Vec::new(), + agent_kind: Some("codex".into()), + agent_active: Some(true), + }), + ), + ) + .expect("started applied"); + + let surface = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&pane_id)) + .and_then(PaneRecord::active_surface) + .expect("surface"); + + assert_eq!(surface.attention, AttentionState::Busy); + assert!(model.activity_items().is_empty()); + } + #[test] fn metadata_signals_do_not_keep_recent_inactive_agents_alive() { let mut model = AppModel::new("Main"); @@ -4447,7 +4703,7 @@ mod tests { } #[test] - fn marking_surface_completed_clears_activity_and_keeps_recent_inactive_status() { + fn marking_surface_completed_clears_activity_and_keeps_recent_completed_status() { let mut model = AppModel::new("Main"); let workspace_id = model.active_workspace_id().expect("workspace"); let pane_id = model.active_workspace().expect("workspace").active_pane; @@ -4501,7 +4757,7 @@ mod tests { .first() .and_then(|summary| summary.agent_summaries.first()) .map(|summary| summary.state), - Some(WorkspaceAgentState::Inactive) + Some(WorkspaceAgentState::Completed) ); } @@ -4578,7 +4834,7 @@ mod tests { .first() .and_then(|summary| summary.agent_summaries.first()) .map(|summary| summary.state), - Some(WorkspaceAgentState::Inactive) + Some(WorkspaceAgentState::Completed) ); } From 15d556d81803f3b005ba8f789923628b13ae7203 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sun, 22 Mar 2026 20:37:52 +0100 Subject: [PATCH 28/63] feat(shell): add runtime-aware snapshots and workspace icons --- crates/taskers-shell-core/src/lib.rs | 433 ++++++++++++++++++++++++++- crates/taskers-shell/src/icons.rs | 82 +++++ crates/taskers-shell/src/lib.rs | 86 ++++-- crates/taskers-shell/src/theme.rs | 100 ++++++- docs/notifications.md | 6 + docs/taskersctl.md | 2 +- docs/usage.md | 2 + 7 files changed, 681 insertions(+), 30 deletions(-) diff --git a/crates/taskers-shell-core/src/lib.rs b/crates/taskers-shell-core/src/lib.rs index 27fcf7e..522f0da 100644 --- a/crates/taskers-shell-core/src/lib.rs +++ b/crates/taskers-shell-core/src/lib.rs @@ -638,10 +638,49 @@ impl Default for LayoutMetrics { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuntimeStateSnapshot { + Idle, + Working, + Waiting, + Completed, + Failed, +} + +impl RuntimeStateSnapshot { + pub fn label(self) -> &'static str { + match self { + Self::Idle => "Idle", + Self::Working => "Working", + Self::Waiting => "Waiting", + Self::Completed => "Completed", + Self::Failed => "Failed", + } + } + + pub fn slug(self) -> &'static str { + match self { + Self::Idle => "idle", + Self::Working => "working", + Self::Waiting => "waiting", + Self::Completed => "completed", + Self::Failed => "failed", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeIdentitySnapshot { + pub key: String, + pub label: String, + pub state: RuntimeStateSnapshot, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SurfaceSnapshot { pub id: SurfaceId, pub kind: SurfaceKind, + pub runtime: RuntimeIdentitySnapshot, pub title: String, pub url: Option, pub cwd: Option, @@ -654,6 +693,7 @@ pub struct PaneSnapshot { pub active: bool, pub attention: AttentionState, pub active_surface: SurfaceId, + pub runtime: RuntimeIdentitySnapshot, pub surfaces: Vec, pub focus_flash_token: u64, } @@ -721,6 +761,7 @@ pub struct WorkspaceSummary { pub title: String, pub preview: String, pub active: bool, + pub runtime: RuntimeIdentitySnapshot, pub pane_count: usize, pub surface_count: usize, pub agent_count: usize, @@ -811,6 +852,7 @@ pub struct WorkspaceWindowSnapshot { pub column_id: WorkspaceColumnId, pub active: bool, pub attention: AttentionState, + pub runtime: RuntimeIdentitySnapshot, pub title: String, pub pane_count: usize, pub surface_count: usize, @@ -868,7 +910,8 @@ pub struct SurfaceDragSessionSnapshot { pub enum AgentStateSnapshot { Working, Waiting, - Inactive, + Completed, + Failed, } impl AgentStateSnapshot { @@ -876,7 +919,8 @@ impl AgentStateSnapshot { match self { Self::Working => "Working", Self::Waiting => "Waiting", - Self::Inactive => "Inactive", + Self::Completed => "Completed", + Self::Failed => "Failed", } } @@ -884,7 +928,8 @@ impl AgentStateSnapshot { match self { Self::Working => "busy", Self::Waiting => "waiting", - Self::Inactive => "completed", + Self::Completed => "completed", + Self::Failed => "error", } } } @@ -894,7 +939,8 @@ impl From for AgentStateSnapshot { match value { taskers_domain::WorkspaceAgentState::Working => Self::Working, taskers_domain::WorkspaceAgentState::Waiting => Self::Waiting, - taskers_domain::WorkspaceAgentState::Inactive => Self::Inactive, + taskers_domain::WorkspaceAgentState::Completed => Self::Completed, + taskers_domain::WorkspaceAgentState::Failed => Self::Failed, } } } @@ -1369,6 +1415,7 @@ impl TaskersCore { fn workspace_summaries(&self, model: &AppModel) -> Vec { let active_window = model.active_window; + let now = OffsetDateTime::now_utc(); model .workspace_summaries(active_window) .unwrap_or_default() @@ -1396,6 +1443,7 @@ impl TaskersCore { title: summary.label.clone(), preview: workspace_preview(&summary), active: model.active_workspace_id() == Some(summary.workspace_id), + runtime: workspace_runtime_identity(workspace, now), pane_count: workspace.map(|ws| ws.panes.len()).unwrap_or_default(), surface_count: workspace.map(workspace_surface_count).unwrap_or_default(), agent_count: summary.agent_summaries.len(), @@ -1511,6 +1559,7 @@ impl TaskersCore { workspace: &Workspace, window_frames: &BTreeMap, ) -> Vec { + let now = OffsetDateTime::now_utc(); let active_column_id = workspace.active_column_id(); workspace .columns @@ -1525,7 +1574,11 @@ impl TaskersCore { .filter_map(|window_id| { let window = workspace.windows.get(window_id)?; let (_, frame) = window_frames.get(window_id)?; - Some(self.workspace_window_snapshot(workspace, column.id, window, *frame)) + Some( + self.workspace_window_snapshot( + workspace, column.id, window, *frame, now, + ), + ) }) .collect(), }) @@ -1538,6 +1591,7 @@ impl TaskersCore { column_id: WorkspaceColumnId, window: &taskers_domain::WorkspaceWindowRecord, frame: Frame, + now: OffsetDateTime, ) -> WorkspaceWindowSnapshot { let pane_ids = window.layout.leaves(); let pane_count = pane_ids.len(); @@ -1553,6 +1607,7 @@ impl TaskersCore { column_id, active: workspace.active_window == window.id, attention: workspace_window_attention(workspace, window), + runtime: workspace_window_runtime_identity(workspace, window, now), title, pane_count, surface_count, @@ -1596,6 +1651,7 @@ impl TaskersCore { workspace: &Workspace, pane: &taskers_domain::PaneRecord, ) -> PaneSnapshot { + let now = OffsetDateTime::now_utc(); let is_active = workspace.active_pane == pane.id; let has_unread = pane.highest_attention() != taskers_domain::AttentionState::Normal; let explicit_flash_token = pane @@ -1616,12 +1672,14 @@ impl TaskersCore { active: is_active, attention: pane.highest_attention().into(), active_surface: pane.active_surface, + runtime: pane_runtime_identity(pane, now), surfaces: pane .surfaces .values() .map(|surface| SurfaceSnapshot { id: surface.id, kind: SurfaceKind::from_domain(&surface.kind), + runtime: surface_runtime_identity(surface, now), title: display_surface_title(surface), url: normalized_surface_url(surface), cwd: normalized_cwd(&surface.metadata), @@ -3541,6 +3599,239 @@ fn workspace_window_attention( .into() } +fn surface_runtime_identity( + surface: &SurfaceRecord, + now: OffsetDateTime, +) -> RuntimeIdentitySnapshot { + let key = runtime_key(surface); + RuntimeIdentitySnapshot { + label: runtime_label(&key), + state: surface_runtime_state(surface, now), + key, + } +} + +fn pane_runtime_identity( + pane: &taskers_domain::PaneRecord, + now: OffsetDateTime, +) -> RuntimeIdentitySnapshot { + pane.active_surface() + .map(|surface| surface_runtime_identity(surface, now)) + .unwrap_or_else(|| fallback_runtime_identity("terminal", RuntimeStateSnapshot::Idle)) +} + +fn workspace_window_runtime_identity( + workspace: &Workspace, + window: &taskers_domain::WorkspaceWindowRecord, + now: OffsetDateTime, +) -> RuntimeIdentitySnapshot { + dominant_runtime_identity( + window + .layout + .leaves() + .into_iter() + .filter_map(|pane_id| workspace.panes.get(&pane_id)) + .map(|pane| { + ( + pane_runtime_identity(pane, now), + pane.id == window.active_pane, + ) + }), + fallback_runtime_identity("terminal", RuntimeStateSnapshot::Idle), + ) +} + +fn workspace_runtime_identity( + workspace: Option<&Workspace>, + now: OffsetDateTime, +) -> RuntimeIdentitySnapshot { + workspace + .map(|workspace| { + dominant_runtime_identity( + workspace.windows.values().map(|window| { + ( + workspace_window_runtime_identity(workspace, window, now), + window.id == workspace.active_window, + ) + }), + fallback_runtime_identity("terminal", RuntimeStateSnapshot::Idle), + ) + }) + .unwrap_or_else(|| fallback_runtime_identity("terminal", RuntimeStateSnapshot::Idle)) +} + +fn fallback_runtime_identity(key: &str, state: RuntimeStateSnapshot) -> RuntimeIdentitySnapshot { + RuntimeIdentitySnapshot { + key: key.to_string(), + label: runtime_label(key), + state, + } +} + +fn dominant_runtime_identity( + candidates: I, + fallback: RuntimeIdentitySnapshot, +) -> RuntimeIdentitySnapshot +where + I: IntoIterator, +{ + let mut best: Option<(RuntimeIdentitySnapshot, bool)> = None; + for candidate in candidates { + let replace = best.as_ref().is_none_or(|(best_runtime, best_active)| { + runtime_priority(&candidate.0, candidate.1) + < runtime_priority(best_runtime, *best_active) + }); + if replace { + best = Some(candidate); + } + } + best.map(|(runtime, _)| runtime).unwrap_or(fallback) +} + +fn runtime_priority(runtime: &RuntimeIdentitySnapshot, active: bool) -> (u8, u8, u8) { + ( + runtime_state_priority(runtime.state), + if active { 0 } else { 1 }, + runtime_kind_priority(&runtime.key), + ) +} + +fn runtime_state_priority(state: RuntimeStateSnapshot) -> u8 { + match state { + RuntimeStateSnapshot::Failed => 0, + RuntimeStateSnapshot::Waiting => 1, + RuntimeStateSnapshot::Working => 2, + RuntimeStateSnapshot::Completed => 3, + RuntimeStateSnapshot::Idle => 4, + } +} + +fn runtime_kind_priority(key: &str) -> u8 { + match key { + "browser" => 1, + "terminal" => 2, + _ => 0, + } +} + +fn runtime_key(surface: &SurfaceRecord) -> String { + if let Some(agent_kind) = surface + .metadata + .agent_kind + .as_deref() + .map(str::trim) + .filter(|agent_kind| !agent_kind.is_empty() && *agent_kind != "shell") + { + return agent_kind.to_ascii_lowercase(); + } + + match surface.kind { + PaneKind::Browser => "browser".into(), + PaneKind::Terminal => "terminal".into(), + } +} + +fn runtime_label(key: &str) -> String { + match key { + "codex" => "Codex".into(), + "claude" => "Claude".into(), + "opencode" => "OpenCode".into(), + "aider" => "Aider".into(), + "browser" => "Browser".into(), + "terminal" => "Terminal".into(), + other => other + .split(|ch: char| matches!(ch, '-' | '_' | ' ')) + .filter(|part| !part.is_empty()) + .map(|part| { + let mut chars = part.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + let mut label = String::new(); + label.push(first.to_ascii_uppercase()); + label.push_str(chars.as_str()); + label + }) + .collect::>() + .join(" "), + } +} + +fn surface_runtime_state(surface: &SurfaceRecord, now: OffsetDateTime) -> RuntimeStateSnapshot { + surface_agent_state(surface, now) + .map(runtime_state_from_agent_state) + .unwrap_or(RuntimeStateSnapshot::Idle) +} + +fn surface_agent_state( + surface: &SurfaceRecord, + now: OffsetDateTime, +) -> Option { + let agent_kind = surface + .metadata + .agent_kind + .as_deref() + .map(str::trim) + .filter(|agent_kind| !agent_kind.is_empty() && *agent_kind != "shell")?; + let _ = agent_kind; + + let state = surface.metadata.agent_state.or_else(|| { + if surface.metadata.agent_active { + match surface.attention { + taskers_domain::AttentionState::Busy => { + Some(taskers_domain::WorkspaceAgentState::Working) + } + taskers_domain::AttentionState::WaitingInput => { + Some(taskers_domain::WorkspaceAgentState::Waiting) + } + taskers_domain::AttentionState::Completed => { + Some(taskers_domain::WorkspaceAgentState::Completed) + } + taskers_domain::AttentionState::Error => { + Some(taskers_domain::WorkspaceAgentState::Failed) + } + taskers_domain::AttentionState::Normal => None, + } + } else { + match surface.attention { + taskers_domain::AttentionState::Completed => { + Some(taskers_domain::WorkspaceAgentState::Completed) + } + taskers_domain::AttentionState::Error => { + Some(taskers_domain::WorkspaceAgentState::Failed) + } + taskers_domain::AttentionState::Busy + | taskers_domain::AttentionState::WaitingInput => { + Some(taskers_domain::WorkspaceAgentState::Completed) + } + taskers_domain::AttentionState::Normal => None, + } + } + })?; + + match state { + taskers_domain::WorkspaceAgentState::Working + | taskers_domain::WorkspaceAgentState::Waiting => Some(state), + taskers_domain::WorkspaceAgentState::Completed + | taskers_domain::WorkspaceAgentState::Failed => surface + .metadata + .last_signal_at + .filter(|timestamp| *timestamp >= now - time::Duration::minutes(15)) + .map(|_| state), + } +} + +fn runtime_state_from_agent_state( + state: taskers_domain::WorkspaceAgentState, +) -> RuntimeStateSnapshot { + match state { + taskers_domain::WorkspaceAgentState::Working => RuntimeStateSnapshot::Working, + taskers_domain::WorkspaceAgentState::Waiting => RuntimeStateSnapshot::Waiting, + taskers_domain::WorkspaceAgentState::Completed => RuntimeStateSnapshot::Completed, + taskers_domain::WorkspaceAgentState::Failed => RuntimeStateSnapshot::Failed, + } +} + fn window_primary_title( workspace: &Workspace, window: &taskers_domain::WorkspaceWindowRecord, @@ -4064,6 +4355,19 @@ mod tests { } } + fn bootstrap_with_model(model: AppModel, name: &str) -> BootstrapModel { + BootstrapModel { + app_state: super::AppState::new( + model, + default_session_path_for_preview(name), + super::BackendChoice::Mock, + super::ShellLaunchSpec::fallback(), + ) + .expect("preview app state"), + ..bootstrap() + } + } + fn find_pane<'a>( node: &'a super::LayoutNodeSnapshot, pane_id: taskers_domain::PaneId, @@ -4192,6 +4496,125 @@ mod tests { assert_eq!(display_surface_title(&surface), "Taskers Docs"); } + #[test] + fn surface_runtime_identity_prefers_agent_key_and_recent_state() { + let now = OffsetDateTime::now_utc(); + let surface = surface_with_metadata( + taskers_domain::PaneKind::Terminal, + taskers_domain::PaneMetadata { + agent_kind: Some("codex".into()), + agent_active: true, + agent_state: Some(taskers_domain::WorkspaceAgentState::Waiting), + last_signal_at: Some(now), + ..taskers_domain::PaneMetadata::default() + }, + ); + + let runtime = super::surface_runtime_identity(&surface, now); + assert_eq!(runtime.key, "codex"); + assert_eq!(runtime.label, "Codex"); + assert_eq!(runtime.state, super::RuntimeStateSnapshot::Waiting); + } + + #[test] + fn workspace_runtime_identity_prioritizes_failed_agents_over_idle_surfaces() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let first_pane_id = model.active_workspace().expect("workspace").active_pane; + model + .split_pane( + workspace_id, + Some(first_pane_id), + taskers_domain::SplitAxis::Horizontal, + ) + .expect("split pane"); + + let now = OffsetDateTime::now_utc(); + let second_pane_id = { + let workspace = model.workspaces.get(&workspace_id).expect("workspace"); + workspace + .windows + .get(&workspace.active_window) + .expect("window") + .layout + .leaves() + .into_iter() + .find(|pane_id| *pane_id != first_pane_id) + .expect("second pane") + }; + + { + let workspace = model.workspaces.get_mut(&workspace_id).expect("workspace"); + let first_surface_id = workspace + .panes + .get(&first_pane_id) + .map(|pane| pane.active_surface) + .expect("first surface"); + let second_surface_id = workspace + .panes + .get(&second_pane_id) + .map(|pane| pane.active_surface) + .expect("second surface"); + let first_surface = workspace + .panes + .get_mut(&first_pane_id) + .and_then(|pane| pane.surfaces.get_mut(&first_surface_id)) + .expect("first surface record"); + first_surface.metadata.agent_kind = Some("codex".into()); + first_surface.metadata.agent_active = true; + first_surface.metadata.agent_state = Some(taskers_domain::WorkspaceAgentState::Working); + first_surface.metadata.last_signal_at = Some(now); + first_surface.attention = taskers_domain::AttentionState::Busy; + + let second_surface = workspace + .panes + .get_mut(&second_pane_id) + .and_then(|pane| pane.surfaces.get_mut(&second_surface_id)) + .expect("second surface record"); + second_surface.metadata.agent_kind = Some("claude".into()); + second_surface.metadata.agent_active = false; + second_surface.metadata.agent_state = Some(taskers_domain::WorkspaceAgentState::Failed); + second_surface.metadata.last_signal_at = Some(now); + second_surface.attention = taskers_domain::AttentionState::Error; + } + + let core = SharedCore::bootstrap(bootstrap_with_model( + model, + "taskers-preview-runtime-priority", + )); + let snapshot = core.snapshot(); + let workspace_summary = snapshot.workspaces.first().expect("workspace summary"); + assert_eq!(workspace_summary.runtime.key, "claude"); + assert_eq!( + workspace_summary.runtime.state, + super::RuntimeStateSnapshot::Failed + ); + + let active_window = snapshot + .current_workspace + .columns + .iter() + .flat_map(|column| column.windows.iter()) + .find(|window| window.id == snapshot.current_workspace.active_window_id) + .expect("active window"); + assert_eq!(active_window.runtime.key, "claude"); + + let first_pane = + find_pane(&snapshot.current_workspace.layout, first_pane_id).expect("first pane"); + let second_pane = + find_pane(&snapshot.current_workspace.layout, second_pane_id).expect("second pane"); + assert_eq!(first_pane.runtime.key, "codex"); + assert_eq!( + first_pane.runtime.state, + super::RuntimeStateSnapshot::Working + ); + assert_eq!(second_pane.runtime.key, "claude"); + assert_eq!( + second_pane.runtime.state, + super::RuntimeStateSnapshot::Failed + ); + } + #[test] fn default_bootstrap_projects_browser_and_terminal_portal_plans() { let core = SharedCore::bootstrap(bootstrap()); diff --git a/crates/taskers-shell/src/icons.rs b/crates/taskers-shell/src/icons.rs index daba526..cd0ae8c 100644 --- a/crates/taskers-shell/src/icons.rs +++ b/crates/taskers-shell/src/icons.rs @@ -332,3 +332,85 @@ pub fn network(size: u32, class: &str) -> Element { } } } + +/// Codex runtime icon +pub fn codex(size: u32, class: &str) -> Element { + rsx! { + svg { + class: "{class}", + width: "{size}", + height: "{size}", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + path { d: "M9 5H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h3" } + path { d: "M15 5h3a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-3" } + path { d: "M10 8l4 8" } + path { d: "M14 8l-4 8" } + } + } +} + +/// Claude runtime icon +pub fn claude(size: u32, class: &str) -> Element { + rsx! { + svg { + class: "{class}", + width: "{size}", + height: "{size}", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + path { d: "M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8L12 3z" } + circle { cx: "12", cy: "10", r: "1.5" } + } + } +} + +/// OpenCode runtime icon +pub fn opencode(size: u32, class: &str) -> Element { + rsx! { + svg { + class: "{class}", + width: "{size}", + height: "{size}", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + polyline { points: "8 7 3 12 8 17" } + polyline { points: "16 7 21 12 16 17" } + line { x1: "13", y1: "5", x2: "11", y2: "19" } + } + } +} + +/// Aider runtime icon +pub fn aider(size: u32, class: &str) -> Element { + rsx! { + svg { + class: "{class}", + width: "{size}", + height: "{size}", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + path { d: "M12 3v6" } + path { d: "M12 15v6" } + path { d: "M3 12h6" } + path { d: "M15 12h6" } + circle { cx: "12", cy: "12", r: "3" } + } + } +} diff --git a/crates/taskers-shell/src/lib.rs b/crates/taskers-shell/src/lib.rs index e587eef..de065dc 100644 --- a/crates/taskers-shell/src/lib.rs +++ b/crates/taskers-shell/src/lib.rs @@ -10,10 +10,11 @@ use dioxus::prelude::*; use taskers_core::{ ActivityItemSnapshot, AgentSessionSnapshot, AttentionState, BrowserChromeSnapshot, Direction, LayoutNodeSnapshot, NotificationPreferenceKey, PaneId, PaneSnapshot, ProgressSnapshot, - PullRequestSnapshot, RuntimeStatus, SettingsSnapshot, SharedCore, ShellAction, ShellSection, - ShellSnapshot, ShortcutAction, ShortcutBindingSnapshot, SplitAxis, SurfaceDragSessionSnapshot, - SurfaceId, SurfaceKind, SurfaceSnapshot, WorkspaceId, WorkspaceLogEntrySnapshot, - WorkspaceSummary, WorkspaceViewSnapshot, WorkspaceWindowMoveTarget, WorkspaceWindowSnapshot, + PullRequestSnapshot, RuntimeIdentitySnapshot, RuntimeStateSnapshot, RuntimeStatus, + SettingsSnapshot, SharedCore, ShellAction, ShellSection, ShellSnapshot, ShortcutAction, + ShortcutBindingSnapshot, SplitAxis, SurfaceDragSessionSnapshot, SurfaceId, SurfaceKind, + SurfaceSnapshot, WorkspaceId, WorkspaceLogEntrySnapshot, WorkspaceSummary, + WorkspaceViewSnapshot, WorkspaceWindowMoveTarget, WorkspaceWindowSnapshot, }; use taskers_shell_core as taskers_core; @@ -52,6 +53,31 @@ enum SurfaceDropTarget { }, } +fn runtime_state_class(state: RuntimeStateSnapshot) -> &'static str { + match state { + RuntimeStateSnapshot::Idle => "runtime-state-idle", + RuntimeStateSnapshot::Working => "runtime-state-working", + RuntimeStateSnapshot::Waiting => "runtime-state-waiting", + RuntimeStateSnapshot::Completed => "runtime-state-completed", + RuntimeStateSnapshot::Failed => "runtime-state-failed", + } +} + +fn render_runtime_icon(runtime: &RuntimeIdentitySnapshot, size: u32, class: &str) -> Element { + render_runtime_icon_by_key(runtime.key.as_str(), size, class) +} + +fn render_runtime_icon_by_key(key: &str, size: u32, class: &str) -> Element { + match key { + "codex" => icons::codex(size, class), + "claude" => icons::claude(size, class), + "opencode" => icons::opencode(size, class), + "aider" => icons::aider(size, class), + "browser" => icons::globe(size, class), + _ => icons::terminal(size, class), + } +} + fn compute_surface_drop_index( dragged: DraggedSurface, target_pane_id: PaneId, @@ -574,6 +600,10 @@ fn render_workspace_item( .as_ref() .map(|color| format!("--workspace-accent: {color};")) .unwrap_or_default(); + let runtime_icon_class = format!( + "workspace-runtime-icon {}", + runtime_state_class(workspace.runtime.state) + ); let is_workspace_drag_target = *drag_target.read() == Some(workspace_id); let is_surface_drag_target = @@ -714,7 +744,10 @@ fn render_workspace_item( } div { class: "workspace-tab-content", div { class: "workspace-tab-header", - div { class: "workspace-tab-title", "{workspace.title}" } + div { class: "workspace-tab-title-row", + {render_runtime_icon(&workspace.runtime, 12, &runtime_icon_class)} + div { class: "workspace-tab-title", "{workspace.title}" } + } div { class: "workspace-tab-trailing", if has_badge { span { class: "{badge_state_class}", "{badge_text}" } @@ -1059,6 +1092,10 @@ fn render_workspace_window( }; let top_target = WorkspaceWindowMoveTarget::StackAbove { window_id }; let bottom_target = WorkspaceWindowMoveTarget::StackBelow { window_id }; + let window_runtime_icon_class = format!( + "workspace-window-runtime-icon {}", + runtime_state_class(window.runtime.state) + ); rsx! { section { class: "{window_class}", style: "{style}", @@ -1099,6 +1136,11 @@ fn render_workspace_window( onclick: focus_window, ondragstart: start_window_drag, ondragend: clear_window_drag, + div { + class: "workspace-window-runtime-badge", + title: "{window.runtime.label} · {window.runtime.state.label()}", + {render_runtime_icon(&window.runtime, 12, &window_runtime_icon_class)} + } div { class: "workspace-window-grip" } } div { class: "workspace-window-body", @@ -1306,26 +1348,31 @@ fn render_pane( } else { "Close current surface" }; + let pane_runtime_icon_class = format!( + "pane-toolbar-kind-icon {}", + runtime_state_class(pane.runtime.state) + ); + let pane_runtime_chip_class = format!( + "pane-runtime-chip {}", + runtime_state_class(pane.runtime.state) + ); rsx! { section { class: "{pane_class}", onclick: focus_pane, div { class: "pane-toolbar", if show_tab_strip { div { class: "pane-toolbar-meta", - {match active_surface.kind { - SurfaceKind::Terminal => icons::terminal(14, "pane-toolbar-kind-icon"), - SurfaceKind::Browser => icons::globe(14, "pane-toolbar-kind-icon"), - }} + {render_runtime_icon(&pane.runtime, 14, &pane_runtime_icon_class)} } } else { div { class: "pane-toolbar-meta pane-toolbar-meta-draggable", title: "Drag surface", onpointerdown: begin_active_surface_drag_candidate, - {match active_surface.kind { - SurfaceKind::Terminal => icons::terminal(14, "pane-toolbar-kind-icon"), - SurfaceKind::Browser => icons::globe(14, "pane-toolbar-kind-icon"), - }} + div { class: "{pane_runtime_chip_class}", + {render_runtime_icon(&pane.runtime, 14, &pane_runtime_icon_class)} + span { class: "pane-runtime-label", "{pane.runtime.label}" } + } } } div { class: "pane-action-cluster", @@ -1522,7 +1569,6 @@ fn render_surface_tab( ordered_surface_ids: &[SurfaceId], ) -> Element { let surface_id = surface.id; - let surface_kind = surface.kind; let is_drop_target = matches!( *surface_drop_target.read(), Some(SurfaceDropTarget::BeforeSurface { @@ -1628,6 +1674,10 @@ fn render_surface_tab( core.dispatch_shell_action(ShellAction::EndDrag); } }; + let surface_runtime_icon_class = format!( + "surface-tab-kind-icon {}", + runtime_state_class(surface.runtime.state) + ); rsx! { button { @@ -1638,10 +1688,7 @@ fn render_surface_tab( onpointermove: set_surface_drop_target_move, onpointerleave: clear_surface_drop_target, onpointerup: drop_surface, - {match surface_kind { - SurfaceKind::Terminal => icons::terminal(10, "surface-tab-kind-icon"), - SurfaceKind::Browser => icons::globe(10, "surface-tab-kind-icon"), - }} + {render_runtime_icon(&surface.runtime, 10, &surface_runtime_icon_class)} span { class: "surface-tab-title", "{surface.title}" } } } @@ -1805,6 +1852,7 @@ fn render_agent_item( current_workspace: &taskers_core::WorkspaceViewSnapshot, ) -> Element { let row_class = format!("activity-item activity-item-state-{}", agent.state.slug()); + let agent_icon_class = format!("agent-kind-icon runtime-state-{}", agent.state.slug()); let workspace_id = agent.workspace_id; let pane_id = agent.pane_id; let surface_id = agent.surface_id; @@ -1823,7 +1871,7 @@ fn render_agent_item( button { class: "activity-item-button", onclick: focus_target, div { class: "{row_class}", div { class: "activity-header", - {icons::terminal(12, "agent-kind-icon")} + {render_runtime_icon_by_key(agent.agent_kind.as_str(), 12, &agent_icon_class)} div { class: "workspace-label", "{agent.title}" } div { class: "activity-time", "{agent.state.label()}" } } diff --git a/crates/taskers-shell/src/theme.rs b/crates/taskers-shell/src/theme.rs index 2649516..30f6892 100644 --- a/crates/taskers-shell/src/theme.rs +++ b/crates/taskers-shell/src/theme.rs @@ -431,6 +431,13 @@ input:focus-visible {{ gap: 6px; }} +.workspace-tab-title-row {{ + min-width: 0; + display: flex; + align-items: center; + gap: 6px; +}} + .workspace-tab-title {{ font-weight: 600; font-size: 12.5px; @@ -547,7 +554,9 @@ input:focus-visible {{ .workspace-add-icon, .workspace-tab-close-icon, .workspace-branch-icon, -.workspace-ports-icon {{ +.workspace-ports-icon, +.workspace-runtime-icon, +.workspace-window-runtime-icon {{ flex: 0 0 auto; display: block; }} @@ -557,6 +566,26 @@ input:focus-visible {{ opacity: 0.5; }} +.runtime-state-idle {{ + color: {text_dim}; +}} + +.runtime-state-working {{ + color: {busy}; +}} + +.runtime-state-waiting {{ + color: {waiting}; +}} + +.runtime-state-completed {{ + color: {completed}; +}} + +.runtime-state-failed {{ + color: {error}; +}} + .workspace-progress {{ display: flex; align-items: center; @@ -1013,6 +1042,7 @@ input:focus-visible {{ min-height: {window_toolbar_height}px; border-bottom: 1px solid {border_06}; background: linear-gradient(180deg, {overlay_05} 0%, {overlay_03} 100%); + position: relative; padding: 0 8px; display: flex; align-items: center; @@ -1030,6 +1060,22 @@ input:focus-visible {{ box-shadow: inset 0 1px 0 rgba(255,255,255,0.04); }} +.workspace-window-runtime-badge {{ + position: absolute; + left: 8px; + top: 50%; + transform: translateY(-50%); + width: 20px; + height: 20px; + border-radius: 999px; + display: flex; + align-items: center; + justify-content: center; + background: {overlay_12}; + border: 1px solid {border_10}; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.04); +}} + .workspace-window-toolbar:hover .workspace-window-grip {{ background: {text_dim}; }} @@ -1144,7 +1190,7 @@ input:focus-visible {{ .pane-toolbar-kind-icon {{ flex: 0 0 auto; - color: {text_dim}; + display: block; }} .pane-toolbar-title {{ @@ -1157,6 +1203,47 @@ input:focus-visible {{ min-width: 0; }} +.pane-runtime-chip {{ + min-width: 0; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 8px; + border-radius: 999px; + background: {overlay_12}; + border: 1px solid {border_10}; +}} + +.pane-runtime-label {{ + min-width: 0; + font-size: 11px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: {text_bright}; +}} + +.pane-runtime-chip.runtime-state-working {{ + background: {busy_10}; + border-color: {busy_12}; +}} + +.pane-runtime-chip.runtime-state-waiting {{ + background: {waiting_10}; + border-color: {waiting_14}; +}} + +.pane-runtime-chip.runtime-state-completed {{ + background: {completed_10}; + border-color: {completed_12}; +}} + +.pane-runtime-chip.runtime-state-failed {{ + background: {error_10}; + border-color: {error_12}; +}} + .pane-action-separator {{ width: 1px; height: 14px; @@ -1274,11 +1361,11 @@ input:focus-visible {{ .surface-tab-kind-icon {{ flex: 0 0 auto; - opacity: 0.5; + opacity: 0.7; }} .surface-tab-active .surface-tab-kind-icon {{ - opacity: 0.8; + opacity: 1.0; }} .pane-utility {{ @@ -1909,7 +1996,7 @@ input:focus-visible {{ .agent-kind-icon {{ flex: 0 0 auto; - color: {text_dim}; + display: block; }} .notification-clear:hover {{ @@ -2029,6 +2116,7 @@ input:focus-visible {{ elevated = p.elevated.to_hex(), overlay_03 = rgba(p.overlay, 0.03), overlay_05 = rgba(p.overlay, 0.05), + overlay_12 = rgba(p.overlay, 0.12), overlay_16 = rgba(p.overlay, 0.16), text = p.text.to_hex(), text_bright = p.text_bright.to_hex(), @@ -2051,10 +2139,12 @@ input:focus-visible {{ accent_22 = rgba(p.accent, 0.22), accent_24 = rgba(p.accent, 0.24), busy = p.busy.to_hex(), + busy_10 = rgba(p.busy, 0.10), busy_12 = rgba(p.busy, 0.12), busy_16 = rgba(p.busy, 0.16), busy_text = p.busy_text.to_hex(), completed = p.completed.to_hex(), + completed_10 = rgba(p.completed, 0.10), completed_12 = rgba(p.completed, 0.12), completed_16 = rgba(p.completed, 0.16), completed_text = p.completed_text.to_hex(), diff --git a/docs/notifications.md b/docs/notifications.md index 45a5354..24694e4 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -106,6 +106,12 @@ taskersctl agent-hook stop --message "Finished" Use this path when you already have structured lifecycle hooks and want to translate them into Taskers attention items directly. +Behavior split: + +- `waiting` creates the follow-up-needed alert immediately. +- `notification` updates the agent's live context and latest message, but does not create the final completion alert by itself. +- `stop` is the final completion/error edge. If it arrives without a message, Taskers reuses the latest agent message for the final alert. + ## CMUX-Compatible Terminal Escapes Taskers understands both the Taskers-native signal frames and the CMUX-compatible notification sequences. diff --git a/docs/taskersctl.md b/docs/taskersctl.md index 8ea8b89..7ea2791 100644 --- a/docs/taskersctl.md +++ b/docs/taskersctl.md @@ -148,7 +148,7 @@ taskersctl agent-hook notification --title "Codex" --message "Turn complete" taskersctl agent-hook stop --message "Finished" ``` -This is the best fit for wrappers around coding agents, CI helpers, and long-running scripts. +Use `notification` for contextual agent output that should update the live pane/workspace summary without finalizing the run. Use `stop` for the final successful completion edge; non-zero shell exits still surface as error stops automatically. This is the best fit for wrappers around coding agents, CI helpers, and long-running scripts. ## Advanced Notes diff --git a/docs/usage.md b/docs/usage.md index 1465140..a1dc574 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -69,6 +69,8 @@ Taskers keeps active agent state visible in three places: - the attention rail on the right - the current pane or surface when a flash or unread item targets it +Workspace rows and pane/window chrome also carry runtime-aware icons, so you can tell at a glance whether a region is currently acting as a Codex, Claude, OpenCode, Aider, browser, or plain terminal surface. + Use that split intentionally: - Status text is for “what this workspace is doing right now” From 33bd9545a77542ff8d5848b9e65ee5fd57303635 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sun, 22 Mar 2026 21:09:42 +0100 Subject: [PATCH 29/63] refactor(ghostty): make embedded config overrides explicit and minimal --- vendor/ghostty/src/taskers_bridge.zig | 97 +++++++++++++++++++++++---- 1 file changed, 84 insertions(+), 13 deletions(-) diff --git a/vendor/ghostty/src/taskers_bridge.zig b/vendor/ghostty/src/taskers_bridge.zig index 5f81d5b..36caf99 100644 --- a/vendor/ghostty/src/taskers_bridge.zig +++ b/vendor/ghostty/src/taskers_bridge.zig @@ -202,19 +202,7 @@ fn taskersSurfaceConfig(app: anytype, ptr: *const Host, opts: *const SurfaceOpti var cloned = try base.get().clone(alloc); defer cloned.deinit(); - cloned.command = if (ptr.command_argv.len == 0) - null - else - configpkg.Command{ .direct = ptr.command_argv }; - cloned.@"shell-integration" = .none; - cloned.@"shell-integration-features" = .{}; - cloned.@"linux-cgroup" = .never; - // Embedded Taskers panes already supply their own chrome and spacing. - // Ghostty's default window padding makes the terminal grid float inside - // the pane body and visibly misalign with the shell layout. - cloned.@"window-padding-x" = .{ .top_left = 0, .bottom_right = 0 }; - cloned.@"window-padding-y" = .{ .top_left = 0, .bottom_right = 0 }; - cloned.@"window-padding-balance" = false; + try applyTaskersEmbeddedSurfaceInvariants(alloc, &cloned, ptr.command_argv); for (ptr.env_entries) |entry| { try cloned.env.parseCLI(alloc, entry); } @@ -227,6 +215,30 @@ fn taskersSurfaceConfig(app: anytype, ptr: *const Host, opts: *const SurfaceOpti return try Config.new(alloc, &cloned); } +fn applyTaskersEmbeddedSurfaceInvariants( + alloc: std.mem.Allocator, + config: *configpkg.Config, + command_argv: []const [:0]u8, +) !void { + // Embedded panes inherit the user's loaded Ghostty config and only pin + // the handful of settings Taskers must own for layout and shell startup. + if (config.command) |command| command.deinit(alloc); + config.command = if (command_argv.len == 0) null else command: { + const direct = configpkg.Command{ .direct = command_argv }; + break :command try direct.clone(alloc); + }; + config.@"shell-integration" = .none; + config.@"shell-integration-features" = .{}; + config.@"linux-cgroup" = .never; + + // Embedded Taskers panes already supply their own chrome and spacing. + // Ghostty's default window padding makes the terminal grid float inside + // the pane body and visibly misalign with the shell layout. + config.@"window-padding-x" = .{ .top_left = 0, .bottom_right = 0 }; + config.@"window-padding-y" = .{ .top_left = 0, .bottom_right = 0 }; + config.@"window-padding-balance" = false; +} + fn duplicateStringList( alloc: std.mem.Allocator, entries_ptr: ?[*]const [*:0]const u8, @@ -251,3 +263,62 @@ fn freeStringList(alloc: std.mem.Allocator, entries: []const [:0]u8) void { for (entries) |entry| alloc.free(entry); alloc.free(entries); } + +test "taskers embedded config preserves user settings beyond required invariants" { + const testing = std.testing; + var config = try configpkg.Config.default(testing.allocator); + defer config.deinit(); + + config.@"font-size" = 19; + config.command = .{ .shell = try testing.allocator.dupeZ(u8, "echo from-user-config") }; + config.@"shell-integration" = .zsh; + config.@"shell-integration-features" = .{ + .cursor = false, + .sudo = true, + .title = false, + .@"ssh-env" = true, + .@"ssh-terminfo" = true, + .path = false, + }; + config.@"linux-cgroup" = .always; + config.@"window-padding-x" = .{ .top_left = 7, .bottom_right = 9 }; + config.@"window-padding-y" = .{ .top_left = 11, .bottom_right = 13 }; + config.@"window-padding-balance" = true; + + const command_argv = [_][:0]const u8{ "/opt/taskers-shell-wrapper.sh", "-i" }; + try applyTaskersEmbeddedSurfaceInvariants(testing.allocator, &config, command_argv[0..]); + + try testing.expectEqual(@as(f32, 19), config.@"font-size"); + try testing.expectEqual(configpkg.Config.ShellIntegration.none, config.@"shell-integration"); + try testing.expectEqual(configpkg.ShellIntegrationFeatures{}, config.@"shell-integration-features"); + try testing.expectEqual(configpkg.Config.LinuxCgroup.never, config.@"linux-cgroup"); + try testing.expectEqual(@as(u32, 0), config.@"window-padding-x".top_left); + try testing.expectEqual(@as(u32, 0), config.@"window-padding-x".bottom_right); + try testing.expectEqual(@as(u32, 0), config.@"window-padding-y".top_left); + try testing.expectEqual(@as(u32, 0), config.@"window-padding-y".bottom_right); + try testing.expect(!config.@"window-padding-balance"); + + const command = config.command orelse return error.TestUnexpectedResult; + switch (command) { + .direct => |argv| { + try testing.expectEqual(@as(usize, 2), argv.len); + try testing.expectEqualStrings("/opt/taskers-shell-wrapper.sh", argv[0]); + try testing.expectEqualStrings("-i", argv[1]); + }, + else => return error.TestUnexpectedResult, + } +} + +test "taskers embedded config clears user command when taskers does not provide one" { + const testing = std.testing; + var config = try configpkg.Config.default(testing.allocator); + defer config.deinit(); + + config.@"font-size" = 17; + config.command = .{ .shell = try testing.allocator.dupeZ(u8, "echo from-user-config") }; + + try applyTaskersEmbeddedSurfaceInvariants(testing.allocator, &config, &.{}); + + try testing.expectEqual(@as(f32, 17), config.@"font-size"); + try testing.expect(config.command == null); +} From a14741f609298170006252008a1ce36e2aa06f76 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Sun, 22 Mar 2026 21:16:53 +0100 Subject: [PATCH 30/63] docs(usage): document embedded ghostty config inheritance --- docs/usage.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/usage.md b/docs/usage.md index a1dc574..d522586 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -27,6 +27,19 @@ Taskers currently ships two live surface kinds: Each pane can hold one or more tabs of either kind. The active tab supplies the live content for that pane. +## Embedded Ghostty Config + +On the GTK/Linux shell, embedded terminal panes start from the user's normal Ghostty config file and inherit most visual and terminal behavior settings from there. + +Taskers still pins a small set of embedded-pane invariants: + +- the launch command stays Taskers-owned +- Ghostty shell integration stays disabled because Taskers provides its own shell wrapper +- Ghostty window padding stays zero so the grid aligns with pane chrome +- Ghostty Linux cgroup settings stay disabled for embedded panes + +That means Ghostty settings such as fonts, theme, colors, cursor behavior, and scrollback should carry over, while window-style and shell-launch behavior remains controlled by Taskers. + ## A Typical Session Start Taskers: From bf9b96d963264bd1cd207030523d389ae014addf Mon Sep 17 00:00:00 2001 From: OneNoted Date: Mon, 23 Mar 2026 09:49:43 +0100 Subject: [PATCH 31/63] chore(dev): point local launcher workflows at cargo-bin installs --- README.md | 9 ++++--- docs/release.md | 2 ++ docs/usage.md | 4 +-- scripts/install-dev-desktop-entry.sh | 39 ++++++++++++++++------------ 4 files changed, 32 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 434915d..184e84b 100644 --- a/README.md +++ b/README.md @@ -83,13 +83,14 @@ On Ubuntu 24.04, install the Linux UI dependencies first: sudo apt-get install -y libgtk-4-dev libadwaita-1-dev libjavascriptcoregtk-6.0-dev libwebkitgtk-6.0-dev xvfb ``` -Run the app directly: +Install the app into Cargo's bin directory, then run it from there: ```bash -cargo run -p taskers-gtk --bin taskers-gtk +cargo install --path crates/taskers-app --force +taskers-gtk ``` -Point the desktop launcher at the repo-local dev build: +Point the desktop launcher at that Cargo-bin install: ```bash bash scripts/install-dev-desktop-entry.sh @@ -100,7 +101,7 @@ Run the headless baseline smoke: ```bash TASKERS_TERMINAL_BACKEND=mock \ bash scripts/headless-smoke.sh \ - ./target/debug/taskers-gtk \ + "$(command -v taskers-gtk)" \ --smoke-script baseline \ --diagnostic-log stderr \ --quit-after-ms 5000 diff --git a/docs/release.md b/docs/release.md index f8eec19..5baad58 100644 --- a/docs/release.md +++ b/docs/release.md @@ -116,3 +116,5 @@ For dev-desktop testing against the local checkout after a release pass: ```bash bash scripts/install-dev-desktop-entry.sh ``` + +That reinstalls the repo-local app into Cargo's bin directory and repoints the desktop entry to that installed binary. diff --git a/docs/usage.md b/docs/usage.md index d522586..6da1b8d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -115,10 +115,10 @@ taskersctl debug terminal render-stats ## Desktop Launcher For Development -If you are testing repo-local changes from your desktop environment, repoint the launcher to the local checkout: +If you are testing repo-local changes from your desktop environment, install the app into Cargo's bin directory and repoint the launcher there: ```bash bash scripts/install-dev-desktop-entry.sh ``` -That writes a dev desktop entry that launches `cargo run` against the repo root instead of a previously installed release bundle. +That reinstalls the local app into Cargo's install root and writes a desktop entry that launches that installed binary directly. diff --git a/scripts/install-dev-desktop-entry.sh b/scripts/install-dev-desktop-entry.sh index 2412d7c..77fbf95 100755 --- a/scripts/install-dev-desktop-entry.sh +++ b/scripts/install-dev-desktop-entry.sh @@ -3,31 +3,37 @@ set -euo pipefail repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" xdg_data_home="${XDG_DATA_HOME:-$HOME/.local/share}" -launcher_home="${HOME}/.local/bin" desktop_entry_path="${xdg_data_home}/applications/dev.taskers.app.desktop" -launcher_path="${launcher_home}/taskers-dev" if [[ -n "${CARGO:-}" ]]; then - cargo_bin="${CARGO}" + cargo_cmd="${CARGO}" elif [[ -n "${CARGO_HOME:-}" ]]; then - cargo_bin="${CARGO_HOME}/bin/cargo" + cargo_cmd="${CARGO_HOME}/bin/cargo" else - cargo_bin="${HOME}/.cargo/bin/cargo" + cargo_cmd="${HOME}/.cargo/bin/cargo" fi -if [[ ! -x "${cargo_bin}" ]]; then - cargo_bin="$(command -v cargo)" +if [[ ! -x "${cargo_cmd}" ]]; then + cargo_cmd="$(command -v cargo)" fi -mkdir -p "${launcher_home}" "$(dirname -- "${desktop_entry_path}")" +if [[ -z "${cargo_cmd:-}" || ! -x "${cargo_cmd}" ]]; then + echo "cargo executable not found" >&2 + exit 1 +fi -cat > "${launcher_path}" <&2 + exit 1 +fi cat > "${desktop_entry_path}" </dev/null 2>&1; then update-desktop-database "${xdg_data_home}/applications" fi +echo "installed ${app_binary_path}" echo "installed ${desktop_entry_path}" From 0334b5ba93426ff05cb68c436a9ac42196853864 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Mon, 23 Mar 2026 10:08:49 +0100 Subject: [PATCH 32/63] fix(ghostty): restore embedded terminal surface startup --- vendor/ghostty/src/taskers_bridge.zig | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/vendor/ghostty/src/taskers_bridge.zig b/vendor/ghostty/src/taskers_bridge.zig index 36caf99..8335cd3 100644 --- a/vendor/ghostty/src/taskers_bridge.zig +++ b/vendor/ghostty/src/taskers_bridge.zig @@ -216,13 +216,14 @@ fn taskersSurfaceConfig(app: anytype, ptr: *const Host, opts: *const SurfaceOpti } fn applyTaskersEmbeddedSurfaceInvariants( - alloc: std.mem.Allocator, + _: std.mem.Allocator, config: *configpkg.Config, command_argv: []const [:0]u8, ) !void { + const alloc = config.arenaAlloc(); + // Embedded panes inherit the user's loaded Ghostty config and only pin // the handful of settings Taskers must own for layout and shell startup. - if (config.command) |command| command.deinit(alloc); config.command = if (command_argv.len == 0) null else command: { const direct = configpkg.Command{ .direct = command_argv }; break :command try direct.clone(alloc); From bac087ab56c7077abb5cb63f43eaf485f48e7cb8 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Mon, 23 Mar 2026 12:07:22 +0100 Subject: [PATCH 33/63] fix(shell): stabilize surface drag state --- crates/taskers-control/src/controller.rs | 93 +++++++++++++++++++++++- crates/taskers-domain/src/model.rs | 77 ++++++++++++++++---- crates/taskers-shell-core/src/lib.rs | 40 ++++++++++ 3 files changed, 195 insertions(+), 15 deletions(-) diff --git a/crates/taskers-control/src/controller.rs b/crates/taskers-control/src/controller.rs index ed2076a..149eeaa 100644 --- a/crates/taskers-control/src/controller.rs +++ b/crates/taskers-control/src/controller.rs @@ -431,7 +431,13 @@ impl InMemoryController { event, } => { if let Some(surface_id) = surface_id { - model.apply_surface_signal(workspace_id, pane_id, surface_id, event)?; + let current = resolve_identify_context(model, None, None, Some(surface_id))?; + model.apply_surface_signal( + current.workspace_id, + current.pane_id, + surface_id, + event, + )?; } else { model.apply_signal(workspace_id, pane_id, event)?; } @@ -842,6 +848,91 @@ mod tests { assert_eq!(controller.revision(), 1); } + #[test] + fn surface_signals_follow_a_moved_surface_even_with_stale_pane_context() { + let controller = InMemoryController::new(AppModel::new("Main")); + let snapshot = controller.snapshot(); + let source_workspace = snapshot.model.active_workspace().expect("workspace"); + let source_workspace_id = source_workspace.id; + let source_pane_id = source_workspace.active_pane; + + controller + .handle(ControlCommand::CreateSurface { + workspace_id: source_workspace_id, + pane_id: source_pane_id, + kind: PaneKind::Browser, + }) + .expect("create moved surface"); + let moved_surface_id = controller + .snapshot() + .model + .workspaces + .get(&source_workspace_id) + .and_then(|workspace| workspace.panes.get(&source_pane_id)) + .map(|pane| pane.active_surface) + .expect("moved surface"); + + controller + .handle(ControlCommand::CreateWorkspace { + label: "Docs".into(), + }) + .expect("create target workspace"); + let target_workspace_id = controller + .snapshot() + .model + .active_workspace_id() + .expect("target workspace"); + + controller + .handle(ControlCommand::MoveSurfaceToWorkspace { + source_workspace_id, + source_pane_id, + surface_id: moved_surface_id, + target_workspace_id, + }) + .expect("move surface"); + + controller + .handle(ControlCommand::EmitSignal { + workspace_id: source_workspace_id, + pane_id: source_pane_id, + surface_id: Some(moved_surface_id), + event: SignalEvent::new("pty", SignalKind::Progress, Some("Running".into())), + }) + .expect("emit moved surface signal"); + + let snapshot = controller.snapshot(); + let target_surface = snapshot + .model + .workspaces + .values() + .flat_map(|workspace| { + workspace.panes.values().flat_map(move |pane| { + pane.surfaces + .values() + .map(move |surface| (workspace, pane, surface)) + }) + }) + .find(|(_, _, surface)| surface.id == moved_surface_id) + .expect("target surface"); + + assert_eq!(target_surface.0.id, target_workspace_id); + assert_eq!( + target_surface.2.attention, + taskers_domain::AttentionState::Busy + ); + assert_eq!( + snapshot + .model + .workspaces + .get(&source_workspace_id) + .and_then(|workspace| workspace.panes.get(&source_pane_id)) + .and_then(|pane| pane.active_surface()) + .map(|surface| surface.attention), + Some(taskers_domain::AttentionState::Normal) + ); + } + #[test] fn identify_returns_focused_context_and_optional_caller() { let controller = InMemoryController::new(AppModel::new("Main")); diff --git a/crates/taskers-domain/src/model.rs b/crates/taskers-domain/src/model.rs index cd75ffe..799ad7d 100644 --- a/crates/taskers-domain/src/model.rs +++ b/crates/taskers-domain/src/model.rs @@ -7,8 +7,8 @@ use time::{Duration, OffsetDateTime}; use crate::{ AttentionState, Direction, LayoutNode, NotificationId, PaneId, SessionId, SignalEvent, - SignalKind, SignalPaneMetadata, SplitAxis, SurfaceId, WindowId, WorkspaceColumnId, - WorkspaceId, WorkspaceWindowId, + SignalKind, SignalPaneMetadata, SplitAxis, SurfaceId, WindowId, WorkspaceColumnId, WorkspaceId, + WorkspaceWindowId, }; pub const SESSION_SCHEMA_VERSION: u32 = 4; @@ -321,6 +321,16 @@ impl PaneRecord { true } + fn normalize_active_surface(&mut self) { + if !self.surfaces.contains_key(&self.active_surface) { + self.active_surface = self + .surfaces + .first() + .map(|(surface_id, _)| *surface_id) + .expect("pane has at least one surface"); + } + } + fn normalize(&mut self) { if self.surfaces.is_empty() { let replacement = SurfaceRecord::new(PaneKind::Terminal); @@ -329,13 +339,7 @@ impl PaneRecord { return; } - if !self.surfaces.contains_key(&self.active_surface) { - self.active_surface = self - .surfaces - .first() - .map(|(surface_id, _)| *surface_id) - .expect("pane has at least one surface"); - } + self.normalize_active_surface(); } } @@ -2537,14 +2541,17 @@ impl AppModel { workspace_id, pane_id, })?; - source_pane - .surfaces - .shift_remove(&surface_id) - .ok_or(DomainError::SurfaceNotInPane { + let moved_surface = source_pane.surfaces.shift_remove(&surface_id).ok_or( + DomainError::SurfaceNotInPane { workspace_id, pane_id, surface_id, - }) + }, + )?; + if !source_pane.surfaces.is_empty() { + source_pane.normalize_active_surface(); + } + Ok(moved_surface) } fn should_close_source_pane(&self, workspace_id: WorkspaceId, pane_id: PaneId) -> bool { @@ -4137,6 +4144,48 @@ mod tests { ); } + #[test] + fn transferring_active_surface_normalizes_the_source_pane() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let source_pane_id = model.active_workspace().expect("workspace").active_pane; + let remaining_surface_id = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&source_pane_id)) + .map(|pane| pane.active_surface) + .expect("remaining surface"); + let moved_surface_id = model + .create_surface(workspace_id, source_pane_id, PaneKind::Browser) + .expect("second surface"); + let target_pane_id = model + .split_pane(workspace_id, Some(source_pane_id), SplitAxis::Horizontal) + .expect("split pane"); + + model + .transfer_surface( + workspace_id, + source_pane_id, + moved_surface_id, + workspace_id, + target_pane_id, + usize::MAX, + ) + .expect("transfer"); + + let workspace = model.workspaces.get(&workspace_id).expect("workspace"); + let source_pane = workspace.panes.get(&source_pane_id).expect("source pane"); + + assert_eq!(source_pane.active_surface, remaining_surface_id); + assert_eq!( + source_pane.active_surface().map(|surface| surface.id), + Some(remaining_surface_id) + ); + assert_eq!( + source_pane.surface_ids().collect::>(), + vec![remaining_surface_id] + ); + } + #[test] fn moving_surface_to_split_in_another_workspace_closes_empty_source_pane() { let mut model = AppModel::new("Main"); diff --git a/crates/taskers-shell-core/src/lib.rs b/crates/taskers-shell-core/src/lib.rs index 522f0da..43ffbd2 100644 --- a/crates/taskers-shell-core/src/lib.rs +++ b/crates/taskers-shell-core/src/lib.rs @@ -5404,6 +5404,46 @@ mod tests { assert_eq!(snapshot.current_workspace.active_pane, new_pane_id); } + #[test] + fn move_surface_to_split_shell_action_keeps_source_pane_live() { + let core = SharedCore::bootstrap(bootstrap()); + let source_pane_id = core.snapshot().current_workspace.active_pane; + core.dispatch_shell_action(ShellAction::AddBrowserSurface { + pane_id: Some(source_pane_id), + }); + + let snapshot = core.snapshot(); + let moved_surface_id = find_pane(&snapshot.current_workspace.layout, source_pane_id) + .map(|pane| pane.active_surface) + .expect("added surface"); + + core.dispatch_shell_action(ShellAction::MoveSurfaceToSplit { + source_pane_id, + surface_id: moved_surface_id, + target_pane_id: source_pane_id, + direction: Direction::Right, + }); + + let snapshot = core.snapshot(); + let source_pane = + find_pane(&snapshot.current_workspace.layout, source_pane_id).expect("source pane"); + let remaining_surface_id = source_pane + .surfaces + .first() + .map(|surface| surface.id) + .expect("remaining surface"); + + assert_eq!(source_pane.active_surface, remaining_surface_id); + assert!(snapshot.portal.panes.iter().any(|plan| { + plan.pane_id == source_pane_id && plan.surface_id == remaining_surface_id + })); + assert!( + snapshot.portal.panes.iter().any(|plan| { + plan.surface_id == moved_surface_id && plan.pane_id != source_pane_id + }) + ); + } + #[test] fn move_surface_to_split_shell_action_moves_surface_into_other_workspace() { let core = SharedCore::bootstrap(bootstrap()); From 3ce99544bb771e44b14b23687017e6d5873637ab Mon Sep 17 00:00:00 2001 From: OneNoted Date: Mon, 23 Mar 2026 12:24:54 +0100 Subject: [PATCH 34/63] fix(startup): hide ghostty self-probe window --- crates/taskers-app/src/main.rs | 55 ++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/crates/taskers-app/src/main.rs b/crates/taskers-app/src/main.rs index 760ba98..0c4ecce 100644 --- a/crates/taskers-app/src/main.rs +++ b/crates/taskers-app/src/main.rs @@ -39,6 +39,7 @@ use webkit6::{Settings as WebKitSettings, WebView, prelude::*}; use glib::variant::ToVariant; const APP_ID: &str = taskers_paths::APP_ID; +const GHOSTTY_PROBE_WINDOW_SIZE_PX: i32 = 64; #[derive(Debug, Clone, Parser)] #[command(name = "taskers")] @@ -1102,18 +1103,21 @@ fn run_internal_surface_probe( selected_shortcut_preset: ShortcutPreset::PowerUser, notification_preferences: NotificationPreferencesSnapshot::default(), }); - core.set_window_size(PixelSize::new(1200, 800)); + core.set_window_size(PixelSize::new( + GHOSTTY_PROBE_WINDOW_SIZE_PX, + GHOSTTY_PROBE_WINDOW_SIZE_PX, + )); let event_sink = Rc::new(|_| {}); let mut taskers_host = TaskersHost::new(&shell_view, Some(host), event_sink, None); let host_widget = taskers_host.widget(); let window = gtk::Window::builder() - .title("Taskers Ghostty Probe") - .default_width(1200) - .default_height(800) + .default_width(GHOSTTY_PROBE_WINDOW_SIZE_PX) + .default_height(GHOSTTY_PROBE_WINDOW_SIZE_PX) .child(&host_widget) .build(); - window.present(); + configure_probe_window(&window); + window.show(); spin_probe_main_context(Duration::from_millis(80)); if let Err(error) = taskers_host.sync_snapshot(&core.snapshot()) { @@ -1142,6 +1146,17 @@ fn run_internal_surface_probe( std::process::exit(0); } +fn configure_probe_window(window: >k::Window) { + // The probe still needs a mapped GTK toplevel so Ghostty can realize an + // embedded surface, but it should not flash a visible window at startup. + window.set_decorated(false); + window.set_deletable(false); + window.set_resizable(false); + window.set_focusable(false); + window.set_can_target(false); + window.set_opacity(0.0); +} + fn spin_probe_main_context(duration: Duration) { let deadline = Instant::now() + duration; let context = glib::MainContext::default(); @@ -1260,7 +1275,14 @@ fn sync_window( } } - let size = PixelSize::new(window.width().max(1), window.height().max(1)); + let width = window.width(); + let height = window.height(); + if should_defer_initial_sync(last_size.get(), width, height) { + host.borrow().tick(); + return; + } + + let size = PixelSize::new(width.max(1), height.max(1)); if last_size.get() != (size.width, size.height) { core.set_window_size(size); last_size.set((size.width, size.height)); @@ -1306,6 +1328,10 @@ fn sync_window( host.borrow().tick(); } +fn should_defer_initial_sync(last_size: (i32, i32), width: i32, height: i32) -> bool { + last_size == (0, 0) && (width <= 1 || height <= 1) +} + fn install_host_bridge( receiver: Receiver, window: &adw::ApplicationWindow, @@ -1872,3 +1898,20 @@ impl DiagnosticsWriter { } } } + +#[cfg(test)] +mod startup_tests { + use super::should_defer_initial_sync; + + #[test] + fn initial_sync_waits_for_real_allocation() { + assert!(should_defer_initial_sync((0, 0), 1, 1)); + assert!(should_defer_initial_sync((0, 0), 1440, 1)); + assert!(!should_defer_initial_sync((0, 0), 1440, 900)); + } + + #[test] + fn later_resizes_do_not_get_blocked() { + assert!(!should_defer_initial_sync((1440, 900), 1, 1)); + } +} From e5067e9c908464e4d8459fe5cec1169ee2ced97f Mon Sep 17 00:00:00 2001 From: OneNoted Date: Mon, 23 Mar 2026 13:01:51 +0100 Subject: [PATCH 35/63] feat(shell-core): expose surface activity and status labels --- crates/taskers-shell-core/src/lib.rs | 202 +++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) diff --git a/crates/taskers-shell-core/src/lib.rs b/crates/taskers-shell-core/src/lib.rs index 43ffbd2..af1d1c6 100644 --- a/crates/taskers-shell-core/src/lib.rs +++ b/crates/taskers-shell-core/src/lib.rs @@ -682,6 +682,8 @@ pub struct SurfaceSnapshot { pub kind: SurfaceKind, pub runtime: RuntimeIdentitySnapshot, pub title: String, + pub activity_label: Option, + pub status_label: Option, pub url: Option, pub cwd: Option, pub attention: AttentionState, @@ -1681,6 +1683,8 @@ impl TaskersCore { kind: SurfaceKind::from_domain(&surface.kind), runtime: surface_runtime_identity(surface, now), title: display_surface_title(surface), + activity_label: surface_activity_label(surface, now), + status_label: surface_status_label(surface, now), url: normalized_surface_url(surface), cwd: normalized_cwd(&surface.metadata), attention: surface.attention.into(), @@ -3851,6 +3855,39 @@ fn display_surface_title(surface: &SurfaceRecord) -> String { } } +fn surface_activity_label(surface: &SurfaceRecord, now: OffsetDateTime) -> Option { + let _ = active_agent_surface_state(surface, now)?; + surface + .metadata + .latest_agent_message + .as_deref() + .map(str::trim) + .filter(|message| !message.is_empty()) + .map(str::to_owned) +} + +fn surface_status_label(surface: &SurfaceRecord, now: OffsetDateTime) -> Option { + match active_agent_surface_state(surface, now)? { + RuntimeStateSnapshot::Waiting => Some("Awaiting response".into()), + RuntimeStateSnapshot::Working => Some("Working".into()), + RuntimeStateSnapshot::Completed => Some("Completed".into()), + RuntimeStateSnapshot::Failed => Some("Failed".into()), + RuntimeStateSnapshot::Idle => None, + } +} + +fn active_agent_surface_state( + surface: &SurfaceRecord, + now: OffsetDateTime, +) -> Option { + if surface.kind != PaneKind::Terminal { + return None; + } + + let state = surface_runtime_state(surface, now); + (!matches!(state, RuntimeStateSnapshot::Idle)).then_some(state) +} + fn display_terminal_title(metadata: &PaneMetadata) -> String { let agent_title = metadata .agent_title @@ -4516,6 +4553,171 @@ mod tests { assert_eq!(runtime.state, super::RuntimeStateSnapshot::Waiting); } + #[test] + fn waiting_agent_labels_prefer_latest_message_and_human_status() { + let now = OffsetDateTime::now_utc(); + let surface = surface_with_metadata( + taskers_domain::PaneKind::Terminal, + taskers_domain::PaneMetadata { + agent_kind: Some("codex".into()), + agent_active: true, + agent_state: Some(taskers_domain::WorkspaceAgentState::Waiting), + latest_agent_message: Some("Summarize recent commits".into()), + last_signal_at: Some(now), + ..taskers_domain::PaneMetadata::default() + }, + ); + + assert_eq!( + super::surface_activity_label(&surface, now).as_deref(), + Some("Summarize recent commits") + ); + assert_eq!( + super::surface_status_label(&surface, now).as_deref(), + Some("Awaiting response") + ); + } + + #[test] + fn working_agent_labels_keep_activity_message_and_status() { + let now = OffsetDateTime::now_utc(); + let surface = surface_with_metadata( + taskers_domain::PaneKind::Terminal, + taskers_domain::PaneMetadata { + agent_kind: Some("codex".into()), + agent_active: true, + agent_state: Some(taskers_domain::WorkspaceAgentState::Working), + latest_agent_message: Some("Updating tests".into()), + last_signal_at: Some(now), + ..taskers_domain::PaneMetadata::default() + }, + ); + + assert_eq!( + super::surface_activity_label(&surface, now).as_deref(), + Some("Updating tests") + ); + assert_eq!( + super::surface_status_label(&surface, now).as_deref(), + Some("Working") + ); + } + + #[test] + fn recent_completed_and_failed_agents_keep_status_badges() { + let now = OffsetDateTime::now_utc(); + let completed = surface_with_metadata( + taskers_domain::PaneKind::Terminal, + taskers_domain::PaneMetadata { + agent_kind: Some("codex".into()), + agent_active: false, + agent_state: Some(taskers_domain::WorkspaceAgentState::Completed), + latest_agent_message: Some("Finished sync".into()), + last_signal_at: Some(now), + ..taskers_domain::PaneMetadata::default() + }, + ); + let failed = surface_with_metadata( + taskers_domain::PaneKind::Terminal, + taskers_domain::PaneMetadata { + agent_kind: Some("codex".into()), + agent_active: false, + agent_state: Some(taskers_domain::WorkspaceAgentState::Failed), + latest_agent_message: Some("Migration failed".into()), + last_signal_at: Some(now), + ..taskers_domain::PaneMetadata::default() + }, + ); + + assert_eq!( + super::surface_activity_label(&completed, now).as_deref(), + Some("Finished sync") + ); + assert_eq!( + super::surface_status_label(&completed, now).as_deref(), + Some("Completed") + ); + assert_eq!( + super::surface_activity_label(&failed, now).as_deref(), + Some("Migration failed") + ); + assert_eq!( + super::surface_status_label(&failed, now).as_deref(), + Some("Failed") + ); + } + + #[test] + fn stale_terminal_agents_clear_activity_and_status_labels() { + let now = OffsetDateTime::now_utc(); + let stale_timestamp = now - time::Duration::minutes(16); + let surface = surface_with_metadata( + taskers_domain::PaneKind::Terminal, + taskers_domain::PaneMetadata { + agent_kind: Some("codex".into()), + agent_active: false, + agent_state: Some(taskers_domain::WorkspaceAgentState::Completed), + latest_agent_message: Some("Finished sync".into()), + last_signal_at: Some(stale_timestamp), + ..taskers_domain::PaneMetadata::default() + }, + ); + + assert_eq!(super::surface_activity_label(&surface, now), None); + assert_eq!(super::surface_status_label(&surface, now), None); + } + + #[test] + fn non_agent_and_browser_surfaces_do_not_emit_activity_or_status_labels() { + let now = OffsetDateTime::now_utc(); + let terminal = surface_with_metadata( + taskers_domain::PaneKind::Terminal, + taskers_domain::PaneMetadata { + title: Some("zsh".into()), + latest_agent_message: Some("Ignored".into()), + ..taskers_domain::PaneMetadata::default() + }, + ); + let browser = surface_with_metadata( + taskers_domain::PaneKind::Browser, + taskers_domain::PaneMetadata { + agent_kind: Some("codex".into()), + agent_active: true, + agent_state: Some(taskers_domain::WorkspaceAgentState::Waiting), + latest_agent_message: Some("Should not surface".into()), + last_signal_at: Some(now), + ..taskers_domain::PaneMetadata::default() + }, + ); + + assert_eq!(super::surface_activity_label(&terminal, now), None); + assert_eq!(super::surface_status_label(&terminal, now), None); + assert_eq!(super::surface_activity_label(&browser, now), None); + assert_eq!(super::surface_status_label(&browser, now), None); + } + + #[test] + fn status_label_survives_when_agent_has_no_latest_message() { + let now = OffsetDateTime::now_utc(); + let surface = surface_with_metadata( + taskers_domain::PaneKind::Terminal, + taskers_domain::PaneMetadata { + agent_kind: Some("codex".into()), + agent_active: true, + agent_state: Some(taskers_domain::WorkspaceAgentState::Waiting), + latest_agent_message: Some(" ".into()), + last_signal_at: Some(now), + ..taskers_domain::PaneMetadata::default() + }, + ); + + assert_eq!(super::surface_activity_label(&surface, now), None); + assert_eq!( + super::surface_status_label(&surface, now).as_deref(), + Some("Awaiting response") + ); + } + #[test] fn workspace_runtime_identity_prioritizes_failed_agents_over_idle_surfaces() { let mut model = AppModel::new("Main"); From 15a1b11eb19a1a5a576e5e6f68691d3d906b2fd8 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Mon, 23 Mar 2026 13:03:22 +0100 Subject: [PATCH 36/63] feat(shell): show active task and agent state in pane headers and tabs --- crates/taskers-shell/src/lib.rs | 72 +++++++++++++++++++++++--- crates/taskers-shell/src/theme.rs | 85 +++++++++++++++++++++++++++++-- 2 files changed, 145 insertions(+), 12 deletions(-) diff --git a/crates/taskers-shell/src/lib.rs b/crates/taskers-shell/src/lib.rs index de065dc..536e4f6 100644 --- a/crates/taskers-shell/src/lib.rs +++ b/crates/taskers-shell/src/lib.rs @@ -78,6 +78,31 @@ fn render_runtime_icon_by_key(key: &str, size: u32, class: &str) -> Element { } } +fn surface_primary_label(surface: &SurfaceSnapshot) -> &str { + surface + .activity_label + .as_deref() + .unwrap_or(surface.title.as_str()) +} + +fn push_surface_summary_part(parts: &mut Vec, value: Option<&str>) { + let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { + return; + }; + if parts.iter().all(|existing| existing != value) { + parts.push(value.to_string()); + } +} + +fn surface_summary_title(surface: &SurfaceSnapshot) -> String { + let mut parts = Vec::new(); + push_surface_summary_part(&mut parts, Some(surface.runtime.label.as_str())); + push_surface_summary_part(&mut parts, surface.status_label.as_deref()); + push_surface_summary_part(&mut parts, surface.activity_label.as_deref()); + push_surface_summary_part(&mut parts, Some(surface.title.as_str())); + parts.join(" · ") +} + fn compute_surface_drop_index( dragged: DraggedSurface, target_pane_id: PaneId, @@ -1350,28 +1375,48 @@ fn render_pane( }; let pane_runtime_icon_class = format!( "pane-toolbar-kind-icon {}", - runtime_state_class(pane.runtime.state) + runtime_state_class(active_surface.runtime.state) ); let pane_runtime_chip_class = format!( "pane-runtime-chip {}", - runtime_state_class(pane.runtime.state) + runtime_state_class(active_surface.runtime.state) ); + let pane_runtime_state_class = format!( + "pane-runtime-state {}", + runtime_state_class(active_surface.runtime.state) + ); + let pane_surface_summary_title = surface_summary_title(active_surface); rsx! { section { class: "{pane_class}", onclick: focus_pane, div { class: "pane-toolbar", if show_tab_strip { - div { class: "pane-toolbar-meta", - {render_runtime_icon(&pane.runtime, 14, &pane_runtime_icon_class)} + div { + class: "pane-toolbar-meta", + title: "{pane_surface_summary_title}", + div { class: "{pane_runtime_chip_class}", + {render_runtime_icon(&active_surface.runtime, 14, &pane_runtime_icon_class)} + div { class: "pane-runtime-copy", + span { class: "pane-runtime-primary", "{surface_primary_label(active_surface)}" } + if let Some(status_label) = active_surface.status_label.as_deref() { + span { class: "{pane_runtime_state_class}", "{status_label}" } + } + } + } } } else { div { class: "pane-toolbar-meta pane-toolbar-meta-draggable", - title: "Drag surface", + title: "{pane_surface_summary_title}", onpointerdown: begin_active_surface_drag_candidate, div { class: "{pane_runtime_chip_class}", - {render_runtime_icon(&pane.runtime, 14, &pane_runtime_icon_class)} - span { class: "pane-runtime-label", "{pane.runtime.label}" } + {render_runtime_icon(&active_surface.runtime, 14, &pane_runtime_icon_class)} + div { class: "pane-runtime-copy", + span { class: "pane-runtime-primary", "{surface_primary_label(active_surface)}" } + if let Some(status_label) = active_surface.status_label.as_deref() { + span { class: "{pane_runtime_state_class}", "{status_label}" } + } + } } } } @@ -1678,10 +1723,16 @@ fn render_surface_tab( "surface-tab-kind-icon {}", runtime_state_class(surface.runtime.state) ); + let surface_tab_state_class = format!( + "surface-tab-state {}", + runtime_state_class(surface.runtime.state) + ); + let surface_tab_title = surface_summary_title(surface); rsx! { button { class: "{tab_class} surface-tab-draggable", + title: "{surface_tab_title}", onclick: focus_surface, onpointerdown: begin_surface_drag_candidate, onpointerenter: set_surface_drop_target_enter, @@ -1689,7 +1740,12 @@ fn render_surface_tab( onpointerleave: clear_surface_drop_target, onpointerup: drop_surface, {render_runtime_icon(&surface.runtime, 10, &surface_runtime_icon_class)} - span { class: "surface-tab-title", "{surface.title}" } + span { class: "surface-tab-copy", + span { class: "surface-tab-primary", "{surface_primary_label(surface)}" } + if let Some(status_label) = surface.status_label.as_deref() { + span { class: "{surface_tab_state_class}", "{status_label}" } + } + } } } } diff --git a/crates/taskers-shell/src/theme.rs b/crates/taskers-shell/src/theme.rs index 30f6892..c6fc70a 100644 --- a/crates/taskers-shell/src/theme.rs +++ b/crates/taskers-shell/src/theme.rs @@ -1214,7 +1214,15 @@ input:focus-visible {{ border: 1px solid {border_10}; }} -.pane-runtime-label {{ +.pane-runtime-copy {{ + min-width: 0; + display: inline-flex; + align-items: center; + gap: 6px; + flex: 1 1 auto; +}} + +.pane-runtime-primary {{ min-width: 0; font-size: 11px; font-weight: 600; @@ -1222,6 +1230,20 @@ input:focus-visible {{ overflow: hidden; text-overflow: ellipsis; color: {text_bright}; + flex: 1 1 auto; +}} + +.pane-runtime-state {{ + flex: 0 0 auto; + display: inline-flex; + align-items: center; + padding: 1px 6px; + border-radius: 999px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.01em; + background: {border_08}; + color: {text_bright}; }} .pane-runtime-chip.runtime-state-working {{ @@ -1244,6 +1266,22 @@ input:focus-visible {{ border-color: {error_12}; }} +.pane-runtime-state.runtime-state-working {{ + background: {busy_16}; +}} + +.pane-runtime-state.runtime-state-waiting {{ + background: {waiting_18}; +}} + +.pane-runtime-state.runtime-state-completed {{ + background: {completed_16}; +}} + +.pane-runtime-state.runtime-state-failed {{ + background: {error_16}; +}} + .pane-action-separator {{ width: 1px; height: 14px; @@ -1305,6 +1343,7 @@ input:focus-visible {{ color: {text_muted}; white-space: nowrap; border-radius: 4px; + overflow: hidden; }} .surface-tab:hover {{ @@ -1346,16 +1385,25 @@ input:focus-visible {{ border-radius: 1px; }} -.surface-tab-title {{ +.surface-tab-copy {{ + min-width: 0; + display: inline-flex; + align-items: center; + gap: 6px; + flex: 1 1 auto; +}} + +.surface-tab-primary {{ min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: {text_subtle}; font-size: 12px; + flex: 1 1 auto; }} -.surface-tab-active .surface-tab-title {{ +.surface-tab-active .surface-tab-primary {{ color: {text_bright}; }} @@ -1364,6 +1412,35 @@ input:focus-visible {{ opacity: 0.7; }} +.surface-tab-state {{ + flex: 0 0 auto; + display: inline-flex; + align-items: center; + padding: 1px 5px; + border-radius: 999px; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.01em; + background: {border_08}; + color: {text_bright}; +}} + +.surface-tab-state.runtime-state-working {{ + background: {busy_16}; +}} + +.surface-tab-state.runtime-state-waiting {{ + background: {waiting_18}; +}} + +.surface-tab-state.runtime-state-completed {{ + background: {completed_16}; +}} + +.surface-tab-state.runtime-state-failed {{ + background: {error_16}; +}} + .surface-tab-active .surface-tab-kind-icon {{ opacity: 1.0; }} @@ -1641,7 +1718,7 @@ input:focus-visible {{ padding: 0 6px; }} -.workspace-main-overview .surface-tab-title {{ +.workspace-main-overview .surface-tab-primary {{ font-size: 10px; }} From 5ba8a620a13a0c9fc2c6c8e3e1a77aab486be148 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Mon, 23 Mar 2026 13:06:08 +0100 Subject: [PATCH 37/63] test(shell): cover activity and status label rendering --- crates/taskers-ghostty/build.rs | 9 +--- crates/taskers-shell/src/lib.rs | 80 ++++++++++++++++++++++++++++++--- 2 files changed, 76 insertions(+), 13 deletions(-) diff --git a/crates/taskers-ghostty/build.rs b/crates/taskers-ghostty/build.rs index d4ae5a2..3b83d31 100644 --- a/crates/taskers-ghostty/build.rs +++ b/crates/taskers-ghostty/build.rs @@ -1,6 +1,5 @@ use std::{ - env, - fs, + env, fs, path::{Path, PathBuf}, process::Command, }; @@ -71,11 +70,7 @@ fn build_bridge(vendor_dir: &Path, install_dir: &Path) { "-Di18n=false", ]) .arg(version_arg) - .args([ - "--summary", - "none", - "--prefix", - ]) + .args(["--summary", "none", "--prefix"]) .arg(install_dir) .output() .expect("failed to invoke zig"); diff --git a/crates/taskers-shell/src/lib.rs b/crates/taskers-shell/src/lib.rs index 536e4f6..568c34f 100644 --- a/crates/taskers-shell/src/lib.rs +++ b/crates/taskers-shell/src/lib.rs @@ -85,6 +85,10 @@ fn surface_primary_label(surface: &SurfaceSnapshot) -> &str { .unwrap_or(surface.title.as_str()) } +fn surface_status_text(surface: &SurfaceSnapshot) -> Option<&str> { + surface.status_label.as_deref() +} + fn push_surface_summary_part(parts: &mut Vec, value: Option<&str>) { let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { return; @@ -1398,7 +1402,7 @@ fn render_pane( {render_runtime_icon(&active_surface.runtime, 14, &pane_runtime_icon_class)} div { class: "pane-runtime-copy", span { class: "pane-runtime-primary", "{surface_primary_label(active_surface)}" } - if let Some(status_label) = active_surface.status_label.as_deref() { + if let Some(status_label) = surface_status_text(active_surface) { span { class: "{pane_runtime_state_class}", "{status_label}" } } } @@ -1413,7 +1417,7 @@ fn render_pane( {render_runtime_icon(&active_surface.runtime, 14, &pane_runtime_icon_class)} div { class: "pane-runtime-copy", span { class: "pane-runtime-primary", "{surface_primary_label(active_surface)}" } - if let Some(status_label) = active_surface.status_label.as_deref() { + if let Some(status_label) = surface_status_text(active_surface) { span { class: "{pane_runtime_state_class}", "{status_label}" } } } @@ -1742,7 +1746,7 @@ fn render_surface_tab( {render_runtime_icon(&surface.runtime, 10, &surface_runtime_icon_class)} span { class: "surface-tab-copy", span { class: "surface-tab-primary", "{surface_primary_label(surface)}" } - if let Some(status_label) = surface.status_label.as_deref() { + if let Some(status_label) = surface_status_text(surface) { span { class: "{surface_tab_state_class}", "{status_label}" } } } @@ -1994,9 +1998,36 @@ fn render_notification_row( mod tests { use super::{ SurfaceDragCandidate, SurfaceKind, show_live_surface_backdrop, - surface_drag_threshold_reached, - }; - use crate::taskers_core::{PaneId, SurfaceId, WorkspaceId}; + surface_drag_threshold_reached, surface_primary_label, surface_status_text, + surface_summary_title, + }; + use crate::taskers_core::{ + AttentionState, PaneId, RuntimeIdentitySnapshot, RuntimeStateSnapshot, SurfaceId, + SurfaceSnapshot, WorkspaceId, + }; + + fn sample_surface( + title: &str, + activity_label: Option<&str>, + status_label: Option<&str>, + state: RuntimeStateSnapshot, + ) -> SurfaceSnapshot { + SurfaceSnapshot { + id: SurfaceId::new(), + kind: SurfaceKind::Terminal, + runtime: RuntimeIdentitySnapshot { + key: "codex".into(), + label: "Codex".into(), + state, + }, + title: title.into(), + activity_label: activity_label.map(str::to_owned), + status_label: status_label.map(str::to_owned), + url: None, + cwd: None, + attention: AttentionState::Normal, + } + } #[test] fn live_browser_panes_skip_decorative_backdrop_outside_overview() { @@ -2018,6 +2049,43 @@ mod tests { assert!(!surface_drag_threshold_reached(candidate, 104.0, 123.0)); assert!(surface_drag_threshold_reached(candidate, 106.0, 120.0)); } + + #[test] + fn primary_label_prefers_activity_over_stable_title() { + let surface = sample_surface( + "Codex · taskers/main", + Some("Summarize recent commits"), + Some("Awaiting response"), + RuntimeStateSnapshot::Waiting, + ); + + assert_eq!(surface_primary_label(&surface), "Summarize recent commits"); + } + + #[test] + fn waiting_status_text_uses_awaiting_response_copy() { + let surface = sample_surface( + "Codex · taskers/main", + Some("Summarize recent commits"), + Some("Awaiting response"), + RuntimeStateSnapshot::Waiting, + ); + + assert_eq!(surface_status_text(&surface), Some("Awaiting response")); + assert_eq!( + surface_summary_title(&surface), + "Codex · Awaiting response · Summarize recent commits · Codex · taskers/main" + ); + } + + #[test] + fn idle_surfaces_render_without_status_badge_text() { + let surface = sample_surface("taskers/main", None, None, RuntimeStateSnapshot::Idle); + + assert_eq!(surface_status_text(&surface), None); + assert_eq!(surface_primary_label(&surface), "taskers/main"); + assert_eq!(surface_summary_title(&surface), "Codex · taskers/main"); + } } fn render_settings(settings: &SettingsSnapshot, core: SharedCore) -> Element { From a422e57ee6e82d5dea76ff7e329738a92ca0d1a9 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Mon, 23 Mar 2026 16:14:17 +0100 Subject: [PATCH 38/63] fix(shell): show runtime badge in pane selector --- crates/taskers-shell/src/lib.rs | 88 +++++++++++++++++++++++++++++-- crates/taskers-shell/src/theme.rs | 26 +++++++++ 2 files changed, 109 insertions(+), 5 deletions(-) diff --git a/crates/taskers-shell/src/lib.rs b/crates/taskers-shell/src/lib.rs index 568c34f..e1090b2 100644 --- a/crates/taskers-shell/src/lib.rs +++ b/crates/taskers-shell/src/lib.rs @@ -89,6 +89,20 @@ fn surface_status_text(surface: &SurfaceSnapshot) -> Option<&str> { surface.status_label.as_deref() } +fn surface_runtime_badge_text(surface: &SurfaceSnapshot) -> Option<&str> { + if matches!(surface.runtime.key.as_str(), "terminal" | "browser") { + return None; + } + + let primary = surface_primary_label(surface).to_ascii_lowercase(); + let runtime = surface.runtime.label.to_ascii_lowercase(); + if primary.contains(&runtime) { + None + } else { + Some(surface.runtime.label.as_str()) + } +} + fn push_surface_summary_part(parts: &mut Vec, value: Option<&str>) { let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { return; @@ -1402,6 +1416,9 @@ fn render_pane( {render_runtime_icon(&active_surface.runtime, 14, &pane_runtime_icon_class)} div { class: "pane-runtime-copy", span { class: "pane-runtime-primary", "{surface_primary_label(active_surface)}" } + if let Some(runtime_label) = surface_runtime_badge_text(active_surface) { + span { class: "pane-runtime-badge", "{runtime_label}" } + } if let Some(status_label) = surface_status_text(active_surface) { span { class: "{pane_runtime_state_class}", "{status_label}" } } @@ -1417,6 +1434,9 @@ fn render_pane( {render_runtime_icon(&active_surface.runtime, 14, &pane_runtime_icon_class)} div { class: "pane-runtime-copy", span { class: "pane-runtime-primary", "{surface_primary_label(active_surface)}" } + if let Some(runtime_label) = surface_runtime_badge_text(active_surface) { + span { class: "pane-runtime-badge", "{runtime_label}" } + } if let Some(status_label) = surface_status_text(active_surface) { span { class: "{pane_runtime_state_class}", "{status_label}" } } @@ -1746,6 +1766,9 @@ fn render_surface_tab( {render_runtime_icon(&surface.runtime, 10, &surface_runtime_icon_class)} span { class: "surface-tab-copy", span { class: "surface-tab-primary", "{surface_primary_label(surface)}" } + if let Some(runtime_label) = surface_runtime_badge_text(surface) { + span { class: "surface-tab-runtime-badge", "{runtime_label}" } + } if let Some(status_label) = surface_status_text(surface) { span { class: "{surface_tab_state_class}", "{status_label}" } } @@ -1998,8 +2021,8 @@ fn render_notification_row( mod tests { use super::{ SurfaceDragCandidate, SurfaceKind, show_live_surface_backdrop, - surface_drag_threshold_reached, surface_primary_label, surface_status_text, - surface_summary_title, + surface_drag_threshold_reached, surface_primary_label, surface_runtime_badge_text, + surface_status_text, surface_summary_title, }; use crate::taskers_core::{ AttentionState, PaneId, RuntimeIdentitySnapshot, RuntimeStateSnapshot, SurfaceId, @@ -2007,6 +2030,8 @@ mod tests { }; fn sample_surface( + runtime_key: &str, + runtime_label: &str, title: &str, activity_label: Option<&str>, status_label: Option<&str>, @@ -2016,8 +2041,8 @@ mod tests { id: SurfaceId::new(), kind: SurfaceKind::Terminal, runtime: RuntimeIdentitySnapshot { - key: "codex".into(), - label: "Codex".into(), + key: runtime_key.into(), + label: runtime_label.into(), state, }, title: title.into(), @@ -2053,6 +2078,8 @@ mod tests { #[test] fn primary_label_prefers_activity_over_stable_title() { let surface = sample_surface( + "codex", + "Codex", "Codex · taskers/main", Some("Summarize recent commits"), Some("Awaiting response"), @@ -2065,6 +2092,8 @@ mod tests { #[test] fn waiting_status_text_uses_awaiting_response_copy() { let surface = sample_surface( + "codex", + "Codex", "Codex · taskers/main", Some("Summarize recent commits"), Some("Awaiting response"), @@ -2080,12 +2109,61 @@ mod tests { #[test] fn idle_surfaces_render_without_status_badge_text() { - let surface = sample_surface("taskers/main", None, None, RuntimeStateSnapshot::Idle); + let surface = sample_surface( + "codex", + "Codex", + "taskers/main", + None, + None, + RuntimeStateSnapshot::Idle, + ); assert_eq!(surface_status_text(&surface), None); assert_eq!(surface_primary_label(&surface), "taskers/main"); assert_eq!(surface_summary_title(&surface), "Codex · taskers/main"); } + + #[test] + fn runtime_badge_shows_for_agent_tabs_when_primary_label_hides_it() { + let surface = sample_surface( + "codex", + "Codex", + "taskers/main", + Some("Summarize recent commits"), + None, + RuntimeStateSnapshot::Working, + ); + + assert_eq!(surface_runtime_badge_text(&surface), Some("Codex")); + } + + #[test] + fn runtime_badge_hides_for_generic_terminal_surfaces() { + let surface = sample_surface( + "terminal", + "Terminal", + "Terminal", + None, + None, + RuntimeStateSnapshot::Idle, + ); + + assert_eq!(surface_runtime_badge_text(&surface), None); + } + + #[test] + fn runtime_badge_hides_when_primary_label_already_mentions_runtime() { + let surface = sample_surface( + "codex", + "Codex", + "Codex · taskers/main", + None, + None, + RuntimeStateSnapshot::Idle, + ); + + assert_eq!(surface_runtime_badge_text(&surface), None); + } } fn render_settings(settings: &SettingsSnapshot, core: SharedCore) -> Element { diff --git a/crates/taskers-shell/src/theme.rs b/crates/taskers-shell/src/theme.rs index c6fc70a..1ac06cd 100644 --- a/crates/taskers-shell/src/theme.rs +++ b/crates/taskers-shell/src/theme.rs @@ -1233,6 +1233,19 @@ input:focus-visible {{ flex: 1 1 auto; }} +.pane-runtime-badge {{ + flex: 0 0 auto; + display: inline-flex; + align-items: center; + padding: 1px 5px; + border-radius: 999px; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.03em; + background: {border_08}; + color: {text_subtle}; +}} + .pane-runtime-state {{ flex: 0 0 auto; display: inline-flex; @@ -1403,6 +1416,19 @@ input:focus-visible {{ flex: 1 1 auto; }} +.surface-tab-runtime-badge {{ + flex: 0 0 auto; + display: inline-flex; + align-items: center; + padding: 1px 5px; + border-radius: 999px; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.03em; + background: {border_08}; + color: {text_subtle}; +}} + .surface-tab-active .surface-tab-primary {{ color: {text_bright}; }} From a12b1adc0f4a6eae2b8842c394bb92d41538d2b4 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Mon, 23 Mar 2026 17:58:46 +0100 Subject: [PATCH 39/63] fix(notifications): repair center dismiss action --- crates/taskers-shell-core/src/lib.rs | 18 ++++++++++++++++++ crates/taskers-shell/src/lib.rs | 2 +- crates/taskers-shell/src/theme.rs | 2 ++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/taskers-shell-core/src/lib.rs b/crates/taskers-shell-core/src/lib.rs index af1d1c6..eab9fe6 100644 --- a/crates/taskers-shell-core/src/lib.rs +++ b/crates/taskers-shell-core/src/lib.rs @@ -5872,6 +5872,24 @@ mod tests { assert_eq!(core.snapshot().current_workspace.id, second_workspace_id); } + #[test] + fn dismiss_activity_moves_notification_into_done_history() { + let core = SharedCore::bootstrap(bootstrap_with_notification(false)); + let activity_id = core + .snapshot() + .activity + .first() + .map(|item| item.id) + .expect("notification activity"); + + core.dispatch_shell_action(ShellAction::DismissActivity { activity_id }); + + let snapshot = core.snapshot(); + assert!(snapshot.activity.is_empty()); + assert_eq!(snapshot.done_activity.len(), 1); + assert_eq!(snapshot.done_activity[0].id, activity_id); + } + #[test] fn surface_flash_command_updates_pane_flash_token() { let app_state = default_preview_app_state(); diff --git a/crates/taskers-shell/src/lib.rs b/crates/taskers-shell/src/lib.rs index e1090b2..b03a497 100644 --- a/crates/taskers-shell/src/lib.rs +++ b/crates/taskers-shell/src/lib.rs @@ -1990,7 +1990,7 @@ fn render_notification_row( }; rsx! { - button { class: "notification-row-button", onclick: focus_target, + div { class: "notification-row-button", onclick: focus_target, div { class: "notification-row", div { class: "{dot_class}" } div { class: "notification-row-content", diff --git a/crates/taskers-shell/src/theme.rs b/crates/taskers-shell/src/theme.rs index 1ac06cd..47f430b 100644 --- a/crates/taskers-shell/src/theme.rs +++ b/crates/taskers-shell/src/theme.rs @@ -1944,11 +1944,13 @@ input:focus-visible {{ }} .notification-row-button {{ + display: block; width: 100%; border: 0; padding: 0; background: transparent; text-align: left; + cursor: pointer; }} .notification-row {{ From f1b2ac5d594ebe6ef296a2ef54061cd9528e4cb6 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 24 Mar 2026 11:17:06 +0100 Subject: [PATCH 40/63] fix(notifications): hide cleared items from center --- crates/taskers-shell-core/src/lib.rs | 11 +++++++---- crates/taskers-shell/src/lib.rs | 5 +---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/taskers-shell-core/src/lib.rs b/crates/taskers-shell-core/src/lib.rs index eab9fe6..88cb6e9 100644 --- a/crates/taskers-shell-core/src/lib.rs +++ b/crates/taskers-shell-core/src/lib.rs @@ -1263,8 +1263,7 @@ impl TaskersCore { let agents = self.agent_sessions_snapshot(&model); let activity = self.activity_snapshot(&model); let done_activity = self.done_activity_snapshot(&model); - let attention_panel_visible = - !agents.is_empty() || !activity.is_empty() || !done_activity.is_empty(); + let attention_panel_visible = !agents.is_empty() || !activity.is_empty(); let workspace_id = model .active_workspace_id() .expect("active workspace should exist"); @@ -5399,10 +5398,13 @@ mod tests { assert!(!unread_snapshot.activity.is_empty()); assert!(unread_snapshot.portal.content.width < empty_snapshot.portal.content.width); - assert!(done_snapshot.attention_panel_visible); + assert!(!done_snapshot.attention_panel_visible); assert!(done_snapshot.activity.is_empty()); assert!(!done_snapshot.done_activity.is_empty()); - assert!(done_snapshot.portal.content.width < empty_snapshot.portal.content.width); + assert_eq!( + done_snapshot.portal.content.width, + empty_snapshot.portal.content.width + ); } #[test] @@ -5888,6 +5890,7 @@ mod tests { assert!(snapshot.activity.is_empty()); assert_eq!(snapshot.done_activity.len(), 1); assert_eq!(snapshot.done_activity[0].id, activity_id); + assert!(!snapshot.attention_panel_visible); } #[test] diff --git a/crates/taskers-shell/src/lib.rs b/crates/taskers-shell/src/lib.rs index b03a497..255ed66 100644 --- a/crates/taskers-shell/src/lib.rs +++ b/crates/taskers-shell/src/lib.rs @@ -537,7 +537,7 @@ pub fn TaskersShell(core: SharedCore) -> Element { } } div { class: "notification-timeline", - if snapshot.activity.is_empty() && snapshot.done_activity.is_empty() { + if snapshot.activity.is_empty() { div { class: "notification-empty", {icons::bell(24, "notification-empty-icon")} div { class: "notification-empty-title", "No notifications" } @@ -547,9 +547,6 @@ pub fn TaskersShell(core: SharedCore) -> Element { for item in &snapshot.activity { {render_notification_row(item, core.clone(), &snapshot.current_workspace)} } - for item in snapshot.done_activity.iter().take(8) { - {render_notification_row(item, core.clone(), &snapshot.current_workspace)} - } } } if !snapshot.current_workspace_log.is_empty() { From dddad5cec02f69094c0c4de6ccaa4b9bae91b868 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 24 Mar 2026 11:25:46 +0100 Subject: [PATCH 41/63] feat(attention): derive agent notification ring state --- crates/taskers-shell-core/src/lib.rs | 210 +++++++++++++++++++++++++-- 1 file changed, 195 insertions(+), 15 deletions(-) diff --git a/crates/taskers-shell-core/src/lib.rs b/crates/taskers-shell-core/src/lib.rs index 88cb6e9..5025e33 100644 --- a/crates/taskers-shell-core/src/lib.rs +++ b/crates/taskers-shell-core/src/lib.rs @@ -121,6 +121,23 @@ impl AttentionState { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AttentionRingState { + Waiting, + Error, + Completed, +} + +impl AttentionRingState { + pub fn slug(self) -> &'static str { + match self { + Self::Waiting => "waiting", + Self::Error => "error", + Self::Completed => "completed", + } + } +} + impl From for AttentionState { fn from(value: taskers_domain::AttentionState) -> Self { match value { @@ -687,6 +704,7 @@ pub struct SurfaceSnapshot { pub url: Option, pub cwd: Option, pub attention: AttentionState, + pub notification_ring: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -694,6 +712,7 @@ pub struct PaneSnapshot { pub id: PaneId, pub active: bool, pub attention: AttentionState, + pub notification_ring: Option, pub active_surface: SurfaceId, pub runtime: RuntimeIdentitySnapshot, pub surfaces: Vec, @@ -1668,27 +1687,34 @@ impl TaskersCore { 0 }; let flash_token = focus_flash_token.max(explicit_flash_token); + let surfaces = pane + .surfaces + .values() + .map(|surface| SurfaceSnapshot { + id: surface.id, + kind: SurfaceKind::from_domain(&surface.kind), + runtime: surface_runtime_identity(surface, now), + title: display_surface_title(surface), + activity_label: surface_activity_label(surface, now), + status_label: surface_status_label(surface, now), + url: normalized_surface_url(surface), + cwd: normalized_cwd(&surface.metadata), + attention: surface.attention.into(), + notification_ring: surface_notification_ring(surface), + }) + .collect::>(); PaneSnapshot { id: pane.id, active: is_active, attention: pane.highest_attention().into(), + notification_ring: dominant_attention_ring( + surfaces + .iter() + .filter_map(|surface| surface.notification_ring), + ), active_surface: pane.active_surface, runtime: pane_runtime_identity(pane, now), - surfaces: pane - .surfaces - .values() - .map(|surface| SurfaceSnapshot { - id: surface.id, - kind: SurfaceKind::from_domain(&surface.kind), - runtime: surface_runtime_identity(surface, now), - title: display_surface_title(surface), - activity_label: surface_activity_label(surface, now), - status_label: surface_status_label(surface, now), - url: normalized_surface_url(surface), - cwd: normalized_cwd(&surface.metadata), - attention: surface.attention.into(), - }) - .collect(), + surfaces, focus_flash_token: flash_token, } } @@ -3717,6 +3743,16 @@ fn runtime_kind_priority(key: &str) -> u8 { } } +fn dominant_attention_ring( + rings: impl IntoIterator, +) -> Option { + rings.into_iter().min_by_key(|ring| match ring { + AttentionRingState::Error => 0, + AttentionRingState::Waiting => 1, + AttentionRingState::Completed => 2, + }) +} + fn runtime_key(surface: &SurfaceRecord) -> String { if let Some(agent_kind) = surface .metadata @@ -3887,6 +3923,30 @@ fn active_agent_surface_state( (!matches!(state, RuntimeStateSnapshot::Idle)).then_some(state) } +fn surface_notification_ring(surface: &SurfaceRecord) -> Option { + if surface.kind != PaneKind::Terminal { + return None; + } + + let is_agent_surface = surface + .metadata + .agent_kind + .as_deref() + .map(str::trim) + .filter(|agent_kind| !agent_kind.is_empty() && *agent_kind != "shell") + .is_some(); + if !is_agent_surface { + return None; + } + + match surface.attention { + taskers_domain::AttentionState::WaitingInput => Some(AttentionRingState::Waiting), + taskers_domain::AttentionState::Error => Some(AttentionRingState::Error), + taskers_domain::AttentionState::Completed => Some(AttentionRingState::Completed), + taskers_domain::AttentionState::Normal | taskers_domain::AttentionState::Busy => None, + } +} + fn display_terminal_title(metadata: &PaneMetadata) -> String { let agent_title = metadata .agent_title @@ -4695,6 +4755,61 @@ mod tests { assert_eq!(super::surface_status_label(&browser, now), None); } + #[test] + fn notification_rings_only_cover_agent_terminal_attention_states() { + let waiting = surface_with_metadata( + taskers_domain::PaneKind::Terminal, + taskers_domain::PaneMetadata { + agent_kind: Some("codex".into()), + ..taskers_domain::PaneMetadata::default() + }, + ); + let completed = surface_with_metadata( + taskers_domain::PaneKind::Terminal, + taskers_domain::PaneMetadata { + agent_kind: Some("codex".into()), + ..taskers_domain::PaneMetadata::default() + }, + ); + let busy = surface_with_metadata( + taskers_domain::PaneKind::Terminal, + taskers_domain::PaneMetadata { + agent_kind: Some("codex".into()), + ..taskers_domain::PaneMetadata::default() + }, + ); + let non_agent = surface_with_metadata( + taskers_domain::PaneKind::Terminal, + taskers_domain::PaneMetadata::default(), + ); + let browser = surface_with_metadata( + taskers_domain::PaneKind::Browser, + taskers_domain::PaneMetadata { + agent_kind: Some("codex".into()), + ..taskers_domain::PaneMetadata::default() + }, + ); + + let mut waiting = waiting; + waiting.attention = taskers_domain::AttentionState::WaitingInput; + let mut completed = completed; + completed.attention = taskers_domain::AttentionState::Completed; + let mut busy = busy; + busy.attention = taskers_domain::AttentionState::Busy; + + assert_eq!( + super::surface_notification_ring(&waiting), + Some(super::AttentionRingState::Waiting) + ); + assert_eq!( + super::surface_notification_ring(&completed), + Some(super::AttentionRingState::Completed) + ); + assert_eq!(super::surface_notification_ring(&busy), None); + assert_eq!(super::surface_notification_ring(&non_agent), None); + assert_eq!(super::surface_notification_ring(&browser), None); + } + #[test] fn status_label_survives_when_agent_has_no_latest_message() { let now = OffsetDateTime::now_utc(); @@ -4816,6 +4931,71 @@ mod tests { ); } + #[test] + fn pane_notification_ring_prioritizes_inactive_agent_tabs() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let pane_id = model.active_workspace().expect("workspace").active_pane; + let inactive_surface_id = model + .workspaces + .get(&workspace_id) + .and_then(|workspace| workspace.panes.get(&pane_id)) + .map(|pane| pane.active_surface) + .expect("inactive surface"); + let active_surface_id = model + .create_surface(workspace_id, pane_id, taskers_domain::PaneKind::Terminal) + .expect("create active terminal"); + + { + let workspace = model.workspaces.get_mut(&workspace_id).expect("workspace"); + let active_surface = workspace + .panes + .get_mut(&pane_id) + .and_then(|pane| pane.surfaces.get_mut(&active_surface_id)) + .expect("active surface record"); + active_surface.metadata.agent_kind = Some("codex".into()); + active_surface.attention = taskers_domain::AttentionState::Completed; + + let inactive_surface = workspace + .panes + .get_mut(&pane_id) + .and_then(|pane| pane.surfaces.get_mut(&inactive_surface_id)) + .expect("inactive surface record"); + inactive_surface.metadata.agent_kind = Some("claude".into()); + inactive_surface.attention = taskers_domain::AttentionState::Error; + } + + let core = SharedCore::bootstrap(bootstrap_with_model( + model, + "taskers-preview-pane-notification-ring", + )); + let snapshot = core.snapshot(); + let pane = find_pane(&snapshot.current_workspace.layout, pane_id).expect("pane"); + let active_surface = pane + .surfaces + .iter() + .find(|surface| surface.id == active_surface_id) + .expect("active surface snapshot"); + let inactive_surface = pane + .surfaces + .iter() + .find(|surface| surface.id == inactive_surface_id) + .expect("inactive surface snapshot"); + + assert_eq!( + active_surface.notification_ring, + Some(super::AttentionRingState::Completed) + ); + assert_eq!( + inactive_surface.notification_ring, + Some(super::AttentionRingState::Error) + ); + assert_eq!( + pane.notification_ring, + Some(super::AttentionRingState::Error) + ); + } + #[test] fn default_bootstrap_projects_browser_and_terminal_portal_plans() { let core = SharedCore::bootstrap(bootstrap()); From 3f0a9b7b521640d09995ec779cc1c5b209a83cd6 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 24 Mar 2026 11:46:03 +0100 Subject: [PATCH 42/63] feat(shell): style agent attention rings --- crates/taskers-shell/src/lib.rs | 49 +++++++++++++++------ crates/taskers-shell/src/theme.rs | 72 +++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 14 deletions(-) diff --git a/crates/taskers-shell/src/lib.rs b/crates/taskers-shell/src/lib.rs index 255ed66..20c8dd3 100644 --- a/crates/taskers-shell/src/lib.rs +++ b/crates/taskers-shell/src/lib.rs @@ -8,13 +8,13 @@ use dioxus::html::{ }; use dioxus::prelude::*; use taskers_core::{ - ActivityItemSnapshot, AgentSessionSnapshot, AttentionState, BrowserChromeSnapshot, Direction, - LayoutNodeSnapshot, NotificationPreferenceKey, PaneId, PaneSnapshot, ProgressSnapshot, - PullRequestSnapshot, RuntimeIdentitySnapshot, RuntimeStateSnapshot, RuntimeStatus, - SettingsSnapshot, SharedCore, ShellAction, ShellSection, ShellSnapshot, ShortcutAction, - ShortcutBindingSnapshot, SplitAxis, SurfaceDragSessionSnapshot, SurfaceId, SurfaceKind, - SurfaceSnapshot, WorkspaceId, WorkspaceLogEntrySnapshot, WorkspaceSummary, - WorkspaceViewSnapshot, WorkspaceWindowMoveTarget, WorkspaceWindowSnapshot, + ActivityItemSnapshot, AgentSessionSnapshot, AttentionRingState, AttentionState, + BrowserChromeSnapshot, Direction, LayoutNodeSnapshot, NotificationPreferenceKey, PaneId, + PaneSnapshot, ProgressSnapshot, PullRequestSnapshot, RuntimeIdentitySnapshot, + RuntimeStateSnapshot, RuntimeStatus, SettingsSnapshot, SharedCore, ShellAction, ShellSection, + ShellSnapshot, ShortcutAction, ShortcutBindingSnapshot, SplitAxis, SurfaceDragSessionSnapshot, + SurfaceId, SurfaceKind, SurfaceSnapshot, WorkspaceId, WorkspaceLogEntrySnapshot, + WorkspaceSummary, WorkspaceViewSnapshot, WorkspaceWindowMoveTarget, WorkspaceWindowSnapshot, }; use taskers_shell_core as taskers_core; @@ -63,6 +63,12 @@ fn runtime_state_class(state: RuntimeStateSnapshot) -> &'static str { } } +fn attention_ring_class(state: Option, prefix: &str) -> String { + state + .map(|state| format!(" {prefix}-{}", state.slug())) + .unwrap_or_default() +} + fn render_runtime_icon(runtime: &RuntimeIdentitySnapshot, size: u32, class: &str) -> Element { render_runtime_icon_by_key(runtime.key.as_str(), size, class) } @@ -1268,9 +1274,11 @@ fn render_pane( ) -> Element { let pane_id = pane.id; let pane_is_drop_target = pane_has_surface_drop_target(*surface_drop_target.read(), pane_id); + let pane_ring_class = attention_ring_class(pane.notification_ring, "pane-card-attention"); let pane_class = if pane.active { format!( - "pane-card pane-card-active{}", + "pane-card pane-card-active{}{}", + pane_ring_class, if pane_is_drop_target { " pane-card-drop-target" } else { @@ -1279,7 +1287,8 @@ fn render_pane( ) } else { format!( - "pane-card{}", + "pane-card{}{}", + pane_ring_class, if pane_is_drop_target { " pane-card-drop-target" } else { @@ -1644,7 +1653,8 @@ fn render_surface_tab( ); let tab_class = if surface.id == active_surface_id { format!( - "surface-tab surface-tab-active{}", + "surface-tab surface-tab-active{}{}", + attention_ring_class(surface.notification_ring, "surface-tab-attention"), if is_drop_target { " surface-tab-drop-target" } else { @@ -1653,7 +1663,8 @@ fn render_surface_tab( ) } else { format!( - "surface-tab{}", + "surface-tab{}{}", + attention_ring_class(surface.notification_ring, "surface-tab-attention"), if is_drop_target { " surface-tab-drop-target" } else { @@ -2017,13 +2028,13 @@ fn render_notification_row( #[cfg(test)] mod tests { use super::{ - SurfaceDragCandidate, SurfaceKind, show_live_surface_backdrop, + SurfaceDragCandidate, SurfaceKind, attention_ring_class, show_live_surface_backdrop, surface_drag_threshold_reached, surface_primary_label, surface_runtime_badge_text, surface_status_text, surface_summary_title, }; use crate::taskers_core::{ - AttentionState, PaneId, RuntimeIdentitySnapshot, RuntimeStateSnapshot, SurfaceId, - SurfaceSnapshot, WorkspaceId, + AttentionRingState, AttentionState, PaneId, RuntimeIdentitySnapshot, RuntimeStateSnapshot, + SurfaceId, SurfaceSnapshot, WorkspaceId, }; fn sample_surface( @@ -2048,9 +2059,19 @@ mod tests { url: None, cwd: None, attention: AttentionState::Normal, + notification_ring: None, } } + #[test] + fn attention_ring_class_uses_expected_state_slug() { + assert_eq!( + attention_ring_class(Some(AttentionRingState::Error), "pane-card-attention"), + " pane-card-attention-error" + ); + assert_eq!(attention_ring_class(None, "surface-tab-attention"), ""); + } + #[test] fn live_browser_panes_skip_decorative_backdrop_outside_overview() { assert!(!show_live_surface_backdrop(SurfaceKind::Browser, false)); diff --git a/crates/taskers-shell/src/theme.rs b/crates/taskers-shell/src/theme.rs index 47f430b..38bb65f 100644 --- a/crates/taskers-shell/src/theme.rs +++ b/crates/taskers-shell/src/theme.rs @@ -1127,6 +1127,18 @@ input:focus-visible {{ border-color: {accent_24}; }} +.pane-card-attention-waiting {{ + box-shadow: inset 0 0 0 1px {waiting}, 0 0 0 1px {waiting_18}; +}} + +.pane-card-attention-error {{ + box-shadow: inset 0 0 0 1px {error}, 0 0 0 1px {error_16}; +}} + +.pane-card-attention-completed {{ + box-shadow: inset 0 0 0 1px {completed}, 0 0 0 1px {completed_16}; +}} + .pane-card-drop-target {{ border-color: {accent_24}; }} @@ -1375,6 +1387,42 @@ input:focus-visible {{ cursor: grab; }} +.surface-tab-attention-waiting {{ + border-color: {waiting_18}; + background: {waiting_18}; + color: {waiting_text}; +}} + +.surface-tab-attention-waiting .surface-tab-primary, +.surface-tab-attention-waiting .surface-tab-kind-icon {{ + color: {waiting_text}; + opacity: 1.0; +}} + +.surface-tab-attention-error {{ + border-color: {error_16}; + background: {error_16}; + color: {error_text}; +}} + +.surface-tab-attention-error .surface-tab-primary, +.surface-tab-attention-error .surface-tab-kind-icon {{ + color: {error_text}; + opacity: 1.0; +}} + +.surface-tab-attention-completed {{ + border-color: {completed_16}; + background: {completed_16}; + color: {completed_text}; +}} + +.surface-tab-attention-completed .surface-tab-primary, +.surface-tab-attention-completed .surface-tab-kind-icon {{ + color: {completed_text}; + opacity: 1.0; +}} + .surface-tab-drop-target {{ border-color: {accent_24}; background: {accent_12}; @@ -1398,6 +1446,30 @@ input:focus-visible {{ border-radius: 1px; }} +.surface-tab-attention-waiting.surface-tab-active {{ + border-color: {waiting}; +}} + +.surface-tab-attention-waiting.surface-tab-active::after {{ + background: {waiting}; +}} + +.surface-tab-attention-error.surface-tab-active {{ + border-color: {error}; +}} + +.surface-tab-attention-error.surface-tab-active::after {{ + background: {error}; +}} + +.surface-tab-attention-completed.surface-tab-active {{ + border-color: {completed}; +}} + +.surface-tab-attention-completed.surface-tab-active::after {{ + background: {completed}; +}} + .surface-tab-copy {{ min-width: 0; display: inline-flex; From 0e9a767547a5e21c4edda9d27a89b31e48ab56ba Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 24 Mar 2026 11:48:25 +0100 Subject: [PATCH 43/63] fix(shell): strengthen agent attention ring styling --- crates/taskers-shell/src/theme.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/taskers-shell/src/theme.rs b/crates/taskers-shell/src/theme.rs index 38bb65f..de8a26c 100644 --- a/crates/taskers-shell/src/theme.rs +++ b/crates/taskers-shell/src/theme.rs @@ -1128,15 +1128,27 @@ input:focus-visible {{ }} .pane-card-attention-waiting {{ - box-shadow: inset 0 0 0 1px {waiting}, 0 0 0 1px {waiting_18}; + border-color: {waiting}; + box-shadow: + inset 0 0 0 1px {waiting}, + 0 0 0 1px {waiting_18}, + 0 0 18px {waiting_18}; }} .pane-card-attention-error {{ - box-shadow: inset 0 0 0 1px {error}, 0 0 0 1px {error_16}; + border-color: {error}; + box-shadow: + inset 0 0 0 1px {error}, + 0 0 0 1px {error_16}, + 0 0 18px {error_16}; }} .pane-card-attention-completed {{ - box-shadow: inset 0 0 0 1px {completed}, 0 0 0 1px {completed_16}; + border-color: {completed}; + box-shadow: + inset 0 0 0 1px {completed}, + 0 0 0 1px {completed_16}, + 0 0 18px {completed_16}; }} .pane-card-drop-target {{ From aee88785b9a26041d4a51fdb93d32ea120fba7a4 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 24 Mar 2026 11:53:35 +0100 Subject: [PATCH 44/63] fix(shell): surround panes with attention ring --- crates/taskers-shell/src/lib.rs | 12 ++++--- crates/taskers-shell/src/theme.rs | 52 +++++++++++++++++-------------- 2 files changed, 35 insertions(+), 29 deletions(-) diff --git a/crates/taskers-shell/src/lib.rs b/crates/taskers-shell/src/lib.rs index 20c8dd3..25e3305 100644 --- a/crates/taskers-shell/src/lib.rs +++ b/crates/taskers-shell/src/lib.rs @@ -1274,11 +1274,9 @@ fn render_pane( ) -> Element { let pane_id = pane.id; let pane_is_drop_target = pane_has_surface_drop_target(*surface_drop_target.read(), pane_id); - let pane_ring_class = attention_ring_class(pane.notification_ring, "pane-card-attention"); let pane_class = if pane.active { format!( - "pane-card pane-card-active{}{}", - pane_ring_class, + "pane-card pane-card-active{}", if pane_is_drop_target { " pane-card-drop-target" } else { @@ -1287,8 +1285,7 @@ fn render_pane( ) } else { format!( - "pane-card{}{}", - pane_ring_class, + "pane-card{}", if pane_is_drop_target { " pane-card-drop-target" } else { @@ -1296,6 +1293,10 @@ fn render_pane( } ) }; + let pane_attention_ring_class = format!( + "pane-attention-ring{}", + attention_ring_class(pane.notification_ring, "pane-attention-ring") + ); let active_surface = pane .surfaces .iter() @@ -1564,6 +1565,7 @@ fn render_pane( } } } + div { class: "{pane_attention_ring_class}" } div { key: "{flash_key}", class: "{flash_class}" } } } diff --git a/crates/taskers-shell/src/theme.rs b/crates/taskers-shell/src/theme.rs index de8a26c..5b1e720 100644 --- a/crates/taskers-shell/src/theme.rs +++ b/crates/taskers-shell/src/theme.rs @@ -1127,30 +1127,6 @@ input:focus-visible {{ border-color: {accent_24}; }} -.pane-card-attention-waiting {{ - border-color: {waiting}; - box-shadow: - inset 0 0 0 1px {waiting}, - 0 0 0 1px {waiting_18}, - 0 0 18px {waiting_18}; -}} - -.pane-card-attention-error {{ - border-color: {error}; - box-shadow: - inset 0 0 0 1px {error}, - 0 0 0 1px {error_16}, - 0 0 18px {error_16}; -}} - -.pane-card-attention-completed {{ - border-color: {completed}; - box-shadow: - inset 0 0 0 1px {completed}, - 0 0 0 1px {completed_16}, - 0 0 18px {completed_16}; -}} - .pane-card-drop-target {{ border-color: {accent_24}; }} @@ -1173,6 +1149,34 @@ input:focus-visible {{ border-radius: 6px; }} +.pane-attention-ring {{ + position: absolute; + inset: 1px; + border: 2px solid transparent; + border-radius: 5px; + pointer-events: none; + opacity: 0; + z-index: 9; +}} + +.pane-attention-ring-waiting {{ + opacity: 1; + border-color: {waiting}; + box-shadow: inset 0 0 0 1px {waiting_18}, 0 0 18px {waiting_18}; +}} + +.pane-attention-ring-error {{ + opacity: 1; + border-color: {error}; + box-shadow: inset 0 0 0 1px {error_16}, 0 0 18px {error_16}; +}} + +.pane-attention-ring-completed {{ + opacity: 1; + border-color: {completed}; + box-shadow: inset 0 0 0 1px {completed_16}, 0 0 18px {completed_16}; +}} + .pane-flash-ring-active {{ animation: focus-flash 0.9s ease-in-out; }} From 8199de631fa9b6aa6cac8db4040d03cdecf8cee3 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 24 Mar 2026 11:59:45 +0100 Subject: [PATCH 45/63] fix(shell): surround panes with attention ring --- crates/taskers-shell/src/lib.rs | 274 +++++++++++++++--------------- crates/taskers-shell/src/theme.rs | 26 ++- 2 files changed, 155 insertions(+), 145 deletions(-) diff --git a/crates/taskers-shell/src/lib.rs b/crates/taskers-shell/src/lib.rs index 25e3305..7813615 100644 --- a/crates/taskers-shell/src/lib.rs +++ b/crates/taskers-shell/src/lib.rs @@ -1293,9 +1293,9 @@ fn render_pane( } ) }; - let pane_attention_ring_class = format!( - "pane-attention-ring{}", - attention_ring_class(pane.notification_ring, "pane-attention-ring") + let pane_frame_ring_class = format!( + "pane-frame-ring{}", + attention_ring_class(pane.notification_ring, "pane-frame-ring") ); let active_surface = pane .surfaces @@ -1413,160 +1413,162 @@ fn render_pane( let pane_surface_summary_title = surface_summary_title(active_surface); rsx! { - section { class: "{pane_class}", onclick: focus_pane, - div { class: "pane-toolbar", - if show_tab_strip { - div { - class: "pane-toolbar-meta", - title: "{pane_surface_summary_title}", - div { class: "{pane_runtime_chip_class}", - {render_runtime_icon(&active_surface.runtime, 14, &pane_runtime_icon_class)} - div { class: "pane-runtime-copy", - span { class: "pane-runtime-primary", "{surface_primary_label(active_surface)}" } - if let Some(runtime_label) = surface_runtime_badge_text(active_surface) { - span { class: "pane-runtime-badge", "{runtime_label}" } - } - if let Some(status_label) = surface_status_text(active_surface) { - span { class: "{pane_runtime_state_class}", "{status_label}" } + div { class: "pane-frame", + section { class: "{pane_class}", onclick: focus_pane, + div { class: "pane-toolbar", + if show_tab_strip { + div { + class: "pane-toolbar-meta", + title: "{pane_surface_summary_title}", + div { class: "{pane_runtime_chip_class}", + {render_runtime_icon(&active_surface.runtime, 14, &pane_runtime_icon_class)} + div { class: "pane-runtime-copy", + span { class: "pane-runtime-primary", "{surface_primary_label(active_surface)}" } + if let Some(runtime_label) = surface_runtime_badge_text(active_surface) { + span { class: "pane-runtime-badge", "{runtime_label}" } + } + if let Some(status_label) = surface_status_text(active_surface) { + span { class: "{pane_runtime_state_class}", "{status_label}" } + } } } } - } - } else { - div { - class: "pane-toolbar-meta pane-toolbar-meta-draggable", - title: "{pane_surface_summary_title}", - onpointerdown: begin_active_surface_drag_candidate, - div { class: "{pane_runtime_chip_class}", - {render_runtime_icon(&active_surface.runtime, 14, &pane_runtime_icon_class)} - div { class: "pane-runtime-copy", - span { class: "pane-runtime-primary", "{surface_primary_label(active_surface)}" } - if let Some(runtime_label) = surface_runtime_badge_text(active_surface) { - span { class: "pane-runtime-badge", "{runtime_label}" } - } - if let Some(status_label) = surface_status_text(active_surface) { - span { class: "{pane_runtime_state_class}", "{status_label}" } + } else { + div { + class: "pane-toolbar-meta pane-toolbar-meta-draggable", + title: "{pane_surface_summary_title}", + onpointerdown: begin_active_surface_drag_candidate, + div { class: "{pane_runtime_chip_class}", + {render_runtime_icon(&active_surface.runtime, 14, &pane_runtime_icon_class)} + div { class: "pane-runtime-copy", + span { class: "pane-runtime-primary", "{surface_primary_label(active_surface)}" } + if let Some(runtime_label) = surface_runtime_badge_text(active_surface) { + span { class: "pane-runtime-badge", "{runtime_label}" } + } + if let Some(status_label) = surface_status_text(active_surface) { + span { class: "{pane_runtime_state_class}", "{status_label}" } + } } } } } - } - div { class: "pane-action-cluster", - button { class: "pane-utility", title: "New terminal tab", onclick: add_terminal_surface, - {icons::terminal(14, "pane-utility-icon")} - } - button { class: "pane-utility", title: "New browser tab", onclick: add_browser_surface, - {icons::globe(14, "pane-utility-icon")} - } - div { class: "pane-action-separator" } - button { class: "pane-utility", title: "Split right", onclick: split_terminal, - {icons::split_horizontal(14, "pane-utility-icon")} - } - button { class: "pane-utility", title: "Split down", onclick: split_down, - {icons::split_vertical(14, "pane-utility-icon")} - } - div { class: "pane-action-separator" } - button { class: "pane-utility pane-utility-close", title: "{close_label}", onclick: close_surface, - {icons::close(12, "pane-utility-icon")} - } - } - } - if show_tab_strip { - div { class: "pane-tabs", - div { class: "surface-tabs", - for surface in &pane.surfaces { - {render_surface_tab( - workspace_id, - pane.id, - pane.active_surface, - surface, - core.clone(), - surface_drop_target, - surface_drag_candidate, - dragged_surface, - &ordered_surface_ids, - )} + div { class: "pane-action-cluster", + button { class: "pane-utility", title: "New terminal tab", onclick: add_terminal_surface, + {icons::terminal(14, "pane-utility-icon")} } - if surface_drag_active { - {render_surface_pane_drop_target( - "surface-tab surface-tab-append-target", - "+", - SurfaceDropTarget::AppendToPane { pane_id }, - core.clone(), - surface_drop_target, - )} + button { class: "pane-utility", title: "New browser tab", onclick: add_browser_surface, + {icons::globe(14, "pane-utility-icon")} + } + div { class: "pane-action-separator" } + button { class: "pane-utility", title: "Split right", onclick: split_terminal, + {icons::split_horizontal(14, "pane-utility-icon")} + } + button { class: "pane-utility", title: "Split down", onclick: split_down, + {icons::split_vertical(14, "pane-utility-icon")} + } + div { class: "pane-action-separator" } + button { class: "pane-utility pane-utility-close", title: "{close_label}", onclick: close_surface, + {icons::close(12, "pane-utility-icon")} } } } - } - if matches!(active_surface.kind, SurfaceKind::Browser) { - BrowserToolbar { - key: "{toolbar_key}", - surface: active_surface.clone(), - chrome: active_browser_chrome, - core: core.clone(), + if show_tab_strip { + div { class: "pane-tabs", + div { class: "surface-tabs", + for surface in &pane.surfaces { + {render_surface_tab( + workspace_id, + pane.id, + pane.active_surface, + surface, + core.clone(), + surface_drop_target, + surface_drag_candidate, + dragged_surface, + &ordered_surface_ids, + )} + } + if surface_drag_active { + {render_surface_pane_drop_target( + "surface-tab surface-tab-append-target", + "+", + SurfaceDropTarget::AppendToPane { pane_id }, + core.clone(), + surface_drop_target, + )} + } + } + } } - } - div { class: "pane-body", - if show_live_surface_backdrop(active_surface.kind, overview_mode) { - {render_surface_backdrop(active_surface, runtime_status)} + if matches!(active_surface.kind, SurfaceKind::Browser) { + BrowserToolbar { + key: "{toolbar_key}", + surface: active_surface.clone(), + chrome: active_browser_chrome, + core: core.clone(), + } } - if surface_drag_active { - div { class: "pane-drop-overlay", - {render_surface_pane_drop_target( - "pane-drop-target pane-drop-target-center", - "append", - SurfaceDropTarget::AppendToPane { pane_id }, - core.clone(), - surface_drop_target, - )} - if pane_allows_split { - {render_surface_pane_drop_target( - "pane-drop-target pane-drop-target-edge pane-drop-target-left", - "split left", - SurfaceDropTarget::SplitPane { - pane_id, - direction: Direction::Left, - }, - core.clone(), - surface_drop_target, - )} - {render_surface_pane_drop_target( - "pane-drop-target pane-drop-target-edge pane-drop-target-right", - "split right", - SurfaceDropTarget::SplitPane { - pane_id, - direction: Direction::Right, - }, - core.clone(), - surface_drop_target, - )} - {render_surface_pane_drop_target( - "pane-drop-target pane-drop-target-edge pane-drop-target-top", - "split up", - SurfaceDropTarget::SplitPane { - pane_id, - direction: Direction::Up, - }, - core.clone(), - surface_drop_target, - )} + div { class: "pane-body", + if show_live_surface_backdrop(active_surface.kind, overview_mode) { + {render_surface_backdrop(active_surface, runtime_status)} + } + if surface_drag_active { + div { class: "pane-drop-overlay", {render_surface_pane_drop_target( - "pane-drop-target pane-drop-target-edge pane-drop-target-bottom", - "split down", - SurfaceDropTarget::SplitPane { - pane_id, - direction: Direction::Down, - }, + "pane-drop-target pane-drop-target-center", + "append", + SurfaceDropTarget::AppendToPane { pane_id }, core.clone(), surface_drop_target, )} + if pane_allows_split { + {render_surface_pane_drop_target( + "pane-drop-target pane-drop-target-edge pane-drop-target-left", + "split left", + SurfaceDropTarget::SplitPane { + pane_id, + direction: Direction::Left, + }, + core.clone(), + surface_drop_target, + )} + {render_surface_pane_drop_target( + "pane-drop-target pane-drop-target-edge pane-drop-target-right", + "split right", + SurfaceDropTarget::SplitPane { + pane_id, + direction: Direction::Right, + }, + core.clone(), + surface_drop_target, + )} + {render_surface_pane_drop_target( + "pane-drop-target pane-drop-target-edge pane-drop-target-top", + "split up", + SurfaceDropTarget::SplitPane { + pane_id, + direction: Direction::Up, + }, + core.clone(), + surface_drop_target, + )} + {render_surface_pane_drop_target( + "pane-drop-target pane-drop-target-edge pane-drop-target-bottom", + "split down", + SurfaceDropTarget::SplitPane { + pane_id, + direction: Direction::Down, + }, + core.clone(), + surface_drop_target, + )} + } } } } + div { key: "{flash_key}", class: "{flash_class}" } } - div { class: "{pane_attention_ring_class}" } - div { key: "{flash_key}", class: "{flash_class}" } + div { class: "{pane_frame_ring_class}" } } } } diff --git a/crates/taskers-shell/src/theme.rs b/crates/taskers-shell/src/theme.rs index 5b1e720..5345a69 100644 --- a/crates/taskers-shell/src/theme.rs +++ b/crates/taskers-shell/src/theme.rs @@ -1109,6 +1109,14 @@ input:focus-visible {{ min-height: 0; }} +.pane-frame {{ + position: relative; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; +}} + .pane-card {{ position: relative; width: 100%; @@ -1149,32 +1157,32 @@ input:focus-visible {{ border-radius: 6px; }} -.pane-attention-ring {{ +.pane-frame-ring {{ position: absolute; - inset: 1px; + inset: -2px; border: 2px solid transparent; - border-radius: 5px; + border-radius: 8px; pointer-events: none; opacity: 0; z-index: 9; }} -.pane-attention-ring-waiting {{ +.pane-frame-ring-waiting {{ opacity: 1; border-color: {waiting}; - box-shadow: inset 0 0 0 1px {waiting_18}, 0 0 18px {waiting_18}; + box-shadow: 0 0 0 1px {waiting_18}, 0 0 18px {waiting_18}; }} -.pane-attention-ring-error {{ +.pane-frame-ring-error {{ opacity: 1; border-color: {error}; - box-shadow: inset 0 0 0 1px {error_16}, 0 0 18px {error_16}; + box-shadow: 0 0 0 1px {error_16}, 0 0 18px {error_16}; }} -.pane-attention-ring-completed {{ +.pane-frame-ring-completed {{ opacity: 1; border-color: {completed}; - box-shadow: inset 0 0 0 1px {completed_16}, 0 0 18px {completed_16}; + box-shadow: 0 0 0 1px {completed_16}, 0 0 18px {completed_16}; }} .pane-flash-ring-active {{ From c8b6f1a8a86c3d0d047c8d15a7697304bca1c5e2 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 24 Mar 2026 12:41:50 +0100 Subject: [PATCH 46/63] fix(host): render pane notification rings in native overlays --- crates/taskers-host/src/lib.rs | 321 +++++++++++++++++++++++++-- crates/taskers-shell-core/src/lib.rs | 60 +++++ crates/taskers-shell/src/lib.rs | 5 - crates/taskers-shell/src/theme.rs | 28 --- 4 files changed, 359 insertions(+), 55 deletions(-) diff --git a/crates/taskers-host/src/lib.rs b/crates/taskers-host/src/lib.rs index bcda65e..051e73e 100644 --- a/crates/taskers-host/src/lib.rs +++ b/crates/taskers-host/src/lib.rs @@ -2,13 +2,14 @@ mod browser_automation; use anyhow::{Result, anyhow}; use gtk::{ - Align, Box as GtkBox, CssProvider, EventControllerFocus, EventControllerScroll, + Align, Box as GtkBox, CssProvider, DrawingArea, EventControllerFocus, EventControllerScroll, EventControllerScrollFlags, GestureClick, Orientation, Overflow, Overlay, STYLE_PROVIDER_PRIORITY_APPLICATION, Widget, glib, prelude::*, }; use std::{ - cell::Cell, + cell::{Cell, RefCell}, collections::{HashMap, HashSet}, + f64::consts::{FRAC_PI_2, PI, TAU}, rc::Rc, sync::Arc, time::{SystemTime, UNIX_EPOCH}, @@ -255,6 +256,7 @@ impl TaskersHost { self.sync_terminal_surfaces( &snapshot.portal, &snapshot.terminal_catalog, + &snapshot.settings.selected_theme_id, snapshot.revision, interactive, )?; @@ -429,6 +431,7 @@ impl TaskersHost { &self.root, entry, visible_plan, + &snapshot.settings.selected_theme_id, snapshot.revision, interactive, self.diagnostics.as_ref(), @@ -438,6 +441,7 @@ impl TaskersHost { &self.root, entry, visible_plan, + &snapshot.settings.selected_theme_id, snapshot.revision, interactive, self.event_sink.clone(), @@ -455,6 +459,7 @@ impl TaskersHost { &mut self, portal: &SurfacePortalPlan, catalog: &[TerminalSurfaceCatalogEntry], + theme_id: &str, revision: u64, interactive: bool, ) -> Result<()> { @@ -502,6 +507,7 @@ impl TaskersHost { &self.root, entry, visible_plan, + theme_id, revision, interactive, host, @@ -512,6 +518,7 @@ impl TaskersHost { &self.root, entry, visible_plan, + theme_id, revision, interactive, self.event_sink.clone(), @@ -548,6 +555,7 @@ impl BrowserSurface { overlay: &Overlay, entry: &BrowserSurfaceCatalogEntry, visible_plan: Option<&PortalSurfacePlan>, + theme_id: &str, revision: u64, interactive: bool, event_sink: HostEventSink, @@ -576,8 +584,12 @@ impl BrowserSurface { }); let shell = NativeSurfaceShell::new(shell_class, visible_plan.is_some() && interactive); shell.mount_child(webview.upcast_ref()); + shell.set_attention_ring( + visible_plan.and_then(|plan| plan.notification_ring), + theme_id, + ); match visible_plan { - Some(plan) => shell.show_at(overlay, plan.frame), + Some(plan) => shell.show_at(overlay, plan.pane_frame, plan.frame), None => shell.park_hidden(overlay), } let devtools_open = Rc::new(Cell::new(false)); @@ -758,6 +770,7 @@ impl BrowserSurface { overlay: &Overlay, entry: &BrowserSurfaceCatalogEntry, visible_plan: Option<&PortalSurfacePlan>, + theme_id: &str, revision: u64, interactive: bool, diagnostics: Option<&DiagnosticsSink>, @@ -767,9 +780,13 @@ impl BrowserSurface { let visible = visible_plan.is_some(); let effective_interactive = visible && interactive; self.shell.set_interactive(effective_interactive); + self.shell.set_attention_ring( + visible_plan.and_then(|plan| plan.notification_ring), + theme_id, + ); self.webview.set_can_target(effective_interactive); match visible_plan { - Some(plan) => self.shell.show_at(overlay, plan.frame), + Some(plan) => self.shell.show_at(overlay, plan.pane_frame, plan.frame), None => self.shell.park_hidden(overlay), } @@ -885,6 +902,7 @@ impl TerminalSurface { overlay: &Overlay, entry: &TerminalSurfaceCatalogEntry, visible_plan: Option<&PortalSurfacePlan>, + theme_id: &str, revision: u64, interactive: bool, event_sink: HostEventSink, @@ -909,8 +927,12 @@ impl TerminalSurface { widget.set_can_target(effective_interactive); let shell = NativeSurfaceShell::new(shell_class, effective_interactive); shell.mount_child(&widget); + shell.set_attention_ring( + visible_plan.and_then(|plan| plan.notification_ring), + theme_id, + ); match visible_plan { - Some(plan) => shell.show_at(overlay, plan.frame), + Some(plan) => shell.show_at(overlay, plan.pane_frame, plan.frame), None => shell.park_hidden(overlay), } @@ -963,6 +985,7 @@ impl TerminalSurface { overlay: &Overlay, entry: &TerminalSurfaceCatalogEntry, visible_plan: Option<&PortalSurfacePlan>, + theme_id: &str, revision: u64, interactive: bool, host: &GhosttyHost, @@ -975,8 +998,12 @@ impl TerminalSurface { let effective_interactive = visible && interactive; self.widget.set_can_target(effective_interactive); self.shell.set_interactive(effective_interactive); + self.shell.set_attention_ring( + visible_plan.and_then(|plan| plan.notification_ring), + theme_id, + ); match visible_plan { - Some(plan) => self.shell.show_at(overlay, plan.frame), + Some(plan) => self.shell.show_at(overlay, plan.pane_frame, plan.frame), None => self.shell.park_hidden(overlay), } if visible_plan.is_some_and(|plan| plan.active) @@ -1012,22 +1039,43 @@ impl TerminalSurface { } struct NativeSurfaceShell { - root: GtkBox, + root: Overlay, + content: GtkBox, + attention_ring: AttentionRingOverlay, } impl NativeSurfaceShell { fn new(kind_class: &'static str, interactive: bool) -> Self { - let root = GtkBox::new(Orientation::Vertical, 0); + let root = Overlay::new(); root.set_hexpand(false); root.set_vexpand(false); root.set_halign(Align::Start); root.set_valign(Align::Start); root.set_overflow(Overflow::Hidden); root.set_focusable(false); - root.set_can_target(interactive); + root.set_can_target(false); root.add_css_class("native-surface-host"); root.add_css_class(kind_class); - Self { root } + + let content = GtkBox::new(Orientation::Vertical, 0); + content.set_hexpand(false); + content.set_vexpand(false); + content.set_halign(Align::Start); + content.set_valign(Align::Start); + content.set_overflow(Overflow::Hidden); + content.set_can_target(interactive); + root.set_child(Some(&content)); + + let attention_ring = AttentionRingOverlay::new(); + root.add_overlay(attention_ring.widget()); + root.set_measure_overlay(attention_ring.widget(), false); + root.set_clip_overlay(attention_ring.widget(), true); + + Self { + root, + content, + attention_ring, + } } fn mount_child(&self, child: &Widget) { @@ -1036,7 +1084,7 @@ impl NativeSurfaceShell { child.set_halign(Align::Fill); child.set_valign(Align::Fill); if child.parent().is_none() { - self.root.append(child); + self.content.append(child); } } @@ -1044,18 +1092,29 @@ impl NativeSurfaceShell { position_widget(overlay, self.root.upcast_ref(), frame); } - fn show_at(&self, overlay: &Overlay, frame: taskers_core::Frame) { + fn show_at( + &self, + overlay: &Overlay, + pane_frame: taskers_core::Frame, + content_frame: taskers_core::Frame, + ) { self.root.set_opacity(1.0); - self.position(overlay, frame); + self.layout_content(pane_frame, content_frame); + self.position(overlay, pane_frame); } fn park_hidden(&self, overlay: &Overlay) { self.root.set_opacity(0.0); + self.layout_content(self.hidden_frame(), self.hidden_frame()); self.position(overlay, self.hidden_frame()); } fn set_interactive(&self, interactive: bool) { - self.root.set_can_target(interactive); + self.content.set_can_target(interactive); + } + + fn set_attention_ring(&self, state: Option, theme_id: &str) { + self.attention_ring.set(state, theme_id); } fn detach(&self, overlay: &Overlay) { @@ -1065,6 +1124,195 @@ impl NativeSurfaceShell { fn hidden_frame(&self) -> taskers_core::Frame { taskers_core::Frame::new(100_000, 100_000, 1, 1) } + + fn layout_content(&self, pane_frame: taskers_core::Frame, content_frame: taskers_core::Frame) { + let relative_x = (content_frame.x - pane_frame.x).max(0); + let relative_y = (content_frame.y - pane_frame.y).max(0); + self.content + .set_size_request(content_frame.width.max(1), content_frame.height.max(1)); + self.content.set_margin_start(relative_x); + self.content.set_margin_top(relative_y); + } +} + +struct AttentionRingOverlay { + widget: DrawingArea, + state: Rc>>, + palette: Rc>, +} + +impl AttentionRingOverlay { + fn new() -> Self { + let widget = DrawingArea::new(); + widget.set_hexpand(true); + widget.set_vexpand(true); + widget.set_halign(Align::Fill); + widget.set_valign(Align::Fill); + widget.set_can_target(false); + widget.set_focusable(false); + widget.set_visible(false); + + let state = Rc::new(Cell::new(None)); + let palette = Rc::new(RefCell::new(host_attention_palette("dark"))); + let draw_state = state.clone(); + let draw_palette = palette.clone(); + widget.set_draw_func(move |_, ctx, width, height| { + let Some(state) = draw_state.get() else { + return; + }; + let width = width.max(1) as f64; + let height = height.max(1) as f64; + if width <= 4.0 || height <= 4.0 { + return; + } + + let paint = draw_palette.borrow().paint(state); + let glow_inset = 3.0; + draw_attention_ring_path( + ctx, + glow_inset, + glow_inset, + (width - glow_inset * 2.0).max(1.0), + (height - glow_inset * 2.0).max(1.0), + 7.0, + ); + apply_host_rgba(ctx, paint.glow); + ctx.set_line_width(6.0); + let _ = ctx.stroke(); + + let stroke_inset = 2.0; + draw_attention_ring_path( + ctx, + stroke_inset, + stroke_inset, + (width - stroke_inset * 2.0).max(1.0), + (height - stroke_inset * 2.0).max(1.0), + 6.0, + ); + apply_host_rgba(ctx, paint.stroke); + ctx.set_line_width(2.5); + let _ = ctx.stroke(); + }); + + Self { + widget, + state, + palette, + } + } + + fn widget(&self) -> &DrawingArea { + &self.widget + } + + fn set(&self, state: Option, theme_id: &str) { + self.state.set(state); + *self.palette.borrow_mut() = host_attention_palette(theme_id); + self.widget.set_visible(state.is_some()); + self.widget.queue_draw(); + } +} + +#[derive(Clone, Copy)] +struct HostRgba { + red: f64, + green: f64, + blue: f64, + alpha: f64, +} + +#[derive(Clone, Copy)] +struct HostRingPaint { + stroke: HostRgba, + glow: HostRgba, +} + +#[derive(Clone, Copy)] +struct HostAttentionPalette { + waiting: HostRingPaint, + error: HostRingPaint, + completed: HostRingPaint, +} + +impl HostAttentionPalette { + fn paint(self, state: taskers_core::AttentionRingState) -> HostRingPaint { + match state { + taskers_core::AttentionRingState::Waiting => self.waiting, + taskers_core::AttentionRingState::Error => self.error, + taskers_core::AttentionRingState::Completed => self.completed, + } + } +} + +fn host_attention_palette(theme_id: &str) -> HostAttentionPalette { + match theme_id { + "catppuccin-mocha" => HostAttentionPalette { + waiting: host_ring_paint(0x94, 0xe2, 0xd5), + error: host_ring_paint(0xf3, 0x8b, 0xa8), + completed: host_ring_paint(0xa6, 0xe3, 0xa1), + }, + "tokyo-night" => HostAttentionPalette { + waiting: host_ring_paint(0x7d, 0xcf, 0xff), + error: host_ring_paint(0xf7, 0x76, 0x8e), + completed: host_ring_paint(0x9e, 0xce, 0x6a), + }, + "gruvbox-dark" => HostAttentionPalette { + waiting: host_ring_paint(0x8e, 0xc0, 0x7c), + error: host_ring_paint(0xfb, 0x49, 0x34), + completed: host_ring_paint(0xb8, 0xbb, 0x26), + }, + _ => HostAttentionPalette { + waiting: host_ring_paint(0x60, 0xa5, 0xfa), + error: host_ring_paint(0xf8, 0x71, 0x71), + completed: host_ring_paint(0x34, 0xd3, 0x99), + }, + } +} + +fn host_ring_paint(red: u8, green: u8, blue: u8) -> HostRingPaint { + let base = host_rgba(red, green, blue, 1.0); + HostRingPaint { + stroke: host_rgba(red, green, blue, 0.98), + glow: HostRgba { + red: base.red, + green: base.green, + blue: base.blue, + alpha: 0.34, + }, + } +} + +fn host_rgba(red: u8, green: u8, blue: u8, alpha: f64) -> HostRgba { + HostRgba { + red: f64::from(red) / 255.0, + green: f64::from(green) / 255.0, + blue: f64::from(blue) / 255.0, + alpha, + } +} + +fn apply_host_rgba(ctx: >k::cairo::Context, color: HostRgba) { + ctx.set_source_rgba(color.red, color.green, color.blue, color.alpha); +} + +fn draw_attention_ring_path( + ctx: >k::cairo::Context, + x: f64, + y: f64, + width: f64, + height: f64, + radius: f64, +) { + let radius = radius.min(width / 2.0).min(height / 2.0).max(0.0); + let right = x + width; + let bottom = y + height; + + ctx.new_sub_path(); + ctx.arc(right - radius, y + radius, radius, -FRAC_PI_2, 0.0); + ctx.arc(right - radius, bottom - radius, radius, 0.0, FRAC_PI_2); + ctx.arc(x + radius, bottom - radius, radius, FRAC_PI_2, PI); + ctx.arc(x + radius, y + radius, radius, PI, TAU - FRAC_PI_2); + ctx.close_path(); } fn install_native_surface_css() { @@ -1393,7 +1641,21 @@ fn clip_to_content( plan: &PortalSurfacePlan, content: &taskers_core::Frame, ) -> Option { - let f = &plan.frame; + let clipped_frame = clip_frame_to_content(plan.frame, *content)?; + let clipped_pane_frame = clip_frame_to_content(plan.pane_frame, *content)?; + + Some(PortalSurfacePlan { + frame: clipped_frame, + pane_frame: clipped_pane_frame, + ..plan.clone() + }) +} + +fn clip_frame_to_content( + frame: taskers_core::Frame, + content: taskers_core::Frame, +) -> Option { + let f = &frame; let cx = content.x; let cy = content.y; let cr = content.x + content.width; @@ -1411,10 +1673,9 @@ fn clip_to_content( return None; } - Some(PortalSurfacePlan { - frame: taskers_core::Frame::new(clipped_x, clipped_y, clipped_w, clipped_h), - ..plan.clone() - }) + Some(taskers_core::Frame::new( + clipped_x, clipped_y, clipped_w, clipped_h, + )) } fn emit_diagnostic(sink: Option<&DiagnosticsSink>, record: DiagnosticRecord) { @@ -1433,11 +1694,13 @@ fn current_timestamp_ms() -> u128 { #[cfg(test)] mod tests { use super::{ - browser_plans, native_surface_classes, native_surface_css, native_surfaces_interactive, - terminal_plans, trim_terminal_tail, workspace_pan_delta, + browser_plans, host_attention_palette, native_surface_classes, native_surface_css, + native_surfaces_interactive, terminal_plans, trim_terminal_tail, workspace_pan_delta, }; use taskers_domain::PaneKind; - use taskers_shell_core::{BootstrapModel, SharedCore, ShellDragMode, SurfaceMountSpec}; + use taskers_shell_core::{ + AttentionRingState, BootstrapModel, SharedCore, ShellDragMode, SurfaceMountSpec, + }; #[test] fn partitions_portal_plans_by_surface_kind() { @@ -1496,4 +1759,18 @@ mod tests { assert_eq!(trim_terminal_tail(text.clone(), Some(2)), "three\nfour"); assert_eq!(trim_terminal_tail(text, Some(10)), "one\ntwo\nthree\nfour"); } + + #[test] + fn host_attention_palette_tracks_selected_theme_ring_colors() { + let dark = host_attention_palette("dark"); + let gruvbox = host_attention_palette("gruvbox-dark"); + + let dark_waiting = dark.paint(AttentionRingState::Waiting); + let gruvbox_waiting = gruvbox.paint(AttentionRingState::Waiting); + let dark_error = dark.paint(AttentionRingState::Error); + + assert!(dark_waiting.stroke.blue > dark_waiting.stroke.red); + assert!(dark_error.stroke.red > dark_error.stroke.green); + assert_ne!(dark_waiting.stroke.green, gruvbox_waiting.stroke.green); + } } diff --git a/crates/taskers-shell-core/src/lib.rs b/crates/taskers-shell-core/src/lib.rs index 5025e33..6f01fda 100644 --- a/crates/taskers-shell-core/src/lib.rs +++ b/crates/taskers-shell-core/src/lib.rs @@ -765,6 +765,8 @@ pub struct PortalSurfacePlan { pub pane_id: PaneId, pub surface_id: SurfaceId, pub active: bool, + pub notification_ring: Option, + pub pane_frame: Frame, pub frame: Frame, pub mount: SurfaceMountSpec, } @@ -1838,6 +1840,8 @@ impl TaskersCore { pane_id: pane.id, surface_id: active_surface.id, active: workspace.active_pane == pane.id, + notification_ring: pane_notification_ring(pane), + pane_frame: frame, frame: pane_body_frame( frame, self.metrics, @@ -3947,6 +3951,10 @@ fn surface_notification_ring(surface: &SurfaceRecord) -> Option Option { + dominant_attention_ring(pane.surfaces.values().filter_map(surface_notification_ring)) +} + fn display_terminal_title(metadata: &PaneMetadata) -> String { let agent_title = metadata .agent_title @@ -4996,6 +5004,58 @@ mod tests { ); } + #[test] + fn portal_plan_carries_pane_notification_ring_for_active_surface_host() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let pane_id = model.active_workspace().expect("workspace").active_pane; + let agent_surface_id = model + .workspaces + .get(&workspace_id) + .and_then(|workspace| workspace.panes.get(&pane_id)) + .map(|pane| pane.active_surface) + .expect("agent surface"); + let browser_surface_id = model + .create_surface(workspace_id, pane_id, taskers_domain::PaneKind::Browser) + .expect("create browser"); + + { + let workspace = model.workspaces.get_mut(&workspace_id).expect("workspace"); + let browser_surface = workspace + .panes + .get_mut(&pane_id) + .and_then(|pane| pane.surfaces.get_mut(&browser_surface_id)) + .expect("browser surface record"); + browser_surface.attention = taskers_domain::AttentionState::Normal; + + let agent_surface = workspace + .panes + .get_mut(&pane_id) + .and_then(|pane| pane.surfaces.get_mut(&agent_surface_id)) + .expect("agent surface record"); + agent_surface.metadata.agent_kind = Some("codex".into()); + agent_surface.attention = taskers_domain::AttentionState::WaitingInput; + } + + let core = SharedCore::bootstrap(bootstrap_with_model( + model, + "taskers-preview-portal-notification-ring", + )); + let portal_plan = core + .snapshot() + .portal + .panes + .into_iter() + .find(|plan| plan.pane_id == pane_id) + .expect("portal plan"); + + assert_eq!(portal_plan.surface_id, browser_surface_id); + assert_eq!( + portal_plan.notification_ring, + Some(super::AttentionRingState::Waiting) + ); + } + #[test] fn default_bootstrap_projects_browser_and_terminal_portal_plans() { let core = SharedCore::bootstrap(bootstrap()); diff --git a/crates/taskers-shell/src/lib.rs b/crates/taskers-shell/src/lib.rs index 7813615..5cb924b 100644 --- a/crates/taskers-shell/src/lib.rs +++ b/crates/taskers-shell/src/lib.rs @@ -1293,10 +1293,6 @@ fn render_pane( } ) }; - let pane_frame_ring_class = format!( - "pane-frame-ring{}", - attention_ring_class(pane.notification_ring, "pane-frame-ring") - ); let active_surface = pane .surfaces .iter() @@ -1568,7 +1564,6 @@ fn render_pane( } div { key: "{flash_key}", class: "{flash_class}" } } - div { class: "{pane_frame_ring_class}" } } } } diff --git a/crates/taskers-shell/src/theme.rs b/crates/taskers-shell/src/theme.rs index 5345a69..b67dd43 100644 --- a/crates/taskers-shell/src/theme.rs +++ b/crates/taskers-shell/src/theme.rs @@ -1157,34 +1157,6 @@ input:focus-visible {{ border-radius: 6px; }} -.pane-frame-ring {{ - position: absolute; - inset: -2px; - border: 2px solid transparent; - border-radius: 8px; - pointer-events: none; - opacity: 0; - z-index: 9; -}} - -.pane-frame-ring-waiting {{ - opacity: 1; - border-color: {waiting}; - box-shadow: 0 0 0 1px {waiting_18}, 0 0 18px {waiting_18}; -}} - -.pane-frame-ring-error {{ - opacity: 1; - border-color: {error}; - box-shadow: 0 0 0 1px {error_16}, 0 0 18px {error_16}; -}} - -.pane-frame-ring-completed {{ - opacity: 1; - border-color: {completed}; - box-shadow: 0 0 0 1px {completed_16}, 0 0 18px {completed_16}; -}} - .pane-flash-ring-active {{ animation: focus-flash 0.9s ease-in-out; }} From f827965c3b361d36abb81b566152a35c2236477f Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 24 Mar 2026 13:01:12 +0100 Subject: [PATCH 47/63] fix(host): raise pane notification rings above terminal surfaces --- crates/taskers-host/src/lib.rs | 165 +++++++++++++++++---------------- 1 file changed, 86 insertions(+), 79 deletions(-) diff --git a/crates/taskers-host/src/lib.rs b/crates/taskers-host/src/lib.rs index 051e73e..1483a58 100644 --- a/crates/taskers-host/src/lib.rs +++ b/crates/taskers-host/src/lib.rs @@ -412,6 +412,7 @@ impl TaskersHost { for surface_id in stale { if let Some(surface) = self.browser_surfaces.remove(&surface_id) { surface.shell.detach(&self.root); + surface.attention_ring.detach(&self.root); emit_diagnostic( self.diagnostics.as_ref(), DiagnosticRecord::new( @@ -484,6 +485,7 @@ impl TaskersHost { for surface_id in stale { if let Some(surface) = self.terminal_surfaces.remove(&surface_id) { surface.shell.detach(&self.root); + surface.attention_ring.detach(&self.root); emit_diagnostic( self.diagnostics.as_ref(), DiagnosticRecord::new( @@ -536,6 +538,7 @@ impl TaskersHost { struct BrowserSurface { shell: NativeSurfaceShell, + attention_ring: AttentionRingOverlay, surface_id: SurfaceId, workspace_id: Rc>, pane_id: Rc>, @@ -583,14 +586,17 @@ impl BrowserSurface { url: url.clone(), }); let shell = NativeSurfaceShell::new(shell_class, visible_plan.is_some() && interactive); + let attention_ring = AttentionRingOverlay::new(); shell.mount_child(webview.upcast_ref()); - shell.set_attention_ring( - visible_plan.and_then(|plan| plan.notification_ring), - theme_id, - ); match visible_plan { - Some(plan) => shell.show_at(overlay, plan.pane_frame, plan.frame), - None => shell.park_hidden(overlay), + Some(plan) => { + shell.show_at(overlay, plan.frame); + attention_ring.show_at(overlay, plan.pane_frame, plan.notification_ring, theme_id); + } + None => { + shell.park_hidden(overlay); + attention_ring.park_hidden(overlay); + } } let devtools_open = Rc::new(Cell::new(false)); let workspace_id = Rc::new(Cell::new(entry.workspace_id)); @@ -750,6 +756,7 @@ impl BrowserSurface { Ok(Self { shell, + attention_ring, surface_id: entry.surface_id, workspace_id, pane_id, @@ -780,14 +787,21 @@ impl BrowserSurface { let visible = visible_plan.is_some(); let effective_interactive = visible && interactive; self.shell.set_interactive(effective_interactive); - self.shell.set_attention_ring( - visible_plan.and_then(|plan| plan.notification_ring), - theme_id, - ); self.webview.set_can_target(effective_interactive); match visible_plan { - Some(plan) => self.shell.show_at(overlay, plan.pane_frame, plan.frame), - None => self.shell.park_hidden(overlay), + Some(plan) => { + self.shell.show_at(overlay, plan.frame); + self.attention_ring.show_at( + overlay, + plan.pane_frame, + plan.notification_ring, + theme_id, + ); + } + None => { + self.shell.park_hidden(overlay); + self.attention_ring.park_hidden(overlay); + } } if self.url != entry.url { @@ -888,6 +902,7 @@ struct TerminalSurface { pane_id: Rc>, spec: TerminalMountSpec, shell: NativeSurfaceShell, + attention_ring: AttentionRingOverlay, widget: Widget, focus_state: Rc>, active: bool, @@ -926,14 +941,17 @@ impl TerminalSurface { let effective_interactive = visible_plan.is_some() && interactive; widget.set_can_target(effective_interactive); let shell = NativeSurfaceShell::new(shell_class, effective_interactive); + let attention_ring = AttentionRingOverlay::new(); shell.mount_child(&widget); - shell.set_attention_ring( - visible_plan.and_then(|plan| plan.notification_ring), - theme_id, - ); match visible_plan { - Some(plan) => shell.show_at(overlay, plan.pane_frame, plan.frame), - None => shell.park_hidden(overlay), + Some(plan) => { + shell.show_at(overlay, plan.frame); + attention_ring.show_at(overlay, plan.pane_frame, plan.notification_ring, theme_id); + } + None => { + shell.park_hidden(overlay); + attention_ring.park_hidden(overlay); + } } let workspace_id = Rc::new(Cell::new(entry.workspace_id)); @@ -970,6 +988,7 @@ impl TerminalSurface { pane_id, spec, shell, + attention_ring, widget, focus_state, active: visible_plan.is_some_and(|plan| plan.active), @@ -998,13 +1017,20 @@ impl TerminalSurface { let effective_interactive = visible && interactive; self.widget.set_can_target(effective_interactive); self.shell.set_interactive(effective_interactive); - self.shell.set_attention_ring( - visible_plan.and_then(|plan| plan.notification_ring), - theme_id, - ); match visible_plan { - Some(plan) => self.shell.show_at(overlay, plan.pane_frame, plan.frame), - None => self.shell.park_hidden(overlay), + Some(plan) => { + self.shell.show_at(overlay, plan.frame); + self.attention_ring.show_at( + overlay, + plan.pane_frame, + plan.notification_ring, + theme_id, + ); + } + None => { + self.shell.park_hidden(overlay); + self.attention_ring.park_hidden(overlay); + } } if visible_plan.is_some_and(|plan| plan.active) && effective_interactive @@ -1039,43 +1065,22 @@ impl TerminalSurface { } struct NativeSurfaceShell { - root: Overlay, - content: GtkBox, - attention_ring: AttentionRingOverlay, + root: GtkBox, } impl NativeSurfaceShell { fn new(kind_class: &'static str, interactive: bool) -> Self { - let root = Overlay::new(); + let root = GtkBox::new(Orientation::Vertical, 0); root.set_hexpand(false); root.set_vexpand(false); root.set_halign(Align::Start); root.set_valign(Align::Start); root.set_overflow(Overflow::Hidden); root.set_focusable(false); - root.set_can_target(false); + root.set_can_target(interactive); root.add_css_class("native-surface-host"); root.add_css_class(kind_class); - - let content = GtkBox::new(Orientation::Vertical, 0); - content.set_hexpand(false); - content.set_vexpand(false); - content.set_halign(Align::Start); - content.set_valign(Align::Start); - content.set_overflow(Overflow::Hidden); - content.set_can_target(interactive); - root.set_child(Some(&content)); - - let attention_ring = AttentionRingOverlay::new(); - root.add_overlay(attention_ring.widget()); - root.set_measure_overlay(attention_ring.widget(), false); - root.set_clip_overlay(attention_ring.widget(), true); - - Self { - root, - content, - attention_ring, - } + Self { root } } fn mount_child(&self, child: &Widget) { @@ -1084,7 +1089,7 @@ impl NativeSurfaceShell { child.set_halign(Align::Fill); child.set_valign(Align::Fill); if child.parent().is_none() { - self.content.append(child); + self.root.append(child); } } @@ -1092,29 +1097,18 @@ impl NativeSurfaceShell { position_widget(overlay, self.root.upcast_ref(), frame); } - fn show_at( - &self, - overlay: &Overlay, - pane_frame: taskers_core::Frame, - content_frame: taskers_core::Frame, - ) { + fn show_at(&self, overlay: &Overlay, frame: taskers_core::Frame) { self.root.set_opacity(1.0); - self.layout_content(pane_frame, content_frame); - self.position(overlay, pane_frame); + self.position(overlay, frame); } fn park_hidden(&self, overlay: &Overlay) { self.root.set_opacity(0.0); - self.layout_content(self.hidden_frame(), self.hidden_frame()); self.position(overlay, self.hidden_frame()); } fn set_interactive(&self, interactive: bool) { - self.content.set_can_target(interactive); - } - - fn set_attention_ring(&self, state: Option, theme_id: &str) { - self.attention_ring.set(state, theme_id); + self.root.set_can_target(interactive); } fn detach(&self, overlay: &Overlay) { @@ -1122,16 +1116,7 @@ impl NativeSurfaceShell { } fn hidden_frame(&self) -> taskers_core::Frame { - taskers_core::Frame::new(100_000, 100_000, 1, 1) - } - - fn layout_content(&self, pane_frame: taskers_core::Frame, content_frame: taskers_core::Frame) { - let relative_x = (content_frame.x - pane_frame.x).max(0); - let relative_y = (content_frame.y - pane_frame.y).max(0); - self.content - .set_size_request(content_frame.width.max(1), content_frame.height.max(1)); - self.content.set_margin_start(relative_x); - self.content.set_margin_top(relative_y); + hidden_frame() } } @@ -1201,16 +1186,34 @@ impl AttentionRingOverlay { } } - fn widget(&self) -> &DrawingArea { - &self.widget - } - - fn set(&self, state: Option, theme_id: &str) { + fn show_at( + &self, + overlay: &Overlay, + frame: taskers_core::Frame, + state: Option, + theme_id: &str, + ) { self.state.set(state); *self.palette.borrow_mut() = host_attention_palette(theme_id); self.widget.set_visible(state.is_some()); + if state.is_some() { + self.widget.set_opacity(1.0); + position_widget(overlay, self.widget.upcast_ref(), frame); + } else { + self.park_hidden(overlay); + } self.widget.queue_draw(); } + + fn park_hidden(&self, overlay: &Overlay) { + self.widget.set_visible(false); + self.widget.set_opacity(0.0); + position_widget(overlay, self.widget.upcast_ref(), hidden_frame()); + } + + fn detach(&self, overlay: &Overlay) { + detach_from_overlay(overlay, self.widget.upcast_ref()); + } } #[derive(Clone, Copy)] @@ -1691,6 +1694,10 @@ fn current_timestamp_ms() -> u128 { .unwrap_or_default() } +fn hidden_frame() -> taskers_core::Frame { + taskers_core::Frame::new(100_000, 100_000, 1, 1) +} + #[cfg(test)] mod tests { use super::{ From 1f842fd7c18383f098fb09684be76c3fb8ac49e8 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 24 Mar 2026 13:18:23 +0100 Subject: [PATCH 48/63] fix(domain): retain agent metadata for notification rings --- crates/taskers-domain/src/model.rs | 110 +++++++++++++++++++++--- crates/taskers-shell-core/src/lib.rs | 123 ++++++++++++++++++++++----- 2 files changed, 198 insertions(+), 35 deletions(-) diff --git a/crates/taskers-domain/src/model.rs b/crates/taskers-domain/src/model.rs index 799ad7d..27b6390 100644 --- a/crates/taskers-domain/src/model.rs +++ b/crates/taskers-domain/src/model.rs @@ -2297,11 +2297,30 @@ impl AppModel { .ok_or(DomainError::MissingWorkspace(workspace_id))?; let (_, pane_id, surface_id) = workspace.notification_target_ids(&target)?; let now = OffsetDateTime::now_utc(); + let normalized_title = title + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()); + let normalized_subtitle = subtitle + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()); + let normalized_external_id = external_id + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()); if let Some(pane) = workspace.panes.get_mut(&pane_id) && let Some(surface) = pane.surfaces.get_mut(&surface_id) { surface.attention = state; + surface.metadata.last_signal_at = Some(now); + surface.metadata.agent_state = Some(agent_state_from_attention(state)); + surface.metadata.agent_active = agent_active_from_attention(state); + surface.metadata.latest_agent_message = Some(message.clone()); + if let Some(agent_title) = normalized_title.clone() { + surface.metadata.agent_title = Some(agent_title.clone()); + if let Some(agent_kind) = normalized_agent_kind(Some(agent_title.as_str())) { + surface.metadata.agent_kind = Some(agent_kind); + } + } } workspace.upsert_notification(NotificationItem { @@ -2310,15 +2329,9 @@ impl AppModel { surface_id, kind, state, - title: title - .map(|value| value.trim().to_owned()) - .filter(|value| !value.is_empty()), - subtitle: subtitle - .map(|value| value.trim().to_owned()) - .filter(|value| !value.is_empty()), - external_id: external_id - .map(|value| value.trim().to_owned()) - .filter(|value| !value.is_empty()), + title: normalized_title, + subtitle: normalized_subtitle, + external_id: normalized_external_id, message, created_at: now, read_at: None, @@ -3165,15 +3178,25 @@ fn signal_kind_creates_notification(kind: &SignalKind) -> bool { } fn is_agent_kind(agent_kind: Option<&str>) -> bool { - agent_kind - .map(str::trim) - .is_some_and(|agent| !agent.is_empty() && agent != "shell") + normalized_agent_kind(agent_kind).is_some() } fn is_agent_hook_source(source: &str) -> bool { source.trim().starts_with("agent-hook:") } +fn normalized_agent_kind(agent_kind: Option<&str>) -> Option { + let normalized = agent_kind + .map(str::trim) + .filter(|agent| !agent.is_empty()) + .map(|agent| agent.to_ascii_lowercase())?; + match normalized.as_str() { + "shell" => None, + "claude code" | "claude-code" => Some("claude".into()), + other => Some(other.to_string()), + } +} + fn is_agent_signal( surface: &SurfaceRecord, source: &str, @@ -3348,6 +3371,22 @@ fn signal_agent_state(kind: &SignalKind) -> Option { } } +fn agent_state_from_attention(state: AttentionState) -> WorkspaceAgentState { + match state { + AttentionState::Normal | AttentionState::Busy => WorkspaceAgentState::Working, + AttentionState::WaitingInput => WorkspaceAgentState::Waiting, + AttentionState::Completed => WorkspaceAgentState::Completed, + AttentionState::Error => WorkspaceAgentState::Failed, + } +} + +fn agent_active_from_attention(state: AttentionState) -> bool { + matches!( + state, + AttentionState::Normal | AttentionState::Busy | AttentionState::WaitingInput + ) +} + #[cfg(test)] mod tests { use serde_json::json; @@ -5118,6 +5157,53 @@ mod tests { ); } + #[test] + fn agent_notifications_stamp_surface_agent_metadata() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let pane_id = model.active_workspace().expect("workspace").active_pane; + let surface_id = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&pane_id)) + .and_then(|pane| pane.active_surface()) + .map(|surface| surface.id) + .expect("surface"); + + model + .create_agent_notification( + AgentTarget::Surface { + workspace_id, + pane_id, + surface_id, + }, + SignalKind::Notification, + Some("Codex".into()), + None, + None, + "Need input".into(), + AttentionState::WaitingInput, + ) + .expect("notification"); + + let surface = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&pane_id)) + .and_then(|pane| pane.surfaces.get(&surface_id)) + .expect("surface record"); + assert_eq!(surface.metadata.agent_kind.as_deref(), Some("codex")); + assert_eq!(surface.metadata.agent_title.as_deref(), Some("Codex")); + assert_eq!( + surface.metadata.agent_state, + Some(WorkspaceAgentState::Waiting) + ); + assert!(surface.metadata.agent_active); + assert_eq!( + surface.metadata.latest_agent_message.as_deref(), + Some("Need input") + ); + assert_eq!(surface.attention, AttentionState::WaitingInput); + } + #[test] fn clearing_notification_moves_it_out_of_active_activity() { let mut model = AppModel::new("Main"); diff --git a/crates/taskers-shell-core/src/lib.rs b/crates/taskers-shell-core/src/lib.rs index 6f01fda..c45c345 100644 --- a/crates/taskers-shell-core/src/lib.rs +++ b/crates/taskers-shell-core/src/lib.rs @@ -3758,14 +3758,8 @@ fn dominant_attention_ring( } fn runtime_key(surface: &SurfaceRecord) -> String { - if let Some(agent_kind) = surface - .metadata - .agent_kind - .as_deref() - .map(str::trim) - .filter(|agent_kind| !agent_kind.is_empty() && *agent_kind != "shell") - { - return agent_kind.to_ascii_lowercase(); + if let Some(agent_key) = surface_agent_key(surface) { + return agent_key; } match surface.kind { @@ -3810,13 +3804,9 @@ fn surface_agent_state( surface: &SurfaceRecord, now: OffsetDateTime, ) -> Option { - let agent_kind = surface - .metadata - .agent_kind - .as_deref() - .map(str::trim) - .filter(|agent_kind| !agent_kind.is_empty() && *agent_kind != "shell")?; - let _ = agent_kind; + if !surface_has_agent_identity(surface) { + return None; + } let state = surface.metadata.agent_state.or_else(|| { if surface.metadata.agent_active { @@ -3932,14 +3922,7 @@ fn surface_notification_ring(surface: &SurfaceRecord) -> Option Option bool { + surface_agent_key(surface).is_some() + || surface.metadata.agent_state.is_some() + || surface + .metadata + .agent_title + .as_deref() + .map(str::trim) + .is_some_and(|title| !title.is_empty()) +} + +fn surface_agent_key(surface: &SurfaceRecord) -> Option { + normalized_agent_key(surface.metadata.agent_kind.as_deref()) + .or_else(|| normalized_agent_key(surface.metadata.agent_title.as_deref())) +} + +fn normalized_agent_key(value: Option<&str>) -> Option { + let normalized = value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_ascii_lowercase())?; + match normalized.as_str() { + "shell" => None, + "claude code" | "claude-code" => Some("claude".into()), + "codex" | "claude" | "opencode" | "aider" => Some(normalized), + _ => None, + } +} + fn display_terminal_title(metadata: &PaneMetadata) -> String { let agent_title = metadata .agent_title @@ -4818,6 +4830,26 @@ mod tests { assert_eq!(super::surface_notification_ring(&browser), None); } + #[test] + fn notification_rings_cover_agent_notifications_without_agent_kind_metadata() { + let mut waiting = surface_with_metadata( + taskers_domain::PaneKind::Terminal, + taskers_domain::PaneMetadata { + agent_title: Some("Codex".into()), + agent_state: Some(taskers_domain::WorkspaceAgentState::Waiting), + latest_agent_message: Some("Need input".into()), + ..taskers_domain::PaneMetadata::default() + }, + ); + waiting.attention = taskers_domain::AttentionState::WaitingInput; + + assert_eq!( + super::surface_notification_ring(&waiting), + Some(super::AttentionRingState::Waiting) + ); + assert_eq!(super::runtime_key(&waiting), "codex"); + } + #[test] fn status_label_survives_when_agent_has_no_latest_message() { let now = OffsetDateTime::now_utc(); @@ -5056,6 +5088,51 @@ mod tests { ); } + #[test] + fn portal_plan_carries_ring_for_agent_notifications_without_prior_agent_kind() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let pane_id = model.active_workspace().expect("workspace").active_pane; + let surface_id = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&pane_id)) + .map(|pane| pane.active_surface) + .expect("surface"); + + model + .create_agent_notification( + taskers_domain::AgentTarget::Surface { + workspace_id, + pane_id, + surface_id, + }, + taskers_domain::SignalKind::Notification, + Some("Codex".into()), + None, + None, + "Need input".into(), + taskers_domain::AttentionState::WaitingInput, + ) + .expect("notification"); + + let core = SharedCore::bootstrap(bootstrap_with_model( + model, + "taskers-preview-agent-notification-ring", + )); + let portal_plan = core + .snapshot() + .portal + .panes + .into_iter() + .find(|plan| plan.pane_id == pane_id) + .expect("portal plan"); + + assert_eq!( + portal_plan.notification_ring, + Some(super::AttentionRingState::Waiting) + ); + } + #[test] fn default_bootstrap_projects_browser_and_terminal_portal_plans() { let core = SharedCore::bootstrap(bootstrap()); From 30ae58c49c6fd48814cc2edec0b666082d5c8aa1 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 24 Mar 2026 13:33:12 +0100 Subject: [PATCH 49/63] fix(shell): key surface tabs by identity --- crates/taskers-shell/src/lib.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/taskers-shell/src/lib.rs b/crates/taskers-shell/src/lib.rs index 5cb924b..e70a67c 100644 --- a/crates/taskers-shell/src/lib.rs +++ b/crates/taskers-shell/src/lib.rs @@ -1762,6 +1762,7 @@ fn render_surface_tab( rsx! { button { + key: "{surface_id}", class: "{tab_class} surface-tab-draggable", title: "{surface_tab_title}", onclick: focus_surface, @@ -1771,13 +1772,17 @@ fn render_surface_tab( onpointerleave: clear_surface_drop_target, onpointerup: drop_surface, {render_runtime_icon(&surface.runtime, 10, &surface_runtime_icon_class)} - span { class: "surface-tab-copy", + span { key: "{surface_id}-copy", class: "surface-tab-copy", span { class: "surface-tab-primary", "{surface_primary_label(surface)}" } if let Some(runtime_label) = surface_runtime_badge_text(surface) { - span { class: "surface-tab-runtime-badge", "{runtime_label}" } + span { key: "{surface_id}-runtime-badge", class: "surface-tab-runtime-badge", "{runtime_label}" } } if let Some(status_label) = surface_status_text(surface) { - span { class: "{surface_tab_state_class}", "{status_label}" } + span { + key: "{surface_id}-status-{status_label}", + class: "{surface_tab_state_class}", + "{status_label}" + } } } } From 21110b1d0cfb2e42670a4bdf1d1bd0cd4657d1cd Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 24 Mar 2026 13:39:15 +0100 Subject: [PATCH 50/63] fix(shell-core): keep terminal targets bound to surfaces --- crates/taskers-core/src/app_state.rs | 90 +++++++++++++++++++++++++++- crates/taskers-shell-core/src/lib.rs | 52 +++++++++++++++- 2 files changed, 139 insertions(+), 3 deletions(-) diff --git a/crates/taskers-core/src/app_state.rs b/crates/taskers-core/src/app_state.rs index 74631c0..8e3b60d 100644 --- a/crates/taskers-core/src/app_state.rs +++ b/crates/taskers-core/src/app_state.rs @@ -2,7 +2,7 @@ use std::{collections::BTreeMap, path::PathBuf}; use anyhow::{Context, Result, anyhow}; use taskers_control::{ControlCommand, ControlResponse, InMemoryController}; -use taskers_domain::{AppModel, PaneId, PaneKind, WorkspaceId}; +use taskers_domain::{AppModel, PaneId, PaneKind, SurfaceId, WorkspaceId}; use taskers_ghostty::{BackendChoice, GhosttyHostOptions, SurfaceDescriptor}; use taskers_runtime::ShellLaunchSpec; @@ -112,10 +112,44 @@ impl AppState { .panes .get(&pane_id) .ok_or_else(|| anyhow!("pane {pane_id} is not present"))?; - let surface = pane + let surface_id = pane .active_surface() + .map(|surface| surface.id) .ok_or_else(|| anyhow!("pane {pane_id} has no active surface"))?; + self.surface_descriptor_for_surface_in_model(&model, workspace_id, pane_id, surface_id) + } + + pub fn surface_descriptor_for_surface( + &self, + workspace_id: WorkspaceId, + pane_id: PaneId, + surface_id: SurfaceId, + ) -> Result { + let model = self.snapshot_model(); + self.surface_descriptor_for_surface_in_model(&model, workspace_id, pane_id, surface_id) + } + + fn surface_descriptor_for_surface_in_model( + &self, + model: &AppModel, + workspace_id: WorkspaceId, + pane_id: PaneId, + surface_id: SurfaceId, + ) -> Result { + let workspace = model + .workspaces + .get(&workspace_id) + .ok_or_else(|| anyhow!("workspace {workspace_id} is not present"))?; + let pane = workspace + .panes + .get(&pane_id) + .ok_or_else(|| anyhow!("pane {pane_id} is not present"))?; + let surface = pane + .surfaces + .get(&surface_id) + .ok_or_else(|| anyhow!("surface {surface_id} is not present in pane {pane_id}"))?; + let env = match surface.kind { PaneKind::Terminal => { let mut env = self.shell_launch.env.clone(); @@ -195,6 +229,58 @@ mod tests { assert!(descriptor.env.contains_key("TASKERS_AGENT_SESSION_ID")); } + #[test] + fn surface_descriptor_for_surface_keeps_target_ids_for_inactive_tabs() { + let mut model = AppModel::new("Main"); + let workspace = model.active_workspace_id().expect("workspace"); + let pane = model.active_workspace().expect("workspace").active_pane; + let first_surface = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&pane)) + .and_then(|pane| pane.active_surface()) + .map(|surface| surface.id) + .expect("first surface"); + let second_surface = model + .create_surface(workspace, pane, PaneKind::Terminal) + .expect("second surface"); + model + .focus_surface(workspace, pane, first_surface) + .expect("restore active surface"); + + let mut shell_launch = ShellLaunchSpec::fallback(); + shell_launch.program = PathBuf::from("/bin/zsh"); + shell_launch.args = vec!["-i".into()]; + shell_launch + .env + .insert("TASKERS_SOCKET".into(), "/tmp/taskers.sock".into()); + + let app_state = AppState::new( + model, + PathBuf::from("/tmp/taskers-session.json"), + BackendChoice::Mock, + shell_launch, + ) + .expect("app state"); + + let descriptor = app_state + .surface_descriptor_for_surface(workspace, pane, second_surface) + .expect("descriptor"); + + assert_eq!(descriptor.kind, PaneKind::Terminal); + assert_eq!( + descriptor.env.get("TASKERS_WORKSPACE_ID"), + Some(&workspace.to_string()) + ); + assert_eq!( + descriptor.env.get("TASKERS_PANE_ID"), + Some(&pane.to_string()) + ); + assert_eq!( + descriptor.env.get("TASKERS_SURFACE_ID"), + Some(&second_surface.to_string()) + ); + } + #[test] fn ghostty_host_options_follow_shell_launch() { let model = AppModel::new("Main"); diff --git a/crates/taskers-shell-core/src/lib.rs b/crates/taskers-shell-core/src/lib.rs index c45c345..e835b18 100644 --- a/crates/taskers-shell-core/src/lib.rs +++ b/crates/taskers-shell-core/src/lib.rs @@ -1784,7 +1784,10 @@ impl TaskersCore { if surface.kind != PaneKind::Terminal { continue; } - let descriptor = fallback_surface_descriptor(surface); + let descriptor = self + .app_state + .surface_descriptor_for_surface(*workspace_id, pane.id, surface.id) + .unwrap_or_else(|_| fallback_surface_descriptor(surface)); let mount = mount_spec_from_descriptor(surface, descriptor); let SurfaceMountSpec::Terminal(spec) = mount else { continue; @@ -5423,6 +5426,53 @@ mod tests { assert!(catalog.iter().all(|entry| entry.spec.rows > 0)); } + #[test] + fn terminal_catalog_preserves_per_surface_env_for_inactive_tabs() { + let core = SharedCore::bootstrap(bootstrap()); + let snapshot = core.snapshot(); + let workspace_id = snapshot.current_workspace.id; + let pane_id = snapshot.current_workspace.active_pane; + let first_surface_id = find_pane(&snapshot.current_workspace.layout, pane_id) + .map(|pane| pane.active_surface) + .expect("first surface"); + + core.dispatch_shell_action(ShellAction::AddTerminalSurface { + pane_id: Some(pane_id), + }); + + let snapshot = core.snapshot(); + let second_surface_id = find_pane(&snapshot.current_workspace.layout, pane_id) + .map(|pane| pane.active_surface) + .expect("second surface"); + assert_ne!(first_surface_id, second_surface_id); + + core.dispatch_shell_action(ShellAction::FocusSurface { + pane_id, + surface_id: first_surface_id, + }); + + let catalog = core.snapshot().terminal_catalog; + let background_entry = catalog + .iter() + .find(|entry| entry.surface_id == second_surface_id) + .expect("background terminal entry"); + + assert_eq!(background_entry.workspace_id, workspace_id); + assert_eq!(background_entry.pane_id, pane_id); + assert_eq!( + background_entry.spec.env.get("TASKERS_WORKSPACE_ID"), + Some(&workspace_id.to_string()) + ); + assert_eq!( + background_entry.spec.env.get("TASKERS_PANE_ID"), + Some(&pane_id.to_string()) + ); + assert_eq!( + background_entry.spec.env.get("TASKERS_SURFACE_ID"), + Some(&second_surface_id.to_string()) + ); + } + #[test] fn browser_navigation_host_events_update_browser_chrome_snapshot() { let core = SharedCore::bootstrap(bootstrap()); From 2d1a9055d7899aead7eecdcfb8d7f31893da6bbc Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 24 Mar 2026 13:59:09 +0100 Subject: [PATCH 51/63] fix(notifications): require pane context for implicit notify targets --- crates/taskers-cli/src/main.rs | 66 ++++++++++++++++++- .../taskers-launcher/assets/taskers-notify.sh | 9 ++- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/crates/taskers-cli/src/main.rs b/crates/taskers-cli/src/main.rs index ecc97b5..07a86e9 100644 --- a/crates/taskers-cli/src/main.rs +++ b/crates/taskers-cli/src/main.rs @@ -1164,6 +1164,7 @@ async fn main() -> anyhow::Result<()> { } => { let client = ControlClient::new(resolve_socket_path(socket)); let model = query_model(&client).await?; + ensure_implicit_notify_target_context(workspace, pane, surface)?; let target = resolve_agent_target( &model, workspace, @@ -1924,6 +1925,28 @@ fn env_surface_id() -> Option { .and_then(|value| value.parse().ok()) } +fn has_implicit_notify_target_context() -> bool { + env_workspace_id().is_some() && env_pane_id().is_some() && env_surface_id().is_some() +} + +fn ensure_implicit_notify_target_context( + workspace: Option, + pane: Option, + surface: Option, +) -> anyhow::Result<()> { + if workspace.is_some() + || pane.is_some() + || surface.is_some() + || has_implicit_notify_target_context() + { + return Ok(()); + } + + bail!( + "notify requires embedded Taskers pane context; pass --workspace/--pane/--surface when running outside Taskers" + ) +} + fn resolve_socket_path(socket: Option) -> PathBuf { socket .or_else(|| env::var_os("TASKERS_SOCKET").map(PathBuf::from)) @@ -2917,8 +2940,8 @@ mod tests { use taskers_control::{BrowserTarget, BrowserWaitCondition}; use super::{ - CliBrowserLoadState, env_pane_id, env_surface_id, env_workspace_id, infer_agent_kind, - resolve_browser_target, resolve_wait_condition, + CliBrowserLoadState, ensure_implicit_notify_target_context, env_pane_id, env_surface_id, + env_workspace_id, infer_agent_kind, resolve_browser_target, resolve_wait_condition, }; static ENV_LOCK: Mutex<()> = Mutex::new(()); @@ -2954,6 +2977,45 @@ mod tests { } } + #[test] + fn implicit_notify_requires_embedded_taskers_context() { + let _guard = ENV_LOCK.lock().expect("env lock"); + unsafe { + std::env::remove_var("TASKERS_WORKSPACE_ID"); + std::env::remove_var("TASKERS_PANE_ID"); + std::env::remove_var("TASKERS_SURFACE_ID"); + } + + assert!(ensure_implicit_notify_target_context(None, None, None).is_err()); + assert!(ensure_implicit_notify_target_context(env_workspace_id(), None, None).is_err()); + } + + #[test] + fn implicit_notify_accepts_embedded_context_or_explicit_target() { + let _guard = ENV_LOCK.lock().expect("env lock"); + unsafe { + std::env::set_var( + "TASKERS_WORKSPACE_ID", + "019cede5-2843-7da1-a281-dd6b5d1cfbe6", + ); + std::env::set_var("TASKERS_PANE_ID", "019cede5-2843-7da1-a281-dd4f2de73c9c"); + std::env::set_var("TASKERS_SURFACE_ID", "019cede5-2843-7da1-a281-dd2119ae9b83"); + } + + assert!(ensure_implicit_notify_target_context(None, None, None).is_ok()); + + unsafe { + std::env::remove_var("TASKERS_WORKSPACE_ID"); + std::env::remove_var("TASKERS_PANE_ID"); + std::env::remove_var("TASKERS_SURFACE_ID"); + } + + let workspace = "019cede5-2843-7da1-a281-dd6b5d1cfbe6" + .parse() + .expect("workspace id"); + assert!(ensure_implicit_notify_target_context(Some(workspace), None, None).is_ok()); + } + #[test] fn browser_targets_require_exactly_one_selector_or_ref() { let target = resolve_browser_target(Some("@e1".into()), None, true).expect("target"); diff --git a/crates/taskers-launcher/assets/taskers-notify.sh b/crates/taskers-launcher/assets/taskers-notify.sh index b3a92b2..63dbc2f 100644 --- a/crates/taskers-launcher/assets/taskers-notify.sh +++ b/crates/taskers-launcher/assets/taskers-notify.sh @@ -24,5 +24,12 @@ if [ -z "$taskers_ctl" ] && command -v taskersctl >/dev/null 2>&1; then fi if [ -n "$taskers_ctl" ] && [ -x "$taskers_ctl" ]; then - "$taskers_ctl" notify --title Taskers --body "$message" >/dev/null 2>&1 || true + if [ -n "${TASKERS_WORKSPACE_ID:-}" ] && [ -n "${TASKERS_PANE_ID:-}" ] && [ -n "${TASKERS_SURFACE_ID:-}" ]; then + "$taskers_ctl" notify --title Taskers --body "$message" >/dev/null 2>&1 || true + exit 0 + fi +fi + +if command -v notify-send >/dev/null 2>&1; then + notify-send "Taskers" "$message" >/dev/null 2>&1 || true fi From 1f13a866f77c406c253e05fdd2e9ebc26f152aed Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 24 Mar 2026 14:16:20 +0100 Subject: [PATCH 52/63] fix(runtime): bind embedded notifications to pane tty --- crates/taskers-cli/src/main.rs | 71 ++++++++++++++++- .../assets/shell/taskers-agent-proxy.sh | 18 ++++- .../assets/shell/taskers-hooks.bash | 21 +++++ .../assets/shell/taskers-hooks.fish | 15 ++++ .../assets/shell/taskers-hooks.zsh | 16 ++++ .../assets/shell/taskers-shell-wrapper.sh | 4 + crates/taskers-runtime/src/shell.rs | 77 ++++++++++++++++--- 7 files changed, 209 insertions(+), 13 deletions(-) diff --git a/crates/taskers-cli/src/main.rs b/crates/taskers-cli/src/main.rs index 07a86e9..a55ce96 100644 --- a/crates/taskers-cli/src/main.rs +++ b/crates/taskers-cli/src/main.rs @@ -1,4 +1,4 @@ -use std::{env, future::pending, path::PathBuf}; +use std::{env, future::pending, path::PathBuf, process::Command as ProcessCommand}; use anyhow::{Context, anyhow, bail}; use clap::{Args, Parser, Subcommand, ValueEnum}; @@ -1908,23 +1908,66 @@ async fn main() -> anyhow::Result<()> { } fn env_workspace_id() -> Option { + if !taskers_env_context_matches_current_tty() { + return None; + } env::var("TASKERS_WORKSPACE_ID") .ok() .and_then(|value| value.parse().ok()) } fn env_pane_id() -> Option { + if !taskers_env_context_matches_current_tty() { + return None; + } env::var("TASKERS_PANE_ID") .ok() .and_then(|value| value.parse().ok()) } fn env_surface_id() -> Option { + if !taskers_env_context_matches_current_tty() { + return None; + } env::var("TASKERS_SURFACE_ID") .ok() .and_then(|value| value.parse().ok()) } +fn env_tty_name() -> Option { + env::var("TASKERS_TTY_NAME") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn current_process_tty_name() -> Option { + let output = ProcessCommand::new("ps") + .args(["-o", "tty=", "-p", &std::process::id().to_string()]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let raw = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if raw.is_empty() || raw == "?" { + return None; + } + if raw.starts_with('/') { + Some(raw) + } else { + Some(format!("/dev/{raw}")) + } +} + +fn taskers_env_context_matches_current_tty() -> bool { + match env_tty_name() { + Some(expected) => current_process_tty_name().is_some_and(|current| current == expected), + None => true, + } +} + fn has_implicit_notify_target_context() -> bool { env_workspace_id().is_some() && env_pane_id().is_some() && env_surface_id().is_some() } @@ -3000,6 +3043,7 @@ mod tests { ); std::env::set_var("TASKERS_PANE_ID", "019cede5-2843-7da1-a281-dd4f2de73c9c"); std::env::set_var("TASKERS_SURFACE_ID", "019cede5-2843-7da1-a281-dd2119ae9b83"); + std::env::remove_var("TASKERS_TTY_NAME"); } assert!(ensure_implicit_notify_target_context(None, None, None).is_ok()); @@ -3016,6 +3060,31 @@ mod tests { assert!(ensure_implicit_notify_target_context(Some(workspace), None, None).is_ok()); } + #[test] + fn runtime_context_ids_are_ignored_when_tty_mismatches() { + let _guard = ENV_LOCK.lock().expect("env lock"); + unsafe { + std::env::set_var( + "TASKERS_WORKSPACE_ID", + "019cede5-2843-7da1-a281-dd6b5d1cfbe6", + ); + std::env::set_var("TASKERS_PANE_ID", "019cede5-2843-7da1-a281-dd4f2de73c9c"); + std::env::set_var("TASKERS_SURFACE_ID", "019cede5-2843-7da1-a281-dd2119ae9b83"); + std::env::set_var("TASKERS_TTY_NAME", "/dev/pts/taskers-mismatch"); + } + + assert!(env_workspace_id().is_none()); + assert!(env_pane_id().is_none()); + assert!(env_surface_id().is_none()); + + unsafe { + std::env::remove_var("TASKERS_WORKSPACE_ID"); + std::env::remove_var("TASKERS_PANE_ID"); + std::env::remove_var("TASKERS_SURFACE_ID"); + std::env::remove_var("TASKERS_TTY_NAME"); + } + } + #[test] fn browser_targets_require_exactly_one_selector_or_ref() { let target = resolve_browser_target(Some("@e1".into()), None, true).expect("target"); diff --git a/crates/taskers-runtime/assets/shell/taskers-agent-proxy.sh b/crates/taskers-runtime/assets/shell/taskers-agent-proxy.sh index 651ee57..3e0f70d 100644 --- a/crates/taskers-runtime/assets/shell/taskers-agent-proxy.sh +++ b/crates/taskers-runtime/assets/shell/taskers-agent-proxy.sh @@ -42,6 +42,20 @@ if [ -z "$real_binary" ]; then exit 127 fi +can_emit_signal() { + [ -x "${TASKERS_CTL_PATH:-}" ] || return 1 + [ -n "${TASKERS_WORKSPACE_ID:-}" ] || return 1 + [ -n "${TASKERS_PANE_ID:-}" ] || return 1 + [ -n "${TASKERS_SURFACE_ID:-}" ] || return 1 + [ -n "${TASKERS_TTY_NAME:-}" ] || return 1 + current_tty=$(tty 2>/dev/null || true) + case "$current_tty" in + /dev/*) ;; + *) return 1 ;; + esac + [ "$current_tty" = "$TASKERS_TTY_NAME" ] || return 1 +} + emit_signal() { kind=$1 message=${2-} @@ -54,9 +68,7 @@ emit_signal() { git_branch=$(git -C "$PWD" branch --show-current 2>/dev/null || true) fi fi - [ -x "${TASKERS_CTL_PATH:-}" ] || return 0 - [ -n "${TASKERS_WORKSPACE_ID:-}" ] || return 0 - [ -n "${TASKERS_PANE_ID:-}" ] || return 0 + can_emit_signal || return 0 set -- signal --source shell --kind "$kind" --agent "$agent_kind" --title "$agent_title" if [ -n "${PWD:-}" ]; then diff --git a/crates/taskers-runtime/assets/shell/taskers-hooks.bash b/crates/taskers-runtime/assets/shell/taskers-hooks.bash index a366fd0..085fd42 100644 --- a/crates/taskers-runtime/assets/shell/taskers-hooks.bash +++ b/crates/taskers-runtime/assets/shell/taskers-hooks.bash @@ -107,6 +107,18 @@ taskers__agent_active_for_kind() { esac } +taskers__context_tty_matches() { + local expected_tty=${TASKERS_TTY_NAME:-} + local current_tty + [ -n "$expected_tty" ] || return 1 + current_tty=$(tty 2>/dev/null || true) + case "$current_tty" in + /dev/*) ;; + *) return 1 ;; + esac + [ "$current_tty" = "$expected_tty" ] +} + taskers__emit_with_metadata() { local kind=$1 local message=${2:-} @@ -119,6 +131,8 @@ taskers__emit_with_metadata() { [ -x "${TASKERS_CTL_PATH:-}" ] || return 0 [ -n "${TASKERS_WORKSPACE_ID:-}" ] || return 0 [ -n "${TASKERS_PANE_ID:-}" ] || return 0 + [ -n "${TASKERS_SURFACE_ID:-}" ] || return 0 + taskers__context_tty_matches || return 0 if [ "$kind" = "metadata" ]; then argv=( @@ -259,4 +273,11 @@ taskers__preexec_invoke_exec() { TASKERS_AT_PROMPT=1 trap 'taskers__preexec_invoke_exec' DEBUG PROMPT_COMMAND="taskers__prompt_command${PROMPT_COMMAND:+;$PROMPT_COMMAND}" +if [ -z "${TASKERS_TTY_NAME:-}" ]; then + TASKERS_TTY_NAME=$(tty 2>/dev/null || true) + case "$TASKERS_TTY_NAME" in + /dev/*) export TASKERS_TTY_NAME ;; + *) unset TASKERS_TTY_NAME ;; + esac +fi taskers__emit_metadata_if_changed diff --git a/crates/taskers-runtime/assets/shell/taskers-hooks.fish b/crates/taskers-runtime/assets/shell/taskers-hooks.fish index 0851f6a..26cb60c 100644 --- a/crates/taskers-runtime/assets/shell/taskers-hooks.fish +++ b/crates/taskers-runtime/assets/shell/taskers-hooks.fish @@ -103,12 +103,21 @@ function taskers__agent_active_for_kind --argument kind end end +function taskers__context_tty_matches + set -q TASKERS_TTY_NAME; or return 1 + set -l current_tty (tty 2>/dev/null) + string match -qr '^/dev/' -- "$current_tty"; or return 1 + test "$current_tty" = "$TASKERS_TTY_NAME" +end + function taskers__emit_with_metadata --argument kind message taskers__collect_metadata set -l agent_active (taskers__agent_active_for_kind "$kind") test -x "$TASKERS_CTL_PATH"; or return 0 test -n "$TASKERS_WORKSPACE_ID"; or return 0 test -n "$TASKERS_PANE_ID"; or return 0 + test -n "$TASKERS_SURFACE_ID"; or return 0 + taskers__context_tty_matches; or return 0 set -l argv \ "$TASKERS_CTL_PATH" \ @@ -205,4 +214,10 @@ set -gx TASKERS_LAST_META_BRANCH '' set -gx TASKERS_LAST_META_AGENT '' set -gx TASKERS_LAST_META_TITLE '' set -gx TASKERS_LAST_META_AGENT_ACTIVE '' +if not set -q TASKERS_TTY_NAME + set -l current_tty (tty 2>/dev/null) + if string match -qr '^/dev/' -- "$current_tty" + set -gx TASKERS_TTY_NAME "$current_tty" + end +end taskers__emit_metadata_if_changed diff --git a/crates/taskers-runtime/assets/shell/taskers-hooks.zsh b/crates/taskers-runtime/assets/shell/taskers-hooks.zsh index 307587c..817687f 100644 --- a/crates/taskers-runtime/assets/shell/taskers-hooks.zsh +++ b/crates/taskers-runtime/assets/shell/taskers-hooks.zsh @@ -101,6 +101,15 @@ taskers__agent_active_for_kind() { esac } +taskers__context_tty_matches() { + local expected_tty=${TASKERS_TTY_NAME:-} + local current_tty + [[ -n "$expected_tty" ]] || return 1 + current_tty=$(tty 2>/dev/null || true) + [[ "$current_tty" = /dev/* ]] || return 1 + [[ "$current_tty" = "$expected_tty" ]] +} + taskers__emit_with_metadata() { local kind=$1 local message=${2:-} @@ -113,6 +122,8 @@ taskers__emit_with_metadata() { [[ -x "${TASKERS_CTL_PATH:-}" ]] || return 0 [[ -n "${TASKERS_WORKSPACE_ID:-}" ]] || return 0 [[ -n "${TASKERS_PANE_ID:-}" ]] || return 0 + [[ -n "${TASKERS_SURFACE_ID:-}" ]] || return 0 + taskers__context_tty_matches || return 0 if [[ "$kind" = "metadata" ]]; then argv=( @@ -244,4 +255,9 @@ typeset -ga precmd_functions preexec_functions+=(taskers__preexec) precmd_functions+=(taskers__precmd) taskers__normalize_backspace +if [[ -z "${TASKERS_TTY_NAME:-}" ]]; then + TASKERS_TTY_NAME=$(tty 2>/dev/null || true) + [[ "$TASKERS_TTY_NAME" = /dev/* ]] || unset TASKERS_TTY_NAME + [[ -n "${TASKERS_TTY_NAME:-}" ]] && export TASKERS_TTY_NAME +fi taskers__emit_metadata_if_changed diff --git a/crates/taskers-runtime/assets/shell/taskers-shell-wrapper.sh b/crates/taskers-runtime/assets/shell/taskers-shell-wrapper.sh index 8e3f04b..7fe6d0f 100644 --- a/crates/taskers-runtime/assets/shell/taskers-shell-wrapper.sh +++ b/crates/taskers-runtime/assets/shell/taskers-shell-wrapper.sh @@ -22,6 +22,10 @@ SHELL_NAME=${REAL_SHELL##*/} SHELL_NAME=${SHELL_NAME#-} export TASKERS_EMBEDDED=1 export TERM_PROGRAM=taskers +current_tty=$(tty 2>/dev/null || true) +case "$current_tty" in + /dev/*) export TASKERS_TTY_NAME="$current_tty" ;; +esac # Taskers owns shell integration for embedded Ghostty panes. Scrub Ghostty's # shell-integration environment so user shell config doesn't double-load it. diff --git a/crates/taskers-runtime/src/shell.rs b/crates/taskers-runtime/src/shell.rs index 5f64eab..a860053 100644 --- a/crates/taskers-runtime/src/shell.rs +++ b/crates/taskers-runtime/src/shell.rs @@ -161,7 +161,11 @@ impl ShellIntegration { env: self.base_env(), }, ShellKind::Fish if !integration_disabled => { - let env = self.base_env(); + let mut env = self.base_env(); + env.insert( + "TASKERS_REAL_SHELL".into(), + self.real_shell.display().to_string(), + ); let mut args = Vec::new(); if profile == "clean" { @@ -172,7 +176,7 @@ impl ShellIntegration { args.push(fish_source_command()); ShellLaunchSpec { - program: self.real_shell.clone(), + program: self.wrapper_path.clone(), args, env, } @@ -183,6 +187,11 @@ impl ShellIntegration { env: self.base_env(), }, ShellKind::Zsh => { + let mut env = self.base_env(); + env.insert( + "TASKERS_REAL_SHELL".into(), + self.real_shell.display().to_string(), + ); let args = if profile == "clean" || integration_disabled { vec!["-d".into(), "-f".into(), "-i".into()] } else { @@ -190,16 +199,23 @@ impl ShellIntegration { }; ShellLaunchSpec { - program: self.real_shell.clone(), + program: self.wrapper_path.clone(), args, - env: self.base_env(), + env, + } + } + ShellKind::Other => { + let mut env = self.base_env(); + env.insert( + "TASKERS_REAL_SHELL".into(), + self.real_shell.display().to_string(), + ); + ShellLaunchSpec { + program: self.wrapper_path.clone(), + args: Vec::new(), + env, } } - ShellKind::Other => ShellLaunchSpec { - program: self.real_shell.clone(), - args: Vec::new(), - env: self.base_env(), - }, } } @@ -525,4 +541,47 @@ mod tests { ); } } + + #[test] + fn shell_wrapper_exports_taskers_tty_name() { + let wrapper = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/taskers-shell-wrapper.sh" + )); + assert!( + wrapper.contains("TASKERS_TTY_NAME"), + "expected wrapper to export TASKERS_TTY_NAME" + ); + } + + #[test] + fn shell_hooks_and_proxy_require_surface_tty_identity() { + let bash_hooks = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/taskers-hooks.bash" + )); + let zsh_hooks = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/taskers-hooks.zsh" + )); + let fish_hooks = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/taskers-hooks.fish" + )); + let agent_proxy = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/taskers-agent-proxy.sh" + )); + + for asset in [bash_hooks, zsh_hooks, fish_hooks, agent_proxy] { + assert!( + asset.contains("TASKERS_SURFACE_ID"), + "expected asset to require TASKERS_SURFACE_ID" + ); + assert!( + asset.contains("TASKERS_TTY_NAME"), + "expected asset to require TASKERS_TTY_NAME" + ); + } + } } From 59d5dfcdca9ce7dbf7a5600aaa48434fb75a9909 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 24 Mar 2026 14:46:07 +0100 Subject: [PATCH 53/63] fix(cli): harden Codex notify helper targeting --- .../assets/taskers-codex-notify.sh | 31 +++++++++++++++++-- crates/taskers-cli/src/main.rs | 25 +++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/crates/taskers-cli/assets/taskers-codex-notify.sh b/crates/taskers-cli/assets/taskers-codex-notify.sh index 2dd382a..adfda45 100644 --- a/crates/taskers-cli/assets/taskers-codex-notify.sh +++ b/crates/taskers-cli/assets/taskers-codex-notify.sh @@ -3,6 +3,11 @@ set -eu payload=${1-} message= +taskers_ctl=${TASKERS_CTL_PATH:-} + +if [ -z "$taskers_ctl" ] && command -v taskersctl >/dev/null 2>&1; then + taskers_ctl=$(command -v taskersctl) +fi if [ -n "$payload" ]; then if command -v jq >/dev/null 2>&1; then @@ -18,6 +23,28 @@ if [ -z "$message" ]; then message="Turn complete" fi -if command -v taskersctl >/dev/null 2>&1; then - taskersctl agent-hook notification --agent codex --title Codex --message "$message" >/dev/null 2>&1 || true +has_embedded_surface_context() { + [ -x "${taskers_ctl:-}" ] || return 1 + [ -n "${TASKERS_WORKSPACE_ID:-}" ] || return 1 + [ -n "${TASKERS_PANE_ID:-}" ] || return 1 + [ -n "${TASKERS_SURFACE_ID:-}" ] || return 1 + [ -n "${TASKERS_TTY_NAME:-}" ] || return 1 + + current_tty=$(tty 2>/dev/null || true) + case "$current_tty" in + /dev/*) ;; + *) return 1 ;; + esac + + [ "$current_tty" = "$TASKERS_TTY_NAME" ] || return 1 +} + +if has_embedded_surface_context; then + "$taskers_ctl" agent-hook notification \ + --workspace "$TASKERS_WORKSPACE_ID" \ + --pane "$TASKERS_PANE_ID" \ + --surface "$TASKERS_SURFACE_ID" \ + --agent codex \ + --title Codex \ + --message "$message" >/dev/null 2>&1 || true fi diff --git a/crates/taskers-cli/src/main.rs b/crates/taskers-cli/src/main.rs index a55ce96..62d1f5a 100644 --- a/crates/taskers-cli/src/main.rs +++ b/crates/taskers-cli/src/main.rs @@ -2997,6 +2997,31 @@ mod tests { assert_eq!(infer_agent_kind("unknown"), None); } + #[test] + fn codex_notify_helper_requires_embedded_surface_context() { + let asset = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/taskers-codex-notify.sh" + )); + + for expected in [ + "TASKERS_WORKSPACE_ID", + "TASKERS_PANE_ID", + "TASKERS_SURFACE_ID", + "TASKERS_TTY_NAME", + "tty 2>/dev/null", + "agent-hook notification", + "--workspace \"$TASKERS_WORKSPACE_ID\"", + "--pane \"$TASKERS_PANE_ID\"", + "--surface \"$TASKERS_SURFACE_ID\"", + ] { + assert!( + asset.contains(expected), + "expected helper asset to contain {expected:?}" + ); + } + } + #[test] fn reads_runtime_context_ids_from_env() { let _guard = ENV_LOCK.lock().expect("env lock"); From 2ce18de40802c6965dd683286abeb330eb814887 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 24 Mar 2026 15:55:47 +0100 Subject: [PATCH 54/63] refactor(shell): remove sidebar branding, nav toggle, and section header --- crates/taskers-shell-core/src/lib.rs | 8 +- crates/taskers-shell/src/icons.rs | 20 ---- crates/taskers-shell/src/lib.rs | 68 +++++--------- crates/taskers-shell/src/theme.rs | 136 +++++++-------------------- 4 files changed, 60 insertions(+), 172 deletions(-) diff --git a/crates/taskers-shell-core/src/lib.rs b/crates/taskers-shell-core/src/lib.rs index e835b18..f54ccca 100644 --- a/crates/taskers-shell-core/src/lib.rs +++ b/crates/taskers-shell-core/src/lib.rs @@ -639,10 +639,10 @@ pub struct LayoutMetrics { impl Default for LayoutMetrics { fn default() -> Self { Self { - sidebar_width: 248, - activity_width: 312, - toolbar_height: 42, - workspace_padding: 16, + sidebar_width: 212, + activity_width: 280, + toolbar_height: 32, + workspace_padding: 12, window_border_width: 2, window_toolbar_height: 20, window_body_padding: 0, diff --git a/crates/taskers-shell/src/icons.rs b/crates/taskers-shell/src/icons.rs index cd0ae8c..fdd8493 100644 --- a/crates/taskers-shell/src/icons.rs +++ b/crates/taskers-shell/src/icons.rs @@ -211,26 +211,6 @@ pub fn settings(size: u32, class: &str) -> Element { } } -/// Stacked layers icon for workspaces nav -pub fn layers(size: u32, class: &str) -> Element { - rsx! { - svg { - class: "{class}", - width: "{size}", - height: "{size}", - view_box: "0 0 24 24", - fill: "none", - stroke: "currentColor", - stroke_width: "2", - stroke_linecap: "round", - stroke_linejoin: "round", - polygon { points: "12 2 2 7 12 12 22 7 12 2" } - polyline { points: "2 17 12 22 22 17" } - polyline { points: "2 12 12 17 22 12" } - } - } -} - /// Git branch fork icon pub fn git_branch(size: u32, class: &str) -> Element { rsx! { diff --git a/crates/taskers-shell/src/lib.rs b/crates/taskers-shell/src/lib.rs index e70a67c..22d5262 100644 --- a/crates/taskers-shell/src/lib.rs +++ b/crates/taskers-shell/src/lib.rs @@ -310,33 +310,22 @@ pub fn TaskersShell(core: SharedCore) -> Element { let snapshot = core.snapshot(); let unread_activity = snapshot.activity.iter().filter(|item| item.unread).count(); let stylesheet = app_css(&snapshot); - let show_workspace_nav = { - let core = core.clone(); - move |_| { - core.dispatch_shell_action(ShellAction::ShowSection { - section: ShellSection::Workspace, - }) - } - }; - let show_settings_nav = { + let toggle_settings = { let core = core.clone(); + let section = snapshot.section; move |_| { - core.dispatch_shell_action(ShellAction::ShowSection { - section: ShellSection::Settings, - }) + let target = if matches!(section, ShellSection::Settings) { + ShellSection::Workspace + } else { + ShellSection::Settings + }; + core.dispatch_shell_action(ShellAction::ShowSection { section: target }); } }; let create_workspace = { let core = core.clone(); move |_| core.dispatch_shell_action(ShellAction::CreateWorkspace) }; - let show_active_section_header = { - let core = core.clone(); - let section = snapshot.section; - move |_| { - core.dispatch_shell_action(ShellAction::ShowSection { section }); - } - }; let jump_unread = { let core = core.clone(); move |_| core.dispatch_shell_action(ShellAction::FocusLatestUnread) @@ -425,25 +414,7 @@ pub fn TaskersShell(core: SharedCore) -> Element { onpointerup: finish_surface_drag_up, onpointercancel: finish_surface_drag_cancel, aside { class: "workspace-sidebar", - div { class: "sidebar-brand", - div { class: "sidebar-brand-wordmark", "TASKERS" } - } - div { class: "sidebar-nav", - button { - class: if matches!(snapshot.section, ShellSection::Workspace) { "sidebar-nav-button sidebar-nav-button-active" } else { "sidebar-nav-button" }, - onclick: show_workspace_nav, - {icons::layers(16, "sidebar-nav-icon")} - span { "Workspaces" } - } - button { - class: if matches!(snapshot.section, ShellSection::Settings) { "sidebar-nav-button sidebar-nav-button-active" } else { "sidebar-nav-button" }, - onclick: show_settings_nav, - {icons::settings(16, "sidebar-nav-icon")} - span { "Settings" } - } - } - div { class: "sidebar-section-header", - div { class: "sidebar-heading", "Workspaces" } + div { class: "sidebar-top", button { class: "workspace-add", onclick: create_workspace, {icons::plus(14, "workspace-add-icon")} } @@ -461,20 +432,23 @@ pub fn TaskersShell(core: SharedCore) -> Element { )} } } + div { class: "sidebar-footer", + button { + class: if matches!(snapshot.section, ShellSection::Settings) { "sidebar-settings-btn sidebar-settings-btn-active" } else { "sidebar-settings-btn" }, + onclick: toggle_settings, + {icons::settings(16, "sidebar-settings-icon")} + } + } } main { class: "{main_class}", header { class: "workspace-header", div { class: "workspace-header-main", - button { - class: "workspace-header-title-btn", - onclick: show_active_section_header, - span { class: "workspace-header-label", - if matches!(snapshot.section, ShellSection::Workspace) { - "{snapshot.current_workspace.title}" - } else { - "Settings" - } + span { class: "workspace-header-label", + if matches!(snapshot.section, ShellSection::Workspace) { + "{snapshot.current_workspace.title}" + } else { + "Settings" } } } diff --git a/crates/taskers-shell/src/theme.rs b/crates/taskers-shell/src/theme.rs index b67dd43..57b6e53 100644 --- a/crates/taskers-shell/src/theme.rs +++ b/crates/taskers-shell/src/theme.rs @@ -247,8 +247,8 @@ input:focus-visible {{ .workspace-sidebar {{ border-right: 1px solid {border_04}; - padding: 8px; - gap: 10px; + padding: 6px; + gap: 6px; backdrop-filter: blur(12px) saturate(1.4); }} @@ -259,20 +259,11 @@ input:focus-visible {{ backdrop-filter: blur(12px) saturate(1.4); }} -.sidebar-brand {{ - padding: 8px; +.sidebar-top {{ display: flex; - flex-direction: column; - gap: 4px; -}} - -.sidebar-brand-wordmark {{ - margin: 0; - font-size: 13px; - font-weight: 700; - letter-spacing: 0.18em; - color: {text_muted}; - line-height: 1; + align-items: center; + justify-content: flex-end; + padding: 2px 0; }} .sidebar-heading {{ @@ -283,7 +274,6 @@ input:focus-visible {{ text-transform: uppercase; }} -.sidebar-nav, .activity-list {{ display: flex; flex-direction: column; @@ -299,39 +289,43 @@ input:focus-visible {{ min-height: 0; }} -.sidebar-nav-button, -.workspace-button, -.theme-card, -.preset-card {{ - width: 100%; - padding: 0; - border: 0; - background: transparent; - text-align: left; +.sidebar-footer {{ + margin-top: auto; + padding-top: 6px; + border-top: 1px solid {border_06}; }} -.sidebar-nav-button {{ - padding: 8px 10px; - color: {text_subtle}; - border-radius: 6px; +.sidebar-settings-btn {{ display: flex; align-items: center; - gap: 8px; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: {text_dim}; }} -.sidebar-nav-button:hover, -.sidebar-nav-button-active {{ +.sidebar-settings-btn:hover {{ background: {border_06}; color: {text_bright}; - border-radius: 6px; }} -.sidebar-section-header {{ - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 0 6px; +.sidebar-settings-btn-active {{ + background: {accent_14}; + color: {text_bright}; +}} + +.workspace-button, +.theme-card, +.preset-card {{ + width: 100%; + padding: 0; + border: 0; + background: transparent; + text-align: left; }} .workspace-add {{ @@ -832,30 +826,7 @@ input:focus-visible {{ background: {base}; }} -.workspace-header-group {{ - display: flex; - align-items: center; - gap: 2px; - background: {border_04}; - padding: 2px; - border-radius: 6px; -}} - -.workspace-header-group .workspace-header-action {{ - border: 0; - min-height: 26px; - padding: 0 8px; -}} - -.workspace-header-divider {{ - width: 1px; - height: 16px; - background: {border_10}; - margin: 0 4px; -}} - .workspace-header-main, -.workspace-header-actions, .pane-header-main, .pane-action-cluster, .surface-meta, @@ -872,33 +843,14 @@ input:focus-visible {{ justify-content: flex-start; }} -.workspace-header-title-btn {{ - background: transparent; - border: 0; - color: inherit; - padding: 4px 0; - text-align: left; -}} - -.workspace-header-title-btn:hover {{ - color: {text_bright}; -}} - .workspace-header-label {{ display: block; font-weight: 600; - font-size: 13px; + font-size: 12px; letter-spacing: 0.02em; color: {text_bright}; }} -.workspace-header-meta {{ - display: block; - font-size: 12px; - color: {text_dim}; -}} - -.workspace-header-action, .pane-action, .activity-action, .shortcut-pill {{ @@ -907,7 +859,6 @@ input:focus-visible {{ border-radius: 4px; }} -.workspace-header-action, .pane-action, .activity-action {{ min-height: 28px; @@ -916,7 +867,6 @@ input:focus-visible {{ border-radius: 4px; }} -.workspace-header-action:hover, .pane-action:hover {{ background: {border_06}; color: {text_bright}; @@ -930,21 +880,6 @@ input:focus-visible {{ background: {border_04}; }} -.workspace-header-action-active {{ - background: {accent_14}; - color: {text_bright}; -}} - -.workspace-header-action-primary {{ - background: {accent_14}; - color: {text_bright}; - border-color: {accent_24}; -}} - -.workspace-header-action-primary:hover {{ - background: {accent_22}; -}} - .workspace-canvas, .settings-canvas {{ flex: 1; @@ -2275,7 +2210,7 @@ input:focus-visible {{ @media (max-width: 1180px) {{ .app-shell {{ - grid-template-columns: 228px minmax(0, 1fr); + grid-template-columns: 196px minmax(0, 1fr); }} .attention-panel {{ @@ -2309,7 +2244,6 @@ input:focus-visible {{ accent_12 = rgba(p.accent, 0.12), accent_14 = rgba(p.accent, 0.14), accent_20 = rgba(p.accent, 0.20), - accent_22 = rgba(p.accent, 0.22), accent_24 = rgba(p.accent, 0.24), busy = p.busy.to_hex(), busy_10 = rgba(p.busy, 0.10), From df35e285e0d396a93ba1a96811d163d820491cdc Mon Sep 17 00:00:00 2001 From: OneNoted Date: Wed, 25 Mar 2026 14:42:14 +0100 Subject: [PATCH 55/63] refactor(shell): replace dead sidebar-nav-icon CSS with settings-icon styles --- crates/taskers-shell/src/theme.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/taskers-shell/src/theme.rs b/crates/taskers-shell/src/theme.rs index 57b6e53..e5cad57 100644 --- a/crates/taskers-shell/src/theme.rs +++ b/crates/taskers-shell/src/theme.rs @@ -536,12 +536,13 @@ input:focus-visible {{ gap: 4px; }} -.sidebar-nav-icon {{ +.sidebar-settings-icon {{ flex: 0 0 auto; - opacity: 0.6; + opacity: 0.7; }} -.sidebar-nav-button-active .sidebar-nav-icon {{ +.sidebar-settings-btn:hover .sidebar-settings-icon, +.sidebar-settings-btn-active .sidebar-settings-icon {{ opacity: 1.0; }} From 8a9ba9c138cb3d1e96b40912a3dca462eb9e4611 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Wed, 25 Mar 2026 18:58:31 +0100 Subject: [PATCH 56/63] feat(core): add workspace-window tab plumbing --- crates/taskers-control/src/controller.rs | 107 ++++ crates/taskers-control/src/protocol.rs | 39 +- crates/taskers-domain/src/ids.rs | 1 + crates/taskers-domain/src/lib.rs | 3 +- crates/taskers-domain/src/model.rs | 677 ++++++++++++++++++++--- crates/taskers-shell-core/src/lib.rs | 319 ++++++++++- 6 files changed, 1038 insertions(+), 108 deletions(-) diff --git a/crates/taskers-control/src/controller.rs b/crates/taskers-control/src/controller.rs index 149eeaa..6c5f22c 100644 --- a/crates/taskers-control/src/controller.rs +++ b/crates/taskers-control/src/controller.rs @@ -145,6 +145,113 @@ impl InMemoryController { true, ) } + ControlCommand::CreateWorkspaceWindowTab { + workspace_id, + workspace_window_id, + } => { + let (workspace_window_tab_id, pane_id) = + model.create_workspace_window_tab(workspace_id, workspace_window_id)?; + ( + ControlResponse::WorkspaceWindowTabCreated { + pane_id, + workspace_window_tab_id, + }, + true, + ) + } + ControlCommand::FocusWorkspaceWindowTab { + workspace_id, + workspace_window_id, + workspace_window_tab_id, + } => { + model.focus_workspace_window_tab( + workspace_id, + workspace_window_id, + workspace_window_tab_id, + )?; + ( + ControlResponse::Ack { + message: "workspace window tab focused".into(), + }, + true, + ) + } + ControlCommand::MoveWorkspaceWindowTab { + workspace_id, + workspace_window_id, + workspace_window_tab_id, + to_index, + } => { + model.move_workspace_window_tab( + workspace_id, + workspace_window_id, + workspace_window_tab_id, + to_index, + )?; + ( + ControlResponse::Ack { + message: "workspace window tab moved".into(), + }, + true, + ) + } + ControlCommand::TransferWorkspaceWindowTab { + workspace_id, + source_workspace_window_id, + workspace_window_tab_id, + target_workspace_window_id, + to_index, + } => { + model.transfer_workspace_window_tab( + workspace_id, + source_workspace_window_id, + workspace_window_tab_id, + target_workspace_window_id, + to_index, + )?; + ( + ControlResponse::Ack { + message: "workspace window tab transferred".into(), + }, + true, + ) + } + ControlCommand::ExtractWorkspaceWindowTab { + workspace_id, + source_workspace_window_id, + workspace_window_tab_id, + target, + } => { + model.extract_workspace_window_tab( + workspace_id, + source_workspace_window_id, + workspace_window_tab_id, + target, + )?; + ( + ControlResponse::Ack { + message: "workspace window tab extracted".into(), + }, + true, + ) + } + ControlCommand::CloseWorkspaceWindowTab { + workspace_id, + workspace_window_id, + workspace_window_tab_id, + } => { + model.close_workspace_window_tab( + workspace_id, + workspace_window_id, + workspace_window_tab_id, + )?; + ( + ControlResponse::Ack { + message: "workspace window tab closed".into(), + }, + true, + ) + } ControlCommand::FocusPane { workspace_id, pane_id, diff --git a/crates/taskers-control/src/protocol.rs b/crates/taskers-control/src/protocol.rs index 9f5197e..4d90aa6 100644 --- a/crates/taskers-control/src/protocol.rs +++ b/crates/taskers-control/src/protocol.rs @@ -6,7 +6,7 @@ use taskers_domain::{ AgentTarget, AppModel, AttentionState, Direction, NotificationDeliveryState, NotificationId, PaneId, PaneKind, PaneMetadataPatch, PersistedSession, ProgressState, SignalEvent, SignalKind, SplitAxis, SurfaceId, WindowId, WorkspaceColumnId, WorkspaceId, WorkspaceLogEntry, - WorkspaceViewport, WorkspaceWindowId, WorkspaceWindowMoveTarget, + WorkspaceViewport, WorkspaceWindowId, WorkspaceWindowMoveTarget, WorkspaceWindowTabId, }; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -46,6 +46,39 @@ pub enum ControlCommand { workspace_window_id: WorkspaceWindowId, target: WorkspaceWindowMoveTarget, }, + CreateWorkspaceWindowTab { + workspace_id: WorkspaceId, + workspace_window_id: WorkspaceWindowId, + }, + FocusWorkspaceWindowTab { + workspace_id: WorkspaceId, + workspace_window_id: WorkspaceWindowId, + workspace_window_tab_id: WorkspaceWindowTabId, + }, + MoveWorkspaceWindowTab { + workspace_id: WorkspaceId, + workspace_window_id: WorkspaceWindowId, + workspace_window_tab_id: WorkspaceWindowTabId, + to_index: usize, + }, + TransferWorkspaceWindowTab { + workspace_id: WorkspaceId, + source_workspace_window_id: WorkspaceWindowId, + workspace_window_tab_id: WorkspaceWindowTabId, + target_workspace_window_id: WorkspaceWindowId, + to_index: usize, + }, + ExtractWorkspaceWindowTab { + workspace_id: WorkspaceId, + source_workspace_window_id: WorkspaceWindowId, + workspace_window_tab_id: WorkspaceWindowTabId, + target: WorkspaceWindowMoveTarget, + }, + CloseWorkspaceWindowTab { + workspace_id: WorkspaceId, + workspace_window_id: WorkspaceWindowId, + workspace_window_tab_id: WorkspaceWindowTabId, + }, FocusPane { workspace_id: WorkspaceId, pane_id: PaneId, @@ -573,6 +606,10 @@ pub enum ControlResponse { WorkspaceWindowCreated { pane_id: PaneId, }, + WorkspaceWindowTabCreated { + pane_id: PaneId, + workspace_window_tab_id: WorkspaceWindowTabId, + }, Status { session: PersistedSession, }, diff --git a/crates/taskers-domain/src/ids.rs b/crates/taskers-domain/src/ids.rs index 8eac275..bae5314 100644 --- a/crates/taskers-domain/src/ids.rs +++ b/crates/taskers-domain/src/ids.rs @@ -43,6 +43,7 @@ define_id!(WindowId); define_id!(WorkspaceId); define_id!(WorkspaceColumnId); define_id!(WorkspaceWindowId); +define_id!(WorkspaceWindowTabId); define_id!(PaneId); define_id!(SurfaceId); define_id!(SessionId); diff --git a/crates/taskers-domain/src/lib.rs b/crates/taskers-domain/src/lib.rs index f04173e..6ffe398 100644 --- a/crates/taskers-domain/src/lib.rs +++ b/crates/taskers-domain/src/lib.rs @@ -7,7 +7,7 @@ pub mod signal; pub use attention::AttentionState; pub use ids::{ NotificationId, PaneId, SessionId, SurfaceId, WindowId, WorkspaceColumnId, WorkspaceId, - WorkspaceWindowId, + WorkspaceWindowId, WorkspaceWindowTabId, }; pub use layout::{Direction, LayoutNode, SplitAxis}; pub use model::{ @@ -19,5 +19,6 @@ pub use model::{ SESSION_SCHEMA_VERSION, SurfaceRecord, WindowFrame, WindowRecord, Workspace, WorkspaceAgentState, WorkspaceAgentSummary, WorkspaceColumnRecord, WorkspaceLogEntry, WorkspaceSummary, WorkspaceViewport, WorkspaceWindowMoveTarget, WorkspaceWindowRecord, + WorkspaceWindowTabRecord, }; pub use signal::{SignalEvent, SignalKind, SignalPaneMetadata}; diff --git a/crates/taskers-domain/src/model.rs b/crates/taskers-domain/src/model.rs index 27b6390..ece9763 100644 --- a/crates/taskers-domain/src/model.rs +++ b/crates/taskers-domain/src/model.rs @@ -8,10 +8,10 @@ use time::{Duration, OffsetDateTime}; use crate::{ AttentionState, Direction, LayoutNode, NotificationId, PaneId, SessionId, SignalEvent, SignalKind, SignalPaneMetadata, SplitAxis, SurfaceId, WindowId, WorkspaceColumnId, WorkspaceId, - WorkspaceWindowId, + WorkspaceWindowId, WorkspaceWindowTabId, }; -pub const SESSION_SCHEMA_VERSION: u32 = 4; +pub const SESSION_SCHEMA_VERSION: u32 = 5; pub const DEFAULT_WORKSPACE_WINDOW_WIDTH: i32 = 1280; pub const DEFAULT_WORKSPACE_WINDOW_HEIGHT: i32 = 860; pub const DEFAULT_WORKSPACE_WINDOW_GAP: i32 = 10; @@ -112,6 +112,8 @@ pub enum DomainError { MissingWorkspaceColumn(WorkspaceColumnId), #[error("workspace window {0} was not found")] MissingWorkspaceWindow(WorkspaceWindowId), + #[error("workspace window tab {0} was not found")] + MissingWorkspaceWindowTab(WorkspaceWindowTabId), #[error("pane {0} was not found")] MissingPane(PaneId), #[error("surface {0} was not found")] @@ -568,21 +570,164 @@ impl WindowFrame { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceWindowTabRecord { + pub id: WorkspaceWindowTabId, + pub layout: LayoutNode, + pub active_pane: PaneId, +} + +impl WorkspaceWindowTabRecord { + fn new(pane_id: PaneId) -> Self { + Self { + id: WorkspaceWindowTabId::new(), + layout: LayoutNode::leaf(pane_id), + active_pane: pane_id, + } + } + + fn normalize(&mut self, panes: &IndexMap, fallback_pane: PaneId) { + if !self.layout.contains(self.active_pane) { + self.active_pane = self + .layout + .leaves() + .into_iter() + .find(|pane_id| panes.contains_key(pane_id)) + .unwrap_or(fallback_pane); + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WorkspaceWindowRecord { pub id: WorkspaceWindowId, pub height: i32, - pub layout: LayoutNode, - pub active_pane: PaneId, + pub tabs: IndexMap, + pub active_tab: WorkspaceWindowTabId, } impl WorkspaceWindowRecord { fn new(pane_id: PaneId) -> Self { + let first_tab = WorkspaceWindowTabRecord::new(pane_id); + let active_tab = first_tab.id; + let mut tabs = IndexMap::new(); + tabs.insert(active_tab, first_tab); Self { id: WorkspaceWindowId::new(), height: DEFAULT_WORKSPACE_WINDOW_HEIGHT, - layout: LayoutNode::leaf(pane_id), - active_pane: pane_id, + tabs, + active_tab, + } + } + + pub fn active_tab_record(&self) -> Option<&WorkspaceWindowTabRecord> { + self.tabs.get(&self.active_tab) + } + + pub fn active_tab_record_mut(&mut self) -> Option<&mut WorkspaceWindowTabRecord> { + self.tabs.get_mut(&self.active_tab) + } + + pub fn active_pane(&self) -> Option { + self.active_tab_record().map(|tab| tab.active_pane) + } + + pub fn active_layout(&self) -> Option<&LayoutNode> { + self.active_tab_record().map(|tab| &tab.layout) + } + + pub fn active_layout_mut(&mut self) -> Option<&mut LayoutNode> { + self.active_tab_record_mut().map(|tab| &mut tab.layout) + } + + pub fn contains_pane(&self, pane_id: PaneId) -> bool { + self.tabs + .values() + .any(|tab| tab.layout.contains(pane_id)) + } + + pub fn tab_for_pane(&self, pane_id: PaneId) -> Option { + self.tabs + .values() + .find_map(|tab| tab.layout.contains(pane_id).then_some(tab.id)) + } + + pub fn focus_tab(&mut self, tab_id: WorkspaceWindowTabId) -> bool { + if self.tabs.contains_key(&tab_id) { + self.active_tab = tab_id; + true + } else { + false + } + } + + pub fn focus_pane(&mut self, pane_id: PaneId) -> bool { + let Some(tab_id) = self.tab_for_pane(pane_id) else { + return false; + }; + self.active_tab = tab_id; + if let Some(tab) = self.tabs.get_mut(&tab_id) { + tab.active_pane = pane_id; + } + true + } + + fn insert_tab(&mut self, tab: WorkspaceWindowTabRecord, to_index: usize) { + let tab_id = tab.id; + self.tabs.insert(tab_id, tab); + if self.tabs.len() > 1 { + let last_index = self.tabs.len() - 1; + let target_index = to_index.min(last_index); + self.tabs.move_index(last_index, target_index); + } + self.active_tab = tab_id; + } + + fn move_tab(&mut self, tab_id: WorkspaceWindowTabId, to_index: usize) -> bool { + let Some(from_index) = self.tabs.get_index_of(&tab_id) else { + return false; + }; + let last_index = self.tabs.len().saturating_sub(1); + let target_index = to_index.min(last_index); + if from_index == target_index { + return true; + } + self.tabs.move_index(from_index, target_index); + true + } + + fn remove_tab(&mut self, tab_id: WorkspaceWindowTabId) -> Option { + let removed = self.tabs.shift_remove(&tab_id)?; + if !self.tabs.contains_key(&self.active_tab) + && let Some((next_tab_id, _)) = self.tabs.first() + { + self.active_tab = *next_tab_id; + } + Some(removed) + } + + fn all_panes(&self) -> Vec { + self.tabs + .values() + .flat_map(|tab| tab.layout.leaves()) + .collect() + } + + fn normalize(&mut self, panes: &IndexMap, fallback_pane: PaneId) { + if self.tabs.is_empty() { + let fallback_tab = WorkspaceWindowTabRecord::new(fallback_pane); + self.active_tab = fallback_tab.id; + self.tabs.insert(fallback_tab.id, fallback_tab); + } + + for tab in self.tabs.values_mut() { + tab.normalize(panes, fallback_pane); + } + + if !self.tabs.contains_key(&self.active_tab) + && let Some((tab_id, _)) = self.tabs.first() + { + self.active_tab = *tab_id; } } } @@ -725,13 +870,13 @@ impl Workspace { pub fn window_for_pane(&self, pane_id: PaneId) -> Option { self.windows .iter() - .find_map(|(window_id, window)| window.layout.contains(pane_id).then_some(*window_id)) + .find_map(|(window_id, window)| window.contains_pane(pane_id).then_some(*window_id)) } fn sync_active_from_window(&mut self, window_id: WorkspaceWindowId) { if let Some(window) = self.windows.get(&window_id) { self.active_window = window_id; - self.active_pane = window.active_pane; + self.active_pane = window.active_pane().unwrap_or(self.active_pane); if let Some(column_id) = self.column_for_window(window_id) && let Some(column) = self.columns.get_mut(&column_id) { @@ -749,7 +894,7 @@ impl Workspace { return false; }; if let Some(window) = self.windows.get_mut(&window_id) { - window.active_pane = pane_id; + let _ = window.focus_pane(pane_id); } self.sync_active_from_window(window_id); true @@ -1161,15 +1306,12 @@ impl Workspace { for window in self.windows.values_mut() { window.height = window.height.max(MIN_WORKSPACE_WINDOW_HEIGHT); - if !window.layout.contains(window.active_pane) { - window.active_pane = window - .layout - .leaves() - .into_iter() - .find(|pane_id| self.panes.contains_key(pane_id)) - .or_else(|| self.panes.first().map(|(pane_id, _)| *pane_id)) - .expect("workspace has at least one pane"); - } + let fallback_pane = self + .panes + .first() + .map(|(pane_id, _)| *pane_id) + .expect("workspace has at least one pane"); + window.normalize(&self.panes, fallback_pane); } for column in self.columns.values_mut() { @@ -1211,12 +1353,12 @@ impl Workspace { if !self .windows .get(&self.active_window) - .is_some_and(|window| window.layout.contains(self.active_pane)) + .is_some_and(|window| window.contains_pane(self.active_pane)) { self.active_pane = self .windows .get(&self.active_window) - .map(|window| window.active_pane) + .and_then(WorkspaceWindowRecord::active_pane) .expect("active window exists"); } self.sync_active_from_window(self.active_window); @@ -1540,6 +1682,343 @@ impl AppModel { Ok(new_pane_id) } + pub fn create_workspace_window_tab( + &mut self, + workspace_id: WorkspaceId, + workspace_window_id: WorkspaceWindowId, + ) -> Result<(WorkspaceWindowTabId, PaneId), DomainError> { + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + if !workspace.windows.contains_key(&workspace_window_id) { + return Err(DomainError::MissingWorkspaceWindow(workspace_window_id)); + } + + let new_pane = PaneRecord::new(PaneKind::Terminal); + let new_pane_id = new_pane.id; + workspace.panes.insert(new_pane_id, new_pane); + + let window = workspace + .windows + .get_mut(&workspace_window_id) + .ok_or(DomainError::MissingWorkspaceWindow(workspace_window_id))?; + let insert_index = window + .tabs + .get_index_of(&window.active_tab) + .map(|index| index + 1) + .unwrap_or(window.tabs.len()); + let new_tab = WorkspaceWindowTabRecord::new(new_pane_id); + let new_tab_id = new_tab.id; + window.insert_tab(new_tab, insert_index); + workspace.sync_active_from_window(workspace_window_id); + + Ok((new_tab_id, new_pane_id)) + } + + pub fn focus_workspace_window_tab( + &mut self, + workspace_id: WorkspaceId, + workspace_window_id: WorkspaceWindowId, + workspace_window_tab_id: WorkspaceWindowTabId, + ) -> Result<(), DomainError> { + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + let window = workspace + .windows + .get_mut(&workspace_window_id) + .ok_or(DomainError::MissingWorkspaceWindow(workspace_window_id))?; + if !window.focus_tab(workspace_window_tab_id) { + return Err(DomainError::MissingWorkspaceWindowTab( + workspace_window_tab_id, + )); + } + workspace.sync_active_from_window(workspace_window_id); + Ok(()) + } + + pub fn move_workspace_window_tab( + &mut self, + workspace_id: WorkspaceId, + workspace_window_id: WorkspaceWindowId, + workspace_window_tab_id: WorkspaceWindowTabId, + to_index: usize, + ) -> Result<(), DomainError> { + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + let window = workspace + .windows + .get_mut(&workspace_window_id) + .ok_or(DomainError::MissingWorkspaceWindow(workspace_window_id))?; + if !window.move_tab(workspace_window_tab_id, to_index) { + return Err(DomainError::MissingWorkspaceWindowTab( + workspace_window_tab_id, + )); + } + workspace.sync_active_from_window(workspace_window_id); + Ok(()) + } + + pub fn transfer_workspace_window_tab( + &mut self, + workspace_id: WorkspaceId, + source_workspace_window_id: WorkspaceWindowId, + workspace_window_tab_id: WorkspaceWindowTabId, + target_workspace_window_id: WorkspaceWindowId, + to_index: usize, + ) -> Result<(), DomainError> { + if source_workspace_window_id == target_workspace_window_id { + return self.move_workspace_window_tab( + workspace_id, + source_workspace_window_id, + workspace_window_tab_id, + to_index, + ); + } + + let (source_column_id, _source_column_index, source_window_index, remove_source_window) = { + let workspace = self + .workspaces + .get(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + let source_window = workspace + .windows + .get(&source_workspace_window_id) + .ok_or(DomainError::MissingWorkspaceWindow(source_workspace_window_id))?; + if !workspace + .windows + .contains_key(&target_workspace_window_id) + { + return Err(DomainError::MissingWorkspaceWindow(target_workspace_window_id)); + } + if !source_window.tabs.contains_key(&workspace_window_tab_id) { + return Err(DomainError::MissingWorkspaceWindowTab( + workspace_window_tab_id, + )); + } + let (source_column_id, source_column_index, source_window_index) = workspace + .position_for_window(source_workspace_window_id) + .ok_or(DomainError::MissingWorkspaceWindow(source_workspace_window_id))?; + ( + source_column_id, + source_column_index, + source_window_index, + source_window.tabs.len() == 1, + ) + }; + + let moved_tab = { + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + let source_window = workspace + .windows + .get_mut(&source_workspace_window_id) + .ok_or(DomainError::MissingWorkspaceWindow(source_workspace_window_id))?; + source_window + .remove_tab(workspace_window_tab_id) + .ok_or(DomainError::MissingWorkspaceWindowTab( + workspace_window_tab_id, + ))? + }; + + if remove_source_window { + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + let same_column_survived = { + let column = workspace + .columns + .get_mut(&source_column_id) + .ok_or(DomainError::MissingWorkspaceColumn(source_column_id))?; + column.window_order.remove(source_window_index); + if column.window_order.is_empty() { + false + } else { + if !column.window_order.contains(&column.active_window) { + let replacement_index = source_window_index.min(column.window_order.len() - 1); + column.active_window = column.window_order[replacement_index]; + } + true + } + }; + if !same_column_survived { + workspace.columns.shift_remove(&source_column_id); + } + workspace.windows.shift_remove(&source_workspace_window_id); + } + + { + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + let target_window = workspace + .windows + .get_mut(&target_workspace_window_id) + .ok_or(DomainError::MissingWorkspaceWindow(target_workspace_window_id))?; + target_window.insert_tab(moved_tab, to_index); + workspace.sync_active_from_window(target_workspace_window_id); + } + + Ok(()) + } + + pub fn extract_workspace_window_tab( + &mut self, + workspace_id: WorkspaceId, + source_workspace_window_id: WorkspaceWindowId, + workspace_window_tab_id: WorkspaceWindowTabId, + target: WorkspaceWindowMoveTarget, + ) -> Result { + let source_tab_count = self + .workspaces + .get(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))? + .windows + .get(&source_workspace_window_id) + .ok_or(DomainError::MissingWorkspaceWindow(source_workspace_window_id))? + .tabs + .len(); + + if source_tab_count <= 1 { + self.move_workspace_window(workspace_id, source_workspace_window_id, target)?; + return Ok(source_workspace_window_id); + } + + let moved_tab = { + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + let source_window = workspace + .windows + .get_mut(&source_workspace_window_id) + .ok_or(DomainError::MissingWorkspaceWindow(source_workspace_window_id))?; + source_window + .remove_tab(workspace_window_tab_id) + .ok_or(DomainError::MissingWorkspaceWindowTab( + workspace_window_tab_id, + ))? + }; + + let new_window_id = { + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + let mut new_window = WorkspaceWindowRecord::new(moved_tab.active_pane); + new_window.tabs.clear(); + new_window.active_tab = moved_tab.id; + new_window.tabs.insert(moved_tab.id, moved_tab); + let new_window_id = new_window.id; + workspace.windows.insert(new_window_id, new_window); + insert_window_relative_to_active(workspace, new_window_id, Direction::Right)?; + workspace.sync_active_from_window(new_window_id); + new_window_id + }; + + self.move_workspace_window(workspace_id, new_window_id, target)?; + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + workspace.sync_active_from_window(new_window_id); + Ok(new_window_id) + } + + pub fn close_workspace_window_tab( + &mut self, + workspace_id: WorkspaceId, + workspace_window_id: WorkspaceWindowId, + workspace_window_tab_id: WorkspaceWindowTabId, + ) -> Result<(), DomainError> { + let (tab_panes, close_entire_window) = { + let workspace = self + .workspaces + .get(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + let window = workspace + .windows + .get(&workspace_window_id) + .ok_or(DomainError::MissingWorkspaceWindow(workspace_window_id))?; + let tab = window + .tabs + .get(&workspace_window_tab_id) + .ok_or(DomainError::MissingWorkspaceWindowTab( + workspace_window_tab_id, + ))?; + (tab.layout.leaves(), window.tabs.len() == 1) + }; + + if close_entire_window { + if self + .workspaces + .get(&workspace_id) + .is_some_and(|workspace| workspace.windows.len() <= 1) + { + return self.close_workspace(workspace_id); + } + + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + let (column_id, column_index, window_index) = workspace + .position_for_window(workspace_window_id) + .ok_or(DomainError::MissingWorkspaceWindow(workspace_window_id))?; + let column = workspace + .columns + .get_mut(&column_id) + .expect("window column should exist"); + column.window_order.remove(window_index); + let same_column_survived = !column.window_order.is_empty(); + if same_column_survived { + if !column.window_order.contains(&column.active_window) { + let replacement_index = window_index.min(column.window_order.len() - 1); + column.active_window = column.window_order[replacement_index]; + } + } else { + workspace.columns.shift_remove(&column_id); + } + workspace.windows.shift_remove(&workspace_window_id); + remove_panes_from_workspace(workspace, &tab_panes); + if let Some(next_window_id) = + workspace.fallback_window_after_close(column_index, window_index, same_column_survived) + { + workspace.sync_active_from_window(next_window_id); + } + return Ok(()); + } + + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + let window = workspace + .windows + .get_mut(&workspace_window_id) + .ok_or(DomainError::MissingWorkspaceWindow(workspace_window_id))?; + let _ = window + .remove_tab(workspace_window_tab_id) + .ok_or(DomainError::MissingWorkspaceWindowTab( + workspace_window_tab_id, + ))?; + remove_panes_from_workspace(workspace, &tab_panes); + if workspace.active_window == workspace_window_id { + workspace.sync_active_from_window(workspace_window_id); + } else if tab_panes.contains(&workspace.active_pane) { + workspace.sync_active_from_window(workspace.active_window); + } + Ok(()) + } + pub fn split_pane( &mut self, workspace_id: WorkspaceId, @@ -1580,10 +2059,11 @@ impl AppModel { workspace.panes.insert(new_pane_id, new_pane); if let Some(window) = workspace.windows.get_mut(&window_id) { - window - .layout - .split_leaf_with_direction(target, direction, new_pane_id, 500); - window.active_pane = new_pane_id; + let Some(layout) = window.active_layout_mut() else { + return Err(DomainError::MissingWorkspaceWindow(window_id)); + }; + layout.split_leaf_with_direction(target, direction, new_pane_id, 500); + let _ = window.focus_pane(new_pane_id); } workspace.sync_active_from_window(window_id); @@ -1695,10 +2175,14 @@ impl AppModel { let next_pane = workspace .windows .get(&active_window_id) - .and_then(|window| window.layout.focus_neighbor(window.active_pane, direction)); + .and_then(|window| { + let active_pane = window.active_pane()?; + let layout = window.active_layout()?; + layout.focus_neighbor(active_pane, direction) + }); if let Some(next_pane) = next_pane { if let Some(window) = workspace.windows.get_mut(&active_window_id) { - window.active_pane = next_pane; + let _ = window.focus_pane(next_pane); } workspace.sync_active_from_window(active_window_id); return Ok(()); @@ -1886,7 +2370,10 @@ impl AppModel { .windows .get_mut(&active_window_id) .ok_or(DomainError::MissingWorkspaceWindow(active_window_id))?; - window.layout.resize_leaf(active_pane, direction, amount); + let layout = window + .active_layout_mut() + .ok_or(DomainError::MissingWorkspaceWindow(active_window_id))?; + layout.resize_leaf(active_pane, direction, amount); Ok(()) } @@ -1941,7 +2428,10 @@ impl AppModel { .windows .get_mut(&workspace_window_id) .ok_or(DomainError::MissingWorkspaceWindow(workspace_window_id))?; - window.layout.set_ratio_at_path(path, ratio); + let layout = window + .active_layout_mut() + .ok_or(DomainError::MissingWorkspaceWindow(workspace_window_id))?; + layout.set_ratio_at_path(path, ratio); Ok(()) } @@ -2796,13 +3286,15 @@ impl AppModel { .windows .get_mut(&target_window_id) .ok_or(DomainError::MissingPane(target_pane_id))?; - target_window.layout.split_leaf_with_direction( - target_pane_id, - direction, - new_pane_id, - 500, - ); - target_window.active_pane = new_pane_id; + let Some(target_tab_id) = target_window.tab_for_pane(target_pane_id) else { + return Err(DomainError::MissingPane(target_pane_id)); + }; + let _ = target_window.focus_tab(target_tab_id); + let layout = target_window + .active_layout_mut() + .ok_or(DomainError::MissingPane(target_pane_id))?; + layout.split_leaf_with_direction(target_pane_id, direction, new_pane_id, 500); + let _ = target_window.focus_pane(new_pane_id); workspace.sync_active_from_window(target_window_id); let _ = workspace.focus_surface(new_pane_id, surface_id); } @@ -2943,52 +3435,67 @@ impl AppModel { .position_for_window(window_id) .ok_or(DomainError::MissingWorkspaceWindow(window_id))?; - let window_leaf_count = workspace + let (tab_id, tab_leaf_count, window_tab_count) = workspace .windows .get(&window_id) - .map(|window| window.layout.leaves().len()) - .unwrap_or_default(); - if window_leaf_count <= 1 && workspace.windows.len() > 1 { - let column = workspace - .columns - .get_mut(&column_id) - .expect("window column should exist"); - column.window_order.remove(window_index); - let same_column_survived = !column.window_order.is_empty(); - if same_column_survived { - if !column.window_order.contains(&column.active_window) { - let replacement_index = window_index.min(column.window_order.len() - 1); - column.active_window = column.window_order[replacement_index]; - } - } else { - workspace.columns.shift_remove(&column_id); + .and_then(|window| { + let tab_id = window.tab_for_pane(pane_id)?; + let tab = window.tabs.get(&tab_id)?; + Some((tab_id, tab.layout.leaves().len(), window.tabs.len())) + }) + .ok_or(DomainError::MissingPane(pane_id))?; + + if tab_leaf_count <= 1 { + if window_tab_count > 1 { + return self.close_workspace_window_tab(workspace_id, window_id, tab_id); } + if workspace.windows.len() > 1 { + let tab_panes = workspace + .windows + .get(&window_id) + .and_then(|window| window.tabs.get(&tab_id)) + .map(|tab| tab.layout.leaves()) + .unwrap_or_else(|| vec![pane_id]); + let column = workspace + .columns + .get_mut(&column_id) + .expect("window column should exist"); + column.window_order.remove(window_index); + let same_column_survived = !column.window_order.is_empty(); + if same_column_survived { + if !column.window_order.contains(&column.active_window) { + let replacement_index = window_index.min(column.window_order.len() - 1); + column.active_window = column.window_order[replacement_index]; + } + } else { + workspace.columns.shift_remove(&column_id); + } - workspace.windows.shift_remove(&window_id); - workspace.panes.shift_remove(&pane_id); - workspace - .notifications - .retain(|item| item.pane_id != pane_id); - if let Some(next_window_id) = workspace.fallback_window_after_close( - column_index, - window_index, - same_column_survived, - ) { - workspace.sync_active_from_window(next_window_id); + workspace.windows.shift_remove(&window_id); + remove_panes_from_workspace(workspace, &tab_panes); + if let Some(next_window_id) = workspace.fallback_window_after_close( + column_index, + window_index, + same_column_survived, + ) { + workspace.sync_active_from_window(next_window_id); + } + return Ok(()); } - return Ok(()); } if let Some(window) = workspace.windows.get_mut(&window_id) { - let fallback_focus = close_layout_pane(window, pane_id) - .or_else(|| window.layout.leaves().into_iter().next()) - .expect("window should retain at least one pane"); - window.active_pane = fallback_focus; + let tab = window + .tabs + .get_mut(&tab_id) + .ok_or(DomainError::MissingWorkspaceWindowTab(tab_id))?; + let fallback_focus = close_layout_pane(tab, pane_id) + .or_else(|| tab.layout.leaves().into_iter().next()) + .expect("tab should retain at least one pane"); + tab.active_pane = fallback_focus; + let _ = window.focus_tab(tab_id); } - workspace.panes.shift_remove(&pane_id); - workspace - .notifications - .retain(|item| item.pane_id != pane_id); + remove_panes_from_workspace(workspace, &[pane_id]); if workspace.active_window == window_id { workspace.sync_active_from_window(window_id); @@ -3321,7 +3828,25 @@ fn remove_window_from_column( Ok(()) } -fn close_layout_pane(window: &mut WorkspaceWindowRecord, pane_id: PaneId) -> Option { +fn remove_panes_from_workspace(workspace: &mut Workspace, pane_ids: &[PaneId]) { + let pane_set = pane_ids.iter().copied().collect::>(); + let surface_set = pane_set + .iter() + .filter_map(|pane_id| workspace.panes.get(pane_id)) + .flat_map(|pane| pane.surface_ids()) + .collect::>(); + for pane_id in &pane_set { + workspace.panes.shift_remove(pane_id); + } + workspace + .notifications + .retain(|item| !pane_set.contains(&item.pane_id)); + workspace + .surface_flash_tokens + .retain(|surface_id, _| !surface_set.contains(surface_id)); +} + +fn close_layout_pane(tab: &mut WorkspaceWindowTabRecord, pane_id: PaneId) -> Option { let fallback = [ Direction::Right, Direction::Down, @@ -3329,15 +3854,15 @@ fn close_layout_pane(window: &mut WorkspaceWindowRecord, pane_id: PaneId) -> Opt Direction::Up, ] .into_iter() - .find_map(|direction| window.layout.focus_neighbor(pane_id, direction)) + .find_map(|direction| tab.layout.focus_neighbor(pane_id, direction)) .or_else(|| { - window + tab .layout .leaves() .into_iter() .find(|candidate| *candidate != pane_id) }); - let removed = window.layout.remove_leaf(pane_id); + let removed = tab.layout.remove_leaf(pane_id); removed.then_some(fallback).flatten() } diff --git a/crates/taskers-shell-core/src/lib.rs b/crates/taskers-shell-core/src/lib.rs index f54ccca..6103a3f 100644 --- a/crates/taskers-shell-core/src/lib.rs +++ b/crates/taskers-shell-core/src/lib.rs @@ -11,7 +11,7 @@ use taskers_domain::{ ActivityItem, AppModel, DEFAULT_WORKSPACE_WINDOW_GAP, KEYBOARD_RESIZE_STEP, MIN_WORKSPACE_WINDOW_HEIGHT, MIN_WORKSPACE_WINDOW_WIDTH, NotificationId, PaneKind, PaneMetadata, PaneMetadataPatch, SplitAxis as DomainSplitAxis, SurfaceRecord, WindowFrame, - Workspace, WorkspaceSummary as DomainWorkspaceSummary, + Workspace, WorkspaceSummary as DomainWorkspaceSummary, WorkspaceWindowTabRecord, }; use taskers_ghostty::{BackendChoice, SurfaceDescriptor}; use taskers_runtime::ShellLaunchSpec; @@ -20,7 +20,7 @@ use tokio::sync::watch; pub use taskers_domain::{ Direction, PaneId, SurfaceId, WorkspaceColumnId, WorkspaceId, WorkspaceWindowId, - WorkspaceWindowMoveTarget, + WorkspaceWindowMoveTarget, WorkspaceWindowTabId, }; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -879,11 +879,24 @@ pub struct WorkspaceWindowSnapshot { pub title: String, pub pane_count: usize, pub surface_count: usize, + pub active_tab: WorkspaceWindowTabId, pub active_pane: PaneId, pub frame: Frame, + pub tabs: Vec, pub layout: LayoutNodeSnapshot, } +#[derive(Debug, Clone, PartialEq)] +pub struct WorkspaceWindowTabSnapshot { + pub id: WorkspaceWindowTabId, + pub active: bool, + pub attention: AttentionState, + pub runtime: RuntimeIdentitySnapshot, + pub title: String, + pub pane_count: usize, + pub surface_count: usize, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ActivityItemSnapshot { pub id: ActivityId, @@ -918,6 +931,7 @@ pub enum ShellDragMode { #[default] None, Window, + WindowTab, Surface, } @@ -1106,6 +1120,33 @@ pub enum ShellAction { window_id: WorkspaceWindowId, target: WorkspaceWindowMoveTarget, }, + CreateWorkspaceWindowTab { + window_id: WorkspaceWindowId, + }, + FocusWorkspaceWindowTab { + window_id: WorkspaceWindowId, + tab_id: WorkspaceWindowTabId, + }, + MoveWorkspaceWindowTab { + window_id: WorkspaceWindowId, + tab_id: WorkspaceWindowTabId, + target_index: usize, + }, + TransferWorkspaceWindowTab { + source_window_id: WorkspaceWindowId, + tab_id: WorkspaceWindowTabId, + target_window_id: WorkspaceWindowId, + target_index: usize, + }, + ExtractWorkspaceWindowTab { + source_window_id: WorkspaceWindowId, + tab_id: WorkspaceWindowTabId, + target: WorkspaceWindowMoveTarget, + }, + CloseWorkspaceWindowTab { + window_id: WorkspaceWindowId, + tab_id: WorkspaceWindowTabId, + }, ScrollViewport { dx: i32, dy: i32, @@ -1146,6 +1187,7 @@ pub enum ShellAction { target_workspace_id: WorkspaceId, }, BeginWindowDrag, + BeginWindowTabDrag, BeginSurfaceDrag { workspace_id: WorkspaceId, pane_id: PaneId, @@ -1373,7 +1415,12 @@ impl TaskersCore { canvas_offset_x: canvas_metrics.offset_x, canvas_offset_y: canvas_metrics.offset_y, columns: self.workspace_columns_snapshot(workspace, &window_frames), - layout: self.snapshot_layout(workspace, &active_window.layout), + layout: self.snapshot_layout( + workspace, + active_window + .active_layout() + .expect("active workspace window tab should exist"), + ), }, browser_chrome: self.browser_chrome_snapshot(workspace), agents, @@ -1615,7 +1662,10 @@ impl TaskersCore { frame: Frame, now: OffsetDateTime, ) -> WorkspaceWindowSnapshot { - let pane_ids = window.layout.leaves(); + let active_tab = window + .active_tab_record() + .expect("workspace window should have an active tab"); + let pane_ids = active_tab.layout.leaves(); let pane_count = pane_ids.len(); let surface_count = pane_ids .iter() @@ -1623,6 +1673,11 @@ impl TaskersCore { .map(|pane| pane.surfaces.len()) .sum(); let title = window_primary_title(workspace, window); + let tabs = window + .tabs + .values() + .map(|tab| workspace_window_tab_snapshot(workspace, tab, window.active_tab, now)) + .collect(); WorkspaceWindowSnapshot { id: window.id, @@ -1633,9 +1688,13 @@ impl TaskersCore { title, pane_count, surface_count, - active_pane: window.active_pane, + active_tab: window.active_tab, + active_pane: window + .active_pane() + .expect("workspace window should have an active pane"), frame, - layout: self.snapshot_layout(workspace, &window.layout), + tabs, + layout: self.snapshot_layout(workspace, &active_tab.layout), } } @@ -1815,10 +1874,11 @@ impl TaskersCore { .values() .filter_map(|window| { let (_, frame) = window_frames.get(&window.id)?; + let layout = window.active_layout()?; Some(self.collect_surface_plans( workspace_id, workspace, - &window.layout, + layout, workspace_window_content_frame(*frame, self.metrics), )) }) @@ -2000,6 +2060,36 @@ impl TaskersCore { ShellAction::MoveWorkspaceWindow { window_id, target } => { self.move_workspace_window_by_id(window_id, target) } + ShellAction::CreateWorkspaceWindowTab { window_id } => { + self.create_workspace_window_tab(window_id) + } + ShellAction::FocusWorkspaceWindowTab { window_id, tab_id } => { + self.focus_workspace_window_tab(window_id, tab_id) + } + ShellAction::MoveWorkspaceWindowTab { + window_id, + tab_id, + target_index, + } => self.move_workspace_window_tab(window_id, tab_id, target_index), + ShellAction::TransferWorkspaceWindowTab { + source_window_id, + tab_id, + target_window_id, + target_index, + } => self.transfer_workspace_window_tab( + source_window_id, + tab_id, + target_window_id, + target_index, + ), + ShellAction::ExtractWorkspaceWindowTab { + source_window_id, + tab_id, + target, + } => self.extract_workspace_window_tab(source_window_id, tab_id, target), + ShellAction::CloseWorkspaceWindowTab { window_id, tab_id } => { + self.close_workspace_window_tab(window_id, tab_id) + } ShellAction::ScrollViewport { dx, dy } => self.scroll_viewport_by(dx, dy), ShellAction::SplitBrowser { pane_id } => { self.split_with_kind_axis(pane_id, PaneKind::Browser, DomainSplitAxis::Horizontal) @@ -2044,6 +2134,7 @@ impl TaskersCore { target_workspace_id, ), ShellAction::BeginWindowDrag => self.begin_window_drag(), + ShellAction::BeginWindowTabDrag => self.begin_window_tab_drag(), ShellAction::BeginSurfaceDrag { workspace_id, pane_id, @@ -2353,6 +2444,113 @@ impl TaskersCore { }) } + fn create_workspace_window_tab(&mut self, window_id: WorkspaceWindowId) -> bool { + let model = self.app_state.snapshot_model(); + let Some(workspace_id) = model.active_workspace_id() else { + return false; + }; + self.dispatch_control(ControlCommand::CreateWorkspaceWindowTab { + workspace_id, + workspace_window_id: window_id, + }) + } + + fn focus_workspace_window_tab( + &mut self, + window_id: WorkspaceWindowId, + tab_id: WorkspaceWindowTabId, + ) -> bool { + let model = self.app_state.snapshot_model(); + let Some(workspace_id) = model.active_workspace_id() else { + return false; + }; + self.dispatch_control(ControlCommand::FocusWorkspaceWindowTab { + workspace_id, + workspace_window_id: window_id, + workspace_window_tab_id: tab_id, + }) + } + + fn move_workspace_window_tab( + &mut self, + window_id: WorkspaceWindowId, + tab_id: WorkspaceWindowTabId, + target_index: usize, + ) -> bool { + let model = self.app_state.snapshot_model(); + let Some(workspace_id) = model.active_workspace_id() else { + return false; + }; + self.dispatch_control(ControlCommand::MoveWorkspaceWindowTab { + workspace_id, + workspace_window_id: window_id, + workspace_window_tab_id: tab_id, + to_index: target_index, + }) + } + + fn transfer_workspace_window_tab( + &mut self, + source_window_id: WorkspaceWindowId, + tab_id: WorkspaceWindowTabId, + target_window_id: WorkspaceWindowId, + target_index: usize, + ) -> bool { + let model = self.app_state.snapshot_model(); + let Some(workspace_id) = model.active_workspace_id() else { + return false; + }; + let changed = self.dispatch_control(ControlCommand::TransferWorkspaceWindowTab { + workspace_id, + source_workspace_window_id: source_window_id, + workspace_window_tab_id: tab_id, + target_workspace_window_id: target_window_id, + to_index: target_index, + }); + if changed { + return self.ensure_active_window_visible() || changed; + } + false + } + + fn extract_workspace_window_tab( + &mut self, + source_window_id: WorkspaceWindowId, + tab_id: WorkspaceWindowTabId, + target: WorkspaceWindowMoveTarget, + ) -> bool { + let model = self.app_state.snapshot_model(); + let Some(workspace_id) = model.active_workspace_id() else { + return false; + }; + let changed = self.dispatch_control(ControlCommand::ExtractWorkspaceWindowTab { + workspace_id, + source_workspace_window_id: source_window_id, + workspace_window_tab_id: tab_id, + target, + }); + if changed { + return self.ensure_active_window_visible() || changed; + } + false + } + + fn close_workspace_window_tab( + &mut self, + window_id: WorkspaceWindowId, + tab_id: WorkspaceWindowTabId, + ) -> bool { + let model = self.app_state.snapshot_model(); + let Some(workspace_id) = model.active_workspace_id() else { + return false; + }; + self.dispatch_control(ControlCommand::CloseWorkspaceWindowTab { + workspace_id, + workspace_window_id: window_id, + workspace_window_tab_id: tab_id, + }) + } + fn scroll_viewport_by(&mut self, dx: i32, dy: i32) -> bool { let model = self.app_state.snapshot_model(); let Some(workspace_id) = model.active_workspace_id() else { @@ -2776,6 +2974,22 @@ impl TaskersCore { changed } + fn begin_window_tab_drag(&mut self) -> bool { + let mut changed = false; + if self.ui.surface_drag.is_some() { + self.ui.surface_drag = None; + changed = true; + } + if self.ui.drag_mode != ShellDragMode::WindowTab { + self.ui.drag_mode = ShellDragMode::WindowTab; + changed = true; + } + if changed { + self.bump_local_revision(); + } + changed + } + fn begin_surface_drag( &mut self, workspace_id: WorkspaceId, @@ -3625,14 +3839,9 @@ fn workspace_window_attention( window: &taskers_domain::WorkspaceWindowRecord, ) -> AttentionState { window - .layout - .leaves() - .into_iter() - .filter_map(|pane_id| workspace.panes.get(&pane_id)) - .map(|pane| pane.highest_attention()) - .max_by_key(|attention| attention.rank()) - .unwrap_or(taskers_domain::AttentionState::Normal) - .into() + .active_tab_record() + .map(|tab| workspace_window_tab_attention(workspace, tab)) + .unwrap_or(AttentionState::Normal) } fn surface_runtime_identity( @@ -3661,20 +3870,10 @@ fn workspace_window_runtime_identity( window: &taskers_domain::WorkspaceWindowRecord, now: OffsetDateTime, ) -> RuntimeIdentitySnapshot { - dominant_runtime_identity( - window - .layout - .leaves() - .into_iter() - .filter_map(|pane_id| workspace.panes.get(&pane_id)) - .map(|pane| { - ( - pane_runtime_identity(pane, now), - pane.id == window.active_pane, - ) - }), - fallback_runtime_identity("terminal", RuntimeStateSnapshot::Idle), - ) + window + .active_tab_record() + .map(|tab| workspace_window_tab_runtime_identity(workspace, tab, now)) + .unwrap_or_else(|| fallback_runtime_identity("terminal", RuntimeStateSnapshot::Idle)) } fn workspace_runtime_identity( @@ -3872,9 +4071,69 @@ fn window_primary_title( workspace: &Workspace, window: &taskers_domain::WorkspaceWindowRecord, ) -> String { + window + .active_tab_record() + .map(|tab| window_tab_primary_title(workspace, tab)) + .unwrap_or_else(|| "Workspace window".into()) +} + +fn workspace_window_tab_snapshot( + workspace: &Workspace, + tab: &WorkspaceWindowTabRecord, + active_tab_id: WorkspaceWindowTabId, + now: OffsetDateTime, +) -> WorkspaceWindowTabSnapshot { + let pane_ids = tab.layout.leaves(); + let pane_count = pane_ids.len(); + let surface_count = pane_ids + .iter() + .filter_map(|pane_id| workspace.panes.get(pane_id)) + .map(|pane| pane.surfaces.len()) + .sum(); + WorkspaceWindowTabSnapshot { + id: tab.id, + active: tab.id == active_tab_id, + attention: workspace_window_tab_attention(workspace, tab), + runtime: workspace_window_tab_runtime_identity(workspace, tab, now), + title: window_tab_primary_title(workspace, tab), + pane_count, + surface_count, + } +} + +fn workspace_window_tab_attention( + workspace: &Workspace, + tab: &WorkspaceWindowTabRecord, +) -> AttentionState { + tab.layout + .leaves() + .into_iter() + .filter_map(|pane_id| workspace.panes.get(&pane_id)) + .map(|pane| pane.highest_attention()) + .max_by_key(|attention| attention.rank()) + .unwrap_or(taskers_domain::AttentionState::Normal) + .into() +} + +fn workspace_window_tab_runtime_identity( + workspace: &Workspace, + tab: &WorkspaceWindowTabRecord, + now: OffsetDateTime, +) -> RuntimeIdentitySnapshot { + dominant_runtime_identity( + tab.layout + .leaves() + .into_iter() + .filter_map(|pane_id| workspace.panes.get(&pane_id)) + .map(|pane| (pane_runtime_identity(pane, now), pane.id == tab.active_pane)), + fallback_runtime_identity("terminal", RuntimeStateSnapshot::Idle), + ) +} + +fn window_tab_primary_title(workspace: &Workspace, tab: &WorkspaceWindowTabRecord) -> String { workspace .panes - .get(&window.active_pane) + .get(&tab.active_pane) .and_then(|pane| pane.active_surface()) .map(display_surface_title) .unwrap_or_else(|| "Workspace window".into()) From 8688b37843b3fb3e0a67a978ecf32a1953e52bae Mon Sep 17 00:00:00 2001 From: OneNoted Date: Wed, 25 Mar 2026 19:11:38 +0100 Subject: [PATCH 57/63] feat(shell): add window-tab chrome and terminal gutter --- crates/taskers-app/Cargo.toml | 4 + .../assets/taskers-codex-notify.sh | 50 + crates/taskers-app/src/bin/taskersctl.rs | 4 + .../assets/taskers-codex-notify.sh | 2 +- crates/taskers-cli/src/main.rs | 88 +- crates/taskers-control/src/controller.rs | 41 + crates/taskers-control/src/protocol.rs | 17 + crates/taskers-domain/src/lib.rs | 8 +- crates/taskers-domain/src/model.rs | 1053 ++++++++++++++--- crates/taskers-host/src/lib.rs | 8 +- .../assets/shell/taskers-agent-claude.sh | 24 + .../assets/shell/taskers-agent-codex.sh | 20 + .../assets/shell/taskers-agent-proxy.sh | 94 +- .../assets/shell/taskers-claude-hook.sh | 108 ++ .../assets/shell/taskers-codex-notify.sh | 48 + .../assets/shell/taskers-hooks.bash | 22 +- .../assets/shell/taskers-hooks.fish | 22 +- .../assets/shell/taskers-hooks.zsh | 22 +- crates/taskers-runtime/src/shell.rs | 776 +++++++++++- crates/taskers-shell-core/src/lib.rs | 402 +++++-- crates/taskers-shell/src/lib.rs | 468 +++++++- crates/taskers-shell/src/theme.rs | 238 +++- docs/notifications.md | 2 +- docs/taskersctl.md | 2 +- docs/usage.md | 18 +- 25 files changed, 3062 insertions(+), 479 deletions(-) create mode 100644 crates/taskers-app/assets/taskers-codex-notify.sh create mode 100644 crates/taskers-app/src/bin/taskersctl.rs create mode 100644 crates/taskers-runtime/assets/shell/taskers-agent-claude.sh create mode 100644 crates/taskers-runtime/assets/shell/taskers-agent-codex.sh create mode 100644 crates/taskers-runtime/assets/shell/taskers-claude-hook.sh create mode 100644 crates/taskers-runtime/assets/shell/taskers-codex-notify.sh diff --git a/crates/taskers-app/Cargo.toml b/crates/taskers-app/Cargo.toml index c6a10c0..d8b3171 100644 --- a/crates/taskers-app/Cargo.toml +++ b/crates/taskers-app/Cargo.toml @@ -13,6 +13,10 @@ publish = false name = "taskers-gtk" path = "src/main.rs" +[[bin]] +name = "taskersctl" +path = "src/bin/taskersctl.rs" + [dependencies] adw.workspace = true anyhow.workspace = true diff --git a/crates/taskers-app/assets/taskers-codex-notify.sh b/crates/taskers-app/assets/taskers-codex-notify.sh new file mode 100644 index 0000000..5ce9977 --- /dev/null +++ b/crates/taskers-app/assets/taskers-codex-notify.sh @@ -0,0 +1,50 @@ +#!/bin/sh +set -eu + +payload=${1-} +message= +taskers_ctl=${TASKERS_CTL_PATH:-} + +if [ -z "$taskers_ctl" ] && command -v taskersctl >/dev/null 2>&1; then + taskers_ctl=$(command -v taskersctl) +fi + +if [ -n "$payload" ]; then + if command -v jq >/dev/null 2>&1; then + message=$( + printf '%s' "$payload" \ + | jq -r '."last-assistant-message" // .message // .title // empty' 2>/dev/null \ + | head -c 160 + ) + fi +fi + +if [ -z "$message" ]; then + message="Turn complete" +fi + +has_embedded_surface_context() { + [ -x "${taskers_ctl:-}" ] || return 1 + [ -n "${TASKERS_WORKSPACE_ID:-}" ] || return 1 + [ -n "${TASKERS_PANE_ID:-}" ] || return 1 + [ -n "${TASKERS_SURFACE_ID:-}" ] || return 1 + [ -n "${TASKERS_TTY_NAME:-}" ] || return 1 + + current_tty=$(tty 2>/dev/null || true) + case "$current_tty" in + /dev/*) ;; + *) return 1 ;; + esac + + [ "$current_tty" = "$TASKERS_TTY_NAME" ] || return 1 +} + +if has_embedded_surface_context; then + "$taskers_ctl" agent-hook stop \ + --workspace "$TASKERS_WORKSPACE_ID" \ + --pane "$TASKERS_PANE_ID" \ + --surface "$TASKERS_SURFACE_ID" \ + --agent codex \ + --title Codex \ + --message "$message" >/dev/null 2>&1 || true +fi diff --git a/crates/taskers-app/src/bin/taskersctl.rs b/crates/taskers-app/src/bin/taskersctl.rs new file mode 100644 index 0000000..02cdcd6 --- /dev/null +++ b/crates/taskers-app/src/bin/taskersctl.rs @@ -0,0 +1,4 @@ +include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../taskers-cli/src/main.rs" +)); diff --git a/crates/taskers-cli/assets/taskers-codex-notify.sh b/crates/taskers-cli/assets/taskers-codex-notify.sh index adfda45..5ce9977 100644 --- a/crates/taskers-cli/assets/taskers-codex-notify.sh +++ b/crates/taskers-cli/assets/taskers-codex-notify.sh @@ -40,7 +40,7 @@ has_embedded_surface_context() { } if has_embedded_surface_context; then - "$taskers_ctl" agent-hook notification \ + "$taskers_ctl" agent-hook stop \ --workspace "$TASKERS_WORKSPACE_ID" \ --pane "$TASKERS_PANE_ID" \ --surface "$TASKERS_SURFACE_ID" \ diff --git a/crates/taskers-cli/src/main.rs b/crates/taskers-cli/src/main.rs index 62d1f5a..e176e07 100644 --- a/crates/taskers-cli/src/main.rs +++ b/crates/taskers-cli/src/main.rs @@ -885,6 +885,40 @@ enum SurfaceCommand { #[arg(long)] surface: SurfaceId, }, + AgentStart { + #[arg(long)] + socket: Option, + #[arg(long)] + workspace: WorkspaceId, + #[arg(long)] + pane: PaneId, + #[arg(long)] + surface: SurfaceId, + #[arg(long)] + agent: String, + }, + AgentStop { + #[arg(long)] + socket: Option, + #[arg(long)] + workspace: WorkspaceId, + #[arg(long)] + pane: PaneId, + #[arg(long)] + surface: SurfaceId, + #[arg(long = "exit-status")] + exit_status: i32, + }, + DismissAlert { + #[arg(long)] + socket: Option, + #[arg(long)] + workspace: WorkspaceId, + #[arg(long)] + pane: PaneId, + #[arg(long)] + surface: SurfaceId, + }, Close { #[arg(long)] socket: Option, @@ -1885,6 +1919,58 @@ async fn main() -> anyhow::Result<()> { .await?; println!("{}", serde_json::to_string_pretty(&response)?); } + SurfaceCommand::AgentStart { + socket, + workspace, + pane, + surface, + agent, + } => { + let client = ControlClient::new(resolve_socket_path(socket)); + let response = client + .send(ControlCommand::StartSurfaceAgentSession { + workspace_id: workspace, + pane_id: pane, + surface_id: surface, + agent_kind: agent, + }) + .await?; + println!("{}", serde_json::to_string_pretty(&response)?); + } + SurfaceCommand::AgentStop { + socket, + workspace, + pane, + surface, + exit_status, + } => { + let client = ControlClient::new(resolve_socket_path(socket)); + let response = client + .send(ControlCommand::StopSurfaceAgentSession { + workspace_id: workspace, + pane_id: pane, + surface_id: surface, + exit_status, + }) + .await?; + println!("{}", serde_json::to_string_pretty(&response)?); + } + SurfaceCommand::DismissAlert { + socket, + workspace, + pane, + surface, + } => { + let client = ControlClient::new(resolve_socket_path(socket)); + let response = client + .send(ControlCommand::DismissSurfaceAlert { + workspace_id: workspace, + pane_id: pane, + surface_id: surface, + }) + .await?; + println!("{}", serde_json::to_string_pretty(&response)?); + } SurfaceCommand::Close { socket, workspace, @@ -3010,7 +3096,7 @@ mod tests { "TASKERS_SURFACE_ID", "TASKERS_TTY_NAME", "tty 2>/dev/null", - "agent-hook notification", + "agent-hook stop", "--workspace \"$TASKERS_WORKSPACE_ID\"", "--pane \"$TASKERS_PANE_ID\"", "--surface \"$TASKERS_SURFACE_ID\"", diff --git a/crates/taskers-control/src/controller.rs b/crates/taskers-control/src/controller.rs index 6c5f22c..4b393dd 100644 --- a/crates/taskers-control/src/controller.rs +++ b/crates/taskers-control/src/controller.rs @@ -381,6 +381,34 @@ impl InMemoryController { true, ) } + ControlCommand::StartSurfaceAgentSession { + workspace_id, + pane_id, + surface_id, + agent_kind, + } => { + model.start_surface_agent_session(workspace_id, pane_id, surface_id, agent_kind)?; + ( + ControlResponse::Ack { + message: "surface agent session started".into(), + }, + true, + ) + } + ControlCommand::StopSurfaceAgentSession { + workspace_id, + pane_id, + surface_id, + exit_status, + } => { + model.stop_surface_agent_session(workspace_id, pane_id, surface_id, exit_status)?; + ( + ControlResponse::Ack { + message: "surface agent session stopped".into(), + }, + true, + ) + } ControlCommand::MarkSurfaceCompleted { workspace_id, pane_id, @@ -683,6 +711,19 @@ impl InMemoryController { true, ) } + ControlCommand::DismissSurfaceAlert { + workspace_id, + pane_id, + surface_id, + } => { + model.dismiss_surface_alert(workspace_id, pane_id, surface_id)?; + ( + ControlResponse::Ack { + message: "surface alert dismissed".into(), + }, + true, + ) + } ControlCommand::AgentTriggerFlash { workspace_id, pane_id, diff --git a/crates/taskers-control/src/protocol.rs b/crates/taskers-control/src/protocol.rs index 4d90aa6..99f2076 100644 --- a/crates/taskers-control/src/protocol.rs +++ b/crates/taskers-control/src/protocol.rs @@ -131,6 +131,18 @@ pub enum ControlCommand { pane_id: PaneId, surface_id: SurfaceId, }, + StartSurfaceAgentSession { + workspace_id: WorkspaceId, + pane_id: PaneId, + surface_id: SurfaceId, + agent_kind: String, + }, + StopSurfaceAgentSession { + workspace_id: WorkspaceId, + pane_id: PaneId, + surface_id: SurfaceId, + exit_status: i32, + }, MarkSurfaceCompleted { workspace_id: WorkspaceId, pane_id: PaneId, @@ -234,6 +246,11 @@ pub enum ControlCommand { AgentClearNotifications { target: AgentTarget, }, + DismissSurfaceAlert { + workspace_id: WorkspaceId, + pane_id: PaneId, + surface_id: SurfaceId, + }, AgentTriggerFlash { workspace_id: WorkspaceId, pane_id: PaneId, diff --git a/crates/taskers-domain/src/lib.rs b/crates/taskers-domain/src/lib.rs index 6ffe398..232c6c8 100644 --- a/crates/taskers-domain/src/lib.rs +++ b/crates/taskers-domain/src/lib.rs @@ -16,9 +16,9 @@ pub use model::{ KEYBOARD_RESIZE_STEP, MIN_WORKSPACE_WINDOW_HEIGHT, MIN_WORKSPACE_WINDOW_WIDTH, NotificationDeliveryState, NotificationItem, PaneKind, PaneMetadata, PaneMetadataPatch, PaneRecord, PersistedSession, PrStatus, ProgressState, PullRequestState, - SESSION_SCHEMA_VERSION, SurfaceRecord, WindowFrame, WindowRecord, Workspace, - WorkspaceAgentState, WorkspaceAgentSummary, WorkspaceColumnRecord, WorkspaceLogEntry, - WorkspaceSummary, WorkspaceViewport, WorkspaceWindowMoveTarget, WorkspaceWindowRecord, - WorkspaceWindowTabRecord, + SESSION_SCHEMA_VERSION, SurfaceAgentProcess, SurfaceAgentSession, SurfaceRecord, WindowFrame, + WindowRecord, Workspace, WorkspaceAgentState, WorkspaceAgentSummary, WorkspaceColumnRecord, + WorkspaceLogEntry, WorkspaceSummary, WorkspaceViewport, WorkspaceWindowMoveTarget, + WorkspaceWindowRecord, WorkspaceWindowTabRecord, }; pub use signal::{SignalEvent, SignalKind, SignalPaneMetadata}; diff --git a/crates/taskers-domain/src/model.rs b/crates/taskers-domain/src/model.rs index ece9763..61e4aac 100644 --- a/crates/taskers-domain/src/model.rs +++ b/crates/taskers-domain/src/model.rs @@ -3,7 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; use indexmap::IndexMap; use serde::{Deserialize, Deserializer, Serialize}; use thiserror::Error; -use time::{Duration, OffsetDateTime}; +use time::OffsetDateTime; use crate::{ AttentionState, Direction, LayoutNode, NotificationId, PaneId, SessionId, SignalEvent, @@ -11,7 +11,7 @@ use crate::{ WorkspaceWindowId, WorkspaceWindowTabId, }; -pub const SESSION_SCHEMA_VERSION: u32 = 5; +pub const SESSION_SCHEMA_VERSION: u32 = 6; pub const DEFAULT_WORKSPACE_WINDOW_WIDTH: i32 = 1280; pub const DEFAULT_WORKSPACE_WINDOW_HEIGHT: i32 = 860; pub const DEFAULT_WORKSPACE_WINDOW_GAP: i32 = 10; @@ -199,11 +199,33 @@ pub struct PaneMetadataPatch { pub agent_kind: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SurfaceAgentSession { + pub id: SessionId, + pub kind: String, + pub title: String, + pub state: WorkspaceAgentState, + pub latest_message: Option, + pub updated_at: OffsetDateTime, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SurfaceAgentProcess { + pub id: SessionId, + pub kind: String, + pub title: String, + pub started_at: OffsetDateTime, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SurfaceRecord { pub id: SurfaceId, pub kind: PaneKind, pub metadata: PaneMetadata, + #[serde(default)] + pub agent_process: Option, + #[serde(default)] + pub agent_session: Option, pub attention: AttentionState, pub session_id: SessionId, pub command: Option>, @@ -215,6 +237,8 @@ impl SurfaceRecord { id: SurfaceId::new(), kind, metadata: PaneMetadata::default(), + agent_process: None, + agent_session: None, attention: AttentionState::Normal, session_id: SessionId::new(), command: None, @@ -641,9 +665,7 @@ impl WorkspaceWindowRecord { } pub fn contains_pane(&self, pane_id: PaneId) -> bool { - self.tabs - .values() - .any(|tab| tab.layout.contains(pane_id)) + self.tabs.values().any(|tab| tab.layout.contains(pane_id)) } pub fn tab_for_pane(&self, pane_id: PaneId) -> Option { @@ -706,13 +728,6 @@ impl WorkspaceWindowRecord { Some(removed) } - fn all_panes(&self) -> Vec { - self.tabs - .values() - .flat_map(|tab| tab.layout.leaves()) - .collect() - } - fn normalize(&mut self, panes: &IndexMap, fallback_pane: PaneId) { if self.tabs.is_empty() { let fallback_tab = WorkspaceWindowTabRecord::new(fallback_pane); @@ -1036,6 +1051,7 @@ impl Workspace { fn clear_notifications_matching(&mut self, target: &AgentTarget) -> Result<(), DomainError> { let now = OffsetDateTime::now_utc(); + let mut cleared_surfaces = Vec::new(); match *target { AgentTarget::Workspace { workspace_id } => { if workspace_id != self.id { @@ -1043,6 +1059,10 @@ impl Workspace { } for notification in &mut self.notifications { if notification.cleared_at.is_none() { + let target = (notification.pane_id, notification.surface_id); + if !cleared_surfaces.contains(&target) { + cleared_surfaces.push(target); + } if notification.read_at.is_none() { notification.read_at = Some(now); } @@ -1065,6 +1085,10 @@ impl Workspace { } for notification in &mut self.notifications { if notification.pane_id == pane_id && notification.cleared_at.is_none() { + let target = (notification.pane_id, notification.surface_id); + if !cleared_surfaces.contains(&target) { + cleared_surfaces.push(target); + } if notification.read_at.is_none() { notification.read_at = Some(now); } @@ -1096,6 +1120,10 @@ impl Workspace { && notification.surface_id == surface_id && notification.cleared_at.is_none() { + let target = (notification.pane_id, notification.surface_id); + if !cleared_surfaces.contains(&target) { + cleared_surfaces.push(target); + } if notification.read_at.is_none() { notification.read_at = Some(now); } @@ -1104,6 +1132,9 @@ impl Workspace { } } } + for (pane_id, surface_id) in cleared_surfaces { + self.sync_surface_attention_with_active_notifications(pane_id, surface_id); + } Ok(()) } @@ -1112,15 +1143,55 @@ impl Workspace { if let Some(notification) = self.notifications.iter_mut().find(|notification| { notification.id == notification_id && notification.cleared_at.is_none() }) { + let pane_id = notification.pane_id; + let surface_id = notification.surface_id; if notification.read_at.is_none() { notification.read_at = Some(now); } notification.cleared_at = Some(now); + self.sync_surface_attention_with_active_notifications(pane_id, surface_id); return true; } false } + fn sync_surface_attention_with_active_notifications( + &mut self, + pane_id: PaneId, + surface_id: SurfaceId, + ) { + let next_attention = self + .notifications + .iter() + .filter(|notification| { + notification.pane_id == pane_id + && notification.surface_id == surface_id + && notification.active() + }) + .map(|notification| notification.state) + .max_by_key(|state| state.rank()); + + let Some(surface) = self + .panes + .get_mut(&pane_id) + .and_then(|pane| pane.surfaces.get_mut(&surface_id)) + else { + return; + }; + + if let Some(attention) = next_attention { + surface.attention = attention; + return; + } + + if matches!( + surface.attention, + AttentionState::Completed | AttentionState::WaitingInput | AttentionState::Error + ) { + surface.attention = AttentionState::Normal; + } + } + fn notification_target(&self, notification_id: NotificationId) -> Option<(PaneId, SurfaceId)> { self.notifications .iter() @@ -1387,7 +1458,7 @@ impl Workspace { .map(|pane| pane.active_surface) } - pub fn agent_summaries(&self, now: OffsetDateTime) -> Vec { + pub fn agent_summaries(&self, _now: OffsetDateTime) -> Vec { let mut summaries = self .panes .iter() @@ -1395,23 +1466,15 @@ impl Workspace { let workspace_window_id = self.window_for_pane(*pane_id); pane.surfaces.values().filter_map(move |surface| { let workspace_window_id = workspace_window_id?; - let state = workspace_agent_state(surface, now)?; - let agent_kind = surface.metadata.agent_kind.clone()?; + let session = surface.agent_session.as_ref()?; Some(WorkspaceAgentSummary { workspace_window_id, pane_id: *pane_id, surface_id: surface.id, - agent_kind, - title: surface - .metadata - .agent_title - .as_deref() - .or(surface.metadata.title.as_deref()) - .map(str::trim) - .filter(|title| !title.is_empty()) - .map(str::to_owned), - state, - last_signal_at: surface.metadata.last_signal_at, + agent_kind: session.kind.clone(), + title: Some(session.title.clone()), + state: session.state, + last_signal_at: Some(session.updated_at), }) }) }) @@ -1785,15 +1848,13 @@ impl AppModel { .workspaces .get(&workspace_id) .ok_or(DomainError::MissingWorkspace(workspace_id))?; - let source_window = workspace - .windows - .get(&source_workspace_window_id) - .ok_or(DomainError::MissingWorkspaceWindow(source_workspace_window_id))?; - if !workspace - .windows - .contains_key(&target_workspace_window_id) - { - return Err(DomainError::MissingWorkspaceWindow(target_workspace_window_id)); + let source_window = workspace.windows.get(&source_workspace_window_id).ok_or( + DomainError::MissingWorkspaceWindow(source_workspace_window_id), + )?; + if !workspace.windows.contains_key(&target_workspace_window_id) { + return Err(DomainError::MissingWorkspaceWindow( + target_workspace_window_id, + )); } if !source_window.tabs.contains_key(&workspace_window_tab_id) { return Err(DomainError::MissingWorkspaceWindowTab( @@ -1802,7 +1863,9 @@ impl AppModel { } let (source_column_id, source_column_index, source_window_index) = workspace .position_for_window(source_workspace_window_id) - .ok_or(DomainError::MissingWorkspaceWindow(source_workspace_window_id))?; + .ok_or(DomainError::MissingWorkspaceWindow( + source_workspace_window_id, + ))?; ( source_column_id, source_column_index, @@ -1819,12 +1882,12 @@ impl AppModel { let source_window = workspace .windows .get_mut(&source_workspace_window_id) - .ok_or(DomainError::MissingWorkspaceWindow(source_workspace_window_id))?; - source_window - .remove_tab(workspace_window_tab_id) - .ok_or(DomainError::MissingWorkspaceWindowTab( - workspace_window_tab_id, - ))? + .ok_or(DomainError::MissingWorkspaceWindow( + source_workspace_window_id, + ))?; + source_window.remove_tab(workspace_window_tab_id).ok_or( + DomainError::MissingWorkspaceWindowTab(workspace_window_tab_id), + )? }; if remove_source_window { @@ -1842,7 +1905,8 @@ impl AppModel { false } else { if !column.window_order.contains(&column.active_window) { - let replacement_index = source_window_index.min(column.window_order.len() - 1); + let replacement_index = + source_window_index.min(column.window_order.len() - 1); column.active_window = column.window_order[replacement_index]; } true @@ -1862,7 +1926,9 @@ impl AppModel { let target_window = workspace .windows .get_mut(&target_workspace_window_id) - .ok_or(DomainError::MissingWorkspaceWindow(target_workspace_window_id))?; + .ok_or(DomainError::MissingWorkspaceWindow( + target_workspace_window_id, + ))?; target_window.insert_tab(moved_tab, to_index); workspace.sync_active_from_window(target_workspace_window_id); } @@ -1883,7 +1949,9 @@ impl AppModel { .ok_or(DomainError::MissingWorkspace(workspace_id))? .windows .get(&source_workspace_window_id) - .ok_or(DomainError::MissingWorkspaceWindow(source_workspace_window_id))? + .ok_or(DomainError::MissingWorkspaceWindow( + source_workspace_window_id, + ))? .tabs .len(); @@ -1900,12 +1968,12 @@ impl AppModel { let source_window = workspace .windows .get_mut(&source_workspace_window_id) - .ok_or(DomainError::MissingWorkspaceWindow(source_workspace_window_id))?; - source_window - .remove_tab(workspace_window_tab_id) - .ok_or(DomainError::MissingWorkspaceWindowTab( - workspace_window_tab_id, - ))? + .ok_or(DomainError::MissingWorkspaceWindow( + source_workspace_window_id, + ))?; + source_window.remove_tab(workspace_window_tab_id).ok_or( + DomainError::MissingWorkspaceWindowTab(workspace_window_tab_id), + )? }; let new_window_id = { @@ -1948,12 +2016,9 @@ impl AppModel { .windows .get(&workspace_window_id) .ok_or(DomainError::MissingWorkspaceWindow(workspace_window_id))?; - let tab = window - .tabs - .get(&workspace_window_tab_id) - .ok_or(DomainError::MissingWorkspaceWindowTab( - workspace_window_tab_id, - ))?; + let tab = window.tabs.get(&workspace_window_tab_id).ok_or( + DomainError::MissingWorkspaceWindowTab(workspace_window_tab_id), + )?; (tab.layout.leaves(), window.tabs.len() == 1) }; @@ -1989,9 +2054,11 @@ impl AppModel { } workspace.windows.shift_remove(&workspace_window_id); remove_panes_from_workspace(workspace, &tab_panes); - if let Some(next_window_id) = - workspace.fallback_window_after_close(column_index, window_index, same_column_survived) - { + if let Some(next_window_id) = workspace.fallback_window_after_close( + column_index, + window_index, + same_column_survived, + ) { workspace.sync_active_from_window(next_window_id); } return Ok(()); @@ -2005,11 +2072,9 @@ impl AppModel { .windows .get_mut(&workspace_window_id) .ok_or(DomainError::MissingWorkspaceWindow(workspace_window_id))?; - let _ = window - .remove_tab(workspace_window_tab_id) - .ok_or(DomainError::MissingWorkspaceWindowTab( - workspace_window_tab_id, - ))?; + let _ = window.remove_tab(workspace_window_tab_id).ok_or( + DomainError::MissingWorkspaceWindowTab(workspace_window_tab_id), + )?; remove_panes_from_workspace(workspace, &tab_panes); if workspace.active_window == workspace_window_id { workspace.sync_active_from_window(workspace_window_id); @@ -2153,10 +2218,15 @@ impl AppModel { surface_id, })?; - surface.attention = AttentionState::Completed; + surface.agent_process = None; + surface.agent_session = None; + surface.attention = AttentionState::Normal; surface.metadata.agent_active = false; - surface.metadata.agent_state = Some(WorkspaceAgentState::Completed); - surface.metadata.last_signal_at = Some(OffsetDateTime::now_utc()); + surface.metadata.agent_state = None; + surface.metadata.last_signal_at = None; + surface.metadata.agent_title = None; + surface.metadata.agent_kind = None; + surface.metadata.latest_agent_message = None; workspace.complete_surface_notifications(pane_id, surface_id); Ok(()) } @@ -2172,14 +2242,11 @@ impl AppModel { .ok_or(DomainError::MissingWorkspace(workspace_id))?; let active_window_id = workspace.active_window; - let next_pane = workspace - .windows - .get(&active_window_id) - .and_then(|window| { - let active_pane = window.active_pane()?; - let layout = window.active_layout()?; - layout.focus_neighbor(active_pane, direction) - }); + let next_pane = workspace.windows.get(&active_window_id).and_then(|window| { + let active_pane = window.active_pane()?; + let layout = window.active_layout()?; + layout.focus_neighbor(active_pane, direction) + }); if let Some(next_pane) = next_pane { if let Some(window) = workspace.windows.get_mut(&active_window_id) { let _ = window.focus_pane(next_pane); @@ -2569,28 +2636,87 @@ impl AppModel { .as_ref() .and_then(|metadata| metadata.agent_active) .is_some_and(|active| !active); + let metadata_clears_agent_identity = + matches!(kind, SignalKind::Metadata) && metadata_reported_inactive; let (surface_attention, should_acknowledge_surface_notifications) = { let mut acknowledged_inactive_resolution = false; if agent_signal && matches!(kind, SignalKind::Started) { surface.metadata.latest_agent_message = None; } - if let Some(metadata) = metadata { - surface.metadata.title = metadata.title; - if metadata.agent_title.is_some() { - surface.metadata.agent_title = metadata.agent_title; - } - surface.metadata.cwd = metadata.cwd; - surface.metadata.repo_name = metadata.repo_name; - surface.metadata.git_branch = metadata.git_branch; - surface.metadata.ports = metadata.ports; - surface.metadata.agent_kind = metadata.agent_kind; + if let Some(metadata) = metadata.as_ref() { + surface.metadata.title = metadata.title.clone(); + surface.metadata.agent_title = metadata.agent_title.clone(); + surface.metadata.cwd = metadata.cwd.clone(); + surface.metadata.repo_name = metadata.repo_name.clone(); + surface.metadata.git_branch = metadata.git_branch.clone(); + surface.metadata.ports = metadata.ports.clone(); + surface.metadata.agent_kind = normalized_agent_kind(metadata.agent_kind.as_deref()); if let Some(agent_active) = metadata.agent_active { surface.metadata.agent_active = agent_active; } + if metadata_clears_agent_identity { + surface.agent_process = None; + surface.agent_session = None; + surface.metadata.agent_state = None; + surface.metadata.latest_agent_message = None; + surface.metadata.last_signal_at = None; + surface.attention = AttentionState::Normal; + acknowledged_inactive_resolution = true; + } } if agent_signal { + let agent_identity = agent_identity_for_surface(surface, metadata.as_ref()); if let Some(agent_state) = signal_agent_state(&kind) { surface.metadata.agent_state = Some(agent_state); + match kind { + SignalKind::Started | SignalKind::Progress => { + if let Some((agent_kind, title)) = agent_identity.clone() { + set_agent_turn( + surface, + agent_kind, + title, + WorkspaceAgentState::Working, + normalized_message.clone(), + timestamp, + ); + } + } + SignalKind::WaitingInput | SignalKind::Notification => { + if (surface.agent_process.is_some() || surface.agent_session.is_some()) + && let Some((agent_kind, title)) = agent_identity.clone() + { + set_agent_turn( + surface, + agent_kind, + title, + WorkspaceAgentState::Waiting, + normalized_message.clone(), + timestamp, + ); + } + } + SignalKind::Completed | SignalKind::Error => { + let session_state = match kind { + SignalKind::Completed => WorkspaceAgentState::Completed, + SignalKind::Error => WorkspaceAgentState::Failed, + _ => unreachable!("only completed/error reach this branch"), + }; + let session_message = normalized_message + .clone() + .or_else(|| surface.metadata.latest_agent_message.clone()); + if let Some((agent_kind, title)) = agent_identity.clone() { + set_agent_turn( + surface, + agent_kind, + title, + session_state, + session_message, + timestamp, + ); + } + } + SignalKind::Metadata => {} + } } if let Some(message) = normalized_message.as_ref() { surface.metadata.latest_agent_message = Some(message.clone()); @@ -2603,14 +2729,14 @@ impl AppModel { surface.metadata.agent_active = agent_active; } } else if metadata_reported_inactive - && matches!( - surface.metadata.agent_state, - Some(WorkspaceAgentState::Working | WorkspaceAgentState::Waiting) - ) + && (surface.agent_process.is_some() || surface.agent_session.is_some()) { - surface.attention = AttentionState::Completed; - surface.metadata.agent_state = Some(WorkspaceAgentState::Completed); - surface.metadata.last_signal_at = Some(timestamp); + surface.agent_process = None; + surface.agent_session = None; + surface.attention = AttentionState::Normal; + surface.metadata.agent_state = None; + surface.metadata.latest_agent_message = None; + surface.metadata.last_signal_at = None; acknowledged_inactive_resolution = true; } @@ -2800,14 +2926,25 @@ impl AppModel { if let Some(pane) = workspace.panes.get_mut(&pane_id) && let Some(surface) = pane.surfaces.get_mut(&surface_id) { - surface.attention = state; + let normalized_kind = normalized_title + .as_deref() + .and_then(|title| normalized_agent_kind(Some(title))); + match state { + AttentionState::Busy | AttentionState::WaitingInput => { + surface.attention = state; + } + AttentionState::Normal | AttentionState::Completed | AttentionState::Error => { + surface.attention = state; + } + } surface.metadata.last_signal_at = Some(now); - surface.metadata.agent_state = Some(agent_state_from_attention(state)); - surface.metadata.agent_active = agent_active_from_attention(state); + surface.metadata.agent_state = + surface.agent_session.as_ref().map(|session| session.state); + surface.metadata.agent_active = surface.agent_process.is_some(); surface.metadata.latest_agent_message = Some(message.clone()); if let Some(agent_title) = normalized_title.clone() { surface.metadata.agent_title = Some(agent_title.clone()); - if let Some(agent_kind) = normalized_agent_kind(Some(agent_title.as_str())) { + if let Some(agent_kind) = normalized_kind { surface.metadata.agent_kind = Some(agent_kind); } } @@ -2844,6 +2981,178 @@ impl AppModel { workspace.clear_notifications_matching(&target) } + pub fn start_surface_agent_session( + &mut self, + workspace_id: WorkspaceId, + pane_id: PaneId, + surface_id: SurfaceId, + agent_kind: String, + ) -> Result<(), DomainError> { + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + let surface = workspace + .panes + .get_mut(&pane_id) + .ok_or(DomainError::PaneNotInWorkspace { + workspace_id, + pane_id, + })? + .surfaces + .get_mut(&surface_id) + .ok_or(DomainError::SurfaceNotInPane { + workspace_id, + pane_id, + surface_id, + })?; + + let normalized_kind = normalized_agent_kind(Some(agent_kind.as_str())) + .ok_or(DomainError::InvalidOperation("invalid agent kind"))?; + let now = OffsetDateTime::now_utc(); + surface.agent_process = Some(SurfaceAgentProcess { + id: SessionId::new(), + kind: normalized_kind.clone(), + title: agent_display_title(&normalized_kind), + started_at: now, + }); + surface.agent_session = None; + surface.attention = AttentionState::Normal; + surface.metadata.agent_kind = Some(normalized_kind); + surface.metadata.agent_title = surface + .agent_process + .as_ref() + .map(|process| process.title.clone()); + surface.metadata.agent_active = true; + surface.metadata.agent_state = None; + surface.metadata.latest_agent_message = None; + surface.metadata.last_signal_at = None; + + Ok(()) + } + + pub fn stop_surface_agent_session( + &mut self, + workspace_id: WorkspaceId, + pane_id: PaneId, + surface_id: SurfaceId, + exit_status: i32, + ) -> Result<(), DomainError> { + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + let title = { + let surface = workspace + .panes + .get_mut(&pane_id) + .ok_or(DomainError::PaneNotInWorkspace { + workspace_id, + pane_id, + })? + .surfaces + .get_mut(&surface_id) + .ok_or(DomainError::SurfaceNotInPane { + workspace_id, + pane_id, + surface_id, + })?; + + let title = surface + .agent_process + .as_ref() + .map(|process| process.title.clone()) + .or_else(|| { + surface + .agent_session + .as_ref() + .map(|session| session.title.clone()) + }); + surface.agent_process = None; + surface.agent_session = None; + surface.attention = AttentionState::Normal; + surface.metadata.agent_active = false; + surface.metadata.agent_state = None; + surface.metadata.agent_title = None; + surface.metadata.agent_kind = None; + surface.metadata.latest_agent_message = None; + surface.metadata.last_signal_at = None; + title + }; + + if exit_status != 0 && exit_status != 130 { + workspace.upsert_notification(NotificationItem { + id: NotificationId::new(), + pane_id, + surface_id, + kind: SignalKind::Error, + state: AttentionState::Error, + title, + subtitle: None, + external_id: None, + message: format!("Exited with status {exit_status}"), + created_at: OffsetDateTime::now_utc(), + read_at: None, + cleared_at: None, + desktop_delivery: NotificationDeliveryState::Pending, + }); + if let Some(surface) = workspace + .panes + .get_mut(&pane_id) + .and_then(|pane| pane.surfaces.get_mut(&surface_id)) + { + surface.attention = AttentionState::Error; + } + } + + Ok(()) + } + + pub fn dismiss_surface_alert( + &mut self, + workspace_id: WorkspaceId, + pane_id: PaneId, + surface_id: SurfaceId, + ) -> Result<(), DomainError> { + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + workspace.clear_notifications_matching(&AgentTarget::Surface { + workspace_id, + pane_id, + surface_id, + })?; + + let surface = workspace + .panes + .get_mut(&pane_id) + .ok_or(DomainError::PaneNotInWorkspace { + workspace_id, + pane_id, + })? + .surfaces + .get_mut(&surface_id) + .ok_or(DomainError::SurfaceNotInPane { + workspace_id, + pane_id, + surface_id, + })?; + + surface.agent_session = None; + surface.attention = AttentionState::Normal; + surface.metadata.agent_active = surface.agent_process.is_some(); + surface.metadata.agent_state = None; + if surface.agent_process.is_none() { + surface.metadata.agent_title = None; + surface.metadata.agent_kind = None; + } + surface.metadata.latest_agent_message = None; + surface.metadata.last_signal_at = None; + + Ok(()) + } + pub fn clear_notification( &mut self, notification_id: NotificationId, @@ -3675,8 +3984,6 @@ impl CurrentWorkspaceSerde { } } -const RECENT_INACTIVE_AGENT_RETENTION: Duration = Duration::minutes(15); - fn signal_kind_creates_notification(kind: &SignalKind) -> bool { matches!( kind, @@ -3704,6 +4011,65 @@ fn normalized_agent_kind(agent_kind: Option<&str>) -> Option { } } +fn agent_display_title(agent_kind: &str) -> String { + match agent_kind { + "codex" => "Codex".into(), + "claude" => "Claude".into(), + "opencode" => "OpenCode".into(), + "aider" => "Aider".into(), + other => other.to_string(), + } +} + +fn agent_identity_for_surface( + surface: &SurfaceRecord, + metadata: Option<&SignalPaneMetadata>, +) -> Option<(String, String)> { + if let Some(process) = surface.agent_process.as_ref() { + return Some((process.kind.clone(), process.title.clone())); + } + + let kind = surface.metadata.agent_kind.clone().or_else(|| { + metadata.and_then(|metadata| normalized_agent_kind(metadata.agent_kind.as_deref())) + })?; + let title = surface + .metadata + .agent_title + .clone() + .or_else(|| metadata.and_then(|metadata| metadata.agent_title.clone())) + .unwrap_or_else(|| agent_display_title(&kind)); + Some((kind, title)) +} + +fn set_agent_turn( + surface: &mut SurfaceRecord, + kind: String, + title: String, + state: WorkspaceAgentState, + latest_message: Option, + updated_at: OffsetDateTime, +) { + match surface.agent_session.as_mut() { + Some(session) => { + session.kind = kind; + session.title = title; + session.state = state; + session.latest_message = latest_message; + session.updated_at = updated_at; + } + None => { + surface.agent_session = Some(SurfaceAgentSession { + id: SessionId::new(), + kind, + title, + state, + latest_message, + updated_at, + }); + } + } +} + fn is_agent_signal( surface: &SurfaceRecord, source: &str, @@ -3711,7 +4077,8 @@ fn is_agent_signal( ) -> bool { is_agent_hook_source(source) || is_agent_kind(metadata.and_then(|metadata| metadata.agent_kind.as_deref())) - || is_agent_kind(surface.metadata.agent_kind.as_deref()) + || surface.agent_process.is_some() + || surface.agent_session.is_some() } fn normalized_signal_message(message: Option<&str>) -> Option { @@ -3722,6 +4089,14 @@ fn normalized_signal_message(message: Option<&str>) -> Option { } fn surface_notification_title(surface: &SurfaceRecord) -> Option { + if let Some(session) = surface.agent_session.as_ref() { + return Some(session.title.clone()); + } + + if let Some(process) = surface.agent_process.as_ref() { + return Some(process.title.clone()); + } + surface .metadata .agent_title @@ -3755,49 +4130,6 @@ fn signal_creates_notification(source: &str, kind: &SignalKind) -> bool { } } -fn recent_inactive_cutoff(now: OffsetDateTime) -> OffsetDateTime { - now - RECENT_INACTIVE_AGENT_RETENTION -} - -fn workspace_agent_state( - surface: &SurfaceRecord, - now: OffsetDateTime, -) -> Option { - if !is_agent_kind(surface.metadata.agent_kind.as_deref()) { - return None; - } - - let state = surface.metadata.agent_state.or_else(|| { - if surface.metadata.agent_active { - match surface.attention { - AttentionState::Busy => Some(WorkspaceAgentState::Working), - AttentionState::WaitingInput => Some(WorkspaceAgentState::Waiting), - AttentionState::Completed => Some(WorkspaceAgentState::Completed), - AttentionState::Error => Some(WorkspaceAgentState::Failed), - AttentionState::Normal => None, - } - } else { - match surface.attention { - AttentionState::Completed => Some(WorkspaceAgentState::Completed), - AttentionState::Error => Some(WorkspaceAgentState::Failed), - AttentionState::Busy | AttentionState::WaitingInput => { - Some(WorkspaceAgentState::Completed) - } - AttentionState::Normal => None, - } - } - })?; - - match state { - WorkspaceAgentState::Working | WorkspaceAgentState::Waiting => Some(state), - WorkspaceAgentState::Completed | WorkspaceAgentState::Failed => surface - .metadata - .last_signal_at - .filter(|timestamp| *timestamp >= recent_inactive_cutoff(now)) - .map(|_| state), - } -} - fn remove_window_from_column( workspace: &mut Workspace, column_id: WorkspaceColumnId, @@ -3856,8 +4188,7 @@ fn close_layout_pane(tab: &mut WorkspaceWindowTabRecord, pane_id: PaneId) -> Opt .into_iter() .find_map(|direction| tab.layout.focus_neighbor(pane_id, direction)) .or_else(|| { - tab - .layout + tab.layout .leaves() .into_iter() .find(|candidate| *candidate != pane_id) @@ -3896,25 +4227,10 @@ fn signal_agent_state(kind: &SignalKind) -> Option { } } -fn agent_state_from_attention(state: AttentionState) -> WorkspaceAgentState { - match state { - AttentionState::Normal | AttentionState::Busy => WorkspaceAgentState::Working, - AttentionState::WaitingInput => WorkspaceAgentState::Waiting, - AttentionState::Completed => WorkspaceAgentState::Completed, - AttentionState::Error => WorkspaceAgentState::Failed, - } -} - -fn agent_active_from_attention(state: AttentionState) -> bool { - matches!( - state, - AttentionState::Normal | AttentionState::Busy | AttentionState::WaitingInput - ) -} - #[cfg(test)] mod tests { use serde_json::json; + use time::Duration; use super::*; use crate::SignalPaneMetadata; @@ -3957,7 +4273,8 @@ mod tests { .windows .get(&upper_window_id) .expect("window") - .active_pane, + .active_pane() + .expect("active pane"), right_pane ); assert_eq!( @@ -4048,7 +4365,10 @@ mod tests { let active_window = workspace.active_window_record().expect("window"); assert_eq!(workspace.active_pane, new_pane); - assert_eq!(active_window.layout.leaves(), vec![first_pane, new_pane]); + assert_eq!( + active_window.active_layout().expect("layout").leaves(), + vec![first_pane, new_pane] + ); } #[test] @@ -4072,7 +4392,7 @@ mod tests { assert_eq!(workspace.active_pane, upper_pane); assert_eq!( - active_window.layout.leaves(), + active_window.active_layout().expect("layout").leaves(), vec![left_pane, upper_pane, first_pane] ); } @@ -4466,7 +4786,10 @@ mod tests { let source_pane = workspace.panes.get(&source_pane_id).expect("source pane"); let target_pane = workspace.panes.get(&new_pane_id).expect("new pane"); - assert_eq!(window.layout.leaves(), vec![source_pane_id, new_pane_id]); + assert_eq!( + window.active_layout().expect("layout").leaves(), + vec![source_pane_id, new_pane_id] + ); assert_eq!( source_pane.surface_ids().collect::>(), vec![first_surface_id] @@ -4517,7 +4840,7 @@ mod tests { assert_eq!(workspace.active_window, target_window_id); assert_eq!(workspace.active_pane, new_pane_id); assert_eq!( - target_window.layout.leaves(), + target_window.active_layout().expect("layout").leaves(), vec![new_pane_id, target_pane_id] ); assert_eq!( @@ -4805,7 +5128,7 @@ mod tests { assert!(!source_workspace.panes.contains_key(&source_pane_id)); assert!(source_workspace.panes.contains_key(&anchor_pane_id)); assert_eq!( - target_window.layout.leaves(), + target_window.active_layout().expect("layout").leaves(), vec![new_pane_id, target_pane_id] ); assert_eq!(target_workspace.active_pane, new_pane_id); @@ -4922,7 +5245,7 @@ mod tests { .active_column_id() .and_then(|column_id| workspace.columns.get(&column_id)) .expect("column"); - let LayoutNode::Split { ratio, .. } = &window.layout else { + let LayoutNode::Split { ratio, .. } = window.active_layout().expect("layout") else { panic!("expected split layout"); }; assert_eq!(*ratio, 440); @@ -5161,11 +5484,17 @@ mod tests { .get(&pane_id) .and_then(PaneRecord::active_surface) .expect("surface"); + let session = surface + .agent_session + .as_ref() + .expect("completed signal should preserve recent agent session"); assert_eq!( surface.metadata.agent_state, Some(WorkspaceAgentState::Completed) ); assert!(!surface.metadata.agent_active); + assert_eq!(session.state, WorkspaceAgentState::Completed); + assert_eq!(session.latest_message.as_deref(), Some("Turn complete")); } #[test] @@ -5245,7 +5574,7 @@ mod tests { } #[test] - fn metadata_signals_do_not_keep_recent_inactive_agents_alive() { + fn metadata_signals_for_shell_prompt_clear_stale_agent_identity() { let mut model = AppModel::new("Main"); let workspace_id = model.active_workspace_id().expect("workspace"); let pane_id = model.active_workspace().expect("workspace").active_pane; @@ -5283,13 +5612,13 @@ mod tests { SignalKind::Metadata, None, Some(SignalPaneMetadata { - title: Some("codex :: taskers".into()), + title: Some("taskers".into()), agent_title: None, cwd: Some("/tmp".into()), repo_name: Some("taskers".into()), git_branch: Some("main".into()), ports: Vec::new(), - agent_kind: Some("codex".into()), + agent_kind: Some("shell".into()), agent_active: Some(false), }), ), @@ -5312,7 +5641,12 @@ mod tests { .and_then(|workspace| workspace.panes.get(&pane_id)) .and_then(PaneRecord::active_surface) .expect("surface"); - assert_eq!(surface.metadata.last_signal_at, Some(stale_timestamp)); + assert_eq!(surface.metadata.agent_kind, None); + assert_eq!(surface.metadata.agent_title, None); + assert_eq!(surface.metadata.agent_state, None); + assert_eq!(surface.metadata.latest_agent_message, None); + assert_eq!(surface.attention, AttentionState::Normal); + assert_eq!(surface.metadata.last_signal_at, None); } #[test] @@ -5359,7 +5693,7 @@ mod tests { .and_then(|workspace| workspace.panes.get(&pane_id)) .and_then(PaneRecord::active_surface) .expect("surface"); - assert_eq!(surface.attention, AttentionState::Completed); + assert_eq!(surface.attention, AttentionState::Normal); assert!(model.activity_items().is_empty()); let summaries = model @@ -5370,7 +5704,7 @@ mod tests { .first() .and_then(|summary| summary.agent_summaries.first()) .map(|summary| summary.state), - Some(WorkspaceAgentState::Completed) + None ); } @@ -5430,8 +5764,12 @@ mod tests { .get(&pane_id) .and_then(PaneRecord::active_surface) .expect("surface"); - assert_eq!(surface.attention, AttentionState::Completed); + assert_eq!(surface.attention, AttentionState::Normal); + assert!(surface.agent_session.is_none()); assert!(!surface.metadata.agent_active); + assert_eq!(surface.metadata.agent_state, None); + assert_eq!(surface.metadata.latest_agent_message, None); + assert_eq!(surface.metadata.last_signal_at, None); assert!( workspace .notifications @@ -5447,7 +5785,7 @@ mod tests { .first() .and_then(|summary| summary.agent_summaries.first()) .map(|summary| summary.state), - Some(WorkspaceAgentState::Completed) + None ); } @@ -5683,7 +6021,7 @@ mod tests { } #[test] - fn agent_notifications_stamp_surface_agent_metadata() { + fn agent_notifications_do_not_create_live_agent_sessions() { let mut model = AppModel::new("Main"); let workspace_id = model.active_workspace_id().expect("workspace"); let pane_id = model.active_workspace().expect("workspace").active_pane; @@ -5715,13 +6053,11 @@ mod tests { .and_then(|workspace| workspace.panes.get(&pane_id)) .and_then(|pane| pane.surfaces.get(&surface_id)) .expect("surface record"); + assert!(surface.agent_session.is_none()); assert_eq!(surface.metadata.agent_kind.as_deref(), Some("codex")); assert_eq!(surface.metadata.agent_title.as_deref(), Some("Codex")); - assert_eq!( - surface.metadata.agent_state, - Some(WorkspaceAgentState::Waiting) - ); - assert!(surface.metadata.agent_active); + assert_eq!(surface.metadata.agent_state, None); + assert!(!surface.metadata.agent_active); assert_eq!( surface.metadata.latest_agent_message.as_deref(), Some("Need input") @@ -5779,6 +6115,228 @@ mod tests { .expect("notification"); assert!(notification.read_at.is_some()); assert!(notification.cleared_at.is_some()); + let surface = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&pane_id)) + .and_then(|pane| pane.surfaces.get(&surface_id)) + .expect("surface"); + assert_eq!(surface.attention, AttentionState::Normal); + } + + #[test] + fn clearing_notification_keeps_surface_attention_when_another_alert_is_still_active() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let pane_id = model.active_workspace().expect("workspace").active_pane; + let surface_id = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&pane_id)) + .and_then(|pane| pane.active_surface()) + .map(|surface| surface.id) + .expect("surface"); + + model + .create_agent_notification( + AgentTarget::Surface { + workspace_id, + pane_id, + surface_id, + }, + SignalKind::Notification, + Some("Heads up".into()), + None, + None, + "Review needed".into(), + AttentionState::WaitingInput, + ) + .expect("waiting notification"); + model + .create_agent_notification( + AgentTarget::Surface { + workspace_id, + pane_id, + surface_id, + }, + SignalKind::Error, + Some("Heads up".into()), + None, + None, + "Build failed".into(), + AttentionState::Error, + ) + .expect("error notification"); + + let first_notification_id = model + .active_workspace() + .and_then(|workspace| workspace.notifications.first()) + .map(|notification| notification.id) + .expect("notification id"); + + model + .clear_notification(first_notification_id) + .expect("clear notification"); + + let surface = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&pane_id)) + .and_then(|pane| pane.surfaces.get(&surface_id)) + .expect("surface"); + assert_eq!(surface.attention, AttentionState::Error); + } + + #[test] + fn dismiss_surface_alert_clears_completed_agent_presentation() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let pane_id = model.active_workspace().expect("workspace").active_pane; + let surface_id = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&pane_id)) + .and_then(|pane| pane.active_surface()) + .map(|surface| surface.id) + .expect("surface"); + + model + .create_agent_notification( + AgentTarget::Surface { + workspace_id, + pane_id, + surface_id, + }, + SignalKind::Completed, + Some("Codex".into()), + None, + None, + "Finished".into(), + AttentionState::Completed, + ) + .expect("completed notification"); + + model + .dismiss_surface_alert(workspace_id, pane_id, surface_id) + .expect("dismiss alert"); + + let surface = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&pane_id)) + .and_then(|pane| pane.surfaces.get(&surface_id)) + .expect("surface"); + assert_eq!(surface.attention, AttentionState::Normal); + assert_eq!(surface.metadata.agent_active, false); + assert_eq!(surface.metadata.agent_state, None); + assert_eq!(surface.metadata.agent_title, None); + assert_eq!(surface.metadata.agent_kind, None); + assert_eq!(surface.metadata.last_signal_at, None); + assert_eq!(surface.metadata.latest_agent_message, None); + } + + #[test] + fn dismiss_surface_alert_clears_working_agent_presentation() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let pane_id = model.active_workspace().expect("workspace").active_pane; + let surface_id = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&pane_id)) + .and_then(|pane| pane.active_surface()) + .map(|surface| surface.id) + .expect("surface"); + + model + .start_surface_agent_session(workspace_id, pane_id, surface_id, "codex".into()) + .expect("working session"); + model + .apply_surface_signal( + workspace_id, + pane_id, + surface_id, + SignalEvent::with_metadata( + "agent-hook:codex", + SignalKind::Started, + Some("Working".into()), + Some(SignalPaneMetadata { + title: None, + agent_title: Some("Codex".into()), + cwd: None, + repo_name: None, + git_branch: None, + ports: Vec::new(), + agent_kind: Some("codex".into()), + agent_active: Some(true), + }), + ), + ) + .expect("started signal applied"); + + model + .dismiss_surface_alert(workspace_id, pane_id, surface_id) + .expect("dismiss alert"); + + let surface = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&pane_id)) + .and_then(|pane| pane.surfaces.get(&surface_id)) + .expect("surface"); + assert_eq!(surface.attention, AttentionState::Normal); + assert!(surface.agent_process.is_some()); + assert!(surface.agent_session.is_none()); + assert_eq!(surface.metadata.agent_active, true); + assert_eq!(surface.metadata.agent_state, None); + assert_eq!(surface.metadata.agent_title.as_deref(), Some("Codex")); + assert_eq!(surface.metadata.agent_kind.as_deref(), Some("codex")); + assert_eq!(surface.metadata.last_signal_at, None); + assert_eq!(surface.metadata.latest_agent_message, None); + } + + #[test] + fn late_agent_notification_does_not_recreate_live_session_after_stop() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let pane_id = model.active_workspace().expect("workspace").active_pane; + let surface_id = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&pane_id)) + .and_then(|pane| pane.active_surface()) + .map(|surface| surface.id) + .expect("surface"); + + model + .start_surface_agent_session(workspace_id, pane_id, surface_id, "codex".into()) + .expect("start agent"); + model + .stop_surface_agent_session(workspace_id, pane_id, surface_id, 0) + .expect("stop agent"); + model + .apply_surface_signal( + workspace_id, + pane_id, + surface_id, + SignalEvent { + source: "agent-hook:codex".into(), + kind: SignalKind::Notification, + message: Some("Turn complete".into()), + metadata: Some(SignalPaneMetadata { + title: None, + agent_title: Some("Codex".into()), + cwd: None, + repo_name: None, + git_branch: None, + ports: Vec::new(), + agent_kind: Some("codex".into()), + agent_active: Some(true), + }), + timestamp: OffsetDateTime::now_utc(), + }, + ) + .expect("late notification"); + + let surface = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&pane_id)) + .and_then(|pane| pane.surfaces.get(&surface_id)) + .expect("surface record"); + assert!(surface.agent_session.is_none()); + assert_eq!(surface.attention, AttentionState::WaitingInput); } #[test] @@ -5813,4 +6371,135 @@ mod tests { assert!(second_token > first_token); } + + #[test] + fn creating_workspace_window_tab_adds_blank_terminal_tab_and_focuses_it() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let window_id = model.active_workspace().expect("workspace").active_window; + + let (tab_id, pane_id) = model + .create_workspace_window_tab(workspace_id, window_id) + .expect("create window tab"); + + let workspace = model.active_workspace().expect("workspace"); + let window = workspace.windows.get(&window_id).expect("window"); + let pane = workspace.panes.get(&pane_id).expect("new pane"); + + assert_eq!(window.tabs.len(), 2); + assert_eq!(window.active_tab, tab_id); + assert_eq!(workspace.active_window, window_id); + assert_eq!(workspace.active_pane, pane_id); + assert_eq!(pane.surfaces.len(), 1); + assert_eq!( + pane.active_surface().map(|surface| surface.kind.clone()), + Some(PaneKind::Terminal) + ); + } + + #[test] + fn closing_last_window_tab_closes_workspace_window() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let first_window_id = model.active_workspace().expect("workspace").active_window; + + model + .create_workspace_window(workspace_id, Direction::Right) + .expect("create second window"); + + let closing_tab_id = model + .workspaces + .get(&workspace_id) + .and_then(|workspace| workspace.windows.get(&first_window_id)) + .map(|window| window.active_tab) + .expect("window tab"); + + model + .close_workspace_window_tab(workspace_id, first_window_id, closing_tab_id) + .expect("close last tab"); + + let workspace = model.active_workspace().expect("workspace"); + assert!(!workspace.windows.contains_key(&first_window_id)); + assert_eq!(workspace.windows.len(), 1); + } + + #[test] + fn transferring_window_tab_merges_into_target_window() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let source_window_id = model.active_workspace().expect("workspace").active_window; + let (tab_id, _) = model + .create_workspace_window_tab(workspace_id, source_window_id) + .expect("create second tab"); + let target_pane_id = model + .create_workspace_window(workspace_id, Direction::Right) + .expect("create second window"); + let target_window_id = model + .active_workspace() + .and_then(|workspace| workspace.window_for_pane(target_pane_id)) + .expect("target window"); + + model + .transfer_workspace_window_tab( + workspace_id, + source_window_id, + tab_id, + target_window_id, + usize::MAX, + ) + .expect("transfer window tab"); + + let workspace = model.active_workspace().expect("workspace"); + assert_eq!( + workspace + .windows + .get(&source_window_id) + .map(|window| window.tabs.len()), + Some(1) + ); + assert_eq!( + workspace + .windows + .get(&target_window_id) + .map(|window| window.tabs.len()), + Some(2) + ); + assert_eq!(workspace.active_window, target_window_id); + } + + #[test] + fn extracting_window_tab_creates_new_workspace_window() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let source_window_id = model.active_workspace().expect("workspace").active_window; + let (tab_id, pane_id) = model + .create_workspace_window_tab(workspace_id, source_window_id) + .expect("create second tab"); + + let extracted_window_id = model + .extract_workspace_window_tab( + workspace_id, + source_window_id, + tab_id, + WorkspaceWindowMoveTarget::StackBelow { + window_id: source_window_id, + }, + ) + .expect("extract tab"); + + let workspace = model.active_workspace().expect("workspace"); + assert_eq!(workspace.windows.len(), 2); + assert_eq!( + workspace + .windows + .get(&source_window_id) + .map(|window| window.tabs.len()), + Some(1) + ); + assert_eq!( + workspace.window_for_pane(pane_id), + Some(extracted_window_id) + ); + assert_eq!(workspace.active_window, extracted_window_id); + } } diff --git a/crates/taskers-host/src/lib.rs b/crates/taskers-host/src/lib.rs index 1483a58..df258e2 100644 --- a/crates/taskers-host/src/lib.rs +++ b/crates/taskers-host/src/lib.rs @@ -1575,7 +1575,7 @@ fn surface_descriptor_from(spec: &TerminalMountSpec) -> SurfaceDescriptor { rows: spec.rows, kind: PaneKind::Terminal, cwd: spec.cwd.clone(), - title: None, + title: Some(spec.title.clone()), url: None, // The current Ghostty bridge is more stable when it controls shell // selection itself, so keep command overrides empty until that path is @@ -1586,13 +1586,15 @@ fn surface_descriptor_from(spec: &TerminalMountSpec) -> SurfaceDescriptor { } fn position_widget(overlay: &Overlay, widget: &Widget, frame: taskers_core::Frame) { + let clamped_margin_start = frame.x.clamp(0, i32::from(i16::MAX)); + let clamped_margin_top = frame.y.clamp(0, i32::from(i16::MAX)); widget.set_size_request(frame.width.max(1), frame.height.max(1)); widget.set_hexpand(false); widget.set_vexpand(false); widget.set_halign(Align::Start); widget.set_valign(Align::Start); - widget.set_margin_start(frame.x.max(0)); - widget.set_margin_top(frame.y.max(0)); + widget.set_margin_start(clamped_margin_start); + widget.set_margin_top(clamped_margin_top); if widget.parent().is_some() { widget.queue_allocate(); } else { diff --git a/crates/taskers-runtime/assets/shell/taskers-agent-claude.sh b/crates/taskers-runtime/assets/shell/taskers-agent-claude.sh new file mode 100644 index 0000000..060001f --- /dev/null +++ b/crates/taskers-runtime/assets/shell/taskers-agent-claude.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -eu + +INVOKED_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +SCRIPT_PATH=$(readlink -f -- "$0" 2>/dev/null || realpath "$0" 2>/dev/null || printf '%s' "$0") +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd) +PROXY_PATH="$SCRIPT_DIR/taskers-agent-proxy.sh" +HOOK_SCRIPT="$SCRIPT_DIR/taskers-claude-hook.sh" + +json_escape() { + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +if [ "${TASKERS_CLAUDE_HOOKS_DISABLED:-0}" = "1" ]; then + exec env TASKERS_AGENT_PROXY_TARGET=claude TASKERS_AGENT_PROXY_SHIM_DIR="$INVOKED_DIR" "$PROXY_PATH" "$@" +fi + +hook_script_escaped=$(json_escape "$HOOK_SCRIPT") +hooks_json=$(cat </dev/null || realpath "$0" 2>/dev/null || printf '%s' "$0") +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd) +PROXY_PATH="$SCRIPT_DIR/taskers-agent-proxy.sh" +NOTIFY_SCRIPT="$SCRIPT_DIR/taskers-codex-notify.sh" + +toml_escape() { + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +if [ "${TASKERS_CODEX_HOOKS_DISABLED:-0}" = "1" ]; then + exec env TASKERS_AGENT_PROXY_TARGET=codex TASKERS_AGENT_PROXY_SHIM_DIR="$INVOKED_DIR" "$PROXY_PATH" "$@" +fi + +notify_override=$(printf 'notify=["bash","%s"]' "$(toml_escape "$NOTIFY_SCRIPT")") + +exec env TASKERS_AGENT_PROXY_TARGET=codex TASKERS_AGENT_PROXY_SHIM_DIR="$INVOKED_DIR" "$PROXY_PATH" -c "$notify_override" "$@" diff --git a/crates/taskers-runtime/assets/shell/taskers-agent-proxy.sh b/crates/taskers-runtime/assets/shell/taskers-agent-proxy.sh index 3e0f70d..c403ff3 100644 --- a/crates/taskers-runtime/assets/shell/taskers-agent-proxy.sh +++ b/crates/taskers-runtime/assets/shell/taskers-agent-proxy.sh @@ -1,27 +1,22 @@ #!/bin/sh set -eu -proxy_name=$(basename -- "$0") -proxy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +proxy_name=${TASKERS_AGENT_PROXY_TARGET:-$(basename -- "$0")} +proxy_invoked_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +proxy_script_path=$(readlink -f -- "$0" 2>/dev/null || realpath "$0" 2>/dev/null || printf '%s' "$0") +proxy_dir=$(CDPATH= cd -- "$(dirname -- "$proxy_script_path")" && pwd) +shim_dir=${TASKERS_AGENT_PROXY_SHIM_DIR:-$proxy_invoked_dir} agent_kind=$proxy_name case "$proxy_name" in claude-code) agent_kind=claude ;; esac -agent_title=$agent_kind -case "$agent_kind" in - codex) agent_title=Codex ;; - claude) agent_title=Claude ;; - opencode) agent_title=OpenCode ;; - aider) agent_title=Aider ;; -esac - filtered_path= old_ifs=${IFS:-" "} IFS=: for entry in ${PATH:-}; do - if [ "$entry" = "$proxy_dir" ] || [ -z "$entry" ]; then + if [ "$entry" = "$proxy_dir" ] || [ "$entry" = "$shim_dir" ] || [ -z "$entry" ]; then continue fi if [ -n "$filtered_path" ]; then @@ -42,52 +37,49 @@ if [ -z "$real_binary" ]; then exit 127 fi -can_emit_signal() { - [ -x "${TASKERS_CTL_PATH:-}" ] || return 1 - [ -n "${TASKERS_WORKSPACE_ID:-}" ] || return 1 - [ -n "${TASKERS_PANE_ID:-}" ] || return 1 - [ -n "${TASKERS_SURFACE_ID:-}" ] || return 1 - [ -n "${TASKERS_TTY_NAME:-}" ] || return 1 +context_tty_matches() { + expected_tty=${TASKERS_TTY_NAME:-} + [ -n "$expected_tty" ] || return 1 current_tty=$(tty 2>/dev/null || true) case "$current_tty" in /dev/*) ;; *) return 1 ;; esac - [ "$current_tty" = "$TASKERS_TTY_NAME" ] || return 1 + [ "$current_tty" = "$expected_tty" ] } -emit_signal() { - kind=$1 - message=${2-} - repo_name= - git_branch= - if [ -n "${PWD:-}" ] && command -v git >/dev/null 2>&1; then - repo_root=$(git -C "$PWD" rev-parse --show-toplevel 2>/dev/null || true) - if [ -n "$repo_root" ]; then - repo_name=$(basename -- "$repo_root") - git_branch=$(git -C "$PWD" branch --show-current 2>/dev/null || true) - fi - fi - can_emit_signal || return 0 +can_emit_lifecycle() { + [ -x "${TASKERS_CTL_PATH:-}" ] || return 1 + [ -n "${TASKERS_WORKSPACE_ID:-}" ] || return 1 + [ -n "${TASKERS_PANE_ID:-}" ] || return 1 + [ -n "${TASKERS_SURFACE_ID:-}" ] || return 1 + context_tty_matches +} - set -- signal --source shell --kind "$kind" --agent "$agent_kind" --title "$agent_title" - if [ -n "${PWD:-}" ]; then - set -- "$@" --cwd "$PWD" - fi - if [ -n "$repo_name" ]; then - set -- "$@" --repo "$repo_name" - fi - if [ -n "$git_branch" ]; then - set -- "$@" --branch "$git_branch" - fi - if [ -n "$message" ]; then - set -- "$@" --message "$message" - fi - "$TASKERS_CTL_PATH" "$@" >/dev/null 2>&1 || true +emit_surface_agent_start() { + can_emit_lifecycle || return 1 + "$TASKERS_CTL_PATH" surface agent-start \ + --workspace "$TASKERS_WORKSPACE_ID" \ + --pane "$TASKERS_PANE_ID" \ + --surface "$TASKERS_SURFACE_ID" \ + --agent "$agent_kind" >/dev/null 2>&1 || true + return 0 +} + +emit_surface_agent_stop() { + status=$1 + can_emit_lifecycle || return 1 + "$TASKERS_CTL_PATH" surface agent-stop \ + --workspace "$TASKERS_WORKSPACE_ID" \ + --pane "$TASKERS_PANE_ID" \ + --surface "$TASKERS_SURFACE_ID" \ + --exit-status "$status" >/dev/null 2>&1 || true + return 0 } -if [ "${TASKERS_AGENT_PROXY_ACTIVE:-0}" != "1" ]; then - emit_signal started +started_lifecycle=0 +if emit_surface_agent_start; then + started_lifecycle=1 fi set +e @@ -95,12 +87,8 @@ TASKERS_AGENT_PROXY_ACTIVE=1 PATH="$filtered_path" "$real_binary" "$@" status=$? set -e -if [ "${TASKERS_AGENT_PROXY_ACTIVE:-0}" != "1" ]; then - if [ "$status" -eq 0 ]; then - emit_signal completed - else - emit_signal error - fi +if [ "$started_lifecycle" = 1 ]; then + emit_surface_agent_stop "$status" || true fi exit "$status" diff --git a/crates/taskers-runtime/assets/shell/taskers-claude-hook.sh b/crates/taskers-runtime/assets/shell/taskers-claude-hook.sh new file mode 100644 index 0000000..e59313b --- /dev/null +++ b/crates/taskers-runtime/assets/shell/taskers-claude-hook.sh @@ -0,0 +1,108 @@ +#!/bin/sh +set -eu + +hook_name=${1-} +payload= +taskers_ctl=${TASKERS_CTL_PATH:-} +message= +notification_type= + +if [ -z "$hook_name" ]; then + exit 64 +fi + +if [ -z "$taskers_ctl" ] && command -v taskersctl >/dev/null 2>&1; then + taskers_ctl=$(command -v taskersctl) +fi + +if [ ! -x "${taskers_ctl:-}" ]; then + exit 0 +fi + +if [ ! -t 0 ]; then + payload=$(cat || true) +fi + +if [ -n "$payload" ] && command -v jq >/dev/null 2>&1; then + case "$hook_name" in + notification) + notification_type=$( + printf '%s' "$payload" \ + | jq -r '.notification_type // empty' 2>/dev/null \ + | head -c 64 + ) + message=$( + printf '%s' "$payload" \ + | jq -r '.message // .title // empty' 2>/dev/null \ + | head -c 160 + ) + ;; + stop) + message=$( + printf '%s' "$payload" \ + | jq -r '.last_assistant_message // empty' 2>/dev/null \ + | head -c 160 + ) + ;; + esac +fi + +has_embedded_surface_context() { + [ -n "${TASKERS_WORKSPACE_ID:-}" ] || return 1 + [ -n "${TASKERS_PANE_ID:-}" ] || return 1 + [ -n "${TASKERS_SURFACE_ID:-}" ] || return 1 + [ -n "${TASKERS_TTY_NAME:-}" ] || return 1 + + current_tty=$(tty 2>/dev/null || true) + case "$current_tty" in + /dev/*) ;; + *) return 1 ;; + esac + + [ "$current_tty" = "$TASKERS_TTY_NAME" ] || return 1 +} + +if ! has_embedded_surface_context; then + exit 0 +fi + +run_hook() { + subcommand=$1 + shift + "$taskers_ctl" agent-hook "$subcommand" \ + --workspace "$TASKERS_WORKSPACE_ID" \ + --pane "$TASKERS_PANE_ID" \ + --surface "$TASKERS_SURFACE_ID" \ + --agent claude \ + --title Claude "$@" >/dev/null 2>&1 || true +} + +case "$hook_name" in + user-prompt-submit) + run_hook progress + ;; + notification) + case "$notification_type" in + permission_prompt|idle_prompt|elicitation_dialog) + if [ -n "$message" ]; then + run_hook waiting --message "$message" + else + run_hook waiting + fi + ;; + *) + exit 0 + ;; + esac + ;; + stop) + if [ -n "$message" ]; then + run_hook stop --message "$message" + else + run_hook stop + fi + ;; + *) + exit 0 + ;; +esac diff --git a/crates/taskers-runtime/assets/shell/taskers-codex-notify.sh b/crates/taskers-runtime/assets/shell/taskers-codex-notify.sh new file mode 100644 index 0000000..d067279 --- /dev/null +++ b/crates/taskers-runtime/assets/shell/taskers-codex-notify.sh @@ -0,0 +1,48 @@ +#!/bin/sh +set -eu + +payload=${1-} +message= +taskers_ctl=${TASKERS_CTL_PATH:-} + +if [ -z "$taskers_ctl" ] && command -v taskersctl >/dev/null 2>&1; then + taskers_ctl=$(command -v taskersctl) +fi + +if [ -n "$payload" ] && command -v jq >/dev/null 2>&1; then + message=$( + printf '%s' "$payload" \ + | jq -r '."last-assistant-message" // .message // .title // empty' 2>/dev/null \ + | head -c 160 + ) +fi + +if [ -z "$message" ]; then + message="Turn complete" +fi + +has_embedded_surface_context() { + [ -x "${taskers_ctl:-}" ] || return 1 + [ -n "${TASKERS_WORKSPACE_ID:-}" ] || return 1 + [ -n "${TASKERS_PANE_ID:-}" ] || return 1 + [ -n "${TASKERS_SURFACE_ID:-}" ] || return 1 + [ -n "${TASKERS_TTY_NAME:-}" ] || return 1 + + current_tty=$(tty 2>/dev/null || true) + case "$current_tty" in + /dev/*) ;; + *) return 1 ;; + esac + + [ "$current_tty" = "$TASKERS_TTY_NAME" ] || return 1 +} + +if has_embedded_surface_context; then + "$taskers_ctl" agent-hook stop \ + --workspace "$TASKERS_WORKSPACE_ID" \ + --pane "$TASKERS_PANE_ID" \ + --surface "$TASKERS_SURFACE_ID" \ + --agent codex \ + --title Codex \ + --message "$message" >/dev/null 2>&1 || true +fi diff --git a/crates/taskers-runtime/assets/shell/taskers-hooks.bash b/crates/taskers-runtime/assets/shell/taskers-hooks.bash index 085fd42..eeff12c 100644 --- a/crates/taskers-runtime/assets/shell/taskers-hooks.bash +++ b/crates/taskers-runtime/assets/shell/taskers-hooks.bash @@ -75,7 +75,7 @@ taskers__collect_metadata() { TASKERS_META_BRANCH= fi - TASKERS_META_AGENT=${TASKERS_ACTIVE_AGENT_KIND:-${TASKERS_PANE_AGENT_KIND:-shell}} + TASKERS_META_AGENT=${TASKERS_ACTIVE_AGENT_KIND:-shell} TASKERS_META_LABEL=$TASKERS_META_REPO_NAME if [ -z "$TASKERS_META_LABEL" ]; then TASKERS_META_LABEL=${PWD##*/} @@ -208,25 +208,29 @@ taskers__emit_metadata_if_changed() { taskers__emit_with_metadata metadata } +taskers__invalidate_metadata_cache() { + unset TASKERS_LAST_META_CWD + unset TASKERS_LAST_META_REPO_NAME + unset TASKERS_LAST_META_BRANCH + unset TASKERS_LAST_META_AGENT + unset TASKERS_LAST_META_TITLE + unset TASKERS_LAST_META_AGENT_ACTIVE +} + taskers__preexec() { local agent agent=$(taskers__classify_command "$1" || true) if [ -n "$agent" ]; then - export TASKERS_PANE_AGENT_KIND=$agent export TASKERS_ACTIVE_AGENT_KIND=$agent - taskers__emit_with_metadata started + taskers__invalidate_metadata_cache + taskers__emit_metadata_if_changed fi } taskers__precmd() { - local status=$1 if [ -n "${TASKERS_ACTIVE_AGENT_KIND:-}" ]; then - if [ "$status" -eq 0 ]; then - taskers__emit_with_metadata completed - else - taskers__emit_with_metadata error "Exited with status $status" - fi unset TASKERS_ACTIVE_AGENT_KIND + taskers__invalidate_metadata_cache fi taskers__emit_metadata_if_changed diff --git a/crates/taskers-runtime/assets/shell/taskers-hooks.fish b/crates/taskers-runtime/assets/shell/taskers-hooks.fish index 26cb60c..646045e 100644 --- a/crates/taskers-runtime/assets/shell/taskers-hooks.fish +++ b/crates/taskers-runtime/assets/shell/taskers-hooks.fish @@ -69,8 +69,6 @@ function taskers__collect_metadata if set -q TASKERS_ACTIVE_AGENT_KIND set -g TASKERS_META_AGENT "$TASKERS_ACTIVE_AGENT_KIND" - else if set -q TASKERS_PANE_AGENT_KIND - set -g TASKERS_META_AGENT "$TASKERS_PANE_AGENT_KIND" else set -g TASKERS_META_AGENT shell end @@ -158,24 +156,28 @@ function taskers__emit_metadata_if_changed taskers__emit_with_metadata metadata end +function taskers__invalidate_metadata_cache + set -e TASKERS_LAST_META_CWD + set -e TASKERS_LAST_META_REPO_NAME + set -e TASKERS_LAST_META_BRANCH + set -e TASKERS_LAST_META_AGENT + set -e TASKERS_LAST_META_TITLE + set -e TASKERS_LAST_META_AGENT_ACTIVE +end + function taskers__on_preexec --on-event fish_preexec set -l agent (taskers__classify_command "$argv[1]" 2>/dev/null) if test -n "$agent" - set -gx TASKERS_PANE_AGENT_KIND "$agent" set -gx TASKERS_ACTIVE_AGENT_KIND "$agent" - taskers__emit_with_metadata started + taskers__invalidate_metadata_cache + taskers__emit_metadata_if_changed end end function taskers__on_postexec --on-event fish_postexec - set -l exit_status $status if set -q TASKERS_ACTIVE_AGENT_KIND - if test "$exit_status" -eq 0 - taskers__emit_with_metadata completed - else - taskers__emit_with_metadata error "Exited with status $exit_status" - end set -e TASKERS_ACTIVE_AGENT_KIND + taskers__invalidate_metadata_cache end taskers__emit_metadata_if_changed diff --git a/crates/taskers-runtime/assets/shell/taskers-hooks.zsh b/crates/taskers-runtime/assets/shell/taskers-hooks.zsh index 817687f..4f6123e 100644 --- a/crates/taskers-runtime/assets/shell/taskers-hooks.zsh +++ b/crates/taskers-runtime/assets/shell/taskers-hooks.zsh @@ -69,7 +69,7 @@ taskers__collect_metadata() { TASKERS_META_BRANCH= fi - TASKERS_META_AGENT=${TASKERS_ACTIVE_AGENT_KIND:-${TASKERS_PANE_AGENT_KIND:-shell}} + TASKERS_META_AGENT=${TASKERS_ACTIVE_AGENT_KIND:-shell} TASKERS_META_LABEL=$TASKERS_META_REPO_NAME if [[ -z "$TASKERS_META_LABEL" ]]; then TASKERS_META_LABEL=${PWD:t} @@ -189,25 +189,29 @@ taskers__emit_metadata_if_changed() { taskers__emit_with_metadata metadata } +taskers__invalidate_metadata_cache() { + unset TASKERS_LAST_META_CWD + unset TASKERS_LAST_META_REPO_NAME + unset TASKERS_LAST_META_BRANCH + unset TASKERS_LAST_META_AGENT + unset TASKERS_LAST_META_TITLE + unset TASKERS_LAST_META_AGENT_ACTIVE +} + taskers__preexec() { local agent agent=$(taskers__classify_command "$1" || true) if [[ -n "$agent" ]]; then - export TASKERS_PANE_AGENT_KIND=$agent export TASKERS_ACTIVE_AGENT_KIND=$agent - taskers__emit_with_metadata started + taskers__invalidate_metadata_cache + taskers__emit_metadata_if_changed fi } taskers__precmd() { - local exit_status=$? if [[ -n "${TASKERS_ACTIVE_AGENT_KIND:-}" ]]; then - if (( exit_status == 0 )); then - taskers__emit_with_metadata completed - else - taskers__emit_with_metadata error "Exited with status ${exit_status}" - fi unset TASKERS_ACTIVE_AGENT_KIND + taskers__invalidate_metadata_cache fi taskers__emit_metadata_if_changed diff --git a/crates/taskers-runtime/src/shell.rs b/crates/taskers-runtime/src/shell.rs index a860053..7b259a5 100644 --- a/crates/taskers-runtime/src/shell.rs +++ b/crates/taskers-runtime/src/shell.rs @@ -81,46 +81,7 @@ impl ShellIntegration { let wrapper_path = root.join("taskers-shell-wrapper.sh"); let real_shell = resolve_shell_program(configured_shell)?; - write_asset( - &wrapper_path, - include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/assets/shell/taskers-shell-wrapper.sh" - )), - true, - )?; - write_asset( - &root.join("bash").join("taskers.bashrc"), - include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/assets/shell/bash/taskers.bashrc" - )), - false, - )?; - write_asset( - &root.join("taskers-hooks.bash"), - include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/assets/shell/taskers-hooks.bash" - )), - false, - )?; - write_asset( - &root.join("taskers-hooks.fish"), - include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/assets/shell/taskers-hooks.fish" - )), - false, - )?; - write_asset( - &root.join("taskers-agent-proxy.sh"), - include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/assets/shell/taskers-agent-proxy.sh" - )), - true, - )?; + install_runtime_assets(&root)?; install_agent_shims(&root)?; Ok(Self { @@ -186,14 +147,24 @@ impl ShellIntegration { args: vec!["--no-config".into(), "--interactive".into()], env: self.base_env(), }, - ShellKind::Zsh => { + ShellKind::Zsh if !integration_disabled => { let mut env = self.base_env(); env.insert( "TASKERS_REAL_SHELL".into(), self.real_shell.display().to_string(), ); - let args = if profile == "clean" || integration_disabled { - vec!["-d".into(), "-f".into(), "-i".into()] + env.insert( + "ZDOTDIR".into(), + zsh_runtime_dir(&self.root).display().to_string(), + ); + if let Some(value) = env::var_os("ZDOTDIR").or_else(|| env::var_os("HOME")) { + env.insert( + "TASKERS_USER_ZDOTDIR".into(), + value.to_string_lossy().into_owned(), + ); + } + let args = if profile == "clean" { + vec!["-d".into(), "-i".into()] } else { vec!["-i".into()] }; @@ -204,6 +175,11 @@ impl ShellIntegration { env, } } + ShellKind::Zsh => ShellLaunchSpec { + program: self.real_shell.clone(), + args: vec!["-d".into(), "-f".into(), "-i".into()], + env: self.base_env(), + }, ShellKind::Other => { let mut env = self.base_env(); env.insert( @@ -276,9 +252,13 @@ fn install_agent_shims(root: &Path) -> Result<()> { let shim_dir = root.join("bin"); fs::create_dir_all(&shim_dir) .with_context(|| format!("failed to create {}", shim_dir.display()))?; - let proxy_path = root.join("taskers-agent-proxy.sh"); - - for name in ["codex", "claude", "claude-code", "opencode", "aider"] { + for (name, target_path) in [ + ("codex", root.join("taskers-agent-codex.sh")), + ("claude", root.join("taskers-agent-claude.sh")), + ("claude-code", root.join("taskers-agent-claude.sh")), + ("opencode", root.join("taskers-agent-proxy.sh")), + ("aider", root.join("taskers-agent-proxy.sh")), + ] { let shim_path = shim_dir.join(name); if shim_path.symlink_metadata().is_ok() { fs::remove_file(&shim_path) @@ -286,19 +266,19 @@ fn install_agent_shims(root: &Path) -> Result<()> { } #[cfg(unix)] - std::os::unix::fs::symlink(&proxy_path, &shim_path).with_context(|| { + std::os::unix::fs::symlink(&target_path, &shim_path).with_context(|| { format!( "failed to symlink {} -> {}", shim_path.display(), - proxy_path.display() + target_path.display() ) })?; #[cfg(not(unix))] - fs::copy(&proxy_path, &shim_path).with_context(|| { + fs::copy(&target_path, &shim_path).with_context(|| { format!( "failed to copy {} -> {}", - proxy_path.display(), + target_path.display(), shim_path.display() ) })?; @@ -347,6 +327,14 @@ fn write_asset(path: &Path, content: &str, executable: bool) -> Result<()> { } fn resolve_taskersctl_path() -> Option { + if let Some(path) = std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(|parent| parent.join("taskersctl"))) + .filter(|path| path.is_file()) + { + return Some(path); + } + if let Some(path) = env::var_os("TASKERS_CTL_PATH") .map(PathBuf::from) .filter(|path| path.is_file()) @@ -496,12 +484,129 @@ fn fish_source_command() -> String { r#"source "$TASKERS_SHELL_INTEGRATION_DIR/taskers-hooks.fish""#.into() } +fn zsh_runtime_dir(root: &Path) -> PathBuf { + root.join("zsh") +} + +fn install_runtime_assets(root: &Path) -> Result<()> { + write_asset( + &root.join("taskers-shell-wrapper.sh"), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/taskers-shell-wrapper.sh" + )), + true, + )?; + write_asset( + &root.join("bash").join("taskers.bashrc"), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/bash/taskers.bashrc" + )), + false, + )?; + write_asset( + &root.join("taskers-hooks.bash"), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/taskers-hooks.bash" + )), + false, + )?; + write_asset( + &root.join("taskers-hooks.fish"), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/taskers-hooks.fish" + )), + false, + )?; + write_asset( + &zsh_runtime_dir(root).join(".zshenv"), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/zsh/.zshenv" + )), + false, + )?; + write_asset( + &zsh_runtime_dir(root).join(".zshrc"), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/zsh/.zshrc" + )), + false, + )?; + write_asset( + &zsh_runtime_dir(root).join(".zcompdump"), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/zsh/.zcompdump" + )), + false, + )?; + write_asset( + &root.join("taskers-codex-notify.sh"), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/taskers-codex-notify.sh" + )), + true, + )?; + write_asset( + &root.join("taskers-claude-hook.sh"), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/taskers-claude-hook.sh" + )), + true, + )?; + write_asset( + &root.join("taskers-agent-codex.sh"), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/taskers-agent-codex.sh" + )), + true, + )?; + write_asset( + &root.join("taskers-agent-claude.sh"), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/taskers-agent-claude.sh" + )), + true, + )?; + write_asset( + &root.join("taskers-agent-proxy.sh"), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/taskers-agent-proxy.sh" + )), + true, + )?; + Ok(()) +} + #[cfg(test)] mod tests { + use std::{ + fs, + path::PathBuf, + sync::Mutex, + time::{Duration, SystemTime}, + }; + + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + use super::{ - INHERITED_TERMINAL_ENV_KEYS, expand_home_prefix, fish_source_command, - normalize_shell_override, + INHERITED_TERMINAL_ENV_KEYS, ShellIntegration, expand_home_prefix, fish_source_command, + install_runtime_assets, normalize_shell_override, resolve_shell_override, zsh_runtime_dir, }; + use crate::{CommandSpec, PtySession}; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); #[test] fn shell_override_normalizes_blank_values() { @@ -521,6 +626,76 @@ mod tests { ); } + #[test] + fn zsh_launch_spec_routes_through_runtime_zdotdir() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner()); + let original_zdotdir = std::env::var_os("ZDOTDIR"); + let original_home = std::env::var_os("HOME"); + unsafe { + std::env::set_var("HOME", "/tmp/taskers-home"); + std::env::set_var("ZDOTDIR", "/tmp/user-zdotdir"); + std::env::remove_var("TASKERS_DISABLE_SHELL_INTEGRATION"); + std::env::remove_var("TASKERS_SHELL_PROFILE"); + } + + let integration = ShellIntegration { + root: PathBuf::from("/tmp/taskers-runtime"), + wrapper_path: PathBuf::from("/tmp/taskers-runtime/taskers-shell-wrapper.sh"), + real_shell: PathBuf::from("/usr/bin/zsh"), + }; + let spec = integration.launch_spec(); + + assert_eq!( + spec.env.get("ZDOTDIR").map(String::as_str), + Some("/tmp/taskers-runtime/zsh") + ); + assert_eq!( + spec.env.get("TASKERS_USER_ZDOTDIR").map(String::as_str), + Some("/tmp/user-zdotdir") + ); + assert_eq!(spec.program, integration.wrapper_path); + assert_eq!(spec.args, vec!["-i"]); + + unsafe { + match original_zdotdir { + Some(value) => std::env::set_var("ZDOTDIR", value), + None => std::env::remove_var("ZDOTDIR"), + } + match original_home { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + } + } + + #[test] + fn zsh_runtime_dir_is_nested_under_runtime_root() { + assert_eq!( + zsh_runtime_dir(&PathBuf::from("/tmp/taskers-runtime")), + PathBuf::from("/tmp/taskers-runtime/zsh") + ); + } + + #[test] + fn install_runtime_assets_writes_zsh_runtime_files() { + let root = unique_temp_dir("taskers-runtime-test"); + install_runtime_assets(&root).expect("install runtime assets"); + + assert!(root.join("taskers-shell-wrapper.sh").is_file()); + assert!(root.join("taskers-hooks.bash").is_file()); + assert!(root.join("taskers-hooks.fish").is_file()); + assert!(root.join("taskers-codex-notify.sh").is_file()); + assert!(root.join("taskers-claude-hook.sh").is_file()); + assert!(root.join("taskers-agent-codex.sh").is_file()); + assert!(root.join("taskers-agent-claude.sh").is_file()); + assert!(root.join("taskers-agent-proxy.sh").is_file()); + assert!(zsh_runtime_dir(&root).join(".zshenv").is_file()); + assert!(zsh_runtime_dir(&root).join(".zshrc").is_file()); + assert!(zsh_runtime_dir(&root).join(".zcompdump").is_file()); + + fs::remove_dir_all(&root).expect("cleanup runtime assets"); + } + #[test] fn home_prefix_expansion_without_home_keeps_original_shape() { let original = "~/bin/fish"; @@ -573,7 +748,7 @@ mod tests { "/assets/shell/taskers-agent-proxy.sh" )); - for asset in [bash_hooks, zsh_hooks, fish_hooks, agent_proxy] { + for asset in [bash_hooks, zsh_hooks, fish_hooks] { assert!( asset.contains("TASKERS_SURFACE_ID"), "expected asset to require TASKERS_SURFACE_ID" @@ -583,5 +758,500 @@ mod tests { "expected asset to require TASKERS_TTY_NAME" ); } + assert!( + agent_proxy.contains("TASKERS_AGENT_PROXY_ACTIVE"), + "expected proxy asset to keep loop-prevention guard" + ); + } + + #[test] + fn shell_hooks_only_treat_agent_identity_as_live_process_state() { + let bash_hooks = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/taskers-hooks.bash" + )); + let zsh_hooks = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/taskers-hooks.zsh" + )); + let fish_hooks = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/taskers-hooks.fish" + )); + + for asset in [bash_hooks, zsh_hooks, fish_hooks] { + assert!( + !asset.contains("TASKERS_PANE_AGENT_KIND"), + "expected hook asset to avoid sticky pane-level agent identity" + ); + } + } + + #[test] + fn shell_assets_do_not_auto_report_completed_on_clean_or_interrupted_exit() { + let bash_hooks = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/taskers-hooks.bash" + )); + let zsh_hooks = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/taskers-hooks.zsh" + )); + let fish_hooks = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/taskers-hooks.fish" + )); + let agent_proxy = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/taskers-agent-proxy.sh" + )); + + for asset in [bash_hooks, zsh_hooks, fish_hooks] { + assert!( + !asset.contains("taskers__emit_with_metadata completed"), + "expected hook asset to avoid auto-emitting completed on bare agent exit" + ); + } + assert!( + !agent_proxy.contains("emit_signal completed"), + "expected proxy to avoid auto-emitting completed on bare agent exit" + ); + assert!( + !agent_proxy.contains("emit_signal error"), + "expected proxy to avoid owning stop/error signaling" + ); + } + + #[test] + fn shell_hooks_invalidate_metadata_cache_after_agent_exit() { + let bash_hooks = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/taskers-hooks.bash" + )); + let zsh_hooks = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/taskers-hooks.zsh" + )); + let fish_hooks = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/taskers-hooks.fish" + )); + + for asset in [bash_hooks, zsh_hooks, fish_hooks] { + assert!( + asset.contains("TASKERS_LAST_META_AGENT"), + "expected hook asset to invalidate cached agent metadata after exit" + ); + assert!( + asset.contains("TASKERS_LAST_META_AGENT_ACTIVE"), + "expected hook asset to invalidate cached agent-active metadata after exit" + ); + } + } + + #[test] + fn agent_proxy_owns_explicit_surface_agent_lifecycle_commands() { + let bash_hooks = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/taskers-hooks.bash" + )); + let zsh_hooks = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/taskers-hooks.zsh" + )); + let fish_hooks = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/taskers-hooks.fish" + )); + let agent_proxy = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/taskers-agent-proxy.sh" + )); + + for asset in [bash_hooks, zsh_hooks, fish_hooks] { + assert!( + !asset.contains("surface agent-start"), + "expected hook asset to leave explicit lifecycle start to the proxy" + ); + assert!( + !asset.contains("surface agent-stop"), + "expected hook asset to leave explicit lifecycle stop to the proxy" + ); + } + assert!( + agent_proxy.contains("surface agent-start"), + "expected proxy asset to emit explicit surface agent start commands" + ); + assert!( + agent_proxy.contains("surface agent-stop"), + "expected proxy asset to emit explicit surface agent stop commands" + ); + } + + #[test] + fn embedded_zsh_codex_command_emits_surface_lifecycle_via_proxy() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner()); + let runtime_root = unique_temp_dir("taskers-runtime-proxy-clean"); + install_runtime_assets(&runtime_root).expect("install runtime assets"); + super::install_agent_shims(&runtime_root).expect("install agent shims"); + + let home_dir = runtime_root.join("home"); + let real_bin_dir = runtime_root.join("real-bin"); + fs::create_dir_all(&home_dir).expect("home dir"); + fs::create_dir_all(&real_bin_dir).expect("real bin dir"); + + let taskersctl_path = runtime_root.join("taskersctl"); + let args_log = runtime_root.join("codex-args.log"); + write_executable( + &taskersctl_path, + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$TASKERS_TEST_LOG\"\n", + ); + write_executable( + &real_bin_dir.join("codex"), + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$FAKE_CODEX_ARGS_LOG\"\nnotify_script=\nprev=\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"-c\" ]; then\n notify_script=$(printf '%s' \"$arg\" | sed -n 's/^notify=\\[\"bash\",\"\\([^\"]*\\)\"\\]$/\\1/p')\n prev=\n continue\n fi\n prev=$arg\ndone\nif [ -n \"$notify_script\" ]; then\n \"$notify_script\" '{\"last-assistant-message\":\"Turn complete\"}'\nfi\nprintf 'fake codex\\n'\nexit 0\n", + ); + + let original_home = std::env::var_os("HOME"); + let original_path = std::env::var_os("PATH"); + let original_zdotdir = std::env::var_os("ZDOTDIR"); + let zsh_path = resolve_shell_override("zsh").expect("resolve zsh"); + let test_log = runtime_root.join("taskersctl.log"); + unsafe { + std::env::set_var("HOME", &home_dir); + std::env::remove_var("ZDOTDIR"); + std::env::remove_var("TASKERS_DISABLE_SHELL_INTEGRATION"); + std::env::remove_var("TASKERS_SHELL_PROFILE"); + std::env::set_var( + "PATH", + format!( + "{}:{}", + real_bin_dir.display(), + original_path + .as_deref() + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_default() + ), + ); + } + + let integration = ShellIntegration { + root: runtime_root.clone(), + wrapper_path: runtime_root.join("taskers-shell-wrapper.sh"), + real_shell: zsh_path, + }; + let mut launch = integration.launch_spec(); + launch.args.push("-c".into()); + launch.args.push("codex".into()); + launch.env.insert( + "TASKERS_CTL_PATH".into(), + taskersctl_path.display().to_string(), + ); + launch + .env + .insert("TASKERS_WORKSPACE_ID".into(), "ws".into()); + launch.env.insert("TASKERS_PANE_ID".into(), "pn".into()); + launch.env.insert("TASKERS_SURFACE_ID".into(), "sf".into()); + launch + .env + .insert("TASKERS_TEST_LOG".into(), test_log.display().to_string()); + launch + .env + .insert("FAKE_CODEX_ARGS_LOG".into(), args_log.display().to_string()); + + let mut spec = CommandSpec::new(launch.program.display().to_string()); + spec.args = launch.args; + spec.env = launch.env; + + let spawned = PtySession::spawn(&spec).expect("spawn shell"); + let mut reader = spawned.reader; + let mut buffer = [0u8; 1024]; + let mut output = String::new(); + loop { + let bytes_read = reader.read_into(&mut buffer).unwrap_or(0); + if bytes_read == 0 { + break; + } + output.push_str(&String::from_utf8_lossy(&buffer[..bytes_read])); + } + + let log = fs::read_to_string(&test_log) + .unwrap_or_else(|error| panic!("read lifecycle log failed: {error}; output={output}")); + let codex_args = fs::read_to_string(&args_log) + .unwrap_or_else(|error| panic!("read codex args log failed: {error}; output={output}")); + assert!( + codex_args.contains("-c\n") || codex_args.contains("-c "), + "expected codex wrapper to inject config override, got: {codex_args}" + ); + assert!( + codex_args.contains("notify=[\"bash\",\""), + "expected codex wrapper to inject notify helper override, got: {codex_args}" + ); + assert!( + log.contains("surface agent-start --workspace ws --pane pn --surface sf --agent codex"), + "expected start lifecycle in log, got: {log}" + ); + assert!( + log.contains("agent-hook stop --workspace ws --pane pn --surface sf --agent codex --title Codex --message Turn complete"), + "expected codex notify helper to emit stop hook, got: {log}" + ); + assert!( + log.contains( + "surface agent-stop --workspace ws --pane pn --surface sf --exit-status 0" + ), + "expected stop lifecycle in log, got: {log}" + ); + + restore_env_var("HOME", original_home); + restore_env_var("PATH", original_path); + restore_env_var("ZDOTDIR", original_zdotdir); + fs::remove_dir_all(&runtime_root).expect("cleanup runtime root"); + } + + #[test] + fn embedded_zsh_claude_command_injects_taskers_hooks_and_process_lifecycle() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner()); + let runtime_root = unique_temp_dir("taskers-runtime-proxy-claude"); + install_runtime_assets(&runtime_root).expect("install runtime assets"); + super::install_agent_shims(&runtime_root).expect("install agent shims"); + + let home_dir = runtime_root.join("home"); + let real_bin_dir = runtime_root.join("real-bin"); + fs::create_dir_all(&home_dir).expect("home dir"); + fs::create_dir_all(&real_bin_dir).expect("real bin dir"); + + let taskersctl_path = runtime_root.join("taskersctl"); + let args_log = runtime_root.join("claude-args.log"); + write_executable( + &taskersctl_path, + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$TASKERS_TEST_LOG\"\n", + ); + write_executable( + &real_bin_dir.join("claude"), + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$FAKE_CLAUDE_ARGS_LOG\"\nexit 0\n", + ); + + let original_home = std::env::var_os("HOME"); + let original_path = std::env::var_os("PATH"); + let original_zdotdir = std::env::var_os("ZDOTDIR"); + let zsh_path = resolve_shell_override("zsh").expect("resolve zsh"); + let test_log = runtime_root.join("taskersctl.log"); + unsafe { + std::env::set_var("HOME", &home_dir); + std::env::remove_var("ZDOTDIR"); + std::env::remove_var("TASKERS_DISABLE_SHELL_INTEGRATION"); + std::env::remove_var("TASKERS_SHELL_PROFILE"); + std::env::set_var( + "PATH", + format!( + "{}:{}", + real_bin_dir.display(), + original_path + .as_deref() + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_default() + ), + ); + } + + let integration = ShellIntegration { + root: runtime_root.clone(), + wrapper_path: runtime_root.join("taskers-shell-wrapper.sh"), + real_shell: zsh_path, + }; + let mut launch = integration.launch_spec(); + launch.args.push("-c".into()); + launch.args.push("claude --help".into()); + launch.env.insert( + "TASKERS_CTL_PATH".into(), + taskersctl_path.display().to_string(), + ); + launch + .env + .insert("TASKERS_WORKSPACE_ID".into(), "ws".into()); + launch.env.insert("TASKERS_PANE_ID".into(), "pn".into()); + launch.env.insert("TASKERS_SURFACE_ID".into(), "sf".into()); + launch + .env + .insert("TASKERS_TEST_LOG".into(), test_log.display().to_string()); + launch.env.insert( + "FAKE_CLAUDE_ARGS_LOG".into(), + args_log.display().to_string(), + ); + + let mut spec = CommandSpec::new(launch.program.display().to_string()); + spec.args = launch.args; + spec.env = launch.env; + + let spawned = PtySession::spawn(&spec).expect("spawn shell"); + let mut reader = spawned.reader; + let mut buffer = [0u8; 1024]; + let mut output = String::new(); + loop { + let bytes_read = reader.read_into(&mut buffer).unwrap_or(0); + if bytes_read == 0 { + break; + } + output.push_str(&String::from_utf8_lossy(&buffer[..bytes_read])); + } + + let log = fs::read_to_string(&test_log) + .unwrap_or_else(|error| panic!("read lifecycle log failed: {error}; output={output}")); + let claude_args = fs::read_to_string(&args_log) + .unwrap_or_else(|error| panic!("read claude args log failed: {error}; output={output}")); + assert!( + claude_args.contains("--settings"), + "expected claude wrapper to inject hook settings, got: {claude_args}" + ); + assert!( + claude_args.contains("taskers-claude-hook.sh user-prompt-submit"), + "expected claude wrapper to inject prompt-submit hook, got: {claude_args}" + ); + assert!( + claude_args.contains("taskers-claude-hook.sh stop"), + "expected claude wrapper to inject stop hook, got: {claude_args}" + ); + assert!( + log.contains( + "surface agent-start --workspace ws --pane pn --surface sf --agent claude" + ), + "expected start lifecycle in log, got: {log}" + ); + assert!( + log.contains( + "surface agent-stop --workspace ws --pane pn --surface sf --exit-status 0" + ), + "expected stop lifecycle in log, got: {log}" + ); + + restore_env_var("HOME", original_home); + restore_env_var("PATH", original_path); + restore_env_var("ZDOTDIR", original_zdotdir); + fs::remove_dir_all(&runtime_root).expect("cleanup runtime root"); + } + + #[test] + fn embedded_zsh_ctrl_c_reports_interrupted_surface_stop_via_proxy() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner()); + let runtime_root = unique_temp_dir("taskers-runtime-proxy-interrupt"); + install_runtime_assets(&runtime_root).expect("install runtime assets"); + super::install_agent_shims(&runtime_root).expect("install agent shims"); + + let home_dir = runtime_root.join("home"); + let real_bin_dir = runtime_root.join("real-bin"); + fs::create_dir_all(&home_dir).expect("home dir"); + fs::create_dir_all(&real_bin_dir).expect("real bin dir"); + + let taskersctl_path = runtime_root.join("taskersctl"); + write_executable( + &taskersctl_path, + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$TASKERS_TEST_LOG\"\n", + ); + write_executable( + &real_bin_dir.join("codex"), + "#!/bin/sh\ntrap 'exit 130' INT\nwhile :; do sleep 1; done\n", + ); + + let original_home = std::env::var_os("HOME"); + let original_path = std::env::var_os("PATH"); + let original_zdotdir = std::env::var_os("ZDOTDIR"); + let zsh_path = resolve_shell_override("zsh").expect("resolve zsh"); + let test_log = runtime_root.join("taskersctl.log"); + unsafe { + std::env::set_var("HOME", &home_dir); + std::env::remove_var("ZDOTDIR"); + std::env::remove_var("TASKERS_DISABLE_SHELL_INTEGRATION"); + std::env::remove_var("TASKERS_SHELL_PROFILE"); + std::env::set_var( + "PATH", + format!( + "{}:{}", + real_bin_dir.display(), + original_path + .as_deref() + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_default() + ), + ); + } + + let integration = ShellIntegration { + root: runtime_root.clone(), + wrapper_path: runtime_root.join("taskers-shell-wrapper.sh"), + real_shell: zsh_path, + }; + let mut launch = integration.launch_spec(); + launch.args.push("-c".into()); + launch.args.push("codex".into()); + launch.env.insert( + "TASKERS_CTL_PATH".into(), + taskersctl_path.display().to_string(), + ); + launch + .env + .insert("TASKERS_WORKSPACE_ID".into(), "ws".into()); + launch.env.insert("TASKERS_PANE_ID".into(), "pn".into()); + launch.env.insert("TASKERS_SURFACE_ID".into(), "sf".into()); + launch + .env + .insert("TASKERS_TEST_LOG".into(), test_log.display().to_string()); + + let mut spec = CommandSpec::new(launch.program.display().to_string()); + spec.args = launch.args; + spec.env = launch.env; + + let mut spawned = PtySession::spawn(&spec).expect("spawn shell"); + std::thread::sleep(Duration::from_millis(250)); + spawned + .session + .write_all(b"\x03") + .expect("send ctrl-c to shell"); + + let mut reader = spawned.reader; + let mut buffer = [0u8; 1024]; + while reader.read_into(&mut buffer).unwrap_or(0) > 0 {} + + let log = fs::read_to_string(&test_log).expect("read lifecycle log"); + assert!( + log.contains("surface agent-start --workspace ws --pane pn --surface sf --agent codex"), + "expected start lifecycle in log, got: {log}" + ); + assert!( + log.contains( + "surface agent-stop --workspace ws --pane pn --surface sf --exit-status 130" + ), + "expected interrupted stop lifecycle in log, got: {log}" + ); + + restore_env_var("HOME", original_home); + restore_env_var("PATH", original_path); + restore_env_var("ZDOTDIR", original_zdotdir); + fs::remove_dir_all(&runtime_root).expect("cleanup runtime root"); + } + + fn unique_temp_dir(prefix: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("time") + .as_nanos(); + std::env::temp_dir().join(format!("{prefix}-{unique}")) + } + + fn restore_env_var(key: &str, value: Option) { + unsafe { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } + + fn write_executable(path: &PathBuf, content: &str) { + fs::write(path, content).expect("write script"); + #[cfg(unix)] + { + let mut permissions = fs::metadata(path).expect("metadata").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).expect("chmod script"); + } } } diff --git a/crates/taskers-shell-core/src/lib.rs b/crates/taskers-shell-core/src/lib.rs index 6103a3f..0f3e64d 100644 --- a/crates/taskers-shell-core/src/lib.rs +++ b/crates/taskers-shell-core/src/lib.rs @@ -618,6 +618,16 @@ impl Frame { height: (self.height - clamped * 2).max(1), } } + + pub fn inset_horizontal(self, amount: i32) -> Self { + let clamped = amount.clamp(0, self.width.saturating_sub(1) / 2); + Self { + x: self.x + clamped, + y: self.y, + width: (self.width - clamped * 2).max(1), + height: self.height, + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -634,6 +644,7 @@ pub struct LayoutMetrics { pub pane_header_height: i32, pub browser_toolbar_height: i32, pub surface_tab_height: i32, + pub terminal_gutter_x: i32, } impl Default for LayoutMetrics { @@ -651,6 +662,7 @@ impl Default for LayoutMetrics { pane_header_height: 26, browser_toolbar_height: 34, surface_tab_height: 28, + terminal_gutter_x: 6, } } } @@ -1225,6 +1237,11 @@ pub enum ShellAction { DismissActivity { activity_id: ActivityId, }, + DismissSurfaceAlert { + workspace_id: WorkspaceId, + pane_id: PaneId, + surface_id: SurfaceId, + }, SelectTheme { theme_id: String, }, @@ -2169,6 +2186,11 @@ impl TaskersCore { } => self.close_surface_by_id(pane_id, surface_id), ShellAction::OpenActivity { activity_id } => self.open_activity(activity_id), ShellAction::DismissActivity { activity_id } => self.dismiss_activity(activity_id), + ShellAction::DismissSurfaceAlert { + workspace_id, + pane_id, + surface_id, + } => self.dismiss_surface_alert(workspace_id, pane_id, surface_id), ShellAction::SelectTheme { theme_id } => { if self.ui.selected_theme_id == theme_id { return false; @@ -2840,6 +2862,19 @@ impl TaskersCore { }) } + fn dismiss_surface_alert( + &mut self, + workspace_id: WorkspaceId, + pane_id: PaneId, + surface_id: SurfaceId, + ) -> bool { + self.dispatch_control(ControlCommand::DismissSurfaceAlert { + workspace_id, + pane_id, + surface_id, + }) + } + fn open_activity(&mut self, activity_id: ActivityId) -> bool { self.dispatch_control(ControlCommand::OpenNotification { window_id: None, @@ -3783,6 +3818,10 @@ fn pane_body_frame( PaneKind::Terminal => 0, PaneKind::Browser => metrics.browser_toolbar_height, }; + let terminal_gutter_x = match kind { + PaneKind::Terminal => metrics.terminal_gutter_x, + PaneKind::Browser => 0, + }; let tab_strip_height = if show_tab_strip { metrics.surface_tab_height } else { @@ -3790,6 +3829,7 @@ fn pane_body_frame( }; frame .inset(metrics.pane_border_width) + .inset_horizontal(terminal_gutter_x) .inset_top(metrics.pane_header_height + tab_strip_height + browser_toolbar_height) } @@ -4006,53 +4046,14 @@ fn surface_agent_state( surface: &SurfaceRecord, now: OffsetDateTime, ) -> Option { - if !surface_has_agent_identity(surface) { - return None; - } - - let state = surface.metadata.agent_state.or_else(|| { - if surface.metadata.agent_active { - match surface.attention { - taskers_domain::AttentionState::Busy => { - Some(taskers_domain::WorkspaceAgentState::Working) - } - taskers_domain::AttentionState::WaitingInput => { - Some(taskers_domain::WorkspaceAgentState::Waiting) - } - taskers_domain::AttentionState::Completed => { - Some(taskers_domain::WorkspaceAgentState::Completed) - } - taskers_domain::AttentionState::Error => { - Some(taskers_domain::WorkspaceAgentState::Failed) - } - taskers_domain::AttentionState::Normal => None, - } - } else { - match surface.attention { - taskers_domain::AttentionState::Completed => { - Some(taskers_domain::WorkspaceAgentState::Completed) - } - taskers_domain::AttentionState::Error => { - Some(taskers_domain::WorkspaceAgentState::Failed) - } - taskers_domain::AttentionState::Busy - | taskers_domain::AttentionState::WaitingInput => { - Some(taskers_domain::WorkspaceAgentState::Completed) - } - taskers_domain::AttentionState::Normal => None, - } - } - })?; - - match state { + let session = surface.agent_session.as_ref()?; + match session.state { taskers_domain::WorkspaceAgentState::Working - | taskers_domain::WorkspaceAgentState::Waiting => Some(state), + | taskers_domain::WorkspaceAgentState::Waiting => Some(session.state), taskers_domain::WorkspaceAgentState::Completed - | taskers_domain::WorkspaceAgentState::Failed => surface - .metadata - .last_signal_at - .filter(|timestamp| *timestamp >= now - time::Duration::minutes(15)) - .map(|_| state), + | taskers_domain::WorkspaceAgentState::Failed => { + (session.updated_at >= now - time::Duration::minutes(15)).then_some(session.state) + } } } @@ -4141,7 +4142,7 @@ fn window_tab_primary_title(workspace: &Workspace, tab: &WorkspaceWindowTabRecor fn display_surface_title(surface: &SurfaceRecord) -> String { match surface.kind { - PaneKind::Terminal => display_terminal_title(&surface.metadata), + PaneKind::Terminal => display_terminal_title(surface), PaneKind::Browser => display_browser_title(&surface.metadata), } } @@ -4149,8 +4150,9 @@ fn display_surface_title(surface: &SurfaceRecord) -> String { fn surface_activity_label(surface: &SurfaceRecord, now: OffsetDateTime) -> Option { let _ = active_agent_surface_state(surface, now)?; surface - .metadata - .latest_agent_message + .agent_session + .as_ref() + .and_then(|session| session.latest_message.as_deref()) .as_deref() .map(str::trim) .filter(|message| !message.is_empty()) @@ -4184,10 +4186,6 @@ fn surface_notification_ring(surface: &SurfaceRecord) -> Option Some(AttentionRingState::Waiting), taskers_domain::AttentionState::Error => Some(AttentionRingState::Error), @@ -4200,22 +4198,20 @@ fn pane_notification_ring(pane: &taskers_domain::PaneRecord) -> Option bool { - surface_agent_key(surface).is_some() - || surface.metadata.agent_state.is_some() - || surface - .metadata - .agent_title - .as_deref() - .map(str::trim) - .is_some_and(|title| !title.is_empty()) -} - fn surface_agent_key(surface: &SurfaceRecord) -> Option { - normalized_agent_key(surface.metadata.agent_kind.as_deref()) - .or_else(|| normalized_agent_key(surface.metadata.agent_title.as_deref())) + surface + .agent_session + .as_ref() + .map(|session| session.kind.clone()) + .or_else(|| { + surface + .agent_process + .as_ref() + .map(|process| process.kind.clone()) + }) } +#[cfg(test)] fn normalized_agent_key(value: Option<&str>) -> Option { let normalized = value .map(str::trim) @@ -4229,26 +4225,29 @@ fn normalized_agent_key(value: Option<&str>) -> Option { } } -fn display_terminal_title(metadata: &PaneMetadata) -> String { - let agent_title = metadata - .agent_title - .as_deref() - .map(str::trim) - .filter(|title| !title.is_empty()); - let context = terminal_context_label(metadata); +fn display_terminal_title(surface: &SurfaceRecord) -> String { + let context = terminal_context_label(&surface.metadata); + + if let Some(session) = surface.agent_session.as_ref() { + if let Some(context) = context.as_deref() { + return format!("{} · {context}", session.title); + } + return session.title.clone(); + } - if let Some(agent_title) = agent_title { + if let Some(process) = surface.agent_process.as_ref() { if let Some(context) = context.as_deref() { - return format!("{agent_title} · {context}"); + return format!("{} · {context}", process.title); } - return agent_title.to_string(); + return process.title.clone(); } if let Some(context) = context { return context; } - if let Some(title) = metadata + if let Some(title) = surface + .metadata .title .as_deref() .map(str::trim) @@ -4357,6 +4356,13 @@ fn is_generic_terminal_title(title: &str) -> bool { .trim() .to_ascii_lowercase(); + if matches!( + basename.as_str(), + "taskers-shell-wrapper.sh" | "taskers-agent-proxy.sh" + ) { + return true; + } + matches!( basename.as_str(), "sh" | "bash" @@ -4797,8 +4803,50 @@ mod tests { kind: taskers_domain::PaneKind, metadata: taskers_domain::PaneMetadata, ) -> taskers_domain::SurfaceRecord { + let agent_kind = metadata + .agent_kind + .as_deref() + .and_then(|kind| super::normalized_agent_key(Some(kind))) + .or_else(|| { + metadata + .agent_title + .as_deref() + .and_then(|title| super::normalized_agent_key(Some(title))) + }); + let process = agent_kind + .clone() + .map(|kind| taskers_domain::SurfaceAgentProcess { + id: taskers_domain::SessionId::new(), + kind: kind.clone(), + title: metadata + .agent_title + .clone() + .unwrap_or_else(|| super::runtime_label(&kind)), + started_at: metadata + .last_signal_at + .unwrap_or_else(OffsetDateTime::now_utc), + }); + let session = agent_kind.clone().and_then(|kind| { + metadata + .agent_state + .map(|state| taskers_domain::SurfaceAgentSession { + id: taskers_domain::SessionId::new(), + kind: kind.clone(), + title: metadata + .agent_title + .clone() + .unwrap_or_else(|| super::runtime_label(&kind)), + state, + latest_message: metadata.latest_agent_message.clone(), + updated_at: metadata + .last_signal_at + .unwrap_or_else(OffsetDateTime::now_utc), + }) + }); let mut surface = taskers_domain::SurfaceRecord::new(kind); surface.metadata = metadata; + surface.agent_process = process; + surface.agent_session = session; surface } @@ -4847,6 +4895,20 @@ mod tests { assert_eq!(display_surface_title(&surface), "taskers"); } + #[test] + fn terminal_surface_titles_treat_taskers_shell_wrapper_as_generic() { + let surface = surface_with_metadata( + taskers_domain::PaneKind::Terminal, + taskers_domain::PaneMetadata { + title: Some("/run/user/1000/taskers/shell/taskers-shell-wrapper.sh".into()), + cwd: Some("/home/notes/Projects/taskers".into()), + ..taskers_domain::PaneMetadata::default() + }, + ); + + assert_eq!(display_surface_title(&surface), "taskers"); + } + #[test] fn terminal_surface_titles_keep_non_generic_host_titles_as_fallback() { let surface = surface_with_metadata( @@ -5154,7 +5216,8 @@ mod tests { .windows .get(&workspace.active_window) .expect("window") - .layout + .active_layout() + .expect("layout") .leaves() .into_iter() .find(|pane_id| *pane_id != first_pane_id) @@ -5182,6 +5245,14 @@ mod tests { first_surface.metadata.agent_active = true; first_surface.metadata.agent_state = Some(taskers_domain::WorkspaceAgentState::Working); first_surface.metadata.last_signal_at = Some(now); + first_surface.agent_session = Some(taskers_domain::SurfaceAgentSession { + id: taskers_domain::SessionId::new(), + kind: "codex".into(), + title: "Codex".into(), + state: taskers_domain::WorkspaceAgentState::Working, + latest_message: None, + updated_at: now, + }); first_surface.attention = taskers_domain::AttentionState::Busy; let second_surface = workspace @@ -5193,6 +5264,14 @@ mod tests { second_surface.metadata.agent_active = false; second_surface.metadata.agent_state = Some(taskers_domain::WorkspaceAgentState::Failed); second_surface.metadata.last_signal_at = Some(now); + second_surface.agent_session = Some(taskers_domain::SurfaceAgentSession { + id: taskers_domain::SessionId::new(), + kind: "claude".into(), + title: "Claude".into(), + state: taskers_domain::WorkspaceAgentState::Failed, + latest_message: None, + updated_at: now, + }); second_surface.attention = taskers_domain::AttentionState::Error; } @@ -5435,6 +5514,49 @@ mod tests { ); } + #[test] + fn workspace_window_snapshots_expose_window_tabs() { + let core = SharedCore::bootstrap(bootstrap()); + let window_id = core.snapshot().current_workspace.active_window_id; + + core.dispatch_shell_action(ShellAction::CreateWorkspaceWindowTab { window_id }); + + let snapshot = core.snapshot(); + let active_window = snapshot + .current_workspace + .columns + .iter() + .flat_map(|column| column.windows.iter()) + .find(|window| window.id == window_id) + .expect("active window"); + + assert_eq!(active_window.tabs.len(), 2); + assert_eq!(active_window.active_tab, active_window.tabs[1].id); + assert!(active_window.tabs[1].active); + assert_eq!( + active_window.active_pane, + snapshot.current_workspace.active_pane + ); + } + + #[test] + fn terminal_portal_frames_include_horizontal_gutter() { + let core = SharedCore::bootstrap(bootstrap()); + let snapshot = core.snapshot(); + let metrics = LayoutMetrics::default(); + let terminal_plan = snapshot + .portal + .panes + .iter() + .find(|plan| matches!(plan.mount, SurfaceMountSpec::Terminal(_))) + .expect("terminal plan"); + + assert_eq!( + terminal_plan.frame.x, + terminal_plan.pane_frame.x + metrics.pane_border_width + metrics.terminal_gutter_x + ); + } + #[test] fn active_portal_surface_frame_matches_layout_insets() { let core = SharedCore::bootstrap(bootstrap()); @@ -6519,6 +6641,132 @@ mod tests { assert!(!snapshot.attention_panel_visible); } + #[test] + fn dismiss_activity_clears_notification_ring_outline_state() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let pane_id = model.active_workspace().expect("workspace").active_pane; + let surface_id = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&pane_id)) + .map(|pane| pane.active_surface) + .expect("surface"); + + model + .create_agent_notification( + taskers_domain::AgentTarget::Surface { + workspace_id, + pane_id, + surface_id, + }, + taskers_domain::SignalKind::Notification, + Some("Codex".into()), + None, + None, + "Need input".into(), + taskers_domain::AttentionState::WaitingInput, + ) + .expect("notification"); + + let core = SharedCore::bootstrap(bootstrap_with_model( + model, + "taskers-preview-dismiss-activity-ring", + )); + + let before = core.snapshot(); + let pane = find_pane( + &before.current_workspace.layout, + before.current_workspace.active_pane, + ) + .expect("pane before dismiss"); + assert_eq!( + pane.notification_ring, + Some(super::AttentionRingState::Waiting) + ); + + let activity_id = before + .activity + .first() + .map(|item| item.id) + .expect("notification activity"); + + core.dispatch_shell_action(ShellAction::DismissActivity { activity_id }); + + let after = core.snapshot(); + let pane = find_pane( + &after.current_workspace.layout, + after.current_workspace.active_pane, + ) + .expect("pane after dismiss"); + assert_eq!(pane.notification_ring, None); + assert!( + pane.surfaces + .iter() + .all(|surface| surface.notification_ring.is_none()) + ); + } + + #[test] + fn dismiss_surface_alert_removes_working_agent_session_and_status() { + let mut model = AppModel::new("Main"); + let workspace_id = model.active_workspace_id().expect("workspace"); + let pane_id = model.active_workspace().expect("workspace").active_pane; + let surface_id = model + .active_workspace() + .and_then(|workspace| workspace.panes.get(&pane_id)) + .map(|pane| pane.active_surface) + .expect("surface"); + + model + .start_surface_agent_session(workspace_id, pane_id, surface_id, "codex".into()) + .expect("working session"); + model + .apply_surface_signal( + workspace_id, + pane_id, + surface_id, + taskers_domain::SignalEvent::with_metadata( + "agent-hook:codex", + taskers_domain::SignalKind::Started, + Some("Working".into()), + Some(taskers_domain::SignalPaneMetadata { + title: None, + agent_title: Some("Codex".into()), + cwd: None, + repo_name: None, + git_branch: None, + ports: Vec::new(), + agent_kind: Some("codex".into()), + agent_active: Some(true), + }), + ), + ) + .expect("started signal"); + + let core = SharedCore::bootstrap(bootstrap_with_model( + model, + "taskers-preview-dismiss-surface-alert", + )); + assert_eq!(core.snapshot().agents.len(), 1); + + core.dispatch_shell_action(ShellAction::DismissSurfaceAlert { + workspace_id, + pane_id, + surface_id, + }); + + let snapshot = core.snapshot(); + assert!(snapshot.agents.is_empty()); + let pane = find_pane(&snapshot.current_workspace.layout, pane_id).expect("pane"); + let surface = pane + .surfaces + .iter() + .find(|surface| surface.id == surface_id) + .expect("surface"); + assert_eq!(surface.status_label, None); + assert_eq!(surface.notification_ring, None); + } + #[test] fn surface_flash_command_updates_pane_flash_token() { let app_state = default_preview_app_state(); diff --git a/crates/taskers-shell/src/lib.rs b/crates/taskers-shell/src/lib.rs index 22d5262..daab73a 100644 --- a/crates/taskers-shell/src/lib.rs +++ b/crates/taskers-shell/src/lib.rs @@ -15,6 +15,7 @@ use taskers_core::{ ShellSnapshot, ShortcutAction, ShortcutBindingSnapshot, SplitAxis, SurfaceDragSessionSnapshot, SurfaceId, SurfaceKind, SurfaceSnapshot, WorkspaceId, WorkspaceLogEntrySnapshot, WorkspaceSummary, WorkspaceViewSnapshot, WorkspaceWindowMoveTarget, WorkspaceWindowSnapshot, + WorkspaceWindowTabId, WorkspaceWindowTabSnapshot, }; use taskers_shell_core as taskers_core; @@ -22,6 +23,7 @@ type DraggedSurface = SurfaceDragSessionSnapshot; const WORKSPACE_DRAG_MIME: &str = "application/x-taskers-workspace"; const WINDOW_DRAG_MIME: &str = "application/x-taskers-window"; +const WINDOW_TAB_DRAG_MIME: &str = "application/x-taskers-window-tab"; const SURFACE_DRAG_THRESHOLD_PX: f64 = 6.0; #[derive(Clone, Copy, PartialEq, Eq)] @@ -29,6 +31,23 @@ struct DraggedWindow { window_id: taskers_core::WorkspaceWindowId, } +#[derive(Clone, Copy, PartialEq, Eq)] +struct DraggedWindowTab { + window_id: taskers_core::WorkspaceWindowId, + tab_id: WorkspaceWindowTabId, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum WindowTabDropTarget { + BeforeTab { + window_id: taskers_core::WorkspaceWindowId, + tab_id: WorkspaceWindowTabId, + }, + AppendToWindow { + window_id: taskers_core::WorkspaceWindowId, + }, +} + #[derive(Clone, Copy, PartialEq)] struct SurfaceDragCandidate { workspace_id: WorkspaceId, @@ -156,6 +175,35 @@ fn compute_surface_drop_index( target_index } +fn compute_window_tab_drop_index( + dragged: DraggedWindowTab, + target_window_id: taskers_core::WorkspaceWindowId, + ordered_tab_ids: &[WorkspaceWindowTabId], + before_tab_id: Option, +) -> usize { + let Some(before_tab_id) = before_tab_id else { + return usize::MAX; + }; + + let Some(mut target_index) = ordered_tab_ids + .iter() + .position(|tab_id| *tab_id == before_tab_id) + else { + return usize::MAX; + }; + + if dragged.window_id == target_window_id + && let Some(source_index) = ordered_tab_ids + .iter() + .position(|tab_id| *tab_id == dragged.tab_id) + && source_index < target_index + { + target_index = target_index.saturating_sub(1); + } + + target_index +} + fn pane_has_surface_drop_target(target: Option, pane_id: PaneId) -> bool { match target { Some(SurfaceDropTarget::AppendToPane { @@ -336,6 +384,8 @@ pub fn TaskersShell(core: SharedCore) -> Element { let mut surface_drag_candidate = use_signal(|| None::); let window_drag_source = use_signal(|| None::); let window_drop_target = use_signal(|| None::); + let dragged_window_tab = use_signal(|| None::); + let window_tab_drop_target = use_signal(|| None::); let workspace_ids: Vec = snapshot.workspaces.iter().map(|ws| ws.id).collect(); let dragged_surface = snapshot.surface_drag; let track_surface_drag = { @@ -467,6 +517,8 @@ pub fn TaskersShell(core: SharedCore) -> Element { dragged_surface, window_drag_source, window_drop_target, + dragged_window_tab, + window_tab_drop_target, )} } } else { @@ -989,6 +1041,8 @@ fn render_workspace_strip( dragged_surface: Option, window_drag_source: Signal>, window_drop_target: Signal>, + dragged_window_tab: Signal>, + window_tab_drop_target: Signal>, ) -> Element { let viewport_class = if workspace.overview_scale < 1.0 { "workspace-viewport workspace-viewport-overview" @@ -1045,6 +1099,8 @@ fn render_workspace_strip( dragged_surface, window_drag_source, window_drop_target, + dragged_window_tab, + window_tab_drop_target, )} } } @@ -1065,6 +1121,8 @@ fn render_workspace_window( dragged_surface: Option, mut window_drag_source: Signal>, window_drop_target: Signal>, + mut dragged_window_tab: Signal>, + mut window_tab_drop_target: Signal>, ) -> Element { let local_x = window.frame.x - workspace.viewport_origin_x; let local_y = window.frame.y - workspace.viewport_origin_y; @@ -1096,14 +1154,18 @@ fn render_workspace_window( let mut window_drag_source = window_drag_source; let mut window_drop_target = window_drop_target; let mut surface_drop_target = surface_drop_target; + let mut dragged_window_tab = dragged_window_tab; + let mut window_tab_drop_target = window_tab_drop_target; move |_: Event| { window_drag_source.set(None); window_drop_target.set(None); surface_drop_target.set(None); + dragged_window_tab.set(None); + window_tab_drop_target.set(None); core.dispatch_shell_action(ShellAction::EndDrag); } }; - let drag_active = window_drag_source.read().is_some(); + let drag_active = window_drag_source.read().is_some() || dragged_window_tab.read().is_some(); let left_target = WorkspaceWindowMoveTarget::ColumnBefore { column_id: window.column_id, }; @@ -1112,10 +1174,21 @@ fn render_workspace_window( }; let top_target = WorkspaceWindowMoveTarget::StackAbove { window_id }; let bottom_target = WorkspaceWindowMoveTarget::StackBelow { window_id }; - let window_runtime_icon_class = format!( - "workspace-window-runtime-icon {}", - runtime_state_class(window.runtime.state) - ); + let ordered_window_tab_ids = window.tabs.iter().map(|tab| tab.id).collect::>(); + let add_window_tab = { + let core = core.clone(); + move |event: Event| { + event.stop_propagation(); + core.dispatch_shell_action(ShellAction::CreateWorkspaceWindowTab { window_id }); + } + }; + let window_tab_add_class = if *window_tab_drop_target.read() + == Some(WindowTabDropTarget::AppendToWindow { window_id }) + { + "workspace-window-tab-add workspace-window-tab-add-active" + } else { + "workspace-window-tab-add" + }; rsx! { section { class: "{window_class}", style: "{style}", @@ -1125,6 +1198,7 @@ fn render_workspace_window( drag_active, window_drag_source, window_drop_target, + dragged_window_tab, core.clone(), )} {render_window_drop_zone( @@ -1133,6 +1207,7 @@ fn render_workspace_window( drag_active, window_drag_source, window_drop_target, + dragged_window_tab, core.clone(), )} {render_window_drop_zone( @@ -1141,6 +1216,7 @@ fn render_workspace_window( drag_active, window_drag_source, window_drop_target, + dragged_window_tab, core.clone(), )} {render_window_drop_zone( @@ -1149,19 +1225,76 @@ fn render_workspace_window( drag_active, window_drag_source, window_drop_target, + dragged_window_tab, core.clone(), )} div { class: "workspace-window-toolbar", - draggable: "true", - onclick: focus_window, - ondragstart: start_window_drag, - ondragend: clear_window_drag, + div { class: "workspace-window-toolbar-tabs", + for tab in &window.tabs { + {render_workspace_window_tab( + window.id, + tab, + &ordered_window_tab_ids, + core.clone(), + dragged_window_tab, + window_tab_drop_target, + )} + } + button { + class: "{window_tab_add_class}", + title: "New window tab", + onclick: add_window_tab, + ondragover: move |event: Event| { + if dragged_window_tab.read().is_none() { + return; + } + mark_move_drop(&event); + window_tab_drop_target.set(Some(WindowTabDropTarget::AppendToWindow { + window_id, + })); + }, + ondragleave: move |_: Event| { + if *window_tab_drop_target.read() + == Some(WindowTabDropTarget::AppendToWindow { window_id }) + { + window_tab_drop_target.set(None); + } + }, + ondrop: move |event: Event| { + let dragged = *dragged_window_tab.read(); + dragged_window_tab.set(None); + window_tab_drop_target.set(None); + let Some(dragged) = dragged else { + return; + }; + event.prevent_default(); + if dragged.window_id == window_id { + core.dispatch_shell_action(ShellAction::MoveWorkspaceWindowTab { + window_id, + tab_id: dragged.tab_id, + target_index: usize::MAX, + }); + } else { + core.dispatch_shell_action(ShellAction::TransferWorkspaceWindowTab { + source_window_id: dragged.window_id, + tab_id: dragged.tab_id, + target_window_id: window_id, + target_index: usize::MAX, + }); + } + core.dispatch_shell_action(ShellAction::EndDrag); + }, + {icons::plus(12, "workspace-window-tab-add-icon")} + } + } div { - class: "workspace-window-runtime-badge", - title: "{window.runtime.label} · {window.runtime.state.label()}", - {render_runtime_icon(&window.runtime, 12, &window_runtime_icon_class)} + class: "workspace-window-toolbar-spacer", + title: "Drag window", + draggable: "true", + onclick: focus_window, + ondragstart: start_window_drag, + ondragend: clear_window_drag, } - div { class: "workspace-window-grip" } } div { class: "workspace-window-body", {render_layout( @@ -1186,6 +1319,7 @@ fn render_window_drop_zone( visible: bool, mut window_drag_source: Signal>, mut window_drop_target: Signal>, + mut dragged_window_tab: Signal>, core: SharedCore, ) -> Element { let class = if *window_drop_target.read() == Some(target) { @@ -1196,7 +1330,7 @@ fn render_window_drop_zone( base_class.to_string() }; let set_drop_target = move |event: Event| { - if window_drag_source.read().is_none() { + if window_drag_source.read().is_none() && dragged_window_tab.read().is_none() { return; } mark_move_drop(&event); @@ -1208,18 +1342,29 @@ fn render_window_drop_zone( } }; let drop_window = move |event: Event| { - if window_drag_source.read().is_none() { + let dragged_window = *window_drag_source.read(); + let dragged_tab = *dragged_window_tab.read(); + if dragged_window.is_none() && dragged_tab.is_none() { return; } event.prevent_default(); - let dragged = *window_drag_source.read(); window_drag_source.set(None); window_drop_target.set(None); - let Some(dragged) = dragged else { + dragged_window_tab.set(None); + if let Some(dragged) = dragged_window { + core.dispatch_shell_action(ShellAction::MoveWorkspaceWindow { + window_id: dragged.window_id, + target, + }); + core.dispatch_shell_action(ShellAction::EndDrag); + return; + } + let Some(dragged_tab) = dragged_tab else { return; }; - core.dispatch_shell_action(ShellAction::MoveWorkspaceWindow { - window_id: dragged.window_id, + core.dispatch_shell_action(ShellAction::ExtractWorkspaceWindowTab { + source_window_id: dragged_tab.window_id, + tab_id: dragged_tab.tab_id, target, }); core.dispatch_shell_action(ShellAction::EndDrag); @@ -1235,6 +1380,157 @@ fn render_window_drop_zone( } } +fn render_workspace_window_tab( + window_id: taskers_core::WorkspaceWindowId, + tab: &WorkspaceWindowTabSnapshot, + ordered_tab_ids: &[WorkspaceWindowTabId], + core: SharedCore, + mut dragged_window_tab: Signal>, + mut window_tab_drop_target: Signal>, +) -> Element { + let tab_id = tab.id; + let is_drop_target = matches!( + *window_tab_drop_target.read(), + Some(WindowTabDropTarget::BeforeTab { + window_id: target_window_id, + tab_id: target_tab_id, + }) if target_window_id == window_id && target_tab_id == tab_id + ); + let attention_class = match tab.attention { + AttentionState::Completed | AttentionState::WaitingInput | AttentionState::Error => { + format!(" workspace-window-tab-attention-{}", tab.attention.slug()) + } + AttentionState::Normal | AttentionState::Busy => String::new(), + }; + let tab_class = if tab.active { + format!( + "workspace-window-tab workspace-window-tab-active{}{}", + attention_class, + if is_drop_target { + " workspace-window-tab-drop-target" + } else { + "" + } + ) + } else { + format!( + "workspace-window-tab{}{}", + attention_class, + if is_drop_target { + " workspace-window-tab-drop-target" + } else { + "" + } + ) + }; + let runtime_icon_class = format!( + "workspace-window-tab-kind-icon {}", + runtime_state_class(tab.runtime.state) + ); + let tab_title = format!("{} · {}", tab.runtime.label, tab.title); + let focus_tab = { + let core = core.clone(); + move |event: Event| { + event.stop_propagation(); + core.dispatch_shell_action(ShellAction::FocusWorkspaceWindowTab { window_id, tab_id }); + } + }; + let close_tab = { + let core = core.clone(); + move |event: Event| { + event.stop_propagation(); + core.dispatch_shell_action(ShellAction::CloseWorkspaceWindowTab { window_id, tab_id }); + } + }; + let start_tab_drag = { + let core = core.clone(); + move |event: Event| { + prime_drag_transfer( + &event, + WINDOW_TAB_DRAG_MIME, + &format!("{window_id}:{tab_id}"), + ); + dragged_window_tab.set(Some(DraggedWindowTab { window_id, tab_id })); + window_tab_drop_target.set(None); + core.dispatch_shell_action(ShellAction::BeginWindowTabDrag); + } + }; + let end_tab_drag = { + let core = core.clone(); + move |_: Event| { + dragged_window_tab.set(None); + window_tab_drop_target.set(None); + core.dispatch_shell_action(ShellAction::EndDrag); + } + }; + let set_drop_target = move |event: Event| { + if dragged_window_tab.read().is_none() { + return; + } + mark_move_drop(&event); + window_tab_drop_target.set(Some(WindowTabDropTarget::BeforeTab { window_id, tab_id })); + }; + let clear_drop_target = move |_: Event| { + if *window_tab_drop_target.read() + == Some(WindowTabDropTarget::BeforeTab { window_id, tab_id }) + { + window_tab_drop_target.set(None); + } + }; + let drop_tab = { + let core = core.clone(); + let ordered_tab_ids = ordered_tab_ids.to_vec(); + move |event: Event| { + let dragged = *dragged_window_tab.read(); + dragged_window_tab.set(None); + window_tab_drop_target.set(None); + let Some(dragged) = dragged else { + return; + }; + event.prevent_default(); + let target_index = + compute_window_tab_drop_index(dragged, window_id, &ordered_tab_ids, Some(tab_id)); + if dragged.window_id == window_id { + core.dispatch_shell_action(ShellAction::MoveWorkspaceWindowTab { + window_id, + tab_id: dragged.tab_id, + target_index, + }); + } else { + core.dispatch_shell_action(ShellAction::TransferWorkspaceWindowTab { + source_window_id: dragged.window_id, + tab_id: dragged.tab_id, + target_window_id: window_id, + target_index, + }); + } + core.dispatch_shell_action(ShellAction::EndDrag); + } + }; + + rsx! { + div { + class: "{tab_class}", + title: "{tab_title}", + draggable: "true", + ondragstart: start_tab_drag, + ondragend: end_tab_drag, + ondragover: set_drop_target, + ondragleave: clear_drop_target, + ondrop: drop_tab, + button { class: "workspace-window-tab-button", onclick: focus_tab, + {render_runtime_icon(&tab.runtime, 11, &runtime_icon_class)} + span { class: "workspace-window-tab-copy", + span { class: "workspace-window-tab-title", "{tab.title}" } + } + } + button { class: "workspace-window-tab-close", title: "Close window tab", onclick: close_tab, + {icons::close(10, "workspace-window-tab-close-icon")} + } + } + } +} + fn render_pane( workspace_id: WorkspaceId, pane: &PaneSnapshot, @@ -1381,6 +1677,20 @@ fn render_pane( runtime_state_class(active_surface.runtime.state) ); let pane_surface_summary_title = surface_summary_title(active_surface); + let dismiss_surface_alert = { + let core = core.clone(); + move |event: Event| { + event.stop_propagation(); + core.dispatch_shell_action(ShellAction::DismissSurfaceAlert { + workspace_id, + pane_id, + surface_id: active_surface_id, + }); + } + }; + let swallow_runtime_state_pointer = move |event: Event| { + event.stop_propagation(); + }; rsx! { div { class: "pane-frame", @@ -1398,7 +1708,15 @@ fn render_pane( span { class: "pane-runtime-badge", "{runtime_label}" } } if let Some(status_label) = surface_status_text(active_surface) { - span { class: "{pane_runtime_state_class}", "{status_label}" } + button { + r#type: "button", + class: "{pane_runtime_state_class} pane-runtime-state-dismiss", + title: "Dismiss alert", + onpointerdown: swallow_runtime_state_pointer, + onpointerup: swallow_runtime_state_pointer, + onclick: dismiss_surface_alert, + "{status_label}" + } } } } @@ -1416,7 +1734,15 @@ fn render_pane( span { class: "pane-runtime-badge", "{runtime_label}" } } if let Some(status_label) = surface_status_text(active_surface) { - span { class: "{pane_runtime_state_class}", "{status_label}" } + button { + r#type: "button", + class: "{pane_runtime_state_class} pane-runtime-state-dismiss", + title: "Dismiss alert", + onpointerdown: swallow_runtime_state_pointer, + onpointerup: swallow_runtime_state_pointer, + onclick: dismiss_surface_alert, + "{status_label}" + } } } } @@ -1733,25 +2059,56 @@ fn render_surface_tab( runtime_state_class(surface.runtime.state) ); let surface_tab_title = surface_summary_title(surface); + let dismissible_status = surface_status_text(surface).is_some(); + let dismiss_surface_alert = { + let core = core.clone(); + move |event: Event| { + event.stop_propagation(); + core.dispatch_shell_action(ShellAction::DismissSurfaceAlert { + workspace_id, + pane_id, + surface_id, + }); + } + }; + let swallow_pointer = move |event: Event| { + event.stop_propagation(); + }; rsx! { - button { + div { key: "{surface_id}", class: "{tab_class} surface-tab-draggable", title: "{surface_tab_title}", - onclick: focus_surface, - onpointerdown: begin_surface_drag_candidate, - onpointerenter: set_surface_drop_target_enter, - onpointermove: set_surface_drop_target_move, - onpointerleave: clear_surface_drop_target, - onpointerup: drop_surface, - {render_runtime_icon(&surface.runtime, 10, &surface_runtime_icon_class)} - span { key: "{surface_id}-copy", class: "surface-tab-copy", - span { class: "surface-tab-primary", "{surface_primary_label(surface)}" } - if let Some(runtime_label) = surface_runtime_badge_text(surface) { - span { key: "{surface_id}-runtime-badge", class: "surface-tab-runtime-badge", "{runtime_label}" } + button { + class: "surface-tab-focus", + onclick: focus_surface, + onpointerdown: begin_surface_drag_candidate, + onpointerenter: set_surface_drop_target_enter, + onpointermove: set_surface_drop_target_move, + onpointerleave: clear_surface_drop_target, + onpointerup: drop_surface, + {render_runtime_icon(&surface.runtime, 10, &surface_runtime_icon_class)} + span { key: "{surface_id}-copy", class: "surface-tab-copy", + span { class: "surface-tab-primary", "{surface_primary_label(surface)}" } + if let Some(runtime_label) = surface_runtime_badge_text(surface) { + span { key: "{surface_id}-runtime-badge", class: "surface-tab-runtime-badge", "{runtime_label}" } + } } - if let Some(status_label) = surface_status_text(surface) { + } + if let Some(status_label) = surface_status_text(surface) { + if dismissible_status { + button { + r#type: "button", + key: "{surface_id}-status-{status_label}", + class: "{surface_tab_state_class} surface-tab-dismiss", + title: "Dismiss alert", + onpointerdown: swallow_pointer, + onpointerup: swallow_pointer, + onclick: dismiss_surface_alert, + "{status_label}" + } + } else { span { key: "{surface_id}-status-{status_label}", class: "{surface_tab_state_class}", @@ -1926,25 +2283,50 @@ fn render_agent_item( let pane_id = agent.pane_id; let surface_id = agent.surface_id; let current_workspace_id = current_workspace.id; + let focus_core = core.clone(); let focus_target = move |_| { if workspace_id != current_workspace_id { - core.dispatch_shell_action(ShellAction::FocusWorkspace { workspace_id }); + focus_core.dispatch_shell_action(ShellAction::FocusWorkspace { workspace_id }); } - core.dispatch_shell_action(ShellAction::FocusSurface { + focus_core.dispatch_shell_action(ShellAction::FocusSurface { pane_id, surface_id, }); }; + let dismissible = true; + let dismiss = { + let core = core.clone(); + move |event: Event| { + event.stop_propagation(); + core.dispatch_shell_action(ShellAction::DismissSurfaceAlert { + workspace_id, + pane_id, + surface_id, + }); + } + }; rsx! { - button { class: "activity-item-button", onclick: focus_target, - div { class: "{row_class}", - div { class: "activity-header", - {render_runtime_icon_by_key(agent.agent_kind.as_str(), 12, &agent_icon_class)} - div { class: "workspace-label", "{agent.title}" } - div { class: "activity-time", "{agent.state.label()}" } + div { class: "activity-item-row", + button { class: "activity-item-button", onclick: focus_target, + div { class: "{row_class}", + div { class: "activity-header", + {render_runtime_icon_by_key(agent.agent_kind.as_str(), 12, &agent_icon_class)} + div { class: "workspace-label", "{agent.title}" } + if !dismissible { + div { class: "activity-time", "{agent.state.label()}" } + } + } + div { class: "activity-meta", "{agent.workspace_title} · {agent.agent_kind}" } + } + } + if dismissible { + button { + class: "activity-time activity-item-dismiss activity-item-dismiss-label", + title: "Dismiss alert", + onclick: dismiss, + "{agent.state.label()}" } - div { class: "activity-meta", "{agent.workspace_title} · {agent.agent_kind}" } } } } diff --git a/crates/taskers-shell/src/theme.rs b/crates/taskers-shell/src/theme.rs index e5cad57..6d67ab4 100644 --- a/crates/taskers-shell/src/theme.rs +++ b/crates/taskers-shell/src/theme.rs @@ -979,49 +979,174 @@ input:focus-visible {{ border-bottom: 1px solid {border_06}; background: linear-gradient(180deg, {overlay_05} 0%, {overlay_03} 100%); position: relative; - padding: 0 8px; + padding: 0 6px 0 8px; display: flex; align-items: center; - justify-content: center; - cursor: grab; + gap: 8px; user-select: none; border-radius: 8px 8px 0 0; }} -.workspace-window-grip {{ - width: 38px; - height: 4px; - border-radius: 999px; - background: {border_10}; +.workspace-window-toolbar-tabs {{ + min-width: 0; + display: flex; + align-items: center; + gap: 4px; + overflow-x: auto; + scrollbar-width: none; +}} + +.workspace-window-toolbar-tabs::-webkit-scrollbar {{ + display: none; +}} + +.workspace-window-toolbar-spacer {{ + flex: 1 1 auto; + min-width: 24px; + align-self: stretch; + cursor: grab; +}} + +.workspace-window-toolbar-spacer:active {{ + cursor: grabbing; +}} + +.workspace-window-tab {{ + flex: 0 0 auto; + min-width: 0; + max-width: 280px; + height: 24px; + display: inline-flex; + align-items: center; + gap: 2px; + padding: 0 3px 0 5px; + background: transparent; + border: 1px solid transparent; + border-radius: 6px; + color: {text_subtle}; + overflow: hidden; +}} + +.workspace-window-tab:hover {{ + background: {border_06}; + border-color: {border_10}; +}} + +.workspace-window-tab-active {{ + background: {overlay_16}; + border-color: {accent_20}; + color: {text_bright}; box-shadow: inset 0 1px 0 rgba(255,255,255,0.04); }} -.workspace-window-runtime-badge {{ - position: absolute; - left: 8px; - top: 50%; - transform: translateY(-50%); - width: 20px; - height: 20px; - border-radius: 999px; - display: flex; +.workspace-window-tab-drop-target {{ + border-color: {accent_24}; + background: {accent_12}; +}} + +.workspace-window-tab-button {{ + flex: 1 1 auto; + min-width: 0; + height: 100%; + display: inline-flex; + align-items: center; + gap: 6px; + border: 0; + background: transparent; + padding: 0; + color: inherit; +}} + +.workspace-window-tab-copy {{ + min-width: 0; + display: inline-flex; + align-items: center; +}} + +.workspace-window-tab-title {{ + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; + font-weight: 600; +}} + +.workspace-window-tab-kind-icon {{ + flex: 0 0 auto; + opacity: 0.8; +}} + +.workspace-window-tab-active .workspace-window-tab-kind-icon {{ + opacity: 1.0; +}} + +.workspace-window-tab-close {{ + flex: 0 0 auto; + width: 18px; + height: 18px; + border: 0; + border-radius: 4px; + background: transparent; + color: {text_dim}; + display: inline-flex; align-items: center; justify-content: center; - background: {overlay_12}; + padding: 0; +}} + +.workspace-window-tab-close:hover {{ + background: {border_08}; + color: {text_bright}; +}} + +.workspace-window-tab-add {{ + flex: 0 0 auto; + width: 22px; + height: 22px; border: 1px solid {border_10}; - box-shadow: inset 0 1px 0 rgba(255,255,255,0.04); + border-radius: 5px; + background: {overlay_05}; + color: {text_dim}; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; }} -.workspace-window-toolbar:hover .workspace-window-grip {{ - background: {text_dim}; +.workspace-window-tab-add:hover {{ + background: {overlay_16}; + color: {text_bright}; }} -.workspace-window-shell-active .workspace-window-grip {{ - background: {accent_24}; +.workspace-window-tab-add-active {{ + border-color: {accent_24}; + background: {accent_12}; + color: {text_bright}; }} -.workspace-window-toolbar:active {{ - cursor: grabbing; +.workspace-window-tab-attention-waiting {{ + border-color: {waiting_18}; + background: {waiting_18}; + color: {waiting_text}; +}} + +.workspace-window-tab-attention-error {{ + border-color: {error_16}; + background: {error_16}; + color: {error_text}; +}} + +.workspace-window-tab-attention-completed {{ + border-color: {completed_16}; + background: {completed_16}; + color: {completed_text}; +}} + +.workspace-window-tab-attention-waiting .workspace-window-tab-kind-icon, +.workspace-window-tab-attention-error .workspace-window-tab-kind-icon, +.workspace-window-tab-attention-completed .workspace-window-tab-kind-icon {{ + opacity: 1.0; }} .workspace-window-body {{ @@ -1203,6 +1328,12 @@ input:focus-visible {{ color: {text_bright}; }} +.pane-runtime-state-dismiss {{ + border: 0; + cursor: pointer; + pointer-events: auto; +}} + .pane-runtime-chip.runtime-state-working {{ background: {busy_10}; border-color: {busy_12}; @@ -1303,6 +1434,20 @@ input:focus-visible {{ overflow: hidden; }} +.surface-tab-focus {{ + min-width: 0; + flex: 1 1 auto; + display: inline-flex; + align-items: center; + gap: 5px; + border: 0; + padding: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +}} + .surface-tab:hover {{ background: {border_06}; border-color: {border_10}; @@ -1455,6 +1600,16 @@ input:focus-visible {{ color: {text_bright}; }} +.surface-tab-dismiss {{ + border: 0; + cursor: pointer; + transition: filter 0.14s ease-in-out; +}} + +.surface-tab-dismiss:hover {{ + filter: brightness(1.08); +}} + .surface-tab-state.runtime-state-working {{ background: {busy_16}; }} @@ -1865,17 +2020,50 @@ input:focus-visible {{ }} .activity-item-button {{ + flex: 1 1 auto; width: 100%; border: 0; padding: 0; background: transparent; text-align: left; + cursor: pointer; +}} + +.activity-item-row {{ + display: flex; + align-items: flex-start; + gap: 6px; }} .activity-item-button:hover .activity-item {{ background: {border_04}; }} +.activity-item-dismiss {{ + min-width: 0; + height: 18px; + border: 0; + margin-top: 4px; + background: transparent; + color: {text_dim}; + padding: 0 6px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; + cursor: pointer; + transition: background 0.14s ease-in-out, color 0.14s ease-in-out; +}} + +.activity-item-dismiss:hover {{ + background: {border_08}; + color: {text_bright}; +}} + +.activity-item-dismiss-label {{ + font-weight: 700; +}} + .notification-header {{ display: flex; align-items: center; diff --git a/docs/notifications.md b/docs/notifications.md index 24694e4..6235a84 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -100,7 +100,7 @@ For tool or wrapper integrations, Taskers also supports explicit hook-style comm ```bash taskersctl agent-hook waiting --message "Need review" -taskersctl agent-hook notification --title "Codex" --message "Turn complete" +taskersctl agent-hook stop --title "Codex" --message "Turn complete" taskersctl agent-hook stop --message "Finished" ``` diff --git a/docs/taskersctl.md b/docs/taskersctl.md index 7ea2791..bd6c6d0 100644 --- a/docs/taskersctl.md +++ b/docs/taskersctl.md @@ -144,7 +144,7 @@ If your tool already emits lifecycle events, use `agent-hook` instead of rebuild ```bash taskersctl agent-hook waiting --title "Codex" --message "Need review" -taskersctl agent-hook notification --title "Codex" --message "Turn complete" +taskersctl agent-hook stop --title "Codex" --message "Turn complete" taskersctl agent-hook stop --message "Finished" ``` diff --git a/docs/usage.md b/docs/usage.md index 6da1b8d..6e2f57d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -4,17 +4,19 @@ This guide is the quickest way to get oriented in the active Taskers app. ## Mental Model -Taskers has three layers of layout: +Taskers has four layers of layout: - A workspace contains top-level workspace windows. -- A workspace window contains panes. -- A pane contains tabs. +- A workspace window contains window tabs. +- A window tab contains panes. +- A pane contains surface tabs. That distinction matters: - Workspace navigation and panning operate on top-level windows. -- Pane splits stay local to the current workspace window. -- Tabs stay local to the current pane until you move them. +- Window tabs stay local to the current workspace window until you merge or extract them. +- Pane splits stay local to the current window tab. +- Surface tabs stay local to the current pane until you move them. If something feels like “Niri behavior,” it should usually happen at the workspace-window layer, not at the pane or tab layer. @@ -25,7 +27,7 @@ Taskers currently ships two live surface kinds: - Terminal surfaces backed by embedded Ghostty - Browser surfaces backed by embedded WebKit -Each pane can hold one or more tabs of either kind. The active tab supplies the live content for that pane. +Each pane can hold one or more surface tabs of either kind. The active surface tab supplies the live content for that pane. ## Embedded Ghostty Config @@ -35,7 +37,7 @@ Taskers still pins a small set of embedded-pane invariants: - the launch command stays Taskers-owned - Ghostty shell integration stays disabled because Taskers provides its own shell wrapper -- Ghostty window padding stays zero so the grid aligns with pane chrome +- Ghostty window padding stays zero and Taskers adds its own host-side terminal gutter - Ghostty Linux cgroup settings stay disabled for embedded panes That means Ghostty settings such as fonts, theme, colors, cursor behavior, and scrollback should carry over, while window-style and shell-launch behavior remains controlled by Taskers. @@ -52,12 +54,14 @@ Build out the workspace: - Create or switch to a workspace from the sidebar. - Use the pane controls to add a new terminal tab or browser tab. +- Use the window top bar to add or rearrange whole window tabs when you want another local pane layout in the same top-level window. - Split the active pane when you want another local work area inside the current workspace window. - Create or move top-level workspace windows when you want side-by-side tiled regions. The important boundary is: - New split inside the current window: pane operation +- New tab inside the current top-level window: window-tab operation - New top-level tile in the scrolling workspace: workspace-window operation ## Working Inside A Taskers Terminal From 5d877b9b9d8791bd827799fea275985700d0f2fe Mon Sep 17 00:00:00 2001 From: OneNoted Date: Fri, 27 Mar 2026 14:39:03 +0100 Subject: [PATCH 58/63] fix(packaging): preserve desktop integration and Ghostty themes --- assets/taskers.desktop.in | 2 +- crates/taskers-app/src/main.rs | 55 ++++++----- crates/taskers-cli/assets/taskers.desktop.in | 2 +- .../assets/taskers.desktop.in | 2 +- crates/taskers-launcher/src/lib.rs | 97 +++++++++++++++++-- docs/usage.md | 3 +- scripts/build_ghostty_runtime_bundle.sh | 3 +- scripts/build_linux_bundle.sh | 3 +- scripts/install-dev-desktop-entry.sh | 17 +++- vendor/ghostty/src/taskers_bridge.zig | 19 ++-- 10 files changed, 149 insertions(+), 54 deletions(-) diff --git a/assets/taskers.desktop.in b/assets/taskers.desktop.in index fb1ac64..86c78f4 100644 --- a/assets/taskers.desktop.in +++ b/assets/taskers.desktop.in @@ -8,5 +8,5 @@ Icon=taskers Terminal=false Categories=Development; StartupNotify=true -StartupWMClass=taskers +StartupWMClass=dev.taskers.app X-GNOME-UsesNotifications=true diff --git a/crates/taskers-app/src/main.rs b/crates/taskers-app/src/main.rs index 0c4ecce..819aadf 100644 --- a/crates/taskers-app/src/main.rs +++ b/crates/taskers-app/src/main.rs @@ -188,6 +188,11 @@ fn default_true() -> bool { true } +fn safe_eprintln(message: impl std::fmt::Display) { + let mut stderr = io::stderr().lock(); + let _ = writeln!(stderr, "{message}"); +} + impl TaskersConfig { fn load() -> Result { let path = taskers_paths::default_config_path(); @@ -245,7 +250,7 @@ fn main() -> glib::ExitCode { let bootstrap = match bootstrap_runtime(None) { Ok(bootstrap) => bootstrap, Err(error) => { - eprintln!("failed to bootstrap Taskers host: {error:?}"); + safe_eprintln(format!("failed to bootstrap Taskers host: {error:?}")); return glib::ExitCode::FAILURE; } }; @@ -282,7 +287,7 @@ fn build_ui( cli: Cli, ) { if let Err(error) = build_ui_result(app, bootstrap, hold_guard, cli) { - eprintln!("failed to launch Taskers host: {error:?}"); + safe_eprintln(format!("failed to launch Taskers host: {error:?}")); } } @@ -331,8 +336,10 @@ fn build_ui_result( .default_width(1440) .default_height(900) .build(); + let app_for_close = app.clone(); window.connect_close_request(move |_| { drop(hold_guard.borrow_mut().take()); + app_for_close.quit(); glib::Propagation::Proceed }); let host_widget = host.borrow().widget(); @@ -362,7 +369,7 @@ fn build_ui_result( diagnostics.as_ref(), DiagnosticRecord::new(DiagnosticCategory::Startup, None, note.clone()), ); - eprintln!("{note}"); + safe_eprintln(note); } log_diagnostic( diagnostics.as_ref(), @@ -372,7 +379,7 @@ fn build_ui_result( control_server_note.clone(), ), ); - eprintln!("{control_server_note}"); + safe_eprintln(control_server_note); log_diagnostic( diagnostics.as_ref(), DiagnosticRecord::new( @@ -381,7 +388,7 @@ fn build_ui_result( format!("shared shell listening on {shell_url}"), ), ); - eprintln!("shared shell listening on {shell_url}"); + safe_eprintln(format!("shared shell listening on {shell_url}")); let smoke_script = cli.smoke_script; let quit_after_ms = cli.quit_after_ms.unwrap_or(8_000); @@ -534,7 +541,9 @@ fn process_pending_notifications( .with_pane(notification.pane_id) .with_surface(notification.surface_id), ); - eprintln!("taskers notification delivery update failed: {error:?}"); + safe_eprintln(format!( + "taskers notification delivery update failed: {error:?}" + )); } } @@ -652,7 +661,7 @@ fn persist_settings_if_needed( format!("failed to persist config: {error:?}"), ), ); - eprintln!("taskers config save failed: {error:?}"); + safe_eprintln(format!("taskers config save failed: {error:?}")); return; } @@ -1030,10 +1039,10 @@ fn run_internal_ghostty_probe(mode: GhosttyProbeMode) -> glib::ExitCode { host } Err(error) => { - eprintln!( + safe_eprintln(format!( "ghostty {} self-probe failed during host init: {error}", mode.as_arg() - ); + )); return glib::ExitCode::FAILURE; } }; @@ -1053,10 +1062,10 @@ fn run_internal_surface_probe( ) -> glib::ExitCode { if !gtk::is_initialized_main_thread() { if let Err(error) = gtk::init() { - eprintln!( + safe_eprintln(format!( "ghostty {} self-probe failed during gtk init: {error}", mode.as_arg() - ); + )); return glib::ExitCode::FAILURE; } } @@ -1084,10 +1093,10 @@ fn run_internal_surface_probe( ) { Ok(app_state) => app_state, Err(error) => { - eprintln!( + safe_eprintln(format!( "ghostty {} self-probe failed during app state bootstrap: {error}", mode.as_arg() - ); + )); return glib::ExitCode::FAILURE; } }; @@ -1121,10 +1130,10 @@ fn run_internal_surface_probe( spin_probe_main_context(Duration::from_millis(80)); if let Err(error) = taskers_host.sync_snapshot(&core.snapshot()) { - eprintln!( + safe_eprintln(format!( "ghostty {} self-probe failed during snapshot sync: {error}", mode.as_arg() - ); + )); return glib::ExitCode::FAILURE; } @@ -1271,7 +1280,7 @@ fn sync_window( format!("host command failed: {error}"), ), ); - eprintln!("taskers host command failed: {error}"); + safe_eprintln(format!("taskers host command failed: {error}")); } } @@ -1320,7 +1329,9 @@ fn sync_window( format!("snapshot sync failed: {error}"), ), ); - eprintln!("taskers host sync failed for revision {revision}: {error}"); + safe_eprintln(format!( + "taskers host sync failed for revision {revision}: {error}" + )); } last_revision.set(revision); } @@ -1574,14 +1585,14 @@ fn spawn_control_server( }; if let Err(error) = serve_with_handler(listener, handler, pending::<()>()).await { - eprintln!("control server error: {error}"); + safe_eprintln(format!("control server error: {error}")); } } Err(error) => { - eprintln!( + safe_eprintln(format!( "control server unavailable at {}: {error}", socket_path.display() - ); + )); } } }); @@ -1649,7 +1660,7 @@ fn launch_liveview_server(core: SharedCore) -> Result { ); if let Err(error) = axum::serve(listener, router.into_make_service()).await { - eprintln!("liveview server failed: {error}"); + safe_eprintln(format!("liveview server failed: {error}")); } }); }); @@ -1873,7 +1884,7 @@ impl DiagnosticsWriter { target: DiagnosticsTarget::File(Arc::new(Mutex::new(file))), }), Err(error) => { - eprintln!("taskers diagnostics log path failed: {error}"); + safe_eprintln(format!("taskers diagnostics log path failed: {error}")); None } } diff --git a/crates/taskers-cli/assets/taskers.desktop.in b/crates/taskers-cli/assets/taskers.desktop.in index 07ac656..64422a9 100644 --- a/crates/taskers-cli/assets/taskers.desktop.in +++ b/crates/taskers-cli/assets/taskers.desktop.in @@ -9,5 +9,5 @@ Icon=taskers Terminal=false Categories=Development; StartupNotify=true -StartupWMClass=taskers +StartupWMClass=dev.taskers.app X-GNOME-UsesNotifications=true diff --git a/crates/taskers-launcher/assets/taskers.desktop.in b/crates/taskers-launcher/assets/taskers.desktop.in index 07ac656..64422a9 100644 --- a/crates/taskers-launcher/assets/taskers.desktop.in +++ b/crates/taskers-launcher/assets/taskers.desktop.in @@ -9,5 +9,5 @@ Icon=taskers Terminal=false Categories=Development; StartupNotify=true -StartupWMClass=taskers +StartupWMClass=dev.taskers.app X-GNOME-UsesNotifications=true diff --git a/crates/taskers-launcher/src/lib.rs b/crates/taskers-launcher/src/lib.rs index 9078e85..3905fee 100644 --- a/crates/taskers-launcher/src/lib.rs +++ b/crates/taskers-launcher/src/lib.rs @@ -233,16 +233,19 @@ impl ManagedInstallation { fs::create_dir_all(&xdg_bin_home) .with_context(|| format!("failed to create {}", xdg_bin_home.display()))?; + let desktop_launcher = xdg_bin_home.join("taskers-desktop-launch"); + write_executable(&desktop_launcher, &desktop_launch_wrapper_contents(&launcher))?; + + let desktop_entry_path = applications_dir.join("dev.taskers.app.desktop"); let desktop_entry = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/assets/taskers.desktop.in" )) - .replace("{{EXEC}}", &desktop_exec(&launcher)); - fs::write( - applications_dir.join("dev.taskers.app.desktop"), - desktop_entry, - ) - .with_context(|| format!("failed to write {}", applications_dir.display()))?; + .replace("{{EXEC}}", &desktop_exec(&desktop_launcher)); + if should_update_desktop_entry(&desktop_entry_path, &desktop_launcher)? { + fs::write(&desktop_entry_path, desktop_entry) + .with_context(|| format!("failed to write {}", applications_dir.display()))?; + } fs::write( icons_dir.join("taskers.svg"), include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/taskers.svg")), @@ -298,6 +301,7 @@ fn validate_bundle_layout(bundle_root: &Path) -> bool { .join("lib") .join("libtaskers_ghostty_bridge.so") .is_file() + && bundle_root.join("ghostty").join("themes").is_dir() && bundle_root.join("terminfo").is_dir() } @@ -449,6 +453,32 @@ fn desktop_exec(path: &Path) -> String { raw.replace('\\', "\\\\").replace(' ', "\\ ") } +fn shell_single_quote(path: &Path) -> String { + format!("'{}'", path.display().to_string().replace('\'', "'\"'\"'")) +} + +fn desktop_launch_wrapper_contents(target: &Path) -> String { + format!( + "#!/bin/sh\nset -eu\nlog_dir=\"${{XDG_CACHE_HOME:-$HOME/.cache}}/taskers\"\nmkdir -p \"$log_dir\"\nexec /usr/bin/setsid -f {target} --diagnostic-log \"$log_dir/desktop-launch-diagnostics.log\" >>\"$log_dir/desktop-launch.log\" 2>&1\n", + target = shell_single_quote(target), + ) +} + +fn should_update_desktop_entry(path: &Path, desktop_launcher: &Path) -> Result { + let Ok(existing) = fs::read_to_string(path) else { + return Ok(true); + }; + let Some(existing_exec) = existing + .lines() + .find_map(|line| line.strip_prefix("Exec=")) + .map(str::trim) + else { + return Ok(true); + }; + + Ok(existing_exec == desktop_exec(desktop_launcher)) +} + fn write_executable(path: &Path, contents: &str) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent) @@ -558,12 +588,13 @@ where mod tests { use super::{ ArtifactKind, ManagedInstallation, ReleaseArtifact, ReleaseManifest, bundle_root, - current_target_triple, default_manifest_url, launcher_path_looks_installed, - path_taskers_executable, sha256_path, + current_target_triple, default_manifest_url, desktop_exec, + desktop_launch_wrapper_contents, launcher_path_looks_installed, path_taskers_executable, + sha256_path, should_update_desktop_entry, }; #[cfg(unix)] use std::os::unix::fs::symlink; - use std::{collections::BTreeMap, ffi::OsString, fs, path::PathBuf}; + use std::{collections::BTreeMap, ffi::OsString, fs, path::{Path, PathBuf}}; use tar::Builder; use tempfile::tempdir; use xz2::write::XzEncoder; @@ -594,6 +625,7 @@ mod tests { let bundle_dir = temp.path().join("bundle"); fs::create_dir_all(bundle_dir.join("bin")).expect("bin dir"); fs::create_dir_all(bundle_dir.join("ghostty").join("lib")).expect("ghostty dir"); + fs::create_dir_all(bundle_dir.join("ghostty").join("themes")).expect("themes dir"); fs::create_dir_all(bundle_dir.join("terminfo").join("g")).expect("terminfo dir"); fs::write( bundle_dir.join("bin").join("taskers"), @@ -618,6 +650,14 @@ mod tests { "bridge", ) .expect("bridge"); + fs::write( + bundle_dir + .join("ghostty") + .join("themes") + .join("Catppuccin Mocha"), + "palette = 0=#1e1e2e\n", + ) + .expect("theme"); fs::write( bundle_dir.join("terminfo").join("g").join("ghostty"), "ghostty", @@ -695,4 +735,43 @@ mod tests { let repo_binary = PathBuf::from("/home/notes/Projects/taskers/target/debug/taskers"); assert!(!launcher_path_looks_installed(&repo_binary)); } + + #[test] + fn preserves_non_launcher_desktop_entry() { + let temp = tempdir().expect("tempdir"); + let desktop_entry = temp.path().join("dev.taskers.app.desktop"); + fs::write( + &desktop_entry, + "[Desktop Entry]\nExec=/home/notes/.cargo/bin/taskers-gtk\n", + ) + .expect("desktop entry"); + + let launcher = PathBuf::from("/home/notes/.local/bin/taskers"); + assert!( + !should_update_desktop_entry(&desktop_entry, &launcher).expect("decision"), + ); + } + + #[test] + fn updates_matching_launcher_desktop_entry() { + let temp = tempdir().expect("tempdir"); + let desktop_entry = temp.path().join("dev.taskers.app.desktop"); + let launcher = PathBuf::from("/home/notes/.local/bin/taskers-desktop-launch"); + fs::write( + &desktop_entry, + format!("[Desktop Entry]\nExec={}\n", desktop_exec(&launcher)), + ) + .expect("desktop entry"); + + assert!(should_update_desktop_entry(&desktop_entry, &launcher).expect("decision")); + } + + #[test] + fn desktop_launch_wrapper_targets_binary_with_log_redirection() { + let contents = desktop_launch_wrapper_contents(Path::new("/home/notes/.local/bin/taskers")); + assert!(contents.contains("desktop-launch-diagnostics.log")); + assert!(contents.contains("desktop-launch.log")); + assert!(contents.contains("/usr/bin/setsid -f")); + assert!(contents.contains("'/home/notes/.local/bin/taskers'")); + } } diff --git a/docs/usage.md b/docs/usage.md index 6e2f57d..17c4079 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -37,10 +37,9 @@ Taskers still pins a small set of embedded-pane invariants: - the launch command stays Taskers-owned - Ghostty shell integration stays disabled because Taskers provides its own shell wrapper -- Ghostty window padding stays zero and Taskers adds its own host-side terminal gutter - Ghostty Linux cgroup settings stay disabled for embedded panes -That means Ghostty settings such as fonts, theme, colors, cursor behavior, and scrollback should carry over, while window-style and shell-launch behavior remains controlled by Taskers. +That means Ghostty settings such as fonts, theme, colors, cursor behavior, scrollback, and window padding should carry over, while shell-launch behavior remains controlled by Taskers. ## A Typical Session diff --git a/scripts/build_ghostty_runtime_bundle.sh b/scripts/build_ghostty_runtime_bundle.sh index 8660374..7c5790d 100755 --- a/scripts/build_ghostty_runtime_bundle.sh +++ b/scripts/build_ghostty_runtime_bundle.sh @@ -29,9 +29,10 @@ trap cleanup EXIT --prefix "$prefix_dir" ) -mkdir -p "$bundle_dir/ghostty/lib" "$bundle_dir/ghostty/shell-integration" "$bundle_dir/terminfo" +mkdir -p "$bundle_dir/ghostty/lib" "$bundle_dir/ghostty/shell-integration" "$bundle_dir/ghostty/themes" "$bundle_dir/terminfo" cp "$prefix_dir/lib/libtaskers_ghostty_bridge.so" "$bundle_dir/ghostty/lib/" cp -R "$prefix_dir/share/ghostty/shell-integration/." "$bundle_dir/ghostty/shell-integration/" +cp -R "$prefix_dir/share/ghostty/themes/." "$bundle_dir/ghostty/themes/" cp -R "$prefix_dir/share/terminfo/." "$bundle_dir/terminfo/" printf '%s\n' "$version" > "$bundle_dir/ghostty/.taskers-runtime-version" diff --git a/scripts/build_linux_bundle.sh b/scripts/build_linux_bundle.sh index 9d58ea2..e3da507 100755 --- a/scripts/build_linux_bundle.sh +++ b/scripts/build_linux_bundle.sh @@ -35,12 +35,13 @@ trap cleanup EXIT --prefix "$prefix_dir" ) -mkdir -p "$bundle_dir/bin" "$bundle_dir/ghostty/lib" "$bundle_dir/ghostty/shell-integration" "$bundle_dir/terminfo" +mkdir -p "$bundle_dir/bin" "$bundle_dir/ghostty/lib" "$bundle_dir/ghostty/shell-integration" "$bundle_dir/ghostty/themes" "$bundle_dir/terminfo" cp "$repo_root/target/release/taskers-gtk" "$bundle_dir/bin/taskers" cp "$repo_root/target/release/taskersctl" "$bundle_dir/bin/taskersctl" chmod +x "$bundle_dir/bin/taskers" "$bundle_dir/bin/taskersctl" cp "$prefix_dir/lib/libtaskers_ghostty_bridge.so" "$bundle_dir/ghostty/lib/" cp -R "$prefix_dir/share/ghostty/shell-integration/." "$bundle_dir/ghostty/shell-integration/" +cp -R "$prefix_dir/share/ghostty/themes/." "$bundle_dir/ghostty/themes/" cp -R "$prefix_dir/share/terminfo/." "$bundle_dir/terminfo/" printf '%s\n' "$version" > "$bundle_dir/ghostty/.taskers-runtime-version" diff --git a/scripts/install-dev-desktop-entry.sh b/scripts/install-dev-desktop-entry.sh index 77fbf95..4ce1fcc 100755 --- a/scripts/install-dev-desktop-entry.sh +++ b/scripts/install-dev-desktop-entry.sh @@ -25,6 +25,7 @@ fi cargo_root="${CARGO_INSTALL_ROOT:-${CARGO_HOME:-$HOME/.cargo}}" app_bin_dir="${cargo_root}/bin" app_binary_path="${app_bin_dir}/taskers-gtk" +desktop_wrapper_path="${app_bin_dir}/taskers-gtk-desktop-launch" mkdir -p "${app_bin_dir}" "$(dirname -- "${desktop_entry_path}")" @@ -35,19 +36,28 @@ if [[ ! -x "${app_binary_path}" ]]; then exit 1 fi +cat > "${desktop_wrapper_path}" <>"\${log_dir}/desktop-launch.log" 2>&1 +EOF +chmod +x "${desktop_wrapper_path}" + cat > "${desktop_entry_path}" </dev/null 2>&1; then fi echo "installed ${app_binary_path}" +echo "installed ${desktop_wrapper_path}" echo "installed ${desktop_entry_path}" diff --git a/vendor/ghostty/src/taskers_bridge.zig b/vendor/ghostty/src/taskers_bridge.zig index 8335cd3..0d507bc 100644 --- a/vendor/ghostty/src/taskers_bridge.zig +++ b/vendor/ghostty/src/taskers_bridge.zig @@ -218,7 +218,7 @@ fn taskersSurfaceConfig(app: anytype, ptr: *const Host, opts: *const SurfaceOpti fn applyTaskersEmbeddedSurfaceInvariants( _: std.mem.Allocator, config: *configpkg.Config, - command_argv: []const [:0]u8, + command_argv: []const [:0]const u8, ) !void { const alloc = config.arenaAlloc(); @@ -231,13 +231,6 @@ fn applyTaskersEmbeddedSurfaceInvariants( config.@"shell-integration" = .none; config.@"shell-integration-features" = .{}; config.@"linux-cgroup" = .never; - - // Embedded Taskers panes already supply their own chrome and spacing. - // Ghostty's default window padding makes the terminal grid float inside - // the pane body and visibly misalign with the shell layout. - config.@"window-padding-x" = .{ .top_left = 0, .bottom_right = 0 }; - config.@"window-padding-y" = .{ .top_left = 0, .bottom_right = 0 }; - config.@"window-padding-balance" = false; } fn duplicateStringList( @@ -293,11 +286,11 @@ test "taskers embedded config preserves user settings beyond required invariants try testing.expectEqual(configpkg.Config.ShellIntegration.none, config.@"shell-integration"); try testing.expectEqual(configpkg.ShellIntegrationFeatures{}, config.@"shell-integration-features"); try testing.expectEqual(configpkg.Config.LinuxCgroup.never, config.@"linux-cgroup"); - try testing.expectEqual(@as(u32, 0), config.@"window-padding-x".top_left); - try testing.expectEqual(@as(u32, 0), config.@"window-padding-x".bottom_right); - try testing.expectEqual(@as(u32, 0), config.@"window-padding-y".top_left); - try testing.expectEqual(@as(u32, 0), config.@"window-padding-y".bottom_right); - try testing.expect(!config.@"window-padding-balance"); + try testing.expectEqual(@as(u32, 7), config.@"window-padding-x".top_left); + try testing.expectEqual(@as(u32, 9), config.@"window-padding-x".bottom_right); + try testing.expectEqual(@as(u32, 11), config.@"window-padding-y".top_left); + try testing.expectEqual(@as(u32, 13), config.@"window-padding-y".bottom_right); + try testing.expect(config.@"window-padding-balance"); const command = config.command orelse return error.TestUnexpectedResult; switch (command) { From 190ac5527cafab34652e4756804eaf9b8a7aef39 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Fri, 27 Mar 2026 18:35:56 +0100 Subject: [PATCH 59/63] chore(release): bump workspace version to 0.3.1 --- Cargo.lock | 24 ++++++++++++------------ Cargo.toml | 20 ++++++++++---------- crates/taskers-cli/Cargo.toml | 4 ++-- crates/taskers-control/Cargo.toml | 2 +- crates/taskers-core/Cargo.toml | 8 ++++---- crates/taskers-ghostty/Cargo.toml | 4 ++-- crates/taskers-runtime/Cargo.toml | 2 +- 7 files changed, 32 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 17881f6..8d343c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2399,7 +2399,7 @@ checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" [[package]] name = "taskers" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "serde", @@ -2414,7 +2414,7 @@ dependencies = [ [[package]] name = "taskers-cli" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "clap", @@ -2427,7 +2427,7 @@ dependencies = [ [[package]] name = "taskers-control" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "serde", @@ -2442,7 +2442,7 @@ dependencies = [ [[package]] name = "taskers-core" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "serde", @@ -2457,7 +2457,7 @@ dependencies = [ [[package]] name = "taskers-domain" -version = "0.3.0" +version = "0.3.1" dependencies = [ "indexmap", "serde", @@ -2469,7 +2469,7 @@ dependencies = [ [[package]] name = "taskers-ghostty" -version = "0.3.0" +version = "0.3.1" dependencies = [ "gtk4", "libloading", @@ -2486,7 +2486,7 @@ dependencies = [ [[package]] name = "taskers-gtk" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "axum", @@ -2513,7 +2513,7 @@ dependencies = [ [[package]] name = "taskers-host" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "gtk4", @@ -2527,11 +2527,11 @@ dependencies = [ [[package]] name = "taskers-paths" -version = "0.3.0" +version = "0.3.1" [[package]] name = "taskers-runtime" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "base64", @@ -2543,7 +2543,7 @@ dependencies = [ [[package]] name = "taskers-shell" -version = "0.3.0" +version = "0.3.1" dependencies = [ "dioxus", "taskers-shell-core", @@ -2551,7 +2551,7 @@ dependencies = [ [[package]] name = "taskers-shell-core" -version = "0.3.0" +version = "0.3.1" dependencies = [ "parking_lot", "taskers-control", diff --git a/Cargo.toml b/Cargo.toml index a432f64..d83eb29 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ edition = "2024" homepage = "https://github.com/OneNoted/taskers" license = "MIT OR Apache-2.0" repository = "https://github.com/OneNoted/taskers" -version = "0.3.0" +version = "0.3.1" [workspace.dependencies] adw = { package = "libadwaita", version = "0.9.1" } @@ -47,12 +47,12 @@ ureq = "2.12" uuid = { version = "1.22.0", features = ["serde", "v7"] } webkit6 = { version = "0.6.1", features = ["v2_50"] } xz2 = "0.1" -taskers-core = { version = "0.3.0", path = "crates/taskers-core" } -taskers-control = { version = "0.3.0", path = "crates/taskers-control" } -taskers-domain = { version = "0.3.0", path = "crates/taskers-domain" } -taskers-ghostty = { version = "0.3.0", path = "crates/taskers-ghostty" } -taskers-host = { version = "0.3.0", path = "crates/taskers-host" } -taskers-paths = { version = "0.3.0", path = "crates/taskers-paths" } -taskers-runtime = { version = "0.3.0", path = "crates/taskers-runtime" } -taskers-shell = { version = "0.3.0", path = "crates/taskers-shell" } -taskers-shell-core = { version = "0.3.0", path = "crates/taskers-shell-core" } +taskers-core = { version = "0.3.1", path = "crates/taskers-core" } +taskers-control = { version = "0.3.1", path = "crates/taskers-control" } +taskers-domain = { version = "0.3.1", path = "crates/taskers-domain" } +taskers-ghostty = { version = "0.3.1", path = "crates/taskers-ghostty" } +taskers-host = { version = "0.3.1", path = "crates/taskers-host" } +taskers-paths = { version = "0.3.1", path = "crates/taskers-paths" } +taskers-runtime = { version = "0.3.1", path = "crates/taskers-runtime" } +taskers-shell = { version = "0.3.1", path = "crates/taskers-shell" } +taskers-shell-core = { version = "0.3.1", path = "crates/taskers-shell-core" } diff --git a/crates/taskers-cli/Cargo.toml b/crates/taskers-cli/Cargo.toml index 551ecc2..5716553 100644 --- a/crates/taskers-cli/Cargo.toml +++ b/crates/taskers-cli/Cargo.toml @@ -18,5 +18,5 @@ clap.workspace = true serde_json.workspace = true time.workspace = true tokio.workspace = true -taskers-control = { version = "0.3.0", path = "../taskers-control" } -taskers-domain = { version = "0.3.0", path = "../taskers-domain" } +taskers-control = { version = "0.3.1", path = "../taskers-control" } +taskers-domain = { version = "0.3.1", path = "../taskers-domain" } diff --git a/crates/taskers-control/Cargo.toml b/crates/taskers-control/Cargo.toml index ad5d454..3c1942f 100644 --- a/crates/taskers-control/Cargo.toml +++ b/crates/taskers-control/Cargo.toml @@ -16,7 +16,7 @@ thiserror.workspace = true tokio.workspace = true uuid.workspace = true taskers-paths.workspace = true -taskers-domain = { version = "0.3.0", path = "../taskers-domain" } +taskers-domain = { version = "0.3.1", path = "../taskers-domain" } [dev-dependencies] tempfile.workspace = true diff --git a/crates/taskers-core/Cargo.toml b/crates/taskers-core/Cargo.toml index a89788b..185899b 100644 --- a/crates/taskers-core/Cargo.toml +++ b/crates/taskers-core/Cargo.toml @@ -12,11 +12,11 @@ version.workspace = true anyhow.workspace = true serde.workspace = true serde_json.workspace = true -taskers-control = { version = "0.3.0", path = "../taskers-control" } -taskers-domain = { version = "0.3.0", path = "../taskers-domain" } -taskers-ghostty = { version = "0.3.0", path = "../taskers-ghostty" } +taskers-control = { version = "0.3.1", path = "../taskers-control" } +taskers-domain = { version = "0.3.1", path = "../taskers-domain" } +taskers-ghostty = { version = "0.3.1", path = "../taskers-ghostty" } taskers-paths.workspace = true -taskers-runtime = { version = "0.3.0", path = "../taskers-runtime" } +taskers-runtime = { version = "0.3.1", path = "../taskers-runtime" } [dev-dependencies] tempfile.workspace = true diff --git a/crates/taskers-ghostty/Cargo.toml b/crates/taskers-ghostty/Cargo.toml index 893ed67..f5121ff 100644 --- a/crates/taskers-ghostty/Cargo.toml +++ b/crates/taskers-ghostty/Cargo.toml @@ -13,9 +13,9 @@ build = "build.rs" libloading = "0.8" serde.workspace = true tar = "0.4" -taskers-domain = { version = "0.3.0", path = "../taskers-domain" } +taskers-domain = { version = "0.3.1", path = "../taskers-domain" } taskers-paths.workspace = true -taskers-runtime = { version = "0.3.0", path = "../taskers-runtime" } +taskers-runtime = { version = "0.3.1", path = "../taskers-runtime" } thiserror.workspace = true ureq = "2.12" xz2 = "0.1" diff --git a/crates/taskers-runtime/Cargo.toml b/crates/taskers-runtime/Cargo.toml index 3b6c415..3c091a2 100644 --- a/crates/taskers-runtime/Cargo.toml +++ b/crates/taskers-runtime/Cargo.toml @@ -14,4 +14,4 @@ base64.workspace = true libc.workspace = true portable-pty.workspace = true taskers-paths.workspace = true -taskers-domain = { version = "0.3.0", path = "../taskers-domain" } +taskers-domain = { version = "0.3.1", path = "../taskers-domain" } From 1e874a7a9a04b13c3ec96bc00170fcdd84d16b99 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Fri, 27 Mar 2026 19:03:29 +0100 Subject: [PATCH 60/63] fix(ci): stabilize release asset workflow --- .github/workflows/release-assets.yml | 15 +++++++++++---- docs/release.md | 1 + 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index c3643ef..f46dd08 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -54,6 +54,7 @@ jobs: - name: Run headless app smoke run: | TASKERS_TERMINAL_BACKEND=mock \ + TIMEOUT_SECONDS=90 \ bash scripts/headless-smoke.sh \ ./target/debug/taskers-gtk \ --smoke-script baseline \ @@ -63,14 +64,19 @@ jobs: - name: Build Linux bundle run: bash scripts/build_linux_bundle.sh + - name: Build Ghostty runtime bundle + run: bash scripts/build_ghostty_runtime_bundle.sh + - name: Run launcher smoke - run: bash scripts/smoke_linux_release_launcher.sh + run: TIMEOUT_SECONDS=90 bash scripts/smoke_linux_release_launcher.sh - - name: Upload Linux bundle + - name: Upload release assets uses: actions/upload-artifact@v4 with: - name: linux-bundle - path: dist/taskers-linux-bundle-v*.tar.xz + name: release-assets + path: | + dist/taskers-linux-bundle-v*.tar.xz + dist/taskers-ghostty-runtime-v*.tar.xz release-manifest: needs: @@ -124,6 +130,7 @@ jobs: echo 'files<> "$GITHUB_OUTPUT" diff --git a/docs/release.md b/docs/release.md index 5baad58..4d94af7 100644 --- a/docs/release.md +++ b/docs/release.md @@ -85,6 +85,7 @@ cargo run -p taskers-cli -- notify --help - Confirm the draft release tagged `v` contains: - `taskers-manifest-v.json` - `taskers-linux-bundle-v-x86_64-unknown-linux-gnu.tar.xz` + - `taskers-ghostty-runtime-v-x86_64-unknown-linux-gnu.tar.xz` - Publish the GitHub release so the launcher assets are publicly downloadable before publishing the crates. - Publish the crates to crates.io in dependency order: From 250ec2afb63ef622c605d017f7edd87cfbb8fce1 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Fri, 27 Mar 2026 19:43:14 +0100 Subject: [PATCH 61/63] fix(launcher): migrate legacy desktop entries --- crates/taskers-launcher/src/lib.rs | 56 +++++++++++++++++++++++------ crates/taskers-runtime/src/shell.rs | 5 +-- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/crates/taskers-launcher/src/lib.rs b/crates/taskers-launcher/src/lib.rs index 3905fee..fc2ba66 100644 --- a/crates/taskers-launcher/src/lib.rs +++ b/crates/taskers-launcher/src/lib.rs @@ -234,7 +234,10 @@ impl ManagedInstallation { .with_context(|| format!("failed to create {}", xdg_bin_home.display()))?; let desktop_launcher = xdg_bin_home.join("taskers-desktop-launch"); - write_executable(&desktop_launcher, &desktop_launch_wrapper_contents(&launcher))?; + write_executable( + &desktop_launcher, + &desktop_launch_wrapper_contents(&launcher), + )?; let desktop_entry_path = applications_dir.join("dev.taskers.app.desktop"); let desktop_entry = include_str!(concat!( @@ -242,7 +245,7 @@ impl ManagedInstallation { "/assets/taskers.desktop.in" )) .replace("{{EXEC}}", &desktop_exec(&desktop_launcher)); - if should_update_desktop_entry(&desktop_entry_path, &desktop_launcher)? { + if should_update_desktop_entry(&desktop_entry_path, &desktop_launcher, &launcher)? { fs::write(&desktop_entry_path, desktop_entry) .with_context(|| format!("failed to write {}", applications_dir.display()))?; } @@ -464,7 +467,11 @@ fn desktop_launch_wrapper_contents(target: &Path) -> String { ) } -fn should_update_desktop_entry(path: &Path, desktop_launcher: &Path) -> Result { +fn should_update_desktop_entry( + path: &Path, + desktop_launcher: &Path, + launcher: &Path, +) -> Result { let Ok(existing) = fs::read_to_string(path) else { return Ok(true); }; @@ -476,7 +483,11 @@ fn should_update_desktop_entry(path: &Path, desktop_launcher: &Path) -> Result Result<()> { @@ -588,13 +599,18 @@ where mod tests { use super::{ ArtifactKind, ManagedInstallation, ReleaseArtifact, ReleaseManifest, bundle_root, - current_target_triple, default_manifest_url, desktop_exec, - desktop_launch_wrapper_contents, launcher_path_looks_installed, path_taskers_executable, - sha256_path, should_update_desktop_entry, + current_target_triple, default_manifest_url, desktop_exec, desktop_launch_wrapper_contents, + launcher_path_looks_installed, path_taskers_executable, sha256_path, + should_update_desktop_entry, }; #[cfg(unix)] use std::os::unix::fs::symlink; - use std::{collections::BTreeMap, ffi::OsString, fs, path::{Path, PathBuf}}; + use std::{ + collections::BTreeMap, + ffi::OsString, + fs, + path::{Path, PathBuf}, + }; use tar::Builder; use tempfile::tempdir; use xz2::write::XzEncoder; @@ -748,7 +764,7 @@ mod tests { let launcher = PathBuf::from("/home/notes/.local/bin/taskers"); assert!( - !should_update_desktop_entry(&desktop_entry, &launcher).expect("decision"), + !should_update_desktop_entry(&desktop_entry, &launcher, &launcher).expect("decision"), ); } @@ -763,7 +779,27 @@ mod tests { ) .expect("desktop entry"); - assert!(should_update_desktop_entry(&desktop_entry, &launcher).expect("decision")); + assert!( + should_update_desktop_entry(&desktop_entry, &launcher, &launcher).expect("decision") + ); + } + + #[test] + fn updates_legacy_launcher_desktop_entry() { + let temp = tempdir().expect("tempdir"); + let desktop_entry = temp.path().join("dev.taskers.app.desktop"); + let desktop_launcher = PathBuf::from("/home/notes/.local/bin/taskers-desktop-launch"); + let launcher = PathBuf::from("/home/notes/.cargo/bin/taskers"); + fs::write( + &desktop_entry, + format!("[Desktop Entry]\nExec={}\n", desktop_exec(&launcher)), + ) + .expect("desktop entry"); + + assert!( + should_update_desktop_entry(&desktop_entry, &desktop_launcher, &launcher) + .expect("decision") + ); } #[test] diff --git a/crates/taskers-runtime/src/shell.rs b/crates/taskers-runtime/src/shell.rs index 7b259a5..7aed7bb 100644 --- a/crates/taskers-runtime/src/shell.rs +++ b/crates/taskers-runtime/src/shell.rs @@ -1096,8 +1096,9 @@ mod tests { let log = fs::read_to_string(&test_log) .unwrap_or_else(|error| panic!("read lifecycle log failed: {error}; output={output}")); - let claude_args = fs::read_to_string(&args_log) - .unwrap_or_else(|error| panic!("read claude args log failed: {error}; output={output}")); + let claude_args = fs::read_to_string(&args_log).unwrap_or_else(|error| { + panic!("read claude args log failed: {error}; output={output}") + }); assert!( claude_args.contains("--settings"), "expected claude wrapper to inject hook settings, got: {claude_args}" From cfdc45e03d1f863516c1a396a64bd4a9028f0a96 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Fri, 27 Mar 2026 19:44:19 +0100 Subject: [PATCH 62/63] fix(runtime): harden agent shims and kitty notifications --- .../assets/shell/taskers-agent-claude.sh | 24 ++- crates/taskers-runtime/src/shell.rs | 72 +++++++- crates/taskers-runtime/src/signals.rs | 162 +++++++++++++++--- 3 files changed, 228 insertions(+), 30 deletions(-) diff --git a/crates/taskers-runtime/assets/shell/taskers-agent-claude.sh b/crates/taskers-runtime/assets/shell/taskers-agent-claude.sh index 060001f..5074f60 100644 --- a/crates/taskers-runtime/assets/shell/taskers-agent-claude.sh +++ b/crates/taskers-runtime/assets/shell/taskers-agent-claude.sh @@ -6,19 +6,35 @@ SCRIPT_PATH=$(readlink -f -- "$0" 2>/dev/null || realpath "$0" 2>/dev/null || pr SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd) PROXY_PATH="$SCRIPT_DIR/taskers-agent-proxy.sh" HOOK_SCRIPT="$SCRIPT_DIR/taskers-claude-hook.sh" +INVOKED_NAME=$(basename -- "$0") + +proxy_target=$INVOKED_NAME +case "$proxy_target" in + taskers-agent-claude.sh) proxy_target=claude ;; +esac json_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' } +shell_single_quote() { + printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\"'\"'/g")" +} + +hook_command() { + printf '%s %s' "$(shell_single_quote "$HOOK_SCRIPT")" "$1" +} + if [ "${TASKERS_CLAUDE_HOOKS_DISABLED:-0}" = "1" ]; then - exec env TASKERS_AGENT_PROXY_TARGET=claude TASKERS_AGENT_PROXY_SHIM_DIR="$INVOKED_DIR" "$PROXY_PATH" "$@" + exec env TASKERS_AGENT_PROXY_TARGET="$proxy_target" TASKERS_AGENT_PROXY_SHIM_DIR="$INVOKED_DIR" "$PROXY_PATH" "$@" fi -hook_script_escaped=$(json_escape "$HOOK_SCRIPT") +user_prompt_submit_command=$(json_escape "$(hook_command user-prompt-submit)") +notification_command=$(json_escape "$(hook_command notification)") +stop_command=$(json_escape "$(hook_command stop)") hooks_json=$(cat <> \"$FAKE_CLAUDE_CAPTURE\"\nprintf '%s\\n' \"$@\" >> \"$FAKE_CLAUDE_CAPTURE\"\nexit 0\n", + ); + + let original_path = std::env::var_os("PATH"); + let shim_path = runtime_root.join("bin").join("claude-code"); + let output = Command::new(&shim_path) + .env( + "PATH", + format!( + "{}:{}", + real_bin_dir.display(), + original_path + .as_deref() + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_default() + ), + ) + .env("FAKE_CLAUDE_CAPTURE", &capture_path) + .arg("--help") + .output() + .expect("run claude-code shim"); + + assert!( + output.status.success(), + "expected claude-code shim to succeed, stdout={}, stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let capture = fs::read_to_string(&capture_path).expect("read capture log"); + let hook_path = runtime_root.join("taskers-claude-hook.sh"); + assert!( + capture.contains("target=claude-code"), + "expected shim to preserve the invoked claude-code lookup target, got: {capture}" + ); + assert!( + capture.contains("--settings"), + "expected claude-code shim to forward hook settings, got: {capture}" + ); + assert!( + capture.contains(&format!("'{}' user-prompt-submit", hook_path.display())), + "expected claude-code hook path to be single-quoted inside settings, got: {capture}" + ); + + restore_env_var("PATH", original_path); + fs::remove_dir_all(&runtime_root).expect("cleanup runtime root"); + } + #[test] fn embedded_zsh_ctrl_c_reports_interrupted_surface_stop_via_proxy() { let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner()); diff --git a/crates/taskers-runtime/src/signals.rs b/crates/taskers-runtime/src/signals.rs index 89c4699..244793d 100644 --- a/crates/taskers-runtime/src/signals.rs +++ b/crates/taskers-runtime/src/signals.rs @@ -42,9 +42,20 @@ impl ParsedSignal { #[derive(Debug, Default, Clone)] struct NotificationDraft { - title: Option, - subtitle: Option, - body: Option, + title: NotificationFieldDraft, + subtitle: NotificationFieldDraft, + body: NotificationFieldDraft, +} + +#[derive(Debug, Default, Clone)] +struct NotificationFieldDraft { + fragments: Vec, +} + +#[derive(Debug, Clone)] +struct NotificationFragment { + payload: String, + encoded: bool, } pub fn parse_terminal_events(buffer: &str) -> Vec { @@ -221,6 +232,7 @@ impl SignalStreamParser { let mut external_id = None; let mut part = None; let mut done = None; + let mut encoded = false; for token in param_tokens { let (key, value) = token.split_once('=')?; @@ -238,7 +250,9 @@ impl SignalStreamParser { _ => None, }; } - "e" => {} + "e" => { + encoded = value == "1"; + } _ => {} } } @@ -250,22 +264,25 @@ impl SignalStreamParser { let payload = Some(payload.to_string()).filter(|value| !value.is_empty()); match part.as_deref() { - Some("title") => draft.title = payload, - Some("subtitle") => draft.subtitle = payload, - Some("body") => draft.body = payload, - Some(_) => {} - None => { + Some("title") | None => { + if let Some(payload) = payload { + draft.title.push(payload, encoded); + } + } + Some("subtitle") => { if let Some(payload) = payload { - if draft.title.is_none() { - draft.title = Some(payload); - } else { - draft.body = Some(payload); - } + draft.subtitle.push(payload, encoded); } } + Some("body") => { + if let Some(payload) = payload { + draft.body.push(payload, encoded); + } + } + Some(_) => {} } - let should_defer = matches!(done, Some(false)) && part.is_some(); + let should_defer = matches!(done, Some(false)); if should_defer { if let Some(external_id) = external_id { self.kitty_notification_drafts.insert(external_id, draft); @@ -273,14 +290,18 @@ impl SignalStreamParser { return None; } - if draft.title.is_none() && draft.subtitle.is_none() && draft.body.is_none() { + let title = draft.title.into_value(); + let subtitle = draft.subtitle.into_value(); + let body = draft.body.into_value(); + + if title.is_none() && subtitle.is_none() && body.is_none() { return None; } Some(ParsedNotification { - title: draft.title, - subtitle: draft.subtitle, - body: draft.body, + title, + subtitle, + body, external_id, }) } @@ -343,10 +364,59 @@ fn parse_bool(value: &str) -> Option { } fn decode_base64(value: &str) -> Option { - let decoded = STANDARD.decode(value).ok()?; + let mut normalized = value.to_string(); + let missing_padding = normalized.len() % 4; + if missing_padding != 0 { + normalized.extend(std::iter::repeat_n('=', 4 - missing_padding)); + } + let decoded = STANDARD.decode(normalized).ok()?; String::from_utf8(decoded).ok() } +impl NotificationFieldDraft { + fn push(&mut self, payload: String, encoded: bool) { + self.fragments + .push(NotificationFragment { payload, encoded }); + } + + fn into_value(self) -> Option { + let mut combined = String::new(); + let mut pending = String::new(); + let mut pending_encoded = None; + + for fragment in self.fragments { + match pending_encoded { + Some(current_encoded) if current_encoded == fragment.encoded => { + pending.push_str(&fragment.payload); + } + Some(current_encoded) => { + combined.push_str(&decode_notification_payload(current_encoded, &pending)?); + pending = fragment.payload; + pending_encoded = Some(fragment.encoded); + } + None => { + pending = fragment.payload; + pending_encoded = Some(fragment.encoded); + } + } + } + + if let Some(current_encoded) = pending_encoded { + combined.push_str(&decode_notification_payload(current_encoded, &pending)?); + } + + Some(combined).filter(|value| !value.is_empty()) + } +} + +fn decode_notification_payload(encoded: bool, payload: &str) -> Option { + if encoded { + decode_base64(payload) + } else { + Some(payload.to_string()) + } +} + fn percent_decode(value: &str) -> Option { let mut bytes = Vec::with_capacity(value.len()); let raw = value.as_bytes(); @@ -551,8 +621,32 @@ mod tests { } #[test] - fn parses_doc_style_kitty_notification_payloads() { - let frames = parse_terminal_events("\u{1b}]99;i=1;e=1;d=0:Hello World\u{1b}\\"); + fn defers_title_first_chunked_kitty_notification_frames_without_part() { + let mut parser = SignalStreamParser::default(); + + assert!( + parser + .push_events("\u{1b}]99;i=kitty;d=0:Kitty Title \u{1b}\\") + .is_empty() + ); + + let frames = parser.push_events("\u{1b}]99;i=kitty;p=body;e=1:Qm9keQ\u{1b}\\"); + assert_eq!( + frames, + vec![ParsedTerminalEvent::Notification( + super::ParsedNotification { + title: Some("Kitty Title ".into()), + subtitle: None, + body: Some("Body".into()), + external_id: Some("kitty".into()), + } + )] + ); + } + + #[test] + fn parses_encoded_kitty_notification_payloads() { + let frames = parse_terminal_events("\u{1b}]99;i=1;e=1:SGVsbG8gV29ybGQ\u{1b}\\"); assert_eq!( frames, @@ -566,4 +660,28 @@ mod tests { )] ); } + + #[test] + fn concatenates_encoded_kitty_notification_chunks_before_decoding() { + let mut parser = SignalStreamParser::default(); + + assert!( + parser + .push_events("\u{1b}]99;i=kitty;e=1;d=0:SGVsbG8g\u{1b}\\") + .is_empty() + ); + + let frames = parser.push_events("\u{1b}]99;i=kitty;e=1:V29ybGQ\u{1b}\\"); + assert_eq!( + frames, + vec![ParsedTerminalEvent::Notification( + super::ParsedNotification { + title: Some("Hello World".into()), + subtitle: None, + body: None, + external_id: Some("kitty".into()), + } + )] + ); + } } From a42a93658d15d5b7105d25303cb9c87b29919b51 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Fri, 27 Mar 2026 20:02:00 +0100 Subject: [PATCH 63/63] fix(pr): address follow-up review comments --- crates/taskers-cli/src/main.rs | 206 +++++++++++++++++++++-- crates/taskers-control/src/controller.rs | 99 ++++++++++- 2 files changed, 287 insertions(+), 18 deletions(-) diff --git a/crates/taskers-cli/src/main.rs b/crates/taskers-cli/src/main.rs index e176e07..d2161d6 100644 --- a/crates/taskers-cli/src/main.rs +++ b/crates/taskers-cli/src/main.rs @@ -2104,6 +2104,31 @@ async fn query_model(client: &ControlClient) -> anyhow::Result { } } +async fn resolve_surface_context( + client: &ControlClient, + surface_id: SurfaceId, +) -> anyhow::Result<(WorkspaceId, PaneId, SurfaceId)> { + let response = send_control_command( + client, + ControlCommand::QueryStatus { + query: ControlQuery::Identify { + workspace_id: None, + pane_id: None, + surface_id: Some(surface_id), + }, + }, + ) + .await?; + + let ControlResponse::Identify { result } = response else { + bail!("unexpected identify response: {response:?}"); + }; + let caller = result + .caller + .ok_or_else(|| anyhow!("missing identify caller context for surface {surface_id}"))?; + Ok((caller.workspace_id, caller.pane_id, caller.surface_id)) +} + fn active_surface_for_pane( model: &AppModel, workspace_id: WorkspaceId, @@ -2956,11 +2981,20 @@ async fn emit_agent_hook( ) .await?; + let (resolved_workspace_id, resolved_pane_id, resolved_surface_id) = match surface_id { + Some(surface_id) => { + let (workspace_id, pane_id, surface_id) = + resolve_surface_context(&client, surface_id).await?; + (workspace_id, pane_id, Some(surface_id)) + } + None => (workspace_id, pane_id, surface_id), + }; + if let Some(log_message) = normalized_message.clone() { let _ = send_control_command( &client, ControlCommand::AgentAppendLog { - workspace_id, + workspace_id: resolved_workspace_id, entry: WorkspaceLogEntry { source: Some(normalized_agent.clone()), message: log_message, @@ -2976,7 +3010,7 @@ async fn emit_agent_hook( let _ = send_control_command( &client, ControlCommand::AgentSetStatus { - workspace_id, + workspace_id: resolved_workspace_id, text: status_text, }, ) @@ -2986,7 +3020,7 @@ async fn emit_agent_hook( let _ = send_control_command( &client, ControlCommand::AgentSetStatus { - workspace_id, + workspace_id: resolved_workspace_id, text: status_text.clone(), }, ) @@ -2996,26 +3030,32 @@ async fn emit_agent_hook( if matches!(kind, CliSignalKind::Completed) { let _ = send_control_command( &client, - ControlCommand::AgentClearStatus { workspace_id }, + ControlCommand::AgentClearStatus { + workspace_id: resolved_workspace_id, + }, ) .await?; let _ = send_control_command( &client, - ControlCommand::AgentClearProgress { workspace_id }, + ControlCommand::AgentClearProgress { + workspace_id: resolved_workspace_id, + }, ) .await?; } else { let _ = send_control_command( &client, ControlCommand::AgentSetStatus { - workspace_id, + workspace_id: resolved_workspace_id, text: status_text, }, ) .await?; let _ = send_control_command( &client, - ControlCommand::AgentClearProgress { workspace_id }, + ControlCommand::AgentClearProgress { + workspace_id: resolved_workspace_id, + }, ) .await?; } @@ -3027,19 +3067,23 @@ async fn emit_agent_hook( kind, CliSignalKind::WaitingInput | CliSignalKind::Notification | CliSignalKind::Error ) { - let flash_surface_id = match surface_id.or_else(env_surface_id) { + let flash_surface_id = match resolved_surface_id.or_else(env_surface_id) { Some(surface_id) => Some(surface_id), None => { let model = query_model(&client).await?; - Some(active_surface_for_pane(&model, workspace_id, pane_id)?) + Some(active_surface_for_pane( + &model, + resolved_workspace_id, + resolved_pane_id, + )?) } }; if let Some(surface_id) = flash_surface_id { let _ = send_control_command( &client, ControlCommand::AgentTriggerFlash { - workspace_id, - pane_id, + workspace_id: resolved_workspace_id, + pane_id: resolved_pane_id, surface_id, }, ) @@ -3064,17 +3108,34 @@ fn infer_agent_kind(value: &str) -> Option { #[cfg(test)] mod tests { - use std::sync::Mutex; + use std::{ + path::PathBuf, + sync::Mutex, + time::{SystemTime, UNIX_EPOCH}, + }; - use taskers_control::{BrowserTarget, BrowserWaitCondition}; + use taskers_control::{ + BrowserTarget, BrowserWaitCondition, ControlCommand, InMemoryController, bind_socket, serve, + }; + use taskers_domain::{AppModel, PaneKind}; + use tokio::sync::oneshot; use super::{ - CliBrowserLoadState, ensure_implicit_notify_target_context, env_pane_id, env_surface_id, - env_workspace_id, infer_agent_kind, resolve_browser_target, resolve_wait_condition, + CliBrowserLoadState, CliSignalKind, emit_agent_hook, ensure_implicit_notify_target_context, + env_pane_id, env_surface_id, env_workspace_id, infer_agent_kind, resolve_browser_target, + resolve_wait_condition, }; static ENV_LOCK: Mutex<()> = Mutex::new(()); + fn unique_temp_dir(prefix: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time") + .as_nanos(); + std::env::temp_dir().join(format!("{prefix}-{unique}")) + } + #[test] fn infers_known_agent_names() { assert_eq!(infer_agent_kind("Codex"), Some("codex".into())); @@ -3196,6 +3257,121 @@ mod tests { } } + #[tokio::test] + async fn agent_hook_status_and_logs_follow_surface_workspace_after_move() { + let tempdir = unique_temp_dir("taskers-cli-agent-hook"); + std::fs::create_dir_all(&tempdir).expect("tempdir"); + let socket_path = tempdir.join("taskers.sock"); + let listener = bind_socket(&socket_path).expect("listener"); + let controller = InMemoryController::new(AppModel::new("Main")); + let snapshot = controller.snapshot(); + let source_workspace = snapshot.model.active_workspace().expect("workspace"); + let source_workspace_id = source_workspace.id; + let source_pane_id = source_workspace.active_pane; + + controller + .handle(ControlCommand::CreateSurface { + workspace_id: source_workspace_id, + pane_id: source_pane_id, + kind: PaneKind::Browser, + }) + .expect("create surface"); + let moved_surface_id = controller + .snapshot() + .model + .workspaces + .get(&source_workspace_id) + .and_then(|workspace| workspace.panes.get(&source_pane_id)) + .map(|pane| pane.active_surface) + .expect("moved surface"); + + controller + .handle(ControlCommand::CreateWorkspace { + label: "Docs".into(), + }) + .expect("create target workspace"); + let target_workspace_id = controller + .snapshot() + .model + .active_workspace_id() + .expect("target workspace"); + + controller + .handle(ControlCommand::MoveSurfaceToWorkspace { + source_workspace_id, + source_pane_id, + surface_id: moved_surface_id, + target_workspace_id, + }) + .expect("move surface"); + + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + let server = tokio::spawn(serve(listener, controller.clone(), async move { + let _ = shutdown_rx.await; + })); + + emit_agent_hook( + Some(socket_path.clone()), + Some(source_workspace_id), + Some(source_pane_id), + Some(moved_surface_id), + Some("codex".into()), + Some("Codex".into()), + Some("Turn complete".into()), + CliSignalKind::Notification, + ) + .await + .expect("emit agent hook"); + + let snapshot = controller.snapshot(); + let source_workspace_after = snapshot + .model + .workspaces + .get(&source_workspace_id) + .expect("source workspace"); + let target_workspace_after = snapshot + .model + .workspaces + .get(&target_workspace_id) + .expect("target workspace"); + + assert_eq!(source_workspace_after.status_text, None); + assert!( + source_workspace_after.log_entries.is_empty(), + "expected source workspace log to stay empty" + ); + assert_eq!( + target_workspace_after.status_text.as_deref(), + Some("Turn complete") + ); + assert_eq!(target_workspace_after.log_entries.len(), 1); + assert_eq!( + target_workspace_after.log_entries[0].message, + "Turn complete" + ); + + let target_surface = target_workspace_after + .panes + .values() + .flat_map(|pane| pane.surfaces.values()) + .find(|surface| surface.id == moved_surface_id) + .expect("target surface"); + assert_eq!( + target_surface.metadata.latest_agent_message.as_deref(), + Some("Turn complete") + ); + assert!( + target_workspace_after + .surface_flash_tokens + .contains_key(&moved_surface_id), + "expected flash token on moved target surface" + ); + + shutdown_tx.send(()).expect("shutdown"); + server.await.expect("server task").expect("serve cleanly"); + std::fs::remove_dir_all(&tempdir).expect("cleanup tempdir"); + } + #[test] fn browser_targets_require_exactly_one_selector_or_ref() { let target = resolve_browser_target(Some("@e1".into()), None, true).expect("target"); diff --git a/crates/taskers-control/src/controller.rs b/crates/taskers-control/src/controller.rs index 4b393dd..3072887 100644 --- a/crates/taskers-control/src/controller.rs +++ b/crates/taskers-control/src/controller.rs @@ -396,12 +396,18 @@ impl InMemoryController { ) } ControlCommand::StopSurfaceAgentSession { - workspace_id, - pane_id, + workspace_id: _, + pane_id: _, surface_id, exit_status, } => { - model.stop_surface_agent_session(workspace_id, pane_id, surface_id, exit_status)?; + let current = resolve_identify_context(model, None, None, Some(surface_id))?; + model.stop_surface_agent_session( + current.workspace_id, + current.pane_id, + surface_id, + exit_status, + )?; ( ControlResponse::Ack { message: "surface agent session stopped".into(), @@ -1081,6 +1087,93 @@ mod tests { ); } + #[test] + fn surface_agent_stop_follows_a_moved_surface_even_with_stale_pane_context() { + let controller = InMemoryController::new(AppModel::new("Main")); + let snapshot = controller.snapshot(); + let source_workspace = snapshot.model.active_workspace().expect("workspace"); + let source_workspace_id = source_workspace.id; + let source_pane_id = source_workspace.active_pane; + + let moved_surface_id = source_workspace + .panes + .get(&source_pane_id) + .and_then(|pane| pane.active_surface()) + .map(|surface| surface.id) + .expect("surface"); + + controller + .handle(ControlCommand::StartSurfaceAgentSession { + workspace_id: source_workspace_id, + pane_id: source_pane_id, + surface_id: moved_surface_id, + agent_kind: "codex".into(), + }) + .expect("start surface agent session"); + + controller + .handle(ControlCommand::CreateWorkspace { + label: "Docs".into(), + }) + .expect("create target workspace"); + let target_workspace_id = controller + .snapshot() + .model + .active_workspace_id() + .expect("target workspace"); + + controller + .handle(ControlCommand::MoveSurfaceToWorkspace { + source_workspace_id, + source_pane_id, + surface_id: moved_surface_id, + target_workspace_id, + }) + .expect("move surface"); + + controller + .handle(ControlCommand::StopSurfaceAgentSession { + workspace_id: source_workspace_id, + pane_id: source_pane_id, + surface_id: moved_surface_id, + exit_status: 1, + }) + .expect("stop moved surface agent session"); + + let snapshot = controller.snapshot(); + let target_surface = snapshot + .model + .workspaces + .values() + .flat_map(|workspace| { + workspace.panes.values().flat_map(move |pane| { + pane.surfaces + .values() + .map(move |surface| (workspace, pane, surface)) + }) + }) + .find(|(_, _, surface)| surface.id == moved_surface_id) + .expect("target surface"); + + assert_eq!(target_surface.0.id, target_workspace_id); + assert!(target_surface.2.agent_process.is_none()); + assert!(target_surface.2.agent_session.is_none()); + assert_eq!( + target_surface.2.attention, + taskers_domain::AttentionState::Error + ); + assert!( + snapshot + .model + .workspaces + .get(&source_workspace_id) + .and_then(|workspace| workspace.panes.get(&source_pane_id)) + .and_then(|pane| pane.active_surface()) + .and_then(|surface| surface.agent_process.as_ref()) + .is_none() + ); + } + #[test] fn identify_returns_focused_context_and_optional_caller() { let controller = InMemoryController::new(AppModel::new("Main"));