diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index c3643efd..f46dd08c 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/Cargo.lock b/Cargo.lock index ccfbce76..8d343c04 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", @@ -2495,6 +2495,8 @@ dependencies = [ "dioxus-liveview", "gtk4", "libadwaita", + "serde", + "serde_json", "taskers-control", "taskers-core", "taskers-domain", @@ -2504,16 +2506,19 @@ dependencies = [ "taskers-runtime", "taskers-shell", "taskers-shell-core", + "time", "tokio", "webkit6", ] [[package]] name = "taskers-host" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "gtk4", + "serde_json", + "taskers-control", "taskers-domain", "taskers-ghostty", "taskers-shell-core", @@ -2522,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", @@ -2538,7 +2543,7 @@ dependencies = [ [[package]] name = "taskers-shell" -version = "0.3.0" +version = "0.3.1" dependencies = [ "dioxus", "taskers-shell-core", @@ -2546,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 a432f64b..d83eb29d 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/README.md b/README.md index 44c5dabc..184e84ba 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,24 @@ -# 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) +- [Taskersctl operator guide](docs/taskersctl.md) +- [Release checklist](docs/release.md) + +## Install Linux (`x86_64-unknown-linux-gnu`): @@ -16,12 +27,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: @@ -30,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 @@ -47,10 +101,10 @@ 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 ``` -Release checklist: [docs/release.md](docs/release.md) +For publishing and release prep, use [docs/release.md](docs/release.md). diff --git a/assets/taskers.desktop.in b/assets/taskers.desktop.in index fb1ac64f..86c78f45 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/Cargo.toml b/crates/taskers-app/Cargo.toml index 152e92f6..d8b31714 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 @@ -21,6 +25,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 @@ -30,5 +36,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/assets/taskers-codex-notify.sh b/crates/taskers-app/assets/taskers-codex-notify.sh new file mode 100644 index 00000000..5ce99775 --- /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 00000000..02cdcd65 --- /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-app/src/main.rs b/crates/taskers-app/src/main.rs index fa0178e1..819aadf4 100644 --- a/crates/taskers-app/src/main.rs +++ b/crates/taskers-app/src/main.rs @@ -2,33 +2,44 @@ 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 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, 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, TerminalDebugCommand, + bind_socket, default_socket_path, serve_with_handler, }; -use taskers_domain::AppModel; +use taskers_core::{AppState, default_session_path, load_or_bootstrap}; +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}; +use taskers_shell_core::{ + BootstrapModel, LayoutNodeSnapshot, NotificationPreferencesSnapshot, PixelSize, + RuntimeCapability, RuntimeStatus, SharedCore, ShellAction, ShellSection, ShortcutAction, + ShortcutPreset, SurfaceKind, +}; 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")] @@ -66,7 +77,10 @@ impl GhosttyProbeMode { struct BootstrapContext { core: SharedCore, + app_state: AppState, + socket_path: PathBuf, ghostty_host: Option, + config: TaskersConfig, startup_notes: Vec, } @@ -79,6 +93,153 @@ struct RuntimeBootstrap { startup_notes: Vec, } +enum HostAutomationCommand { + Browser(BrowserControlCommand), + TerminalDebug(TerminalDebugCommand), +} + +struct HostAutomationRequest { + command: HostAutomationCommand, + 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, +} + +#[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 +} + +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(); + 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(); @@ -89,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; } }; @@ -126,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:?}")); } } @@ -167,6 +328,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) @@ -174,21 +336,50 @@ 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(); 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::(); + let control_server_note = spawn_control_server( + bootstrap.app_state.clone(), + bootstrap.socket_path, + host_request_tx, + ); + install_host_bridge( + host_request_rx, + &window, + &core, + &host, + &last_revision, + &last_size, + diagnostics.clone(), + ); for note in bootstrap.startup_notes { log_diagnostic( diagnostics.as_ref(), DiagnosticRecord::new(DiagnosticCategory::Startup, None, note.clone()), ); - eprintln!("{note}"); + safe_eprintln(note); } + log_diagnostic( + diagnostics.as_ref(), + DiagnosticRecord::new( + DiagnosticCategory::Startup, + Some(core.revision()), + control_server_note.clone(), + ), + ); + safe_eprintln(control_server_note); log_diagnostic( diagnostics.as_ref(), DiagnosticRecord::new( @@ -197,18 +388,19 @@ 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); - 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(); 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(); + let tick_config = persisted_config.clone(); glib::timeout_add_local(Duration::from_millis(16), move || { sync_window( &tick_window, @@ -218,6 +410,14 @@ 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(), + ); + persist_settings_if_needed(&tick_core, &tick_config, tick_diagnostics.as_ref()); glib::ControlFlow::Continue }); @@ -229,6 +429,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, @@ -238,6 +440,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 { @@ -257,6 +466,344 @@ 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 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 prefs.suppress_when_visible + && 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), + ); + safe_eprintln(format!( + "taskers notification delivery update failed: {error:?}" + )); + } + } + + core.sync_external_changes(); +} + +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(¬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, + ), + }) + }) + }) + .collect() +} + +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 { + 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) +} + +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:?}"), + ), + ); + safe_eprintln(format!("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_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() { + 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] + 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_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 { matches!( key, @@ -350,6 +897,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!( @@ -405,22 +959,22 @@ fn bootstrap_runtime(diagnostics: Option<&DiagnosticsWriter>) -> Result glib::ExitCode { host } Err(error) => { - eprintln!( + safe_eprintln(format!( "ghostty {} self-probe failed during host init: {error}", mode.as_arg() - ); + )); return glib::ExitCode::FAILURE; } }; @@ -508,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; } } @@ -539,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; } }; @@ -556,26 +1110,30 @@ fn run_internal_surface_probe( }, selected_theme_id: "dark".into(), 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()) { - eprintln!( + safe_eprintln(format!( "ghostty {} self-probe failed during snapshot sync: {error}", mode.as_arg() - ); + )); return glib::ExitCode::FAILURE; } @@ -597,6 +1155,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(); @@ -711,11 +1280,18 @@ fn sync_window( format!("host command failed: {error}"), ), ); - eprintln!("taskers host command failed: {error}"); + safe_eprintln(format!("taskers host command failed: {error}")); } } - 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)); @@ -753,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); } @@ -761,7 +1339,179 @@ fn sync_window( host.borrow().tick(); } -fn spawn_control_server(app_state: AppState, socket_path: PathBuf) -> String { +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, + 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_host_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_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, + 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 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, .. } + | 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, + host_tx: Sender, +) -> String { if let Some(parent) = socket_path.parent() && let Err(error) = std::fs::create_dir_all(parent) { @@ -781,20 +1531,68 @@ 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 host_tx = host_tx.clone(); + async move { + match command { + ControlCommand::Browser { browser_command } => { + let (response_tx, response_rx) = + tokio::sync::oneshot::channel(); + 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( + "host automation bridge is unavailable", + ) + })?; + response_rx.await.map_err(|_| { + ControlError::internal( + "host 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 { - 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() - ); + )); } } }); @@ -862,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}")); } }); }); @@ -1086,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 } } @@ -1111,3 +1909,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)); + } +} diff --git a/crates/taskers-cli/Cargo.toml b/crates/taskers-cli/Cargo.toml index 551ecc2b..5716553f 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-cli/assets/taskers-codex-notify.sh b/crates/taskers-cli/assets/taskers-codex-notify.sh index 2dd382a3..5ce99775 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 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-cli/assets/taskers.desktop.in b/crates/taskers-cli/assets/taskers.desktop.in index 07ac6566..64422a93 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-cli/src/main.rs b/crates/taskers-cli/src/main.rs index 17d40bb6..d2161d6d 100644 --- a/crates/taskers-cli/src/main.rs +++ b/crates/taskers-cli/src/main.rs @@ -1,18 +1,17 @@ -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::{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, TerminalDebugCommand, bind_socket, 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; @@ -76,10 +75,18 @@ 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, }, + Agent { + #[command(subcommand)] + command: AgentCommand, + }, Workspace { #[command(subcommand)] command: WorkspaceCommand, @@ -92,6 +99,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, @@ -257,8 +278,24 @@ enum AgentHookCommand { } #[derive(Debug, Subcommand)] -enum BrowserCommand { - Open { +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)] @@ -266,9 +303,81 @@ enum BrowserCommand { #[arg(long)] pane: Option, #[arg(long)] - url: Option, + surface: Option, }, - Navigate { + 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)] @@ -276,10 +385,384 @@ enum BrowserCommand { #[arg(long)] pane: Option, #[arg(long)] - surface: SurfaceId, + surface: Option, + #[arg(long, value_enum, default_value_t = CliAgentTargetScope::Surface)] + scope: CliAgentTargetScope, + #[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, + }, + 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 { + #[arg(long)] + socket: Option, + #[arg(long)] + workspace: Option, + #[arg(long)] + pane: Option, + #[arg(long)] + url: Option, + }, + Navigate { + #[command(flatten)] + browser: BrowserSurfaceArgs, #[arg(long)] 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)] + script: String, + }, + Wait { + #[command(flatten)] + browser: BrowserSurfaceArgs, + #[arg(long)] + selector: Option, + #[arg(long)] + text: Option, + #[arg(long)] + 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, 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)] + socket: Option, + #[arg(long)] + workspace: Option, + #[arg(long)] + pane: Option, + #[arg(long)] + 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")] + 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, + }, } #[derive(Debug, Subcommand)] @@ -402,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, @@ -445,6 +962,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,12 +1021,35 @@ impl From for PaneKind { } } -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let cli = Cli::parse(); +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, + } + } +} - match cli.command { - Command::Serve { socket, demo } => { +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(); + + match cli.command { + Command::Serve { socket, demo } => { let socket = resolve_socket_path(socket); let listener = bind_socket(&socket) .with_context(|| format!("failed to bind socket at {}", socket.display()))?; @@ -506,9 +1062,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 @@ -571,7 +1125,7 @@ async fn main() -> anyhow::Result<()> { let model = query_model(&client).await?; println!("{}", serde_json::to_string_pretty(&model)?); } - } + }, Command::Signal { socket, workspace, @@ -637,17 +1191,21 @@ async fn main() -> anyhow::Result<()> { pane, surface, title, + subtitle, body, - agent, + notification_id, + agent: _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?; + ensure_implicit_notify_target_context(workspace, pane, surface)?; + let target = resolve_agent_target( + &model, + workspace, + pane, + surface, + CliAgentTargetScope::Surface, + )?; let normalized_title = title.trim(); let normalized_body = body .as_deref() @@ -655,33 +1213,249 @@ 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 response = client - .send(ControlCommand::EmitSignal { + .send(ControlCommand::AgentCreateNotification { + target, + kind: SignalKind::Notification, + title: Some(normalized_title.to_string()), + subtitle, + external_id: notification_id, + 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, + subtitle, + notification_id, + 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, + kind: SignalKind::Notification, + title, + subtitle, + external_id: notification_id, + 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, + "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, + }) + }) + .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)); @@ -863,101 +1637,37 @@ 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, + 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?; - 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; + }, + ) + .await?; + match response { + ControlResponse::Identify { result } => { + println!("{}", serde_json::to_string_pretty(&result)?); } - 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)?); + 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 { @@ -1209,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, @@ -1232,35 +1994,100 @@ 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 resolve_socket_path(socket: Option) -> PathBuf { - socket - .or_else(|| env::var_os("TASKERS_SOCKET").map(PathBuf::from)) - .unwrap_or_else(default_socket_path) +fn env_tty_name() -> Option { + env::var("TASKERS_TTY_NAME") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) } -async fn send_control_command( - client: &ControlClient, - command: ControlCommand, -) -> anyhow::Result { - let response = client.send(command).await?; - response.response.map_err(|error| anyhow!(error)) +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() +} + +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)) + .unwrap_or_else(default_socket_path) +} + +async fn send_control_command( + client: &ControlClient, + command: ControlCommand, +) -> anyhow::Result { + let response = client.send(command).await?; + response.response.map_err(|error| anyhow!(error)) } async fn query_model(client: &ControlClient) -> anyhow::Result { @@ -1277,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, @@ -1290,6 +2142,53 @@ 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, @@ -1333,6 +2232,691 @@ 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 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, +) -> 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 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, +) -> 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)) +} + +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, +) -> 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, @@ -1372,9 +2956,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, @@ -1385,9 +2977,121 @@ async fn emit_agent_hook( metadata, timestamp: OffsetDateTime::now_utc(), }, - }) + }, + ) + .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: resolved_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 => { + let _ = send_control_command( + &client, + ControlCommand::AgentSetStatus { + workspace_id: resolved_workspace_id, + text: status_text, + }, + ) + .await?; + } + CliSignalKind::WaitingInput | CliSignalKind::Notification => { + let _ = send_control_command( + &client, + ControlCommand::AgentSetStatus { + workspace_id: resolved_workspace_id, + text: status_text.clone(), + }, + ) + .await?; + } + CliSignalKind::Completed | CliSignalKind::Error => { + if matches!(kind, CliSignalKind::Completed) { + let _ = send_control_command( + &client, + ControlCommand::AgentClearStatus { + workspace_id: resolved_workspace_id, + }, + ) + .await?; + let _ = send_control_command( + &client, + ControlCommand::AgentClearProgress { + workspace_id: resolved_workspace_id, + }, + ) + .await?; + } else { + let _ = send_control_command( + &client, + ControlCommand::AgentSetStatus { + workspace_id: resolved_workspace_id, + text: status_text, + }, + ) + .await?; + let _ = send_control_command( + &client, + ControlCommand::AgentClearProgress { + workspace_id: resolved_workspace_id, + }, + ) + .await?; + } + } + CliSignalKind::Metadata => {} + } + + if matches!( + kind, + CliSignalKind::WaitingInput | CliSignalKind::Notification | CliSignalKind::Error + ) { + 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, + resolved_workspace_id, + resolved_pane_id, + )?) + } + }; + if let Some(surface_id) = flash_surface_id { + let _ = send_control_command( + &client, + ControlCommand::AgentTriggerFlash { + workspace_id: resolved_workspace_id, + pane_id: resolved_pane_id, + surface_id, + }, + ) + .await?; + } + } + + println!("{}", serde_json::to_string_pretty(&signal_response)?); Ok(()) } @@ -1404,12 +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, ControlCommand, InMemoryController, bind_socket, serve, + }; + use taskers_domain::{AppModel, PaneKind}; + use tokio::sync::oneshot; - use super::{env_pane_id, env_surface_id, env_workspace_id, infer_agent_kind}; + use super::{ + 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())); @@ -1418,6 +3144,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 stop", + "--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"); @@ -1440,4 +3191,242 @@ mod tests { std::env::remove_var("TASKERS_SURFACE_ID"); } } + + #[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"); + std::env::remove_var("TASKERS_TTY_NAME"); + } + + 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 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"); + } + } + + #[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"); + 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/Cargo.toml b/crates/taskers-control/Cargo.toml index ad5d4541..3c1942f5 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-control/src/controller.rs b/crates/taskers-control/src/controller.rs index 1f348d32..30728870 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 { @@ -141,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, @@ -270,6 +381,40 @@ 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, + } => { + 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(), + }, + true, + ) + } ControlCommand::MarkSurfaceCompleted { workspace_id, pane_id, @@ -311,16 +456,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, )?; @@ -332,16 +479,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, )?; @@ -423,7 +572,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)?; } @@ -434,6 +589,179 @@ 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, + kind, + title, + subtitle, + external_id, + message, + state, + } => { + model.create_agent_notification( + target, + kind, + title, + subtitle, + external_id, + message, + state, + )?; + ( + ControlResponse::Ack { + message: "agent notification created".into(), + }, + 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)?; + ( + ControlResponse::Ack { + message: "agent notifications cleared".into(), + }, + 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, + 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::Browser { .. } => { + return Err(DomainError::InvalidOperation( + "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 { @@ -445,6 +773,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, + ), }, }; @@ -480,11 +818,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; @@ -526,4 +1001,223 @@ 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 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")); + 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 096ad683..c560bd69 100644 --- a/crates/taskers-control/src/lib.rs +++ b/crates/taskers-control/src/lib.rs @@ -7,5 +7,10 @@ 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, 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 7bb16015..99f2076e 100644 --- a/crates/taskers-control/src/protocol.rs +++ b/crates/taskers-control/src/protocol.rs @@ -1,10 +1,12 @@ use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; 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, NotificationDeliveryState, NotificationId, + PaneId, PaneKind, PaneMetadataPatch, PersistedSession, ProgressState, SignalEvent, SignalKind, + SplitAxis, SurfaceId, WindowId, WorkspaceColumnId, WorkspaceId, WorkspaceLogEntry, + WorkspaceViewport, WorkspaceWindowId, WorkspaceWindowMoveTarget, WorkspaceWindowTabId, }; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -44,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, @@ -96,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, @@ -113,16 +160,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, }, @@ -153,17 +202,400 @@ 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, + 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, + }, + DismissSurfaceAlert { + workspace_id: WorkspaceId, + pane_id: PaneId, + surface_id: SurfaceId, + }, + AgentTriggerFlash { + workspace_id: WorkspaceId, + pane_id: PaneId, + surface_id: SurfaceId, + }, + AgentFocusLatestUnread { + window_id: Option, + }, + Browser { + browser_command: BrowserControlCommand, + }, + TerminalDebug { + debug_command: TerminalDebugCommand, + }, 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 = "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, } @@ -191,6 +623,10 @@ pub enum ControlResponse { WorkspaceWindowCreated { pane_id: PaneId, }, + WorkspaceWindowTabCreated { + pane_id: PaneId, + workspace_window_tab_id: WorkspaceWindowTabId, + }, Status { session: PersistedSession, }, @@ -198,6 +634,15 @@ pub enum ControlResponse { workspace_id: WorkspaceId, session: PersistedSession, }, + Browser { + result: JsonValue, + }, + TerminalDebug { + result: TerminalDebugResult, + }, + Identify { + result: IdentifyResult, + }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -209,7 +654,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 0e1290cc..aad07ed4 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-core/Cargo.toml b/crates/taskers-core/Cargo.toml index a89788b0..185899b7 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-core/src/app_state.rs b/crates/taskers-core/src/app_state.rs index 74631c0f..8e3b60db 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-core/src/pane_runtime.rs b/crates/taskers-core/src/pane_runtime.rs index 8e8ea2be..af6582d9 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-domain/src/ids.rs b/crates/taskers-domain/src/ids.rs index 81a3f7b7..bae5314c 100644 --- a/crates/taskers-domain/src/ids.rs +++ b/crates/taskers-domain/src/ids.rs @@ -43,6 +43,8 @@ define_id!(WindowId); define_id!(WorkspaceId); define_id!(WorkspaceColumnId); define_id!(WorkspaceWindowId); +define_id!(WorkspaceWindowTabId); 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 84ebea7e..232c6c8a 100644 --- a/crates/taskers-domain/src/lib.rs +++ b/crates/taskers-domain/src/lib.rs @@ -6,16 +6,19 @@ 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, WorkspaceWindowTabId, }; 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, + 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, + NotificationDeliveryState, NotificationItem, PaneKind, PaneMetadata, PaneMetadataPatch, PaneRecord, PersistedSession, PrStatus, ProgressState, PullRequestState, - SESSION_SCHEMA_VERSION, SurfaceRecord, WindowFrame, WindowRecord, Workspace, - WorkspaceAgentState, WorkspaceAgentSummary, WorkspaceColumnRecord, WorkspaceSummary, - WorkspaceViewport, WorkspaceWindowMoveTarget, WorkspaceWindowRecord, + 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 d460a8d1..61e4aacf 100644 --- a/crates/taskers-domain/src/model.rs +++ b/crates/taskers-domain/src/model.rs @@ -3,20 +3,22 @@ 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, PaneId, SessionId, SignalEvent, SignalKind, SplitAxis, - SurfaceId, WindowId, WorkspaceColumnId, WorkspaceId, WorkspaceWindowId, + AttentionState, Direction, LayoutNode, NotificationId, PaneId, SessionId, SignalEvent, + SignalKind, SignalPaneMetadata, SplitAxis, SurfaceId, WindowId, WorkspaceColumnId, WorkspaceId, + WorkspaceWindowId, WorkspaceWindowTabId, }; -pub const SESSION_SCHEMA_VERSION: u32 = 4; +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; 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); @@ -110,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")] @@ -173,6 +177,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, @@ -191,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>, @@ -207,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, @@ -315,6 +347,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); @@ -323,18 +365,13 @@ 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(); } } #[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")] @@ -342,13 +379,40 @@ 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)] +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 notification_id: NotificationId, pub workspace_id: WorkspaceId, pub workspace_window_id: Option, pub pane_id: PaneId, @@ -356,16 +420,27 @@ 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 { Working, Waiting, - Inactive, + Completed, + Failed, } impl WorkspaceAgentState { @@ -373,7 +448,8 @@ impl WorkspaceAgentState { match self { Self::Working => "Working", Self::Waiting => "Waiting", - Self::Inactive => "Inactive", + Self::Completed => "Completed", + Self::Failed => "Failed", } } @@ -381,7 +457,8 @@ impl WorkspaceAgentState { match self { Self::Waiting => 0, Self::Working => 1, - Self::Inactive => 2, + Self::Failed => 2, + Self::Completed => 3, } } } @@ -397,10 +474,31 @@ 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 } +fn default_notification_delivery_state() -> NotificationDeliveryState { + NotificationDeliveryState::Shown +} + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct WorkspaceViewport { #[serde(default)] @@ -496,21 +594,155 @@ 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 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; } } } @@ -558,6 +790,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 +836,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, } } @@ -638,13 +885,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) { @@ -662,7 +909,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 @@ -681,8 +928,11 @@ 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); } } } @@ -692,108 +942,404 @@ impl Workspace { for notification in &mut self.notifications { if notification.pane_id == pane_id && notification.surface_id == surface_id - && notification.cleared_at.is_none() + && notification.active() + && notification.read_at.is_none() { - notification.cleared_at = Some(now); + notification.read_at = Some(now); } } } - fn top_level_neighbor( - &self, - source_window_id: WorkspaceWindowId, - direction: Direction, - ) -> Option { - let (_, column_index, window_index) = self.position_for_window(source_window_id)?; - match direction { - Direction::Left => column_index - .checked_sub(1) - .and_then(|index| self.columns.get_index(index)) - .map(|(_, column)| column.active_window), - Direction::Right => self - .columns - .get_index(column_index + 1) - .map(|(_, column)| column.active_window), - Direction::Up => self - .columns - .get_index(column_index) - .and_then(|(_, column)| { - window_index - .checked_sub(1) - .and_then(|index| column.window_order.get(index)) - }) - .copied(), - Direction::Down => self - .columns - .get_index(column_index) - .and_then(|(_, column)| column.window_order.get(window_index + 1)) - .copied(), - } - } - - fn fallback_window_after_close( - &self, - source_column_index: usize, - source_window_index: usize, - same_column_survived: bool, - ) -> Option { - if same_column_survived - && let Some((_, column)) = self.columns.get_index(source_column_index) - { - if let Some(window_id) = column.window_order.get(source_window_index) { - return Some(*window_id); - } - if let Some(window_id) = source_window_index - .checked_sub(1) - .and_then(|index| column.window_order.get(index)) + 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() { - return Some(*window_id); + if notification.read_at.is_none() { + notification.read_at = Some(now); + } + notification.cleared_at = Some(now); } } + } - let right_column_index = if same_column_survived { - source_column_index + 1 - } else { - source_column_index - }; - if let Some((_, column)) = self.columns.get_index(right_column_index) - && let Some(window_id) = column.window_order.first() + 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 + }) { - return Some(*window_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; } - source_column_index - .checked_sub(1) - .and_then(|index| self.columns.get_index(index)) - .and_then(|(_, column)| column.window_order.first()) - .copied() + self.notifications.push(notification); } - fn insert_column_at(&mut self, index: usize, column: WorkspaceColumnRecord) { - let insert_index = index.min(self.columns.len()); - let mut next = IndexMap::with_capacity(self.columns.len() + 1); - let mut pending = Some(column); - for (current_index, (column_id, current_column)) in - std::mem::take(&mut self.columns).into_iter().enumerate() - { - if current_index == insert_index - && let Some(column) = pending.take() - { - next.insert(column.id, column); + 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)) } - next.insert(column_id, current_column); - } - if let Some(column) = pending.take() { - next.insert(column.id, column); } - self.columns = next; } - fn append_missing_windows_to_columns(&mut self) { - let assigned = self - .columns - .values() - .flat_map(|column| column.window_order.iter().copied()) + 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 { + return Err(DomainError::MissingWorkspace(workspace_id)); + } + 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); + } + 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() { + 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); + } + 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() + { + 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); + } + notification.cleared_at = Some(now); + } + } + } + } + for (pane_id, surface_id) in cleared_surfaces { + self.sync_surface_attention_with_active_notifications(pane_id, surface_id); + } + 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() + }) { + 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() + .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); + 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, + direction: Direction, + ) -> Option { + let (_, column_index, window_index) = self.position_for_window(source_window_id)?; + match direction { + Direction::Left => column_index + .checked_sub(1) + .and_then(|index| self.columns.get_index(index)) + .map(|(_, column)| column.active_window), + Direction::Right => self + .columns + .get_index(column_index + 1) + .map(|(_, column)| column.active_window), + Direction::Up => self + .columns + .get_index(column_index) + .and_then(|(_, column)| { + window_index + .checked_sub(1) + .and_then(|index| column.window_order.get(index)) + }) + .copied(), + Direction::Down => self + .columns + .get_index(column_index) + .and_then(|(_, column)| column.window_order.get(window_index + 1)) + .copied(), + } + } + + fn fallback_window_after_close( + &self, + source_column_index: usize, + source_window_index: usize, + same_column_survived: bool, + ) -> Option { + if same_column_survived + && let Some((_, column)) = self.columns.get_index(source_column_index) + { + if let Some(window_id) = column.window_order.get(source_window_index) { + return Some(*window_id); + } + if let Some(window_id) = source_window_index + .checked_sub(1) + .and_then(|index| column.window_order.get(index)) + { + return Some(*window_id); + } + } + + let right_column_index = if same_column_survived { + source_column_index + 1 + } else { + source_column_index + }; + if let Some((_, column)) = self.columns.get_index(right_column_index) + && let Some(window_id) = column.window_order.first() + { + return Some(*window_id); + } + + source_column_index + .checked_sub(1) + .and_then(|index| self.columns.get_index(index)) + .and_then(|(_, column)| column.window_order.first()) + .copied() + } + + fn insert_column_at(&mut self, index: usize, column: WorkspaceColumnRecord) { + let insert_index = index.min(self.columns.len()); + let mut next = IndexMap::with_capacity(self.columns.len() + 1); + let mut pending = Some(column); + for (current_index, (column_id, current_column)) in + std::mem::take(&mut self.columns).into_iter().enumerate() + { + if current_index == insert_index + && let Some(column) = pending.take() + { + next.insert(column.id, column); + } + next.insert(column_id, current_column); + } + if let Some(column) = pending.take() { + next.insert(column.id, column); + } + self.columns = next; + } + + fn append_missing_windows_to_columns(&mut self) { + let assigned = self + .columns + .values() + .flat_map(|column| column.window_order.iter().copied()) .collect::>(); for window_id in self.windows.keys().copied().collect::>() { if assigned.contains(&window_id) { @@ -831,15 +1377,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() { @@ -881,12 +1424,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); @@ -915,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() @@ -923,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), }) }) }) @@ -971,6 +1506,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)] @@ -1209,107 +1745,448 @@ impl AppModel { Ok(new_pane_id) } - pub fn split_pane( - &mut self, - workspace_id: WorkspaceId, - target_pane: Option, - axis: SplitAxis, - ) -> Result { - let direction = match axis { - SplitAxis::Horizontal => Direction::Right, - SplitAxis::Vertical => Direction::Down, - }; - self.split_pane_direction(workspace_id, target_pane, direction) - } - - pub fn split_pane_direction( + pub fn create_workspace_window_tab( &mut self, workspace_id: WorkspaceId, - target_pane: Option, - direction: Direction, - ) -> Result { + workspace_window_id: WorkspaceWindowId, + ) -> Result<(WorkspaceWindowTabId, PaneId), DomainError> { let workspace = self .workspaces .get_mut(&workspace_id) .ok_or(DomainError::MissingWorkspace(workspace_id))?; - - let target = target_pane.unwrap_or(workspace.active_pane); - if !workspace.panes.contains_key(&target) { - return Err(DomainError::PaneNotInWorkspace { - workspace_id, - pane_id: target, - }); + if !workspace.windows.contains_key(&workspace_window_id) { + return Err(DomainError::MissingWorkspaceWindow(workspace_window_id)); } - let window_id = workspace - .window_for_pane(target) - .ok_or(DomainError::MissingPane(target))?; let new_pane = PaneRecord::new(PaneKind::Terminal); let new_pane_id = new_pane.id; 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; - } - workspace.sync_active_from_window(window_id); + 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_pane_id) + Ok((new_tab_id, new_pane_id)) } - pub fn focus_workspace_window( + 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))?; - if !workspace.windows.contains_key(&workspace_window_id) { - return Err(DomainError::MissingWorkspaceWindow(workspace_window_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.focus_window(workspace_window_id); + workspace.sync_active_from_window(workspace_window_id); Ok(()) } - pub fn focus_pane( + pub fn move_workspace_window_tab( &mut self, workspace_id: WorkspaceId, - pane_id: PaneId, + 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))?; - - if !workspace.panes.contains_key(&pane_id) { - return Err(DomainError::PaneNotInWorkspace { - workspace_id, - pane_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.focus_pane(pane_id); + workspace.sync_active_from_window(workspace_window_id); Ok(()) } - pub fn acknowledge_pane_notifications( + pub fn transfer_workspace_window_tab( &mut self, workspace_id: WorkspaceId, - pane_id: PaneId, + source_workspace_window_id: WorkspaceWindowId, + workspace_window_tab_id: WorkspaceWindowTabId, + target_workspace_window_id: WorkspaceWindowId, + to_index: usize, ) -> Result<(), DomainError> { - let workspace = self - .workspaces - .get_mut(&workspace_id) - .ok_or(DomainError::MissingWorkspace(workspace_id))?; - if !workspace.panes.contains_key(&pane_id) { - return Err(DomainError::PaneNotInWorkspace { + if source_workspace_window_id == target_workspace_window_id { + return self.move_workspace_window_tab( workspace_id, - pane_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, + target_pane: Option, + axis: SplitAxis, + ) -> Result { + let direction = match axis { + SplitAxis::Horizontal => Direction::Right, + SplitAxis::Vertical => Direction::Down, + }; + self.split_pane_direction(workspace_id, target_pane, direction) + } + + pub fn split_pane_direction( + &mut self, + workspace_id: WorkspaceId, + target_pane: Option, + direction: Direction, + ) -> Result { + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + + let target = target_pane.unwrap_or(workspace.active_pane); + if !workspace.panes.contains_key(&target) { + return Err(DomainError::PaneNotInWorkspace { + workspace_id, + pane_id: target, + }); + } + + let window_id = workspace + .window_for_pane(target) + .ok_or(DomainError::MissingPane(target))?; + let new_pane = PaneRecord::new(PaneKind::Terminal); + let new_pane_id = new_pane.id; + workspace.panes.insert(new_pane_id, new_pane); + + if let Some(window) = workspace.windows.get_mut(&window_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); + + Ok(new_pane_id) + } + + pub fn focus_workspace_window( + &mut self, + workspace_id: WorkspaceId, + workspace_window_id: WorkspaceWindowId, + ) -> Result<(), 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)); + } + workspace.focus_window(workspace_window_id); + Ok(()) + } + + pub fn focus_pane( + &mut self, + workspace_id: WorkspaceId, + pane_id: PaneId, + ) -> Result<(), DomainError> { + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + + if !workspace.panes.contains_key(&pane_id) { + return Err(DomainError::PaneNotInWorkspace { + workspace_id, + pane_id, + }); + } + + workspace.focus_pane(pane_id); + workspace.acknowledge_pane_notifications(pane_id); + Ok(()) + } + + pub fn acknowledge_pane_notifications( + &mut self, + workspace_id: WorkspaceId, + pane_id: PaneId, + ) -> Result<(), DomainError> { + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(DomainError::MissingWorkspace(workspace_id))?; + if !workspace.panes.contains_key(&pane_id) { + return Err(DomainError::PaneNotInWorkspace { + workspace_id, + pane_id, + }); } workspace.acknowledge_pane_notifications(pane_id); Ok(()) @@ -1341,10 +2218,16 @@ 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.last_signal_at = Some(OffsetDateTime::now_utc()); - workspace.acknowledge_surface_notifications(pane_id, surface_id); + 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(()) } @@ -1359,13 +2242,14 @@ 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| window.layout.focus_neighbor(window.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) { - window.active_pane = next_pane; + let _ = window.focus_pane(next_pane); } workspace.sync_active_from_window(active_window_id); return Ok(()); @@ -1553,7 +2437,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(()) } @@ -1608,7 +2495,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(()) } @@ -1713,6 +2603,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) @@ -1733,69 +2630,144 @@ 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 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 let Some(metadata) = event.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 agent_signal && matches!(kind, SignalKind::Started) { + surface.metadata.latest_agent_message = None; + } + 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()); + } } - 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 !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.agent_process.is_some() || surface.agent_session.is_some()) { - surface.attention = AttentionState::Completed; - surface.metadata.last_signal_at = Some(event.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; } (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.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 { + 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, }); } @@ -1843,9 +2815,448 @@ impl AppModel { surface_id, }); } + workspace.acknowledge_surface_notifications(pane_id, surface_id); + 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, + kind: SignalKind, + title: Option, + subtitle: Option, + external_id: 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(); + 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) + { + 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 = + 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_kind { + surface.metadata.agent_kind = Some(agent_kind); + } + } + } + + workspace.upsert_notification(NotificationItem { + id: NotificationId::new(), + pane_id, + surface_id, + kind, + state, + title: normalized_title, + subtitle: normalized_subtitle, + external_id: normalized_external_id, + message, + created_at: now, + read_at: None, + cleared_at: None, + desktop_delivery: NotificationDeliveryState::Pending, + }); + 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 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, + ) -> 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, + 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.unread()) + .map(move |notification| (workspace.id, notification)) + }) + .max_by_key(|(_, notification)| notification.created_at) + .map(|(_, notification)| notification.id); + + 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)?; + + 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( &mut self, workspace_id: WorkspaceId, @@ -1924,83 +3335,158 @@ impl AppModel { Ok(()) } - pub fn transfer_surface( + fn take_surface_from_pane( &mut self, workspace_id: WorkspaceId, - source_pane_id: PaneId, + pane_id: PaneId, surface_id: SurfaceId, - 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); - } - - { - let workspace = self - .workspaces - .get(&workspace_id) - .ok_or(DomainError::MissingWorkspace(workspace_id))?; - if !workspace.panes.contains_key(&source_pane_id) { - return Err(DomainError::PaneNotInWorkspace { - workspace_id, + ) -> 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, + })?; + 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 { + 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_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 source_workspace = self + .workspaces + .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: 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, }, )?; @@ -2011,61 +3497,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", )); @@ -2074,75 +3572,62 @@ 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 .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; - for notification in &mut workspace.notifications { - if notification.surface_id == surface_id { - notification.pane_id = 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); } + 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) } @@ -2182,48 +3667,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 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 moved_surface = + self.take_surface_from_pane(source_workspace_id, source_pane_id, surface_id)?; - 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; @@ -2238,13 +3686,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)?; @@ -2294,52 +3744,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))?; - 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); + 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); + 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); @@ -2399,7 +3864,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() @@ -2421,6 +3886,7 @@ impl AppModel { display_attention: unread_attention.unwrap_or(highest_attention), unread_count: unread.len(), latest_notification, + status_text: workspace.status_text.clone(), } }) .collect(); @@ -2436,8 +3902,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, @@ -2445,7 +3912,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, }) }) @@ -2478,6 +3947,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 +3972,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(); @@ -2500,53 +3984,149 @@ 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 ) } fn is_agent_kind(agent_kind: Option<&str>) -> bool { - agent_kind + 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) - .is_some_and(|agent| !agent.is_empty() && agent != "shell") + .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 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 recent_inactive_cutoff(now: OffsetDateTime) -> OffsetDateTime { - now - RECENT_INACTIVE_AGENT_RETENTION +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 workspace_agent_state( +fn is_agent_signal( surface: &SurfaceRecord, - now: OffsetDateTime, -) -> Option { - if !is_agent_kind(surface.metadata.agent_kind.as_deref()) { - return None; + source: &str, + metadata: Option<&SignalPaneMetadata>, +) -> bool { + is_agent_hook_source(source) + || is_agent_kind(metadata.and_then(|metadata| metadata.agent_kind.as_deref())) + || surface.agent_process.is_some() + || surface.agent_session.is_some() +} + +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 { + 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()); } - if !surface.metadata.agent_active { - return surface - .metadata - .last_signal_at - .filter(|timestamp| *timestamp >= recent_inactive_cutoff(now)) - .map(|_| WorkspaceAgentState::Inactive); + 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()), } +} - match surface.attention { - AttentionState::Busy => Some(WorkspaceAgentState::Working), - AttentionState::WaitingInput => Some(WorkspaceAgentState::Waiting), - AttentionState::Completed | AttentionState::Error | AttentionState::Normal => surface - .metadata - .last_signal_at - .filter(|timestamp| *timestamp >= recent_inactive_cutoff(now)) - .map(|_| WorkspaceAgentState::Inactive), +fn signal_creates_notification(source: &str, kind: &SignalKind) -> bool { + match kind { + SignalKind::Notification => !is_agent_hook_source(source), + _ => signal_kind_creates_notification(kind), } } @@ -2580,7 +4160,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, @@ -2588,15 +4186,14 @@ 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 - .layout + 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() } @@ -2620,9 +4217,20 @@ 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; + use time::Duration; use super::*; use crate::SignalPaneMetadata; @@ -2665,7 +4273,8 @@ mod tests { .windows .get(&upper_window_id) .expect("window") - .active_pane, + .active_pane() + .expect("active pane"), right_pane ); assert_eq!( @@ -2756,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] @@ -2780,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] ); } @@ -3122,6 +4734,7 @@ mod tests { workspace_id, source_pane_id, second_surface_id, + workspace_id, target_pane_id, 0, ) @@ -3162,6 +4775,7 @@ mod tests { workspace_id, source_pane_id, moved_surface_id, + workspace_id, source_pane_id, Direction::Right, ) @@ -3172,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] @@ -3209,6 +4826,7 @@ mod tests { workspace_id, source_pane_id, moved_surface_id, + workspace_id, target_pane_id, Direction::Left, ) @@ -3222,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!( @@ -3248,7 +4866,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!( @@ -3316,6 +4941,208 @@ 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 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"); + 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.active_layout().expect("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"); @@ -3335,6 +5162,7 @@ mod tests { workspace_id, source_pane_id, moved_surface_id, + workspace_id, target_pane_id, usize::MAX, ) @@ -3417,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); @@ -3546,36 +5374,198 @@ mod tests { } #[test] - fn progress_signals_update_attention_without_creating_activity_items() { + 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 - .update_pane_metadata( - pane_id, - PaneMetadataPatch { - title: Some("Codex".into()), - cwd: None, - url: None, - repo_name: None, - git_branch: None, - ports: None, - agent_kind: Some("codex".into()), - }, - ) - .expect("metadata updated"); model .apply_signal( workspace_id, pane_id, - SignalEvent::new("test", SignalKind::Progress, Some("Still working".into())), + 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("signal applied"); + .expect("notification applied"); - let surface = model - .active_workspace() - .and_then(|workspace| workspace.panes.get(&pane_id)) + 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"); + 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] + fn progress_signals_update_attention_without_creating_activity_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 + .update_pane_metadata( + pane_id, + PaneMetadataPatch { + title: Some("Codex".into()), + cwd: None, + url: None, + repo_name: None, + git_branch: None, + ports: None, + agent_kind: Some("codex".into()), + }, + ) + .expect("metadata updated"); + model + .apply_signal( + workspace_id, + pane_id, + SignalEvent::new("test", SignalKind::Progress, Some("Still working".into())), + ) + .expect("signal 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 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"); @@ -3584,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; @@ -3622,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), }), ), @@ -3651,11 +5641,16 @@ 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] - 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; @@ -3698,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 @@ -3709,7 +5704,7 @@ mod tests { .first() .and_then(|summary| summary.agent_summaries.first()) .map(|summary| summary.state), - Some(WorkspaceAgentState::Inactive) + None ); } @@ -3769,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 @@ -3786,7 +5785,7 @@ mod tests { .first() .and_then(|summary| summary.agent_summaries.first()) .map(|summary| summary.state), - Some(WorkspaceAgentState::Inactive) + None ); } @@ -3851,4 +5850,656 @@ 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, + }, + SignalKind::Notification, + Some("Older".into()), + None, + None, + "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, + }, + SignalKind::Notification, + Some("Newest".into()), + None, + None, + "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 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 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; + 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!(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, None); + 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"); + 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()); + 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] + 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); + } + + #[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-ghostty/Cargo.toml b/crates/taskers-ghostty/Cargo.toml index 893ed67a..f5121ff1 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-ghostty/build.rs b/crates/taskers-ghostty/build.rs index d4ae5a21..3b83d312 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-ghostty/src/bridge.rs b/crates/taskers-ghostty/src/bridge.rs index 40cb817c..6636d662 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/Cargo.toml b/crates/taskers-host/Cargo.toml index c55c4617..992cc69a 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 00000000..4864e1c1 --- /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 f8d1a9ca..df258e20 100644 --- a/crates/taskers-host/src/lib.rs +++ b/crates/taskers-host/src/lib.rs @@ -1,24 +1,32 @@ -use anyhow::{Result, anyhow, bail}; +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}, }; -use taskers_shell_core as taskers_core; +use taskers_control::{ + BrowserControlCommand, BrowserLoadState, ControlError, TerminalDebugCommand, + TerminalDebugResult, TerminalRenderStats, +}; use taskers_core::{ - BrowserMountSpec, HostCommand, HostEvent, PortalSurfacePlan, ShellDragMode, ShellSnapshot, - SurfaceId, SurfaceMountSpec, SurfacePortalPlan, TerminalMountSpec, + BrowserSurfaceCatalogEntry, HostCommand, HostEvent, PaneId, PortalSurfacePlan, ShellDragMode, + ShellSnapshot, SurfaceId, SurfaceMountSpec, SurfacePortalPlan, TerminalMountSpec, + TerminalSurfaceCatalogEntry, 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 +108,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,8 +252,14 @@ impl TaskersHost { format!("host sync start panes={}", snapshot.portal.panes.len()), ), ); - self.sync_browser_surfaces(&snapshot.portal, snapshot.revision, interactive)?; - self.sync_terminal_surfaces(&snapshot.portal, snapshot.revision, interactive)?; + self.sync_browser_surfaces(snapshot, interactive)?; + self.sync_terminal_surfaces( + &snapshot.portal, + &snapshot.terminal_catalog, + &snapshot.settings.selected_theme_id, + snapshot.revision, + interactive, + )?; Ok(()) } @@ -204,6 +295,66 @@ 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 + } + + 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, @@ -226,17 +377,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 @@ -248,11 +412,12 @@ 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( DiagnosticCategory::SurfaceLifecycle, - Some(revision), + Some(snapshot.revision), "browser surface removed", ) .with_surface(surface_id), @@ -260,25 +425,30 @@ 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.settings.selected_theme_id, + snapshot.revision, interactive, self.diagnostics.as_ref(), )?, None => { let surface = BrowserSurface::new( &self.root, - &plan, - revision, + entry, + visible_plan, + &snapshot.settings.selected_theme_id, + 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); } } } @@ -289,14 +459,21 @@ impl TaskersHost { fn sync_terminal_surfaces( &mut self, portal: &SurfacePortalPlan, + catalog: &[TerminalSurfaceCatalogEntry], + theme_id: &str, 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 @@ -308,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( @@ -324,12 +502,14 @@ 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, + theme_id, revision, interactive, host, @@ -338,14 +518,16 @@ impl TaskersHost { None => { let surface = TerminalSurface::new( &self.root, - &plan, + entry, + visible_plan, + theme_id, 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); } } } @@ -356,12 +538,17 @@ impl TaskersHost { struct BrowserSurface { shell: NativeSurfaceShell, + attention_ring: AttentionRingOverlay, 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 +556,15 @@ struct BrowserSurface { impl BrowserSurface { fn new( overlay: &Overlay, - plan: &PortalSurfacePlan, + entry: &BrowserSurfaceCatalogEntry, + visible_plan: Option<&PortalSurfacePlan>, + theme_id: &str, 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 +579,37 @@ 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); + let attention_ring = AttentionRingOverlay::new(); shell.mount_child(webview.upcast_ref()); - shell.position(overlay, plan.frame); + match visible_plan { + 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)); + 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 +624,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 +644,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 +665,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 +681,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 +718,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 +731,7 @@ impl BrowserSurface { }); } - if plan.active && interactive { + if visible_plan.is_some_and(|plan| plan.active) && interactive { webview.grab_focus(); } @@ -535,13 +742,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 +756,17 @@ impl BrowserSurface { Ok(Self { shell, - surface_id: plan.surface_id, + attention_ring, + 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 +775,48 @@ impl BrowserSurface { fn sync( &mut self, overlay: &Overlay, - plan: &PortalSurfacePlan, + entry: &BrowserSurfaceCatalogEntry, + visible_plan: Option<&PortalSurfacePlan>, + theme_id: &str, 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); + 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); + } + } - 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 +825,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 +837,6 @@ impl BrowserSurface { self.webview.load_uri(url); self.url = url.to_string(); } - self.webview.grab_focus(); self.emit_navigation_state(); } @@ -610,7 +844,6 @@ impl BrowserSurface { if self.webview.can_go_back() { self.webview.go_back(); } - self.webview.grab_focus(); self.emit_navigation_state(); } @@ -618,13 +851,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 +872,6 @@ impl BrowserSurface { inspector.show(); self.devtools_open.set(true); } - self.webview.grab_focus(); self.emit_navigation_state(); } @@ -654,26 +884,47 @@ 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 { + surface_id: SurfaceId, + workspace_id: Rc>, + pane_id: Rc>, + spec: TerminalMountSpec, shell: NativeSurfaceShell, + attention_ring: AttentionRingOverlay, 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>, + theme_id: &str, 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) @@ -687,14 +938,36 @@ 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); + let attention_ring = AttentionRingOverlay::new(); shell.mount_child(&widget); - shell.position(overlay, plan.frame); + match visible_plan { + 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); + } + } - 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); } @@ -705,36 +978,74 @@ 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, + attention_ring, 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>, + theme_id: &str, 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); + 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 + && (!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, @@ -742,9 +1053,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 { @@ -754,10 +1071,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); @@ -780,6 +1097,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 +1114,208 @@ impl NativeSurfaceShell { fn detach(&self, overlay: &Overlay) { detach_from_overlay(overlay, self.root.upcast_ref()); } + + fn hidden_frame(&self) -> taskers_core::Frame { + hidden_frame() + } +} + +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 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)] +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() { @@ -835,18 +1364,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( @@ -861,12 +1392,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( @@ -879,9 +1412,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, _| { @@ -902,7 +1438,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, _| { @@ -923,12 +1458,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") @@ -972,13 +1507,75 @@ 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 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, 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 @@ -988,26 +1585,16 @@ 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), - SurfaceMountSpec::Browser(_) => bail!("surface {} is not a terminal", plan.surface_id), - } -} - 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 { @@ -1059,7 +1646,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; @@ -1077,10 +1678,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) { @@ -1096,14 +1696,20 @@ 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::{ - browser_plans, native_surface_classes, native_surface_css, native_surfaces_interactive, - terminal_plans, 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_shell_core::{BootstrapModel, SharedCore, ShellDragMode, SurfaceMountSpec}; use taskers_domain::PaneKind; + use taskers_shell_core::{ + AttentionRingState, BootstrapModel, SharedCore, ShellDragMode, SurfaceMountSpec, + }; #[test] fn partitions_portal_plans_by_surface_kind() { @@ -1154,4 +1760,26 @@ 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"); + } + + #[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-launcher/assets/taskers-notify.sh b/crates/taskers-launcher/assets/taskers-notify.sh index b3a92b2f..63dbc2ff 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 diff --git a/crates/taskers-launcher/assets/taskers.desktop.in b/crates/taskers-launcher/assets/taskers.desktop.in index 07ac6566..64422a93 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 9078e85c..fc2ba668 100644 --- a/crates/taskers-launcher/src/lib.rs +++ b/crates/taskers-launcher/src/lib.rs @@ -233,16 +233,22 @@ 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, &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 +304,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 +456,40 @@ 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, + 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); + }; + + let managed_execs = [desktop_exec(desktop_launcher), desktop_exec(launcher)]; + + Ok(managed_execs + .iter() + .any(|candidate| candidate == existing_exec)) +} + fn write_executable(path: &Path, contents: &str) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent) @@ -558,12 +599,18 @@ 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 +641,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 +666,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 +751,63 @@ 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, &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, &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] + 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/crates/taskers-runtime/Cargo.toml b/crates/taskers-runtime/Cargo.toml index 3b6c4154..3c091a20 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" } 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 00000000..5074f606 --- /dev/null +++ b/crates/taskers-runtime/assets/shell/taskers-agent-claude.sh @@ -0,0 +1,40 @@ +#!/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" +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="$proxy_target" TASKERS_AGENT_PROXY_SHIM_DIR="$INVOKED_DIR" "$PROXY_PATH" "$@" +fi + +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 </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 651ee571..c403ff36 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,40 +37,49 @@ if [ -z "$real_binary" ]; then exit 127 fi -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 - [ -x "${TASKERS_CTL_PATH:-}" ] || return 0 - [ -n "${TASKERS_WORKSPACE_ID:-}" ] || return 0 - [ -n "${TASKERS_PANE_ID:-}" ] || return 0 +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" = "$expected_tty" ] +} - 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 +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 +} + +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 } -if [ "${TASKERS_AGENT_PROXY_ACTIVE:-0}" != "1" ]; then - emit_signal started +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 +} + +started_lifecycle=0 +if emit_surface_agent_start; then + started_lifecycle=1 fi set +e @@ -83,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 00000000..e59313be --- /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 00000000..d0672796 --- /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 a366fd07..eeff12c0 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##*/} @@ -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=( @@ -194,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 @@ -259,4 +277,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 0851f6a6..646045e6 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 @@ -103,12 +101,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" \ @@ -149,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 @@ -205,4 +216,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 307587c0..4f6123e8 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} @@ -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=( @@ -178,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 @@ -244,4 +259,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 8e3f04b6..7fe6d0fd 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/lib.rs b/crates/taskers-runtime/src/lib.rs index 2304dd61..36a9042f 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/shell.rs b/crates/taskers-runtime/src/shell.rs index 5f64eab0..82d8ebb8 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 { @@ -161,7 +122,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 +137,7 @@ impl ShellIntegration { args.push(fish_source_command()); ShellLaunchSpec { - program: self.real_shell.clone(), + program: self.wrapper_path.clone(), args, env, } @@ -182,24 +147,51 @@ impl ShellIntegration { args: vec!["--no-config".into(), "--interactive".into()], env: self.base_env(), }, - ShellKind::Zsh => { - let args = if profile == "clean" || integration_disabled { - vec!["-d".into(), "-f".into(), "-i".into()] + ShellKind::Zsh if !integration_disabled => { + let mut env = self.base_env(); + env.insert( + "TASKERS_REAL_SHELL".into(), + self.real_shell.display().to_string(), + ); + 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()] }; ShellLaunchSpec { - program: self.real_shell.clone(), + program: self.wrapper_path.clone(), args, - env: self.base_env(), + env, } } - ShellKind::Other => ShellLaunchSpec { + ShellKind::Zsh => ShellLaunchSpec { program: self.real_shell.clone(), - args: Vec::new(), + args: vec!["-d".into(), "-f".into(), "-i".into()], env: self.base_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, + } + } } } @@ -260,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) @@ -270,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() ) })?; @@ -331,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()) @@ -480,12 +484,130 @@ 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, + process::Command, + 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() { @@ -505,6 +627,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"; @@ -525,4 +717,606 @@ 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] { + 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" + ); + } + 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}") + }); + let hook_path = runtime_root.join("taskers-claude-hook.sh"); + assert!( + claude_args.contains("--settings"), + "expected claude wrapper to inject hook settings, got: {claude_args}" + ); + assert!( + claude_args.contains(&hook_path.display().to_string()) + && claude_args.contains("user-prompt-submit"), + "expected claude wrapper to inject prompt-submit hook path, got: {claude_args}" + ); + assert!( + claude_args.contains(&hook_path.display().to_string()) && claude_args.contains("stop"), + "expected claude wrapper to inject stop hook path, 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 claude_code_shim_preserves_binary_lookup_and_quotes_hook_paths() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner()); + let runtime_root = unique_temp_dir("taskers runtime claude code"); + install_runtime_assets(&runtime_root).expect("install runtime assets"); + super::install_agent_shims(&runtime_root).expect("install agent shims"); + + let real_bin_dir = runtime_root.join("real-bin"); + fs::create_dir_all(&real_bin_dir).expect("real bin dir"); + + let capture_path = runtime_root.join("claude-code-capture.log"); + write_executable( + &real_bin_dir.join("claude-code"), + "#!/bin/sh\nprintf 'target=%s\\n' \"${TASKERS_AGENT_PROXY_TARGET:-}\" >> \"$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()); + 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-runtime/src/signals.rs b/crates/taskers-runtime/src/signals.rs index 14f3d224..244793db 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,29 @@ impl ParsedSignal { } } +#[derive(Debug, Default, Clone)] +struct NotificationDraft { + 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 { + 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 +70,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 +99,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 +109,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 +205,145 @@ 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; + let mut encoded = false; + + 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" => { + encoded = value == "1"; + } + _ => {} + } + } + + 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") | None => { + if let Some(payload) = payload { + draft.title.push(payload, encoded); + } + } + Some("subtitle") => { + if let Some(payload) = 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)); + if should_defer { + if let Some(external_id) = external_id { + self.kitty_notification_drafts.insert(external_id, draft); + } + return 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, + subtitle, + 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()); @@ -159,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(); @@ -218,7 +472,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 +499,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 +561,127 @@ 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 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, + vec![ParsedTerminalEvent::Notification( + super::ParsedNotification { + title: Some("Hello World".into()), + subtitle: None, + body: None, + external_id: Some("1".into()), + } + )] + ); + } + + #[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()), + } + )] + ); + } } diff --git a/crates/taskers-shell-core/src/lib.rs b/crates/taskers-shell-core/src/lib.rs index 7f4977d4..0f3e64d1 100644 --- a/crates/taskers-shell-core/src/lib.rs +++ b/crates/taskers-shell-core/src/lib.rs @@ -5,13 +5,13 @@ 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, - 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, WorkspaceWindowTabRecord, }; use taskers_ghostty::{BackendChoice, SurfaceDescriptor}; use taskers_runtime::ShellLaunchSpec; @@ -20,23 +20,17 @@ use tokio::sync::watch; pub use taskers_domain::{ Direction, PaneId, SurfaceId, WorkspaceColumnId, WorkspaceId, WorkspaceWindowId, - WorkspaceWindowMoveTarget, + WorkspaceWindowMoveTarget, WorkspaceWindowTabId, }; #[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) } } @@ -127,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 { @@ -191,6 +202,7 @@ impl ShortcutPreset { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ShortcutAction { ToggleOverview, + FocusLatestUnread, CloseTerminal, OpenBrowserSplit, FocusBrowserAddress, @@ -221,8 +233,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 +268,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 +302,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 +336,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 +383,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 +413,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 +444,7 @@ impl ShortcutAction { }, ShortcutPreset::PowerUser => match self { Self::ToggleOverview => &["o"], + Self::FocusLatestUnread => &["u"], Self::CloseTerminal => &["x"], Self::OpenBrowserSplit => &["l"], Self::FocusBrowserAddress => &["l"], @@ -507,6 +527,7 @@ pub struct BootstrapModel { pub runtime_status: RuntimeStatus, pub selected_theme_id: String, pub selected_shortcut_preset: ShortcutPreset, + pub notification_preferences: NotificationPreferencesSnapshot, } impl Default for BootstrapModel { @@ -516,10 +537,38 @@ impl Default for BootstrapModel { runtime_status: RuntimeStatus::default(), selected_theme_id: "dark".into(), selected_shortcut_preset: ShortcutPreset::Balanced, + notification_preferences: NotificationPreferencesSnapshot::default(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NotificationPreferencesSnapshot { + pub alerts_on_waiting: bool, + pub alerts_on_error: bool, + pub alerts_on_completed: bool, + pub suppress_when_visible: bool, +} + +impl Default for NotificationPreferencesSnapshot { + fn default() -> 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, @@ -569,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)] @@ -585,35 +644,79 @@ 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 { 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: 28, + window_toolbar_height: 20, window_body_padding: 0, split_gap: 8, pane_border_width: 1, pane_header_height: 26, browser_toolbar_height: 34, surface_tab_height: 28, + terminal_gutter_x: 6, + } + } +} + +#[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 activity_label: Option, + pub status_label: Option, pub url: Option, pub cwd: Option, pub attention: AttentionState, + pub notification_ring: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -621,7 +724,9 @@ 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, pub focus_flash_token: u64, } @@ -672,6 +777,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, } @@ -689,6 +796,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, @@ -696,6 +804,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 +819,29 @@ 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 BrowserSurfaceCatalogEntry { + pub workspace_id: WorkspaceId, + pub pane_id: PaneId, + pub surface_id: SurfaceId, + 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, @@ -755,14 +887,28 @@ 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, + 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, @@ -797,14 +943,24 @@ pub enum ShellDragMode { #[default] None, Window, + WindowTab, 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, Waiting, - Inactive, + Completed, + Failed, } impl AgentStateSnapshot { @@ -812,7 +968,8 @@ impl AgentStateSnapshot { match self { Self::Working => "Working", Self::Waiting => "Waiting", - Self::Inactive => "Inactive", + Self::Completed => "Completed", + Self::Failed => "Failed", } } @@ -820,7 +977,8 @@ impl AgentStateSnapshot { match self { Self::Working => "busy", Self::Waiting => "waiting", - Self::Inactive => "completed", + Self::Completed => "completed", + Self::Failed => "error", } } } @@ -830,7 +988,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, } } } @@ -877,6 +1036,7 @@ pub struct SettingsSnapshot { pub theme_options: Vec, pub shortcut_presets: Vec, pub shortcuts: Vec, + pub notification_preferences: NotificationPreferencesSnapshot, } #[derive(Debug, Clone, PartialEq)] @@ -885,6 +1045,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, @@ -892,6 +1053,11 @@ 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 browser_catalog: Vec, + pub terminal_catalog: Vec, pub portal: SurfacePortalPlan, pub metrics: LayoutMetrics, pub runtime_status: RuntimeStatus, @@ -966,6 +1132,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, @@ -1006,12 +1199,22 @@ pub enum ShellAction { target_workspace_id: WorkspaceId, }, BeginWindowDrag, - BeginSurfaceDrag, + BeginWindowTabDrag, + BeginSurfaceDrag { + workspace_id: WorkspaceId, + pane_id: PaneId, + surface_id: SurfaceId, + }, + PreviewSurfaceDragWorkspace { + workspace_id: WorkspaceId, + }, + CancelSurfaceDrag, EndDrag, NavigateBrowser { surface_id: SurfaceId, url: String, }, + FocusLatestUnread, BrowserBack { surface_id: SurfaceId, }, @@ -1028,15 +1231,27 @@ pub enum ShellAction { pane_id: PaneId, surface_id: SurfaceId, }, + OpenActivity { + activity_id: ActivityId, + }, DismissActivity { activity_id: ActivityId, }, + DismissSurfaceAlert { + workspace_id: WorkspaceId, + pane_id: PaneId, + surface_id: SurfaceId, + }, SelectTheme { theme_id: String, }, SelectShortcutPreset { preset_id: String, }, + SetNotificationPreference { + key: NotificationPreferenceKey, + enabled: bool, + }, } #[derive(Debug, Clone)] @@ -1044,8 +1259,10 @@ struct UiState { section: ShellSection, overview_mode: bool, drag_mode: ShellDragMode, + surface_drag: Option, selected_theme_id: String, selected_shortcut_preset: ShortcutPreset, + notification_preferences: NotificationPreferencesSnapshot, window_size: PixelSize, } @@ -1106,8 +1323,10 @@ 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, window_size: PixelSize::new(1440, 900), }, host_commands: VecDeque::new(), @@ -1124,8 +1343,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"); @@ -1133,6 +1351,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"); @@ -1177,6 +1407,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 { @@ -1201,12 +1432,22 @@ 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, activity, done_activity, + current_workspace_status: workspace.status_text.clone(), + 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, @@ -1254,11 +1495,13 @@ impl TaskersCore { }) .collect(), shortcuts: shortcut_bindings(self.ui.selected_shortcut_preset), + notification_preferences: self.ui.notification_preferences, } } 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() @@ -1286,6 +1529,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(), @@ -1299,24 +1543,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()) @@ -1372,7 +1604,7 @@ impl TaskersCore { model .activity_items() .into_iter() - .map(|item| activity_item_snapshot(model, &item, true)) + .map(|item| activity_item_snapshot(model, &item)) .collect() } @@ -1386,6 +1618,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, @@ -1393,7 +1626,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, }) }) @@ -1401,7 +1636,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() } @@ -1410,6 +1645,7 @@ impl TaskersCore { workspace: &Workspace, window_frames: &BTreeMap, ) -> Vec { + let now = OffsetDateTime::now_utc(); let active_column_id = workspace.active_column_id(); workspace .columns @@ -1424,7 +1660,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(), }) @@ -1437,8 +1677,12 @@ impl TaskersCore { column_id: WorkspaceColumnId, window: &taskers_domain::WorkspaceWindowRecord, 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() @@ -1446,18 +1690,28 @@ 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, 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, - 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), } } @@ -1495,30 +1749,50 @@ 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 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); + 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, - surfaces: pane - .surfaces - .values() - .map(|surface| SurfaceSnapshot { - id: surface.id, - kind: SurfaceKind::from_domain(&surface.kind), - title: display_surface_title(surface), - url: normalized_surface_url(surface), - cwd: normalized_cwd(&surface.metadata), - attention: surface.attention.into(), - }) - .collect(), + runtime: pane_runtime_identity(pane, now), + surfaces, focus_flash_token: flash_token, } } @@ -1534,8 +1808,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) @@ -1554,6 +1827,59 @@ 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 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 = 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; + }; + 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, @@ -1565,10 +1891,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), )) }) @@ -1593,7 +1920,14 @@ 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), + notification_ring: pane_notification_ring(pane), + pane_frame: frame, + 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, @@ -1743,6 +2077,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) @@ -1786,12 +2150,24 @@ 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::BeginWindowTabDrag => self.begin_window_tab_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) } + ShellAction::FocusLatestUnread => { + self.dispatch_control(ControlCommand::AgentFocusLatestUnread { window_id: None }) + } ShellAction::BrowserBack { surface_id } => { self.queue_host_command(HostCommand::BrowserBack { surface_id }) } @@ -1808,7 +2184,13 @@ 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::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; @@ -1828,6 +2210,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 + } } } @@ -1836,6 +2240,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 @@ -2059,67 +2466,174 @@ impl TaskersCore { }) } - fn scroll_viewport_by(&mut self, dx: i32, dy: i32) -> bool { + 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; }; - let Some(workspace) = model.workspaces.get(&workspace_id) else { - return false; - }; - let viewport_frame = self.workspace_viewport_frame(attention_panel_visible(&model)); - let current_viewport = clamped_workspace_viewport( - workspace, - viewport_frame.width, - viewport_frame.height, - workspace.viewport.clone(), - ); - let next_viewport = clamped_workspace_viewport( - workspace, - viewport_frame.width, - viewport_frame.height, - taskers_domain::WorkspaceViewport { - x: current_viewport.x.saturating_add(dx), - y: current_viewport.y.saturating_add(dy), - }, - ); - if next_viewport == workspace.viewport { - return false; - } - self.dispatch_control(ControlCommand::SetWorkspaceViewport { + self.dispatch_control(ControlCommand::CreateWorkspaceWindowTab { workspace_id, - viewport: next_viewport, + workspace_window_id: window_id, }) } - fn split_with_kind_axis( + fn focus_workspace_window_tab( &mut self, - pane_id: Option, - kind: PaneKind, - axis: DomainSplitAxis, + window_id: WorkspaceWindowId, + tab_id: WorkspaceWindowTabId, ) -> bool { - let Some((workspace_id, target_pane_id)) = self.resolve_target_pane(pane_id) else { + let model = self.app_state.snapshot_model(); + let Some(workspace_id) = model.active_workspace_id() else { return false; }; - - let response = match self.dispatch_control_with_response(ControlCommand::SplitPane { + self.dispatch_control(ControlCommand::FocusWorkspaceWindowTab { workspace_id, - pane_id: Some(target_pane_id), - axis, - }) { - Some(response) => response, - None => return false, - }; + workspace_window_id: window_id, + workspace_window_tab_id: tab_id, + }) + } - let ControlResponse::PaneSplit { - pane_id: new_pane_id, - } = response - else { + 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, + }) + } - if kind == PaneKind::Terminal { - return true; + 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 { + return false; + }; + let Some(workspace) = model.workspaces.get(&workspace_id) else { + return false; + }; + let viewport_frame = self.workspace_viewport_frame(attention_panel_visible(&model)); + let current_viewport = clamped_workspace_viewport( + workspace, + viewport_frame.width, + viewport_frame.height, + workspace.viewport.clone(), + ); + let next_viewport = clamped_workspace_viewport( + workspace, + viewport_frame.width, + viewport_frame.height, + taskers_domain::WorkspaceViewport { + x: current_viewport.x.saturating_add(dx), + y: current_viewport.y.saturating_add(dy), + }, + ); + if next_viewport == workspace.viewport { + return false; + } + self.dispatch_control(ControlCommand::SetWorkspaceViewport { + workspace_id, + viewport: next_viewport, + }) + } + + fn split_with_kind_axis( + &mut self, + pane_id: Option, + kind: PaneKind, + axis: DomainSplitAxis, + ) -> bool { + let Some((workspace_id, target_pane_id)) = self.resolve_target_pane(pane_id) else { + return false; + }; + + let response = match self.dispatch_control_with_response(ControlCommand::SplitPane { + workspace_id, + pane_id: Some(target_pane_id), + axis, + }) { + Some(response) => response, + None => return false, + }; + + let ControlResponse::PaneSplit { + pane_id: new_pane_id, + } = response + else { + return false; + }; + + if kind == PaneKind::Terminal { + return true; } let placeholder_surface = self @@ -2226,9 +2740,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, @@ -2237,13 +2748,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( @@ -2254,7 +2772,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; @@ -2263,14 +2781,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, }) @@ -2338,10 +2857,28 @@ 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 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, + notification_id: activity_id.notification_id, }) } @@ -2456,6 +2993,109 @@ 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_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, + 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 { @@ -2470,21 +3110,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 { @@ -3173,14 +3808,29 @@ 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 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 { + 0 + }; frame .inset(metrics.pane_border_width) - .inset_top(metrics.pane_header_height + metrics.surface_tab_height + browser_toolbar_height) + .inset_horizontal(terminal_gutter_x) + .inset_top(metrics.pane_header_height + tab_strip_height + browser_toolbar_height) } fn workspace_window_content_frame(frame: Frame, metrics: LayoutMetrics) -> Frame { @@ -3229,54 +3879,432 @@ 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 window_primary_title( +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, -) -> String { + now: OffsetDateTime, +) -> RuntimeIdentitySnapshot { + 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( + workspace: Option<&Workspace>, + now: OffsetDateTime, +) -> RuntimeIdentitySnapshot { workspace - .panes - .get(&window.active_pane) - .and_then(|pane| pane.active_surface()) - .map(display_surface_title) - .unwrap_or_else(|| "Workspace window".into()) + .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 display_surface_title(surface: &SurfaceRecord) -> String { - if let Some(title) = surface - .metadata +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 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_key) = surface_agent_key(surface) { + return agent_key; + } + + 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 session = surface.agent_session.as_ref()?; + match session.state { + taskers_domain::WorkspaceAgentState::Working + | taskers_domain::WorkspaceAgentState::Waiting => Some(session.state), + taskers_domain::WorkspaceAgentState::Completed + | taskers_domain::WorkspaceAgentState::Failed => { + (session.updated_at >= now - time::Duration::minutes(15)).then_some(session.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, +) -> 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(&tab.active_pane) + .and_then(|pane| pane.active_surface()) + .map(display_surface_title) + .unwrap_or_else(|| "Workspace window".into()) +} + +fn display_surface_title(surface: &SurfaceRecord) -> String { + match surface.kind { + PaneKind::Terminal => display_terminal_title(surface), + PaneKind::Browser => display_browser_title(&surface.metadata), + } +} + +fn surface_activity_label(surface: &SurfaceRecord, now: OffsetDateTime) -> Option { + let _ = active_agent_surface_state(surface, now)?; + surface + .agent_session + .as_ref() + .and_then(|session| session.latest_message.as_deref()) + .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 surface_notification_ring(surface: &SurfaceRecord) -> Option { + if surface.kind != PaneKind::Terminal { + 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 pane_notification_ring(pane: &taskers_domain::PaneRecord) -> Option { + dominant_attention_ring(pane.surfaces.values().filter_map(surface_notification_ring)) +} + +fn surface_agent_key(surface: &SurfaceRecord) -> Option { + 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) + .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(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(process) = surface.agent_process.as_ref() { + if let Some(context) = context.as_deref() { + return format!("{} · {context}", process.title); + } + return process.title.clone(); + } + + if let Some(context) = context { + return context; + } + + if let Some(title) = surface + .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 { @@ -3298,6 +4326,67 @@ 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(); + + if matches!( + basename.as_str(), + "taskers-shell-wrapper.sh" | "taskers-agent-proxy.sh" + ) { + return true; + } + + 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; @@ -3354,11 +4443,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()) @@ -3371,9 +4456,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), @@ -3382,7 +4465,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, @@ -3424,19 +4507,46 @@ 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()) + || model.workspaces.values().any(|workspace| { + !workspace.notifications.is_empty() + || !workspace.log_entries.is_empty() + || workspace.progress.is_some() + }) } fn fallback_surface_descriptor(surface: &SurfaceRecord) -> SurfaceDescriptor { @@ -3471,9 +4581,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, @@ -3543,10 +4651,11 @@ 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, + AppModel, AttentionState as DomainAttentionState, NotificationId, NotificationItem, + SignalKind, }; use taskers_ghostty::BackendChoice; use taskers_runtime::ShellLaunchSpec; @@ -3554,9 +4663,11 @@ 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, + LayoutMetrics, NotificationPreferencesSnapshot, RuntimeCapability, RuntimeStatus, + SharedCore, ShellAction, ShellDragMode, ShellSection, SurfaceDragSessionSnapshot, + SurfaceMountSpec, WorkspaceDirection, default_preview_app_state, + 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, }; @@ -3572,6 +4683,7 @@ mod tests { }, selected_theme_id: "dark".into(), selected_shortcut_preset: super::ShortcutPreset::Balanced, + notification_preferences: NotificationPreferencesSnapshot::default(), } } @@ -3596,14 +4708,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 { @@ -3622,6 +4739,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, @@ -3669,58 +4799,774 @@ mod tests { } } + fn surface_with_metadata( + 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 + } + #[test] - fn default_bootstrap_projects_browser_and_terminal_portal_plans() { - let core = SharedCore::bootstrap(bootstrap()); - let snapshot = core.snapshot(); + 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() + }, + ); - let browser_count = snapshot - .portal - .panes - .iter() - .filter(|plan| matches!(plan.mount, SurfaceMountSpec::Browser(_))) - .count(); - let terminal_count = snapshot - .portal - .panes - .iter() - .filter(|plan| matches!(plan.mount, SurfaceMountSpec::Terminal(_))) - .count(); + assert_eq!(display_surface_title(&surface), "Codex · taskers/main"); + } - assert_eq!(browser_count, 1); - assert_eq!(terminal_count, 1); + #[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 portal_surface_frames_start_below_window_toolbar() { - 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; + 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!( - snapshot - .portal - .panes - .iter() - .all(|plan| plan.frame.y >= min_content_y), - "expected native surfaces to stay below window chrome" + 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 active_portal_surface_frame_matches_layout_insets() { - let core = SharedCore::bootstrap(bootstrap()); - 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()) + 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 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 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 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 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(); + 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"); + 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") + .active_layout() + .expect("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.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 + .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.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; + } + + 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 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 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 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()); + let snapshot = core.snapshot(); + + let browser_count = snapshot + .portal + .panes + .iter() + .filter(|plan| matches!(plan.mount, SurfaceMountSpec::Browser(_))) + .count(); + let terminal_count = snapshot + .portal + .panes + .iter() + .filter(|plan| matches!(plan.mount, SurfaceMountSpec::Terminal(_))) + .count(); + + assert_eq!(browser_count, 1); + assert_eq!(terminal_count, 1); + } + + #[test] + fn portal_surface_frames_start_below_window_toolbar() { + 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; + + assert!( + snapshot + .portal + .panes + .iter() + .all(|plan| plan.frame.y >= min_content_y), + "expected native surfaces to stay below window chrome" + ); + } + + #[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()); + 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 @@ -3740,14 +5586,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()); @@ -3850,6 +5752,108 @@ 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 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 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()); @@ -3882,13 +5886,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"); } @@ -3908,15 +5918,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::BeginWindowDrag); - assert_eq!(core.snapshot().drag_mode, ShellDragMode::Window); + 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); - assert_eq!(core.snapshot().drag_mode, ShellDragMode::None); + + 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] @@ -4020,10 +6146,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] @@ -4136,6 +6265,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()); @@ -4185,6 +6356,97 @@ 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()); + 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()); @@ -4234,4 +6496,304 @@ 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, + }, + kind: SignalKind::Notification, + title: Some("Older".into()), + subtitle: None, + external_id: None, + 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, + }, + kind: SignalKind::Notification, + title: Some("Newest".into()), + subtitle: None, + external_id: None, + 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 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); + 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(); + 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/icons.rs b/crates/taskers-shell/src/icons.rs new file mode 100644 index 00000000..fdd8493a --- /dev/null +++ b/crates/taskers-shell/src/icons.rs @@ -0,0 +1,396 @@ +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" } + } + } +} + +/// 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" } + } + } +} + +/// 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" } + } + } +} + +/// 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 3264e3b7..daab73a2 100644 --- a/crates/taskers-shell/src/lib.rs +++ b/crates/taskers-shell/src/lib.rs @@ -1,24 +1,60 @@ +mod icons; mod theme; +use dioxus::html::{ + PointerData, + input_data::MouseButton, + point_interaction::{InteractionLocation, PointerInteraction}, +}; 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, + 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, + WorkspaceWindowTabId, WorkspaceWindowTabSnapshot, }; +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 WINDOW_TAB_DRAG_MIME: &str = "application/x-taskers-window-tab"; +const SURFACE_DRAG_THRESHOLD_PX: f64 = 6.0; #[derive(Clone, Copy, PartialEq, Eq)] -struct DraggedSurface { - pane_id: PaneId, - surface_id: SurfaceId, +struct DraggedWindow { + window_id: taskers_core::WorkspaceWindowId, } #[derive(Clone, Copy, PartialEq, Eq)] -struct DraggedWindow { +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, + pane_id: PaneId, + surface_id: SurfaceId, + start_x: f64, + start_y: f64, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -36,6 +72,80 @@ 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 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) +} + +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 surface_primary_label(surface: &SurfaceSnapshot) -> &str { + surface + .activity_label + .as_deref() + .unwrap_or(surface.title.as_str()) +} + +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; + }; + 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, @@ -65,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 { @@ -90,10 +229,63 @@ 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) } +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 pointer_client_position(event: &Event) -> (f64, f64) { + let position = event.data().client_coordinates(); + (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, + 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, @@ -164,42 +356,94 @@ 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(); - 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 jump_unread = { let core = core.clone(); - let section = snapshot.section; - move |_| { - core.dispatch_shell_action(ShellAction::ShowSection { section }); - } + 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::); - let surface_drop_target = use_signal(|| None::); - let surface_workspace_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 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 = { + 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 => { @@ -214,27 +458,17 @@ 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" } - } - 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" - } - button { - class: if matches!(snapshot.section, ShellSection::Settings) { "sidebar-nav-button sidebar-nav-button-active" } else { "sidebar-nav-button" }, - onclick: show_settings_nav, - "Settings" + div { class: "sidebar-top", + button { class: "workspace-add", onclick: create_workspace, + {icons::plus(14, "workspace-add-icon")} } } - div { class: "sidebar-section-header", - div { class: "sidebar-heading", "Workspaces" } - button { class: "workspace-add", onclick: create_workspace, "+" } - } div { class: "workspace-list", for workspace in &snapshot.workspaces { {render_workspace_item( @@ -242,26 +476,29 @@ 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, )} } } + 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" } } } @@ -275,10 +512,13 @@ pub fn TaskersShell(core: SharedCore) -> Element { snapshot.browser_chrome.as_ref(), core.clone(), &snapshot.runtime_status, - surface_drag_source, surface_drop_target, + surface_drag_candidate, + dragged_surface, window_drag_source, window_drop_target, + dragged_window_tab, + window_tab_drop_target, )} } } else { @@ -298,13 +538,29 @@ 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", + 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 { @@ -313,16 +569,25 @@ 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" } + div { class: "notification-empty-subtitle", "Activity and alerts will appear here." } } } else { 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() { + 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)} + } } } } @@ -337,8 +602,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 { @@ -407,9 +672,14 @@ 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 = *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 { @@ -419,44 +689,93 @@ 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 = move |event: Event| { - event.prevent_default(); - if surface_drag_source.read().is_some() { - surface_workspace_target.set(Some(workspace_id)); + let on_dragover = { + let core = core.clone(); + move |event: 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); + core.dispatch_shell_action(ShellAction::PreviewSurfaceDragWorkspace { + workspace_id, + }); + } else { + drag_target.set(Some(workspace_id)); + } + } + }; + 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); - } else { - drag_target.set(Some(workspace_id)); - surface_workspace_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); } - 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); - 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; } @@ -488,13 +807,19 @@ 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" } } 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}" } @@ -502,18 +827,26 @@ fn render_workspace_item( button { class: "workspace-tab-close", onclick: close_workspace, - "×" + {icons::close(12, "workspace-tab-close-icon")} } } } - 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 { - 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)} @@ -555,14 +888,106 @@ 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_surface_workspace_fallback_drop( + target_workspace_id: WorkspaceId, + core: SharedCore, + mut surface_drop_target: Signal>, + dragged_surface: Option, +) -> Element { + 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 preview_workspace_enter = { + 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.stop_propagation(); + surface_drop_target.set(None); + core.dispatch_shell_action(ShellAction::PreviewSurfaceDragWorkspace { + workspace_id: target_workspace_id, + }); + } + }; + let preview_workspace_move = { + 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.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, + surface_id: dragged.surface_id, + target_workspace_id, + }); + } + core.dispatch_shell_action(ShellAction::EndDrag); + } + }; + + rsx! { + div { + class: "{class}", + 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" } + } + } +} + fn render_layout( + workspace_id: WorkspaceId, node: &LayoutNodeSnapshot, overview_mode: bool, browser_chrome: Option<&BrowserChromeSnapshot>, core: SharedCore, runtime_status: &RuntimeStatus, - surface_drag_source: Signal>, surface_drop_target: Signal>, + surface_drag_candidate: Signal>, + dragged_surface: Option, ) -> Element { match node { LayoutNodeSnapshot::Split { @@ -583,22 +1008,24 @@ 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_drop_target, surface_drag_candidate, dragged_surface)} } 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_drop_target, surface_drag_candidate, dragged_surface)} } } } } LayoutNodeSnapshot::Pane(pane) => render_pane( + workspace_id, pane, overview_mode, browser_chrome, core, runtime_status, - surface_drag_source, surface_drop_target, + surface_drag_candidate, + dragged_surface, ), } } @@ -609,10 +1036,13 @@ fn render_workspace_strip( browser_chrome: Option<&BrowserChromeSnapshot>, core: SharedCore, runtime_status: &RuntimeStatus, - surface_drag_source: Signal>, surface_drop_target: Signal>, + surface_drag_candidate: Signal>, + 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" @@ -646,6 +1076,15 @@ fn render_workspace_strip( rsx! { div { class: "{viewport_class}", onwheel: scroll_viewport, div { class: "workspace-strip-canvas", style: "{canvas_style}", + if dragged_surface.is_some_and(|dragged| dragged.workspace_id != workspace.id) + { + {render_surface_workspace_fallback_drop( + workspace.id, + core.clone(), + surface_drop_target, + dragged_surface, + )} + } for column in &workspace.columns { for window in &column.windows { {render_workspace_window( @@ -655,10 +1094,13 @@ fn render_workspace_strip( browser_chrome, core.clone(), runtime_status, - surface_drag_source, surface_drop_target, + surface_drag_candidate, + dragged_surface, window_drag_source, window_drop_target, + dragged_window_tab, + window_tab_drop_target, )} } } @@ -674,10 +1116,13 @@ fn render_workspace_window( browser_chrome: Option<&BrowserChromeSnapshot>, core: SharedCore, runtime_status: &RuntimeStatus, - mut surface_drag_source: Signal>, mut surface_drop_target: Signal>, + surface_drag_candidate: Signal>, + 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; @@ -697,8 +1142,8 @@ fn render_workspace_window( }; let start_window_drag = { let core = core.clone(); - move |_: Event| { - surface_drag_source.set(None); + 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); @@ -709,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, }; @@ -725,6 +1174,21 @@ fn render_workspace_window( }; let top_target = WorkspaceWindowMoveTarget::StackAbove { window_id }; let bottom_target = WorkspaceWindowMoveTarget::StackBelow { window_id }; + 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}", @@ -734,6 +1198,7 @@ fn render_workspace_window( drag_active, window_drag_source, window_drop_target, + dragged_window_tab, core.clone(), )} {render_window_drop_zone( @@ -742,6 +1207,7 @@ fn render_workspace_window( drag_active, window_drag_source, window_drop_target, + dragged_window_tab, core.clone(), )} {render_window_drop_zone( @@ -750,6 +1216,7 @@ fn render_workspace_window( drag_active, window_drag_source, window_drop_target, + dragged_window_tab, core.clone(), )} {render_window_drop_zone( @@ -758,26 +1225,88 @@ 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-title", - span { class: "workspace-label", "{window.title}" } + 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-toolbar-spacer", + title: "Drag window", + draggable: "true", + onclick: focus_window, + ondragstart: start_window_drag, + ondragend: clear_window_drag, } } div { class: "workspace-window-body", {render_layout( + workspace.id, &window.layout, overview_mode, browser_chrome, core.clone(), runtime_status, - surface_drag_source, surface_drop_target, + surface_drag_candidate, + dragged_surface, )} } } @@ -790,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) { @@ -800,10 +1330,10 @@ 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; } - event.prevent_default(); + mark_move_drop(&event); window_drop_target.set(Some(target)); }; let clear_drop_target = move |_: Event| { @@ -812,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); @@ -839,17 +1380,169 @@ 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, overview_mode: bool, browser_chrome: Option<&BrowserChromeSnapshot>, core: SharedCore, runtime_status: &RuntimeStatus, - surface_drag_source: Signal>, surface_drop_target: Signal>, + mut surface_drag_candidate: 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!( @@ -898,15 +1591,7 @@ 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 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(); @@ -917,7 +1602,8 @@ fn render_pane( }; 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), }) @@ -925,7 +1611,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), }) @@ -933,7 +1620,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), }) @@ -941,20 +1629,29 @@ 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, }) } }; + 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 { @@ -967,119 +1664,206 @@ fn render_pane( } else { "Close current surface" }; + let pane_runtime_icon_class = format!( + "pane-toolbar-kind-icon {}", + runtime_state_class(active_surface.runtime.state) + ); + let pane_runtime_chip_class = format!( + "pane-runtime-chip {}", + 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); + 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! { - 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}" } - } - 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" } - } - } - div { class: "pane-tabs", - div { class: "surface-tabs", - for surface in &pane.surfaces { - {render_surface_tab( - pane.id, - pane.active_surface, - surface, - core.clone(), - surface_drag_source, - surface_drop_target, - &ordered_surface_ids, - )} + 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) { + 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}" + } + } + } + } + } + } 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) { + 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}" + } + } + } + } + } } - if surface_drag_active { - {render_surface_pane_drop_target( - "surface-tab surface-tab-append-target", - "+", - SurfaceDropTarget::AppendToPane { pane_id }, - core.clone(), - surface_drag_source, - surface_drop_target, - )} + 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 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_drag_source, - 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_drag_source, - 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_drag_source, - 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_drag_source, - 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_drag_source, 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 { key: "{flash_key}", class: "{flash_class}" } } } } @@ -1089,7 +1873,6 @@ fn render_surface_pane_drop_target( label: &'static str, target: SurfaceDropTarget, core: SharedCore, - mut surface_drag_source: Signal>, mut surface_drop_target: Signal>, ) -> Element { let class = if *surface_drop_target.read() == Some(target) { @@ -1097,28 +1880,40 @@ fn render_surface_pane_drop_target( } else { base_class.to_string() }; - let set_drop_target = move |event: Event| { - if surface_drag_source.read().is_none() { - return; + let set_drop_target_enter = { + let core = core.clone(); + move |event: Event| { + if core.snapshot().surface_drag.is_none() { + return; + } + event.stop_propagation(); + surface_drop_target.set(Some(target)); } - event.prevent_default(); - 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(); - let dragged = *surface_drag_source.read(); - surface_drag_source.set(None); + 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); } @@ -1127,27 +1922,26 @@ 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}" } } } 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_drag_candidate: Signal>, + 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 is_drop_target = matches!( *surface_drop_target.read(), @@ -1158,7 +1952,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 { @@ -1167,7 +1962,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 { @@ -1176,41 +1972,53 @@ 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 begin_surface_drag_candidate = move |event: Event| { + if let Some(candidate) = + surface_drag_candidate_from_event(&event, workspace_id, pane_id, surface_id) + { + surface_drag_candidate.set(Some(candidate)); + } + }; + let set_surface_drop_target_enter = { let core = core.clone(); - move |_: Event| { - surface_drag_source.set(Some(DraggedSurface { + 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, })); - core.dispatch_shell_action(ShellAction::BeginSurfaceDrag); } }; - let clear_drag = { + let set_surface_drop_target_move = { let core = core.clone(); - move |_: Event| { - surface_drag_source.set(None); - surface_drop_target.set(None); - core.dispatch_shell_action(ShellAction::EndDrag); - } - }; - let set_surface_drop_target = move |event: Event| { - if surface_drag_source.read().is_none() { - return; + 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, + })); } - event.prevent_default(); - 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, @@ -1223,14 +2031,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(); - let dragged = *surface_drag_source.read(); - surface_drag_source.set(None); + 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, @@ -1243,19 +2050,72 @@ 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) + ); + let surface_tab_state_class = format!( + "surface-tab-state {}", + 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 { - class: "{tab_class}", - draggable: "true", - onclick: focus_surface, - ondragstart: start_drag, - ondragend: clear_drag, - ondragover: set_surface_drop_target, - ondragleave: clear_surface_drop_target, - ondrop: drop_surface, - span { class: "surface-tab-label", "{kind_label}" } - span { class: "surface-tab-title", "{surface.title}" } + div { + key: "{surface_id}", + class: "{tab_class} surface-tab-draggable", + title: "{surface_tab_title}", + 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 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}", + "{status_label}" + } + } + } } } } @@ -1328,28 +2188,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")} + } } } } @@ -1358,18 +2237,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}" } + } } } } @@ -1378,15 +2255,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}" } } } } @@ -1401,28 +2278,55 @@ 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; 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", - 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}" } } } } @@ -1431,7 +2335,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" @@ -1448,28 +2352,13 @@ 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 }); } }; 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", @@ -1484,12 +2373,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, + {icons::close(12, "notification-clear-icon")} } } } @@ -1500,7 +2387,50 @@ fn render_notification_row( #[cfg(test)] mod tests { - use super::{SurfaceKind, show_live_surface_backdrop}; + use super::{ + 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::{ + AttentionRingState, AttentionState, PaneId, RuntimeIdentitySnapshot, RuntimeStateSnapshot, + SurfaceId, SurfaceSnapshot, WorkspaceId, + }; + + fn sample_surface( + runtime_key: &str, + runtime_label: &str, + title: &str, + activity_label: Option<&str>, + status_label: Option<&str>, + state: RuntimeStateSnapshot, + ) -> SurfaceSnapshot { + SurfaceSnapshot { + id: SurfaceId::new(), + kind: SurfaceKind::Terminal, + runtime: RuntimeIdentitySnapshot { + key: runtime_key.into(), + label: runtime_label.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, + 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() { @@ -1508,6 +2438,110 @@ mod tests { assert!(show_live_surface_backdrop(SurfaceKind::Browser, true)); assert!(show_live_surface_backdrop(SurfaceKind::Terminal, false)); } + + #[test] + 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)); + } + + #[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"), + 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", + "Codex", + "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( + "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 { @@ -1535,6 +2569,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", @@ -1624,3 +2694,35 @@ 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 track_class = if enabled { + "toggle-track toggle-track-active" + } else { + "toggle-track" + }; + + rsx! { + button { class: "settings-toggle-row", onclick: toggle, + div { class: "settings-toggle-copy", + div { class: "workspace-label", "{label}" } + div { class: "settings-copy", "{detail}" } + } + 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 1b372f23..6d67ab4e 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 { @@ -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; @@ -236,28 +247,23 @@ button {{ .workspace-sidebar {{ border-right: 1px solid {border_04}; - padding: 8px; - gap: 10px; + padding: 6px; + gap: 6px; + 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 {{ - padding: 8px; +.sidebar-top {{ display: flex; - flex-direction: column; - gap: 4px; -}} - -.sidebar-brand h1 {{ - margin: 0; - font-size: 24px; - line-height: 1; - color: {text_bright}; + align-items: center; + justify-content: flex-end; + padding: 2px 0; }} .sidebar-heading {{ @@ -268,7 +274,6 @@ button {{ text-transform: uppercase; }} -.sidebar-nav, .activity-list {{ display: flex; flex-direction: column; @@ -284,34 +289,43 @@ button {{ min-height: 0; }} -.sidebar-nav-button, -.workspace-button, -.theme-card, -.preset-card {{ - width: 100%; +.sidebar-footer {{ + margin-top: auto; + padding-top: 6px; + border-top: 1px solid {border_06}; +}} + +.sidebar-settings-btn {{ + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; padding: 0; border: 0; + border-radius: 6px; background: transparent; - text-align: left; + color: {text_dim}; }} -.sidebar-nav-button {{ - padding: 8px 10px; - color: {text_subtle}; +.sidebar-settings-btn:hover {{ + background: {border_06}; + color: {text_bright}; }} -.sidebar-nav-button:hover, -.sidebar-nav-button-active {{ - background: {border_06}; +.sidebar-settings-btn-active {{ + background: {accent_14}; color: {text_bright}; }} -.sidebar-section-header {{ - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 0 6px; +.workspace-button, +.theme-card, +.preset-card {{ + width: 100%; + padding: 0; + border: 0; + background: transparent; + text-align: left; }} .workspace-add {{ @@ -322,6 +336,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 +365,7 @@ button {{ position: relative; padding: 8px 10px 8px 14px; border: 1px solid transparent; + border-radius: 6px; display: flex; align-items: stretch; gap: 0; @@ -406,6 +425,13 @@ button {{ 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; @@ -436,6 +462,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 +486,7 @@ button {{ font-weight: 700; background: var(--workspace-accent, {accent}); color: {base}; + border-radius: 9999px; }} .workspace-unread-badge-error {{ @@ -483,6 +511,10 @@ button {{ overflow: hidden; }} +.workspace-status {{ + color: {text_bright}; +}} + .workspace-branch-row {{ color: {text_muted}; font-size: 10px; @@ -490,12 +522,63 @@ button {{ 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-settings-icon {{ + flex: 0 0 auto; + opacity: 0.7; +}} + +.sidebar-settings-btn:hover .sidebar-settings-icon, +.sidebar-settings-btn-active .sidebar-settings-icon {{ + opacity: 1.0; +}} + +.workspace-add-icon, +.workspace-tab-close-icon, +.workspace-branch-icon, +.workspace-ports-icon, +.workspace-runtime-icon, +.workspace-window-runtime-icon {{ + flex: 0 0 auto; + display: block; +}} + +.workspace-branch-icon, +.workspace-ports-icon {{ + 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 {{ @@ -508,12 +591,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 {{ @@ -589,6 +674,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, @@ -598,6 +685,64 @@ 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; + border-radius: 6px; +}} + +.settings-toggle-row:hover {{ + border-color: {accent_24}; + background: {accent_08}; +}} + +.settings-toggle-copy {{ + display: flex; + flex-direction: column; + gap: 4px; +}} + +.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; +}} + +.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 {{ display: flex; align-items: center; @@ -613,6 +758,7 @@ button {{ font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; + border-radius: 4px; }} .status-pill-inline {{ @@ -681,29 +827,7 @@ button {{ background: {base}; }} -.workspace-header-group {{ - display: flex; - align-items: center; - gap: 2px; - background: {border_04}; - padding: 2px; -}} - -.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, @@ -720,52 +844,34 @@ button {{ 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 {{ border: 1px solid {border_10}; background: transparent; + border-radius: 4px; }} -.workspace-header-action, .pane-action, .activity-action {{ 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 {{ @@ -775,21 +881,6 @@ button {{ 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; @@ -803,6 +894,9 @@ button {{ .settings-canvas {{ padding: 16px; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; }} .workspace-viewport {{ @@ -825,6 +919,39 @@ 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 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 {{ + 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; + border-radius: 4px; +}} + .workspace-viewport-overview .workspace-strip-canvas {{ position: relative; inset: auto; @@ -837,69 +964,232 @@ 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 {{ 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%); + position: relative; + padding: 0 6px 0 8px; display: flex; align-items: center; - justify-content: flex-start; - gap: 6px; - cursor: grab; + gap: 8px; user-select: none; + border-radius: 8px 8px 0 0; }} -.workspace-window-title {{ - color: {text_bright}; +.workspace-window-toolbar-tabs {{ min-width: 0; - font-size: 12px; - font-weight: 600; + display: flex; + align-items: center; + gap: 4px; + overflow-x: auto; + scrollbar-width: none; }} -.workspace-window-toolbar:active {{ - cursor: grabbing; +.workspace-window-toolbar-tabs::-webkit-scrollbar {{ + display: none; }} -.workspace-window-body {{ - flex: 1; - min-height: 0; - padding: {window_body_padding}px; - background: {surface}; +.workspace-window-toolbar-spacer {{ + flex: 1 1 auto; + min-width: 24px; + align-self: stretch; + cursor: grab; }} -.split-container {{ - width: 100%; - height: 100%; - min-width: 0; - min-height: 0; - display: flex; - gap: {split_gap}px; +.workspace-window-toolbar-spacer:active {{ + cursor: grabbing; }} -.split-child {{ +.workspace-window-tab {{ + flex: 0 0 auto; min-width: 0; - min-height: 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; }} -.pane-card {{ - position: relative; - width: 100%; - height: 100%; - min-width: 0; - min-height: 0; - display: flex; - flex-direction: column; - background: {elevated}; - border: {pane_border_width}px solid {border_10}; +.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-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; + 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}; + border-radius: 5px; + background: {overlay_05}; + color: {text_dim}; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; +}} + +.workspace-window-tab-add:hover {{ + background: {overlay_16}; + color: {text_bright}; +}} + +.workspace-window-tab-add-active {{ + border-color: {accent_24}; + background: {accent_12}; + color: {text_bright}; +}} + +.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 {{ + flex: 1; + min-height: 0; + padding: {window_body_padding}px; + background: {surface}; +}} + +.split-container {{ + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + display: flex; + gap: {split_gap}px; +}} + +.split-child {{ + min-width: 0; + min-height: 0; +}} + +.pane-frame {{ + position: relative; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; +}} + +.pane-card {{ + position: relative; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + background: {elevated}; + border: {pane_border_width}px solid {border_10}; overflow: hidden; + border-radius: 6px; }} .pane-card-active {{ @@ -925,6 +1215,7 @@ button {{ pointer-events: none; opacity: 0; z-index: 10; + border-radius: 6px; }} .pane-flash-ring-active {{ @@ -941,10 +1232,10 @@ button {{ justify-content: space-between; gap: 8px; background: {surface}; + border-radius: 6px 6px 0 0; }} .pane-toolbar-meta, -.surface-tab-label, .shortcut-label {{ color: {text_subtle}; font-size: 11px; @@ -953,22 +1244,141 @@ button {{ .pane-toolbar-meta {{ display: flex; align-items: center; - gap: 8px; + gap: 6px; min-width: 0; }} -.pane-toolbar-eyebrow {{ - text-transform: uppercase; - letter-spacing: 0.1em; - font-size: 10px; - color: {text_dim}; - font-family: "IBM Plex Mono", ui-monospace, monospace; +.pane-toolbar-meta-draggable {{ + cursor: grab; + padding: 0 2px; }} -.pane-toolbar-detail {{ - color: {text_subtle}; +.pane-toolbar-meta-draggable:active {{ + cursor: grabbing; +}} + +.pane-toolbar-kind-icon {{ + flex: 0 0 auto; + display: block; +}} + +.pane-toolbar-title {{ + font-size: 12px; + font-weight: 500; + color: {text_bright}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + 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-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; white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: {text_bright}; + 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; + 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-state-dismiss {{ + border: 0; + cursor: pointer; + pointer-events: auto; +}} + +.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-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; + background: {border_10}; + margin: 0 2px; +}} + +.pane-utility-icon {{ + display: block; }} .pane-action-cluster {{ @@ -1020,6 +1430,22 @@ button {{ padding: 0 6px; color: {text_muted}; white-space: nowrap; + border-radius: 4px; + 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 {{ @@ -1034,10 +1460,46 @@ button {{ color: {text_dim}; }} -.surface-tab[draggable] {{ +.surface-tab-draggable {{ 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}; @@ -1047,26 +1509,125 @@ button {{ 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-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; + align-items: center; + gap: 6px; + flex: 1 1 auto; }} -.surface-tab-title {{ +.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-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-title {{ +.surface-tab-active .surface-tab-primary {{ color: {text_bright}; }} -.surface-tab-label {{ - text-transform: uppercase; - letter-spacing: 0.08em; +.surface-tab-kind-icon {{ + flex: 0 0 auto; + 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; - color: {text_dim}; + font-weight: 700; + letter-spacing: 0.01em; + background: {border_08}; + 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}; +}} + +.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; }} .pane-utility {{ @@ -1079,15 +1640,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}; -}} - -.pane-utility-split {{ - color: {text_subtle}; + box-shadow: 0 1px 2px rgba(0,0,0,0.20); }} .workspace-window-drop-zone {{ @@ -1169,11 +1731,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 {{ @@ -1186,10 +1759,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 {{ @@ -1219,13 +1793,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 {{ @@ -1279,9 +1854,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 {{ @@ -1289,11 +1865,15 @@ button {{ }} .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}; @@ -1323,8 +1903,7 @@ button {{ padding: 0 6px; }} -.workspace-main-overview .surface-tab-title, -.workspace-main-overview .surface-tab-label {{ +.workspace-main-overview .surface-tab-primary {{ font-size: 10px; }} @@ -1351,12 +1930,14 @@ button {{ 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 {{ @@ -1376,11 +1957,22 @@ button {{ 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 {{ @@ -1412,10 +2004,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 {{ @@ -1423,20 +2016,54 @@ button {{ display: flex; flex-direction: column; gap: 3px; + border-radius: 6px; }} .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; @@ -1446,15 +2073,27 @@ 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; + border-radius: 4px; +}} + .notification-count-pill {{ font-size: 10px; font-weight: 600; color: {text_dim}; padding: 2px 6px; background: {border_06}; + border-radius: 12px; }} .notification-count-unread {{ @@ -1468,6 +2107,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; @@ -1478,11 +2136,13 @@ button {{ }} .notification-row-button {{ + display: block; width: 100%; border: 0; padding: 0; background: transparent; text-align: left; + cursor: pointer; }} .notification-row {{ @@ -1492,6 +2152,43 @@ button {{ padding: 10px; background: {border_03}; transition: background 0.14s ease-in-out; + border-radius: 6px; +}} + +.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; + border-radius: 6px; +}} + +.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 {{ @@ -1503,6 +2200,7 @@ button {{ width: 8px; height: 8px; margin-top: 4px; + border-radius: 9999px; }} .notification-dot-unread {{ @@ -1580,9 +2278,24 @@ button {{ 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; + display: block; +}} + .notification-clear:hover {{ background: {error_16}; color: {error}; @@ -1632,6 +2345,7 @@ button {{ .preset-card {{ border: 1px solid {border_08}; padding: 10px; + border-radius: 6px; }} .theme-card:hover, @@ -1685,7 +2399,7 @@ button {{ @media (max-width: 1180px) {{ .app-shell {{ - grid-template-columns: 228px minmax(0, 1fr); + grid-template-columns: 196px minmax(0, 1fr); }} .attention-panel {{ @@ -1699,6 +2413,7 @@ button {{ 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(), @@ -1718,13 +2433,14 @@ button {{ 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), 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 new file mode 100644 index 00000000..6235a84f --- /dev/null +++ b/docs/notifications.md @@ -0,0 +1,157 @@ +# 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 stop --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. + +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. + +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/release.md b/docs/release.md index ac0f6d23..4d94af7e 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,12 +72,20 @@ 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. - 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: @@ -99,3 +111,11 @@ 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 +``` + +That reinstalls the repo-local app into Cargo's bin directory and repoints the desktop entry to that installed binary. diff --git a/docs/taskersctl.md b/docs/taskersctl.md new file mode 100644 index 00000000..bd6c6d06 --- /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 stop --title "Codex" --message "Turn complete" +taskersctl agent-hook stop --message "Finished" +``` + +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 + +- 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. diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 00000000..17c4079a --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,127 @@ +# Daily Usage + +This guide is the quickest way to get oriented in the active Taskers app. + +## Mental Model + +Taskers has four layers of layout: + +- A workspace contains top-level workspace windows. +- 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. +- 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. + +## 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 surface tabs of either kind. The active surface 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 Linux cgroup settings stay disabled for embedded panes + +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 + +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. +- 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 + +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 + +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” +- 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, install the app into Cargo's bin directory and repoint the launcher there: + +```bash +bash scripts/install-dev-desktop-entry.sh +``` + +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/build_ghostty_runtime_bundle.sh b/scripts/build_ghostty_runtime_bundle.sh index 8660374b..7c5790d1 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 9d58ea27..e3da507e 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 2412d7cd..4ce1fcc7 100755 --- a/scripts/install-dev-desktop-entry.sh +++ b/scripts/install-dev-desktop-entry.sh @@ -3,31 +3,47 @@ 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_wrapper_path}" <>"\${log_dir}/desktop-launch.log" 2>&1 EOF -chmod +x "${launcher_path}" +chmod +x "${desktop_wrapper_path}" 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_wrapper_path}" echo "installed ${desktop_entry_path}" diff --git a/vendor/ghostty/include/taskers_ghostty_bridge.h b/vendor/ghostty/include/taskers_ghostty_bridge.h index f3296b77..aa9be3bc 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 51d52a72..0d507bca 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(); @@ -149,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); } @@ -174,6 +215,24 @@ fn taskersSurfaceConfig(app: anytype, ptr: *const Host, opts: *const SurfaceOpti return try Config.new(alloc, &cloned); } +fn applyTaskersEmbeddedSurfaceInvariants( + _: std.mem.Allocator, + config: *configpkg.Config, + command_argv: []const [:0]const 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. + 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; +} + fn duplicateStringList( alloc: std.mem.Allocator, entries_ptr: ?[*]const [*:0]const u8, @@ -198,3 +257,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, 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) { + .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); +}