use std::time::{Duration, Instant};
use super::{ClientId, Server, frame_bytes};
use crate::agent::AgentState;
use crate::config::NotifyMode;
use crate::layout::PaneId;
const MAX_PENDING_WARNINGS: usize = 50;
#[derive(Debug, Clone, PartialEq)]
pub enum Attention {
Pane(PaneId),
Remote { server: String, pane: u32 },
}
impl Server {
pub fn client_focus(&self, id: ClientId) -> Option<PaneId> {
let c = self.clients.get(&id)?;
let wi = self.ws_index(c.workspace)?;
self.workspaces[wi].tab().focus
}
pub fn reveal(&mut self, client: ClientId, pane: PaneId) {
let Some((wi, ti)) = self.workspace_of(pane) else { return };
let ws = &mut self.workspaces[wi];
ws.active = ti;
let zoom = ws.tabs[ti].zoom;
ws.tabs[ti].set_focus(pane);
let zoom_moved = ws.tabs[ti].zoom != zoom;
let wid = ws.id;
if let Some(c) = self.clients.get_mut(&client) {
c.workspace = wid;
}
self.mark_seen_focused(client);
if zoom_moved {
self.relayout();
}
self.mark_dirty();
}
pub(super) fn follow_focus(&mut self) {
let shown: std::collections::HashSet<PaneId> = self
.clients
.values()
.filter(|c| c.attached && c.terminal_focused)
.filter_map(|c| self.client_focus(c.id))
.collect();
if shown != self.focus_reported {
for p in self.focus_reported.difference(&shown).copied().collect::<Vec<_>>() {
self.report_focus(p, false);
}
for p in shown.difference(&self.focus_reported).copied().collect::<Vec<_>>() {
self.report_focus(p, true);
}
self.focus_reported = shown;
}
let ids: Vec<ClientId> = self.clients.values().filter(|c| c.attached).map(|c| c.id).collect();
for id in ids {
let wants = self.config.mouse
&& matches!(self.clients[&id].mode, super::ui::Mode::Normal)
&& self.client_focus(id).and_then(|p| self.panes.get(&p)).is_some_and(|p| p.input_modes().mouse_motion);
let Some(c) = self.clients.get_mut(&id) else { continue };
if c.pointer_motion != wants {
let mode: &[u8] = match (wants, self.config.mouse) {
(true, _) => b"\x1b[?1003h",
(false, true) => b"\x1b[?1003l\x1b[?1002h",
(false, false) => b"\x1b[?1003l",
};
if c.out.try_send(frame_bytes(mode)).is_ok() {
c.pointer_motion = wants;
}
}
}
self.follow_keyboard();
}
fn report_focus(&mut self, pane: PaneId, gained: bool) {
if let Some(p) = self.panes.get_mut(&pane)
&& p.input_modes().focus_events
{
p.write_input(if gained { b"\x1b[I".to_vec() } else { b"\x1b[O".to_vec() });
}
}
pub fn mark_seen_focused(&mut self, client: ClientId) {
if let Some(p) = self.client_focus(client)
&& let Some(pane) = self.panes.get_mut(&p)
&& (pane.agent.mark_seen() | pane.set_command_unseen(false))
{
self.mark_dirty();
}
}
pub fn attention_queue(&self) -> Vec<Attention> {
let mut v: Vec<(Attention, bool, Instant)> = self
.panes
.values()
.filter_map(|p| {
if p.agent.state.needs_attention() {
Some((Attention::Pane(p.id), p.agent.state != AgentState::Blocked, p.agent.since))
} else {
p.command_unseen().map(|at| (Attention::Pane(p.id), true, at))
}
})
.collect();
v.extend(self.remote_agents().filter(|(_, a)| a.state.needs_attention()).map(|(server, a)| {
(Attention::Remote { server: server.to_string(), pane: a.pane }, a.state != AgentState::Blocked, a.since)
}));
v.sort_by_key(|(_, later, at)| (*later, *at));
v.into_iter().map(|(a, ..)| a).collect()
}
pub(super) fn attention_shown(&self, client: ClientId, a: &Attention) -> bool {
match a {
Attention::Pane(p) => self.client_focus(client) == Some(*p),
Attention::Remote { server, pane } => self.remote_shown(client, server, *pane),
}
}
pub(super) fn go_to(&mut self, client: ClientId, a: Attention) {
match a {
Attention::Pane(p) => self.reveal(client, p),
Attention::Remote { server, pane } => self.go_to_remote(client, &server, pane),
}
}
pub(super) fn pane_watched(&self, pane: PaneId) -> bool {
self.clients.values().any(|c| {
c.attached
&& c.terminal_focused
&& self.client_focus(c.id) == Some(pane)
&& c.last_input.elapsed() < Duration::from_secs(120)
})
}
pub fn on_agent_change(&mut self, pane: PaneId, before: AgentState) {
let Some(p) = self.panes.get(&pane) else { return };
let state = p.agent.state;
if state == before {
return;
}
if state != AgentState::Unknown {
self.announce_pane(pane);
let Some(p) = self.panes.get(&pane) else { return };
let fields = serde_json::json!({
"agent": p.agent.kind,
"account": p.spec.account,
"previous": before.label(),
"message": p.agent.message,
});
self.emit_pane(&format!("agent.{}", state.label()), pane, fields);
}
let Some(p) = self.panes.get(&pane) else { return };
if self.pane_watched(pane) {
if state == AgentState::Done
&& let Some(p) = self.panes.get_mut(&pane)
{
p.agent.mark_seen();
}
self.check_waits();
return;
}
let label = self.pane_label(pane);
let agent = p.agent.kind.clone().unwrap_or_else(|| "agent".into());
match state {
AgentState::Blocked if self.config.notifications.on_blocked => {
let msg = p.agent.message.clone().unwrap_or_else(|| "needs your input".into());
self.notify(&format!("{agent} · {label}"), &msg, Some(pane));
}
AgentState::Done if self.config.notifications.on_done && before == AgentState::Working => {
self.notify(&format!("{agent} · {label}"), "finished", Some(pane));
}
_ => {}
}
self.check_waits();
}
pub fn notify(&mut self, title: &str, body: &str, pane: Option<PaneId>) {
let mut fields = pane.map(|p| self.pane_fields(p)).unwrap_or_default();
fields.insert("title".into(), title.into());
fields.insert("body".into(), body.into());
self.emit("notification", serde_json::Value::Object(fields));
let mode = self.config.notifications.mode;
if matches!(mode, NotifyMode::Terminal | NotifyMode::Both) {
let seq = crate::pane::osc_notify(title, body);
for c in self.clients.values().filter(|c| c.attached) {
let _ = c.out.try_send(frame_bytes(&seq));
}
}
if matches!(mode, NotifyMode::System | NotifyMode::Both) {
let spawned = std::process::Command::new("notify-send")
.args(["--app-name=kraal", title, body])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
if let Ok(child) = spawned {
crate::exec::reap_later(child);
}
}
for c in self.clients.values_mut().filter(|c| c.attached) {
c.toast = Some((format!("{title}: {body}"), Instant::now() + Duration::from_secs(4)));
c.dirty = true;
}
self.schedule_render();
}
pub fn toast(&mut self, client: ClientId, msg: impl Into<String>) {
let msg = msg.into();
self.log_message(&msg, false);
if let Some(c) = self.clients.get_mut(&client) {
let secs = (3 + msg.chars().count() as u64 / 25).min(10);
c.toast = Some((msg, Instant::now() + Duration::from_secs(secs)));
c.dirty = true;
}
self.schedule_render();
}
fn log_message(&mut self, text: &str, warning: bool) {
if self.messages.len() >= super::MAX_MESSAGES {
self.messages.pop_front();
}
self.messages.push_back(super::Message { at: std::time::SystemTime::now(), text: text.to_string(), warning });
}
pub(super) fn show_warnings(&mut self) {
if self.warnings.is_empty() {
return;
}
if !self.clients.values().any(|c| c.attached) {
let mut seen = std::collections::HashSet::new();
let mut kept: Vec<String> = self.warnings.drain(..).rev().filter(|w| seen.insert(w.clone())).collect();
kept.truncate(MAX_PENDING_WARNINGS);
kept.reverse();
self.warnings = kept;
return;
}
let warnings = std::mem::take(&mut self.warnings);
for w in &warnings {
self.log_message(w, true);
}
let text = match warnings.len() {
1 => warnings[0].clone(),
n => format!(
"{} (and {} more: {} shows them)",
warnings[0],
n - 1,
self.keymap.hint(crate::keymap::Action::Messages).unwrap_or_default()
),
};
for c in self.clients.values_mut().filter(|c| c.attached) {
c.toast = Some((text.clone(), Instant::now() + Duration::from_secs(10)));
c.dirty = true;
}
self.schedule_render();
}
pub(super) fn dismiss_welcome(&mut self, client: ClientId) {
if let Some(c) = self.clients.get_mut(&client)
&& std::mem::take(&mut c.welcome)
{
let marker = crate::paths::state_dir().join("welcomed");
let _ = crate::paths::ensure_private_dir(&crate::paths::state_dir());
let _ = std::fs::write(marker, "");
}
}
pub fn open_for(&mut self, client: ClientId, url: &str) {
let remote = self.clients.get(&client).is_some_and(|c| c.remote);
if remote && crate::hints::loopback_port(url).is_some() {
let host = crate::context::hostname().unwrap_or_else(|| "the server".into());
self.copy_to_client(client, url);
self.toast(client, format!("{url} is on {host}, where kraal runs: copied (open it there, or ssh -L)"));
return;
}
self.send_msg(client, &crate::protocol::ServerMsg::Open { url: url.to_string() });
self.toast(client, format!("opening {url}"));
}
}