diff --git a/crates/okena-app-core/src/settings.rs b/crates/okena-app-core/src/settings.rs index b79bc25e..0fc769d6 100644 --- a/crates/okena-app-core/src/settings.rs +++ b/crates/okena-app-core/src/settings.rs @@ -130,6 +130,12 @@ impl SettingsState { self.save_and_notify(cx); } + /// Allow or deny terminal apps reading the system clipboard via OSC 52. + pub fn set_allow_clipboard_read(&mut self, value: bool, cx: &mut Context) { + self.settings.allow_clipboard_read = value; + self.save_and_notify(cx); + } + /// Set file finder "show ignored" preference (persisted default for future opens). pub fn set_file_finder_show_ignored(&mut self, value: bool, cx: &mut Context) { self.settings.file_finder.show_ignored = value; diff --git a/crates/okena-app-core/src/workspace/actions/execute/project.rs b/crates/okena-app-core/src/workspace/actions/execute/project.rs index a609eeda..0c143535 100644 --- a/crates/okena-app-core/src/workspace/actions/execute/project.rs +++ b/crates/okena-app-core/src/workspace/actions/execute/project.rs @@ -310,6 +310,7 @@ mod set_show_in_overview_tests { is_remote: false, connection_id: None, service_terminals: HashMap::new(), + agent_sessions: Default::default(), default_shell: None, hook_terminals: HashMap::new(), pinned: false, diff --git a/crates/okena-app/src/app/headless.rs b/crates/okena-app/src/app/headless.rs index 82d77988..fc193ecd 100644 --- a/crates/okena-app/src/app/headless.rs +++ b/crates/okena-app/src/app/headless.rs @@ -347,6 +347,9 @@ impl HeadlessApp { // Collect exit events for service manager processing let mut exit_events: Vec<(String, Option)> = Vec::new(); + // Terminals that produced output this batch — used to drain the + // runtime-only `remote_dirty` edge (agent status, OSC 9001). + let mut data_terminal_ids: Vec = Vec::new(); // Process first event (broadcasting handled by PtyOutputSink in reader threads) match &event { @@ -355,6 +358,8 @@ impl HeadlessApp { if let Some(terminal) = terminals_guard.get(terminal_id) { terminal.process_output(data); } + drop(terminals_guard); + data_terminal_ids.push(terminal_id.clone()); } PtyEvent::Exit { terminal_id, exit_code } => { pty_manager.cleanup_exited(terminal_id); @@ -370,6 +375,8 @@ impl HeadlessApp { if let Some(terminal) = terminals_guard.get(terminal_id) { terminal.process_output(data); } + drop(terminals_guard); + data_terminal_ids.push(terminal_id.clone()); } PtyEvent::Exit { terminal_id, exit_code } => { pty_manager.cleanup_exited(terminal_id); @@ -378,6 +385,19 @@ impl HeadlessApp { } } + // Push runtime-only terminal state (agent status, OSC 9001) to + // remote clients: drain the remote-dirty edge for terminals that + // produced output this batch and bump state_version if any + // changed. Without this, headless serializes terminal_agent_status + // on GetState but never signals subscribers to re-fetch, so agent + // indicators would stay stale until an unrelated change. Mirrors + // the desktop loop (Okena::process_remote_dirty). + if !data_terminal_ids.is_empty() + && super::notifications::drain_remote_dirty(&terminals, &data_terminal_ids) + { + state_version.send_modify(|v| *v += 1); + } + if !exit_events.is_empty() { cx.update(|cx| { // Let service manager handle service terminals diff --git a/crates/okena-app/src/app/mod.rs b/crates/okena-app/src/app/mod.rs index 076e2d24..8fff6168 100644 --- a/crates/okena-app/src/app/mod.rs +++ b/crates/okena-app/src/app/mod.rs @@ -922,6 +922,17 @@ impl Okena { // Stamp project activity for any command that finished // this batch (OSC 133 ;D), independent of bell/OSC alerts. this.process_command_finished_activity(&dirty_terminal_ids, cx); + // Push runtime-only terminal state (e.g. agent status, + // OSC 9001) to remote clients by bumping state_version + // when any terminal marked itself remote-dirty this batch. + this.process_remote_dirty(&dirty_terminal_ids); + // Persist any captured AI agent session (OSC 9001 lbl=) + // into its project so a pane can offer to resume it after + // a restart. + this.process_agent_session_dirty(&dirty_terminal_ids, cx); + // Answer (or, when disabled, drop) OSC 52 clipboard + // read requests queued by these terminals. + this.process_clipboard_reads(&dirty_terminal_ids, cx); } if !exit_events.is_empty() { diff --git a/crates/okena-app/src/app/notifications.rs b/crates/okena-app/src/app/notifications.rs index 762df1ca..5803ccf3 100644 --- a/crates/okena-app/src/app/notifications.rs +++ b/crates/okena-app/src/app/notifications.rs @@ -24,6 +24,25 @@ use notify_rust::Notification; use super::Okena; +/// Drain the generic "remote-visible state changed" edge for the given +/// terminals; returns true if any was set. Shared by the desktop ([`Okena`]) +/// and the headless PTY loops so they can't diverge on whether a runtime-only +/// terminal signal (currently agent status, `OSC 9001`) bumps the remote +/// `state_version`. See [`Okena::process_remote_dirty`]. +pub(crate) fn drain_remote_dirty( + terminals: &crate::views::window::TerminalsRegistry, + terminal_ids: &[String], +) -> bool { + let reg = terminals.lock(); + let mut dirty = false; + for tid in terminal_ids { + if reg.get(tid).is_some_and(|t| t.take_remote_dirty()) { + dirty = true; + } + } + dirty +} + /// Where a clicked desktop notification should send the user: the exact /// terminal that raised the alert. #[derive(Clone, Debug)] @@ -229,6 +248,125 @@ impl Okena { self.bump_activity_for_terminals(finished.iter().map(|s| s.as_str()), cx); } + /// Drain the generic "remote-visible state changed" edge for each dirty + /// terminal and bump the remote `state_version` once if any was marked. + /// + /// Some terminal state is runtime-only on `Terminal` (currently agent + /// status, `OSC 9001`) — it never flows through the workspace, so the + /// workspace-change observer that normally bumps `state_version` won't fire + /// for it. Any such signal stores into the shared `remote_dirty` edge on + /// `Terminal`; this single drain turns it into a push, so subscribed remote + /// / mobile clients re-fetch `/v1/state`. Generic by design — a new + /// runtime-only signal reuses this drain instead of adding its own. + pub(super) fn process_remote_dirty(&mut self, dirty_terminal_ids: &[String]) { + if drain_remote_dirty(&self.terminals, dirty_terminal_ids) { + self.state_version.send_modify(|v| *v += 1); + } + } + + /// Drain the per-terminal "agent session changed" edge and persist any new + /// session into its project's `agent_sessions` (workspace.json). The runtime + /// capture lives on `Terminal` (sticky — it survives `st=clear`); this drain + /// is what makes it durable, so a pane can offer to resume its agent after a + /// restart. + pub(super) fn process_agent_session_dirty( + &mut self, + dirty_terminal_ids: &[String], + cx: &mut Context, + ) { + // Collect (project, terminal, session) for terminals whose edge fired, + // releasing the registry lock before mutating the workspace. + let mut to_persist: Vec<(String, String, okena_core::agent_session::AgentSession)> = + Vec::new(); + { + let reg = self.terminals.lock(); + for tid in dirty_terminal_ids { + let Some(term) = reg.get(tid) else { continue }; + if !term.take_agent_session_dirty() { + continue; + } + let Some(session) = term.agent_session() else { continue }; + if let Some(pid) = self + .workspace + .read(cx) + .find_project_for_terminal(tid) + .map(|p| p.id.clone()) + { + to_persist.push((pid, tid.clone(), session)); + } + } + } + if to_persist.is_empty() { + return; + } + self.workspace.update(cx, |ws, cx| { + for (pid, tid, session) in to_persist { + ws.set_agent_session(&pid, &tid, session, cx); + } + }); + } + + /// Answer (or silently deny) OSC 52 clipboard *read* requests + /// (`OSC 52 ; c ; ?`) queued by terminals that produced output this batch. + /// Runs here, in the PTY event loop, because this is where the opt-in + /// setting and the system clipboard are reachable — the event listener + /// that enqueues the requests has neither. + /// + /// Clipboard read is gated behind `allow_clipboard_read` (off by default): + /// a program reading the clipboard can exfiltrate whatever the user has + /// copied. When allowed, every requesting terminal gets the current + /// clipboard contents; when not, the requests are dropped without a reply + /// so the per-terminal queues stay bounded. + pub(super) fn process_clipboard_reads( + &mut self, + dirty_terminal_ids: &[String], + cx: &mut Context, + ) { + // Collect the terminals that actually have a queued read. Almost every + // batch has none, so we bail before touching settings or the clipboard. + let pending: Vec = { + let reg = self.terminals.lock(); + dirty_terminal_ids + .iter() + .filter(|tid| reg.get(*tid).is_some_and(|t| t.has_pending_clipboard_reads())) + .cloned() + .collect() + }; + if pending.is_empty() { + return; + } + + // Read the opt-in setting once for the whole batch. + let allow = crate::settings::settings_entity(cx) + .read(cx) + .get() + .allow_clipboard_read; + + if allow { + // Read the system clipboard once; hand the same contents to every + // requesting terminal. An empty/imageless clipboard answers "". + let content = cx + .read_from_clipboard() + .and_then(|item| item.text()) + .unwrap_or_default(); + let reg = self.terminals.lock(); + for tid in &pending { + if let Some(term) = reg.get(tid) { + term.answer_clipboard_reads(&content); + } + } + } else { + // Silently deny: drop the queued requests without replying so the + // queue stays bounded while the feature is off. + let reg = self.terminals.lock(); + for tid in &pending { + if let Some(term) = reg.get(tid) { + term.drop_clipboard_reads(); + } + } + } + } + /// Resolve each terminal id to its owning project and bump that project's /// activity timestamp once. Deduplicates projects so a batch touching /// several terminals of the same project only notifies/persists once. diff --git a/crates/okena-app/src/app/remote_commands.rs b/crates/okena-app/src/app/remote_commands.rs index 43699fb7..8473b740 100644 --- a/crates/okena-app/src/app/remote_commands.rs +++ b/crates/okena-app/src/app/remote_commands.rs @@ -287,6 +287,17 @@ pub(crate) async fn remote_command_loop( }).collect() }; + // Snapshot per-terminal agent status (OSC 9001) for terminals + // that currently report one, so remote/mobile clients can show + // the same indicators as the desktop. + let agent_status_map: HashMap = { + let registry = terminals.lock(); + registry + .iter() + .filter_map(|(id, term)| term.agent_status().map(|s| (id.clone(), s))) + .collect() + }; + // Build a lookup map for projects let project_map: std::collections::HashMap<&str, &crate::workspace::state::ProjectData> = data.projects.iter().map(|p| (p.id.as_str(), p)).collect(); @@ -333,6 +344,18 @@ pub(crate) async fn remote_command_loop( show_in_overview: api_project_visibility(&p.id, hidden_project_ids), layout: p.layout.as_ref().map(|l| l.to_api_with_sizes(&size_map)), terminal_names: p.terminal_names.clone(), + terminal_agent_status: p + .layout + .as_ref() + .map(|l| { + l.collect_terminal_ids() + .into_iter() + .filter_map(|tid| { + agent_status_map.get(&tid).map(|s| (tid, s.clone())) + }) + .collect() + }) + .unwrap_or_default(), git_status, folder_color: p.folder_color, services, diff --git a/crates/okena-app/src/keybindings/config.rs b/crates/okena-app/src/keybindings/config.rs index 11565cd2..8582a6b5 100644 --- a/crates/okena-app/src/keybindings/config.rs +++ b/crates/okena-app/src/keybindings/config.rs @@ -288,6 +288,15 @@ impl KeybindingConfig { vec![KeybindingEntry::new("cmd-down", Some("TerminalPane"))], ); + bindings.insert( + "JumpToPreviousFailedCommand".to_string(), + vec![KeybindingEntry::new("cmd-shift-up", Some("TerminalPane"))], + ); + bindings.insert( + "JumpToNextFailedCommand".to_string(), + vec![KeybindingEntry::new("cmd-shift-down", Some("TerminalPane"))], + ); + bindings.insert( "TogglePaneSwitcher".to_string(), vec![ diff --git a/crates/okena-app/src/keybindings/descriptions.rs b/crates/okena-app/src/keybindings/descriptions.rs index b5bc30bc..486f118f 100644 --- a/crates/okena-app/src/keybindings/descriptions.rs +++ b/crates/okena-app/src/keybindings/descriptions.rs @@ -5,7 +5,7 @@ use super::{ AddTab, Cancel, CheckForUpdates, ClearFocus, CloseSearch, CloseTerminal, Copy, CreateWorktree, FocusActiveProject, FocusDown, FocusLeft, FocusNextTerminal, FocusPrevTerminal, FocusRight, FocusSidebar, FocusUp, FullscreenNextTerminal, FullscreenPrevTerminal, InstallUpdate, - JumpToNextPrompt, JumpToPreviousPrompt, + JumpToNextFailedCommand, JumpToNextPrompt, JumpToPreviousFailedCommand, JumpToPreviousPrompt, MinimizeTerminal, NewProject, NewWindow, OpenSettingsFile, Paste, Quit, ResetZoom, ScrollDown, ScrollUp, Search, SearchNext, SearchPrev, SendEscape, ShowCommandPalette, ShowDiffViewer, ReviewChanges, ShowContentSearch, ShowFileSearch, ShowHookLog, ShowLogConsole, ShowKeybindings, ShowProjectSwitcher, ShowSessionManager, @@ -248,6 +248,24 @@ pub fn get_action_descriptions() -> HashMap<&'static str, ActionDescription> { factory: || Box::new(JumpToNextPrompt), }, ); + map.insert( + "JumpToPreviousFailedCommand", + ActionDescription { + name: "Jump to Previous Failed Command", + description: "Scroll to the previous command that exited with a non-zero status (OSC 133)", + category: "Terminal", + factory: || Box::new(JumpToPreviousFailedCommand), + }, + ); + map.insert( + "JumpToNextFailedCommand", + ActionDescription { + name: "Jump to Next Failed Command", + description: "Scroll forward to the next command that exited with a non-zero status (OSC 133)", + category: "Terminal", + factory: || Box::new(JumpToNextFailedCommand), + }, + ); // Zoom actions map.insert( diff --git a/crates/okena-app/src/keybindings/mod.rs b/crates/okena-app/src/keybindings/mod.rs index b6e91ee6..621e0a2b 100644 --- a/crates/okena-app/src/keybindings/mod.rs +++ b/crates/okena-app/src/keybindings/mod.rs @@ -67,6 +67,7 @@ pub use okena_views_terminal::actions::{ SendTab, SendBacktab, ZoomIn, ZoomOut, ResetZoom, ToggleFullscreen, FullscreenNextTerminal, FullscreenPrevTerminal, JumpToPreviousPrompt, JumpToNextPrompt, + JumpToPreviousFailedCommand, JumpToNextFailedCommand, }; // Sidebar-specific actions (defined in okena-views-sidebar crate) @@ -299,6 +300,8 @@ fn create_keybinding(action: &str, keystroke: &str, context: Option<&str>) -> Op "SearchPrev" => Some(KeyBinding::new(keystroke, SearchPrev, context)), "JumpToPreviousPrompt" => Some(KeyBinding::new(keystroke, JumpToPreviousPrompt, context)), "JumpToNextPrompt" => Some(KeyBinding::new(keystroke, JumpToNextPrompt, context)), + "JumpToPreviousFailedCommand" => Some(KeyBinding::new(keystroke, JumpToPreviousFailedCommand, context)), + "JumpToNextFailedCommand" => Some(KeyBinding::new(keystroke, JumpToNextFailedCommand, context)), "CloseSearch" => Some(KeyBinding::new(keystroke, CloseSearch, context)), "ShowKeybindings" => Some(KeyBinding::new(keystroke, ShowKeybindings, context)), "ShowSessionManager" => Some(KeyBinding::new(keystroke, ShowSessionManager, context)), diff --git a/crates/okena-app/src/views/overlays/project_switcher.rs b/crates/okena-app/src/views/overlays/project_switcher.rs index 35ad8a98..d8e3120f 100644 --- a/crates/okena-app/src/views/overlays/project_switcher.rs +++ b/crates/okena-app/src/views/overlays/project_switcher.rs @@ -582,6 +582,7 @@ mod tests { is_remote: false, connection_id: None, service_terminals: HashMap::new(), + agent_sessions: Default::default(), default_shell: None::, hook_terminals: HashMap::::new(), pinned: false, diff --git a/crates/okena-app/src/views/overlays/settings_panel/render_general.rs b/crates/okena-app/src/views/overlays/settings_panel/render_general.rs index d33d2a48..c1e60f24 100644 --- a/crates/okena-app/src/views/overlays/settings_panel/render_general.rs +++ b/crates/okena-app/src/views/overlays/settings_panel/render_general.rs @@ -192,6 +192,17 @@ impl SettingsPanel { )) }) }) + .child(section_header("Clipboard", &t, cx)) + .child( + section_container(&t).child(self.render_toggle( + "clipboard-read", + "Allow Clipboard Read (OSC 52)", + s.allow_clipboard_read, + false, + |state, val, cx| state.set_allow_clipboard_read(val, cx), + cx, + )), + ) } fn render_header_density_row( diff --git a/crates/okena-cli/src/resolve.rs b/crates/okena-cli/src/resolve.rs index 54e8e437..b54ddefb 100644 --- a/crates/okena-cli/src/resolve.rs +++ b/crates/okena-cli/src/resolve.rs @@ -301,6 +301,7 @@ mod tests { show_in_overview: true, layout, terminal_names, + terminal_agent_status: std::collections::HashMap::new(), git_status: None, folder_color: FolderColor::Default, services: vec![], diff --git a/crates/okena-core/src/agent_harness.rs b/crates/okena-core/src/agent_harness.rs new file mode 100644 index 00000000..b72bf7b1 --- /dev/null +++ b/crates/okena-core/src/agent_harness.rs @@ -0,0 +1,88 @@ +//! Per-harness agent capabilities — *resume a session* and *parse a transcript* +//! — keyed by agent id, kept **gpui-free** so they work in the desktop app AND +//! in headless/remote (where resume/stats may be driven from a mobile client). +//! +//! The agent-status protocol is harness-agnostic: a pane reports its `agent` id +//! (`"claude-code"`, `"codex"`, …) plus a `session_id` via `OSC 9001` (see +//! [`crate::agent_session`]). This registry is how Okena turns that id into +//! actions, without baking any single agent's specifics into the core or app. +//! Each harness implementation lives in its matching `okena-ext-*` crate and is +//! registered at startup via [`init`]; an unknown id simply has no entry, so its +//! session is stored/displayed but not resumable until a harness for it exists. +//! New harnesses are therefore purely additive. + +use std::collections::HashMap; +use std::path::Path; +use std::sync::{Arc, OnceLock}; + +/// Best-effort stats parsed from a session transcript, for the session-info +/// view. Each harness fills what its format exposes; absent fields stay `None`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct TranscriptStats { + pub user_messages: usize, + pub assistant_messages: usize, + pub tool_calls: usize, + pub input_tokens: Option, + pub output_tokens: Option, +} + +/// What one agent harness (Claude Code, Codex, …) knows how to do with its own +/// sessions. Implemented in the matching `okena-ext-*` crate. Must stay +/// gpui-free so headless/remote can use it too. +pub trait AgentHarness: Send + Sync { + /// Harness id; must equal the `agent` id agents report on the wire + /// (e.g. `"claude-code"`, `"codex"`). + fn id(&self) -> &str; + + /// Build the argv that resumes `session_id` from working dir `cwd`, or + /// `None` if this harness can't / shouldn't resume. `session_id` is already + /// [`is_uuid_like`](crate::agent_session::is_uuid_like)-validated by the + /// caller. The argv is launched as a command in the pane and is **never** + /// passed through a shell, so no quoting/escaping is needed here. + fn resume_command(&self, session_id: &str, cwd: &Path) -> Option>; + + /// Parse a transcript file into [`TranscriptStats`], or `None` when the path + /// is unreadable / unsupported. Best-effort — partial stats are fine. + fn transcript_stats(&self, transcript_path: &Path) -> Option { + let _ = transcript_path; + None + } +} + +/// Registry of harnesses keyed by [`AgentHarness::id`]. Built once at startup +/// (desktop + headless) and installed via [`init`]. +#[derive(Default)] +pub struct AgentHarnessRegistry { + by_id: HashMap>, +} + +impl AgentHarnessRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Register a harness (last registration for a given id wins). + pub fn register(&mut self, harness: Arc) { + self.by_id.insert(harness.id().to_string(), harness); + } + + pub fn get(&self, agent_id: &str) -> Option<&Arc> { + self.by_id.get(agent_id) + } +} + +static REGISTRY: OnceLock = OnceLock::new(); + +/// Install the process-wide harness registry. Call once at startup; later calls +/// are ignored (first wins), so desktop and headless each build their own +/// before serving requests. +pub fn init(registry: AgentHarnessRegistry) { + let _ = REGISTRY.set(registry); +} + +/// Look up the harness for an agent id, if one is registered. Returns `None` +/// before [`init`] or for an unknown id (caller treats that as "no resume/stats +/// for this agent"). +pub fn for_agent(agent_id: &str) -> Option<&'static Arc> { + REGISTRY.get()?.get(agent_id) +} diff --git a/crates/okena-core/src/agent_session.rs b/crates/okena-core/src/agent_session.rs new file mode 100644 index 00000000..cb39c04a --- /dev/null +++ b/crates/okena-core/src/agent_session.rs @@ -0,0 +1,82 @@ +//! Durable agent session identity captured from the agent-status OSC's `lbl=`. +//! +//! Unlike [`crate::agent_status::AgentStatus`] (ephemeral, runtime-only — it +//! drives the indicator and is dropped on `st=clear` / restart), this is the +//! **sticky** identity of the AI session running in a pane: its `session_id` +//! (and transcript path). It is captured from a `session_id` label, survives +//! `st=clear`, and is meant to be *persisted* so a pane can offer to resume its +//! session (`claude --resume `) after a restart and surface transcript +//! stats. Kept deliberately separate from the ephemeral status so that status +//! can stay runtime-only. +//! +//! The values arrive in-band from an **untrusted** byte stream (any process in +//! the pane can emit the OSC), so [`is_uuid_like`] gates the `session_id` before +//! it is ever stored or handed to a resume command. + +use serde::{Deserialize, Serialize}; + +/// The agent session running (or last run) in a pane, captured from the +/// agent-status OSC `lbl=` `agent` / `session_id` / `transcript_path` keys. +/// +/// Deliberately harness-agnostic: the [`agent`](Self::agent) id selects which +/// harness knows how to *resume* it and *parse* its transcript (Claude Code, +/// Codex, …) via the harness registry. An unknown agent id is still stored and +/// displayed — it just has no resume/stats until a harness for it is +/// registered, so new harnesses are additive. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentSession { + /// Harness id that produced this session — matches the extension id + /// (`"claude-code"`, `"codex"`, …). Selects the per-harness resume command + /// and transcript parser. Free-form on the wire so a new harness needs no + /// core change. + pub agent: String, + /// The agent's own session id (e.g. Claude Code / Codex `session_id`). + /// Always [`is_uuid_like`]-validated before construction here, since it is + /// untrusted in-band data that may later be passed to a resume command. + pub session_id: String, + /// Absolute path to the session transcript, when the agent reported one. + /// Format/location is the harness's concern; here it is just an opaque path + /// handed to that harness's transcript parser. Drives the stats view. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub transcript_path: Option, +} + +/// Conservative check that `s` is a canonical UUID (`8-4-4-4-12` lowercase- or +/// uppercase-hex groups joined by hyphens). Guards the in-band, untrusted +/// `session_id` before it is stored or used to build a resume command, so a +/// hostile pane can't plant an arbitrary string there. +pub fn is_uuid_like(s: &str) -> bool { + const GROUPS: [usize; 5] = [8, 4, 4, 4, 12]; + let mut parts = s.split('-'); + for len in GROUPS { + match parts.next() { + Some(p) if p.len() == len && p.bytes().all(|b| b.is_ascii_hexdigit()) => {} + _ => return false, + } + } + // Reject trailing junk after the final group ("…-12345-extra"). + parts.next().is_none() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_canonical_uuid() { + assert!(is_uuid_like("3b9c1f2a-4d5e-6f70-8a9b-0c1d2e3f4a5b")); + assert!(is_uuid_like("3B9C1F2A-4D5E-6F70-8A9B-0C1D2E3F4A5B")); + } + + #[test] + fn rejects_non_uuid() { + assert!(!is_uuid_like("")); + assert!(!is_uuid_like("not-a-uuid")); + assert!(!is_uuid_like("3b9c1f2a4d5e6f708a9b0c1d2e3f4a5b")); // no hyphens + assert!(!is_uuid_like("3b9c1f2a-4d5e-6f70-8a9b-0c1d2e3f4a5b-extra")); // trailing + assert!(!is_uuid_like("zzzzzzzz-4d5e-6f70-8a9b-0c1d2e3f4a5b")); // non-hex + assert!(!is_uuid_like("3b9c1f2a-4d5e-6f70-8a9b-0c1d2e3f4a5")); // short group + // Defends against an injection attempt smuggled as a session id. + assert!(!is_uuid_like("$(rm -rf ~)")); + } +} diff --git a/crates/okena-core/src/agent_status.rs b/crates/okena-core/src/agent_status.rs new file mode 100644 index 00000000..e65e2c25 --- /dev/null +++ b/crates/okena-core/src/agent_status.rs @@ -0,0 +1,287 @@ +//! Agent status — what an AI coding agent reports about itself in a pane. +//! +//! An agent (e.g. Claude Code) pushes its state in-band via the agent-status +//! OSC sequence (parsed in `okena-terminal`); it then surfaces in the tab, the +//! sidebar "Agents" section, and the remote API. The model is intentionally +//! **open**: a small fixed [`AgentLifecycle`] drives color / sort / notification, +//! while the free-form [`AgentStatus::custom`] string and [`AgentStatus::labels`] +//! carry whatever the agent wants and are rendered verbatim. This is the +//! deliberate inversion of a closed status enum — canonical states stay fixed, +//! agents stay free to attach arbitrary text. +//! +//! Runtime-only: agent status is ephemeral and never persisted. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// Caps on the agent-supplied free-form fields, mirroring the OSC 99 caps in +/// `okena-terminal`. The terminal byte stream is untrusted — any process in a +/// pane can push an `OSC 9001` with an arbitrarily large `msg`/`lbl`. Unlike a +/// one-shot notification body, an agent status is held on the `Terminal` *and* +/// re-serialized into every remote `/v1/state` response, so an unbounded value +/// would be pinned in memory and amplified to every connected client. Clamp at +/// the point the status is built ([`AgentStatus::new_clamped`]). +pub const MAX_CUSTOM_LEN: usize = 4096; +/// Max number of `labels` entries kept (lowest keys first; extras dropped). +pub const MAX_LABELS: usize = 32; +/// Max byte length of a single label key (over-long keys are truncated). +pub const MAX_LABEL_KEY_LEN: usize = 128; +/// Max byte length of a single label value (over-long values are truncated). +pub const MAX_LABEL_VALUE_LEN: usize = 1024; + +/// Lifecycle state an agent reports about itself. +/// +/// The token names match the `st=` values of the agent-status OSC (`clear` is +/// not a lifecycle — it removes the status, handled by the parser). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentLifecycle { + /// The agent is actively working. + Working, + /// The agent is blocked waiting on the user (permission, input, …). + Blocked, + /// The agent finished its turn. + Done, + /// The agent is idle / at rest. + Idle, +} + +impl AgentLifecycle { + /// Parse the `st=` token from the agent-status OSC. Returns `None` for + /// unknown tokens (callers treat that as "ignore"). `clear` is handled by + /// the caller as "remove status", not a lifecycle, so it is not parsed here. + pub fn from_token(token: &str) -> Option { + match token { + "working" => Some(Self::Working), + "blocked" => Some(Self::Blocked), + "done" => Some(Self::Done), + "idle" => Some(Self::Idle), + _ => None, + } + } + + /// Sort / attention priority, highest first: a blocked agent needs you now, + /// a done agent wants acknowledging, working is in flight, idle is at rest. + pub fn priority(self) -> u8 { + match self { + Self::Blocked => 3, + Self::Done => 2, + Self::Working => 1, + Self::Idle => 0, + } + } + + /// Whether a transition *into* this state should raise a desktop + /// notification. `blocked` and `done` are the actionable edges; `working` + /// and `idle` are silent. (Visibility gating — e.g. not pinging for the + /// pane you're watching — is the GPUI layer's job, not this enum's.) + pub fn notifies(self) -> bool { + matches!(self, Self::Blocked | Self::Done) + } + + /// Short human-readable label for this lifecycle (e.g. a tab tooltip). + /// Distinct from the `st=` wire token ([`from_token`]) even though they + /// currently coincide — one is for display, the other for the protocol. + pub fn label(self) -> &'static str { + match self { + Self::Working => "working", + Self::Blocked => "blocked", + Self::Done => "done", + Self::Idle => "idle", + } + } + + /// The theme palette slot this lifecycle maps to: blocked needs attention + /// (error), working is in flight (warning yellow), done is finished + /// (success), idle is at rest (muted). The single source of truth for agent + /// coloring, shared by the tab indicator and the sidebar Agents list. + /// Returns the raw `u32` color so `okena-core` needn't depend on gpui; + /// callers wrap it with `rgb(..)`. + pub fn theme_color(self, theme: &crate::theme::ThemeColors) -> u32 { + match self { + Self::Blocked => theme.error, + Self::Working => theme.term_yellow, + Self::Done => theme.success, + Self::Idle => theme.text_muted, + } + } +} + +/// A status an agent reports about itself in a terminal pane. +/// +/// Pushed in-band via the agent-status OSC and surfaced in the tab, the sidebar +/// "Agents" section, and the remote API. Runtime-only — never persisted. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentStatus { + pub lifecycle: AgentLifecycle, + /// Free-form, human-readable status set by the agent, e.g. + /// `"running tests 3/5"`. Rendered verbatim. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom: Option, + /// Optional structured key/value extras the agent attaches. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub labels: BTreeMap, +} + +impl AgentStatus { + /// A status carrying only a lifecycle, no custom text or labels. + pub fn new(lifecycle: AgentLifecycle) -> Self { + Self { + lifecycle, + custom: None, + labels: BTreeMap::new(), + } + } + + /// Build a status from raw decoded OSC fields, clamping `custom` and + /// `labels` to bounded sizes (see the `MAX_*` consts) so a hostile pane + /// can't pin unbounded memory or amplify it to every remote client. An + /// empty `custom` (e.g. a `msg=` that decodes to `""`) collapses to `None`, + /// so a notifying state still falls back to its default body instead of an + /// empty string. This is the only constructor the OSC parser uses. + pub fn new_clamped( + lifecycle: AgentLifecycle, + custom: Option, + labels: BTreeMap, + ) -> Self { + let custom = custom.and_then(|mut c| { + truncate_to_bytes(&mut c, MAX_CUSTOM_LEN); + (!c.is_empty()).then_some(c) + }); + Self { + lifecycle, + custom, + labels: clamp_labels(labels), + } + } +} + +/// Truncate `s` in place to at most `max` bytes, never splitting a UTF-8 char +/// (drops the straddling char whole). Used to bound agent-supplied text. +fn truncate_to_bytes(s: &mut String, max: usize) { + if s.len() <= max { + return; + } + let mut end = max; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + s.truncate(end); +} + +/// Bound a labels map against hostile input: keep at most [`MAX_LABELS`] +/// entries (lowest keys first) and truncate over-long keys/values. Truncated +/// keys may collide; the map dedups them, which is fine — this is a memory +/// bound, not a fidelity guarantee. +fn clamp_labels(labels: BTreeMap) -> BTreeMap { + labels + .into_iter() + .take(MAX_LABELS) + .map(|(mut k, mut v)| { + truncate_to_bytes(&mut k, MAX_LABEL_KEY_LEN); + truncate_to_bytes(&mut v, MAX_LABEL_VALUE_LEN); + (k, v) + }) + .collect() +} + +/// Parse a flat `{"k":"v"}` JSON object into labels for the agent-status OSC's +/// `lbl=` field. Anything that isn't a string→string object yields an empty +/// map, so a malformed field is simply ignored. Lives here (next to the type) +/// rather than in `okena-terminal` so the parser crate needn't depend on +/// `serde_json`. +pub fn parse_labels_json(json: &str) -> BTreeMap { + serde_json::from_str(json).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lifecycle_token_roundtrips() { + for (tok, life) in [ + ("working", AgentLifecycle::Working), + ("blocked", AgentLifecycle::Blocked), + ("done", AgentLifecycle::Done), + ("idle", AgentLifecycle::Idle), + ] { + assert_eq!(AgentLifecycle::from_token(tok), Some(life)); + } + assert_eq!(AgentLifecycle::from_token("clear"), None); + assert_eq!(AgentLifecycle::from_token("bogus"), None); + } + + #[test] + fn priority_orders_blocked_first() { + assert!(AgentLifecycle::Blocked.priority() > AgentLifecycle::Done.priority()); + assert!(AgentLifecycle::Done.priority() > AgentLifecycle::Working.priority()); + assert!(AgentLifecycle::Working.priority() > AgentLifecycle::Idle.priority()); + } + + #[test] + fn only_blocked_and_done_notify() { + assert!(AgentLifecycle::Blocked.notifies()); + assert!(AgentLifecycle::Done.notifies()); + assert!(!AgentLifecycle::Working.notifies()); + assert!(!AgentLifecycle::Idle.notifies()); + } + + #[test] + fn parse_labels_json_handles_valid_and_malformed() { + let m = parse_labels_json(r#"{"stage":"verify","eta":"5m"}"#); + assert_eq!(m.get("stage").map(String::as_str), Some("verify")); + assert_eq!(m.len(), 2); + // Anything that isn't a flat string->string object yields an empty map + // (the documented `unwrap_or_default` branch) rather than an error. + assert!(parse_labels_json("[1,2]").is_empty()); + assert!(parse_labels_json(r#""just a string""#).is_empty()); + assert!(parse_labels_json(r#"{"k":5}"#).is_empty()); + assert!(parse_labels_json("not json").is_empty()); + assert!(parse_labels_json("").is_empty()); + } + + #[test] + fn new_clamped_bounds_hostile_input() { + // Oversized custom is truncated to the byte cap... + let huge = "x".repeat(MAX_CUSTOM_LEN * 4); + let s = AgentStatus::new_clamped(AgentLifecycle::Working, Some(huge), BTreeMap::new()); + assert_eq!(s.custom.as_ref().map(|c| c.len()), Some(MAX_CUSTOM_LEN)); + + // ...empty custom collapses to None (so the default body still applies)... + let s = AgentStatus::new_clamped(AgentLifecycle::Blocked, Some(String::new()), BTreeMap::new()); + assert_eq!(s.custom, None); + + // ...and labels are bounded in count and per-key/value length. + let mut labels = BTreeMap::new(); + for i in 0..(MAX_LABELS * 2) { + labels.insert(format!("k{i:04}"), "v".to_string()); + } + labels.insert("k".repeat(500), "v".repeat(5000)); + let s = AgentStatus::new_clamped(AgentLifecycle::Working, None, labels); + assert!(s.labels.len() <= MAX_LABELS); + for (k, v) in &s.labels { + assert!(k.len() <= MAX_LABEL_KEY_LEN); + assert!(v.len() <= MAX_LABEL_VALUE_LEN); + } + } + + #[test] + fn truncate_to_bytes_respects_char_boundary() { + // A multi-byte char straddling the cap is dropped whole, not split. + let mut s = "€€€".to_string(); // 3 bytes each = 9 bytes total + truncate_to_bytes(&mut s, 4); // cap lands inside the 2nd char + assert_eq!(s, "€"); // only the first whole char survives + assert!(s.is_char_boundary(s.len())); + } + + #[test] + fn serde_omits_empty_optional_fields() { + let s = AgentStatus::new(AgentLifecycle::Working); + let json = serde_json::to_string(&s).expect("serialize"); + assert_eq!(json, r#"{"lifecycle":"working"}"#); + + let parsed: AgentStatus = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(parsed, s); + } +} diff --git a/crates/okena-core/src/api.rs b/crates/okena-core/src/api.rs index 7e96b8f1..491b1b17 100644 --- a/crates/okena-core/src/api.rs +++ b/crates/okena-core/src/api.rs @@ -220,6 +220,12 @@ pub struct ApiProject { pub show_in_overview: bool, pub layout: Option, pub terminal_names: std::collections::HashMap, + /// Per-terminal agent status (`OSC 9001`) for terminals in this project that + /// currently have one. Lets remote / mobile clients show the same agent + /// indicators as the desktop. Runtime-only; omitted when empty. + #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] + pub terminal_agent_status: + std::collections::HashMap, #[serde(default, skip_serializing_if = "Option::is_none")] pub git_status: Option, #[serde(default)] @@ -751,6 +757,18 @@ mod tests { ], }), terminal_names: [("t1".into(), "bash".into())].into_iter().collect(), + terminal_agent_status: [( + "t1".to_string(), + crate::agent_status::AgentStatus { + lifecycle: crate::agent_status::AgentLifecycle::Working, + custom: Some("running tests 3/5".into()), + labels: [("stage".to_string(), "verify".to_string())] + .into_iter() + .collect(), + }, + )] + .into_iter() + .collect(), git_status: None, folder_color: FolderColor::Blue, services: vec![], diff --git a/crates/okena-core/src/lib.rs b/crates/okena-core/src/lib.rs index 815497c3..d378c9b4 100644 --- a/crates/okena-core/src/lib.rs +++ b/crates/okena-core/src/lib.rs @@ -1,5 +1,8 @@ #![cfg_attr(not(test), warn(clippy::unwrap_used, clippy::expect_used))] +pub mod agent_harness; +pub mod agent_session; +pub mod agent_status; pub mod api; pub mod keys; pub mod process; diff --git a/crates/okena-ext-claude/src/harness.rs b/crates/okena-ext-claude/src/harness.rs new file mode 100644 index 00000000..cbac7801 --- /dev/null +++ b/crates/okena-ext-claude/src/harness.rs @@ -0,0 +1,55 @@ +//! The Claude Code agent harness — how Okena resumes a captured Claude session +//! (and, later, reads its transcript). Registered into the gpui-free +//! [`okena_core::agent_harness`] registry so resume works on desktop *and* +//! headless/remote. + +use okena_core::agent_harness::AgentHarness; +use std::path::Path; +use std::sync::Arc; + +/// Claude Code (`claude` CLI). Agent id `"claude-code"` — matches the extension +/// id and the `OKENA_AGENT` the bundled lifecycle plugin sets. +pub struct ClaudeHarness; + +impl AgentHarness for ClaudeHarness { + fn id(&self) -> &str { + "claude-code" + } + + fn resume_command(&self, session_id: &str, _cwd: &Path) -> Option> { + // `claude --resume ` resumes a specific conversation. Claude scopes + // session lookup to the cwd it runs in, which is exactly the pane's + // restored working directory — so cwd needs no special handling here. + // `session_id` is already UUID-validated upstream and is passed as a + // distinct argv element (never shell-interpolated). + Some(vec![ + "claude".to_string(), + "--resume".to_string(), + session_id.to_string(), + ]) + } +} + +/// Build the Claude Code harness for registration in `okena_core::agent_harness`. +pub fn register_harness() -> Arc { + Arc::new(ClaudeHarness) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resume_command_shape() { + let h = ClaudeHarness; + assert_eq!(h.id(), "claude-code"); + assert_eq!( + h.resume_command("3b9c1f2a-4d5e-6f70-8a9b-0c1d2e3f4a5b", Path::new("/proj")), + Some(vec![ + "claude".to_string(), + "--resume".to_string(), + "3b9c1f2a-4d5e-6f70-8a9b-0c1d2e3f4a5b".to_string(), + ]) + ); + } +} diff --git a/crates/okena-ext-claude/src/lib.rs b/crates/okena-ext-claude/src/lib.rs index 7e91bf04..204301d5 100644 --- a/crates/okena-ext-claude/src/lib.rs +++ b/crates/okena-ext-claude/src/lib.rs @@ -1,10 +1,12 @@ #![cfg_attr(not(test), warn(clippy::unwrap_used, clippy::expect_used))] +mod harness; mod status; mod settings; pub mod usage; mod ui_helpers; +pub use harness::register_harness; pub use usage::resolve_claude_dir; use gpui::{AppContext as _, AnyView}; diff --git a/crates/okena-ext-codex/src/harness.rs b/crates/okena-ext-codex/src/harness.rs new file mode 100644 index 00000000..0c976924 --- /dev/null +++ b/crates/okena-ext-codex/src/harness.rs @@ -0,0 +1,32 @@ +//! The Codex agent harness. The resume command is a placeholder until the exact +//! `codex` resume invocation is confirmed — the point here is the **seam**: a +//! second harness plugging into [`okena_core::agent_harness`] purely by +//! registering, no core/app change. Codex session *capture* (its own hook glue) +//! and transcript stats land alongside this. `okena-ext-codex` already knows how +//! to read `~/.codex/sessions/**.jsonl` (see `usage.rs`), so `transcript_stats` +//! is a natural follow-up here. + +use okena_core::agent_harness::AgentHarness; +use std::path::Path; +use std::sync::Arc; + +/// Codex (`codex` CLI). Agent id `"codex"` — matches the extension id. +pub struct CodexHarness; + +impl AgentHarness for CodexHarness { + fn id(&self) -> &str { + "codex" + } + + fn resume_command(&self, _session_id: &str, _cwd: &Path) -> Option> { + // TODO: confirm Codex's resume CLI invocation before enabling + // auto-resume for Codex. `None` = no auto-resume yet (graceful — the + // session is still captured, persisted, and shown). + None + } +} + +/// Build the Codex harness for registration in `okena_core::agent_harness`. +pub fn register_harness() -> Arc { + Arc::new(CodexHarness) +} diff --git a/crates/okena-ext-codex/src/lib.rs b/crates/okena-ext-codex/src/lib.rs index 1900b25e..de49ea00 100644 --- a/crates/okena-ext-codex/src/lib.rs +++ b/crates/okena-ext-codex/src/lib.rs @@ -1,10 +1,13 @@ #![cfg_attr(not(test), warn(clippy::unwrap_used, clippy::expect_used))] +mod harness; mod status; mod settings; mod usage; mod ui_helpers; +pub use harness::register_harness; + use gpui::{AppContext as _, AnyView}; use okena_extensions::{ExtensionInstance, ExtensionManifest, ExtensionRegistration}; use std::sync::Arc; diff --git a/crates/okena-state/src/workspace_data.rs b/crates/okena-state/src/workspace_data.rs index c72f73d3..49658d97 100644 --- a/crates/okena-state/src/workspace_data.rs +++ b/crates/okena-state/src/workspace_data.rs @@ -170,6 +170,12 @@ pub struct ProjectData { /// Used to reconnect to persistent sessions across restarts #[serde(default)] pub service_terminals: HashMap, + /// Per-terminal AI agent session (terminal_id -> session) captured in-band + /// via the agent-status OSC `lbl=`. Persisted so a pane can offer to resume + /// its agent (`claude --resume `, …) after a restart. Harness-agnostic: + /// the session's `agent` field selects which harness resumes it. + #[serde(default)] + pub agent_sessions: HashMap, /// Per-project default shell (overrides global default when ShellType::Default is used) #[serde(default)] pub default_shell: Option, @@ -262,6 +268,7 @@ mod tests { is_remote: false, connection_id: None, service_terminals: HashMap::new(), + agent_sessions: HashMap::new(), default_shell: None, hook_terminals: HashMap::new(), pinned: false, diff --git a/crates/okena-terminal/CLAUDE.md b/crates/okena-terminal/CLAUDE.md index df94f8c4..05398b12 100644 --- a/crates/okena-terminal/CLAUDE.md +++ b/crates/okena-terminal/CLAUDE.md @@ -14,6 +14,58 @@ Wraps `alacritty_terminal` for ANSI processing and `portable-pty` for cross-plat | `backend.rs` | Terminal backend abstraction. | | `process.rs` | Process spawning utilities. | +## OSC sequences (sidecar) + +`terminal/osc_sidecar.rs` is a side-channel VTE parser run on the same byte +stream as the main processor, for sequences alacritty ignores or answers +differently than Okena wants: `OSC 7` / `OSC 1337 CurrentDir` (cwd), `OSC 9` / +`OSC 777` / `OSC 99` (notifications), `OSC 9;4` (progress), `OSC 133` (shell +marks, via a separate prompt sidecar), and `XTVERSION`. + +### `OSC 9001` — agent status (Okena private) + +A **stable contract** other tools depend on (see `docs/agent-status.md`). An AI +agent reports its own lifecycle by writing to its terminal: + +``` +ESC ] 9001 ; st= [ ; msg= ] [ ; lbl= ] ST +``` + +- `9001` is a private OSC number (not a standard sequence); keep it stable. +- `msg`/`lbl` values are base64(UTF-8) so they stay `;`/`ST`-safe (the VTE parser + splits OSC params on `;`). +- Unknown/missing `st` leaves the current status untouched; `clear` removes it. +- Parsed into the canonical `okena_core::agent_status::AgentStatus`, stored on + `Terminal.agent_status` (runtime-only), read via `Terminal::agent_status()`. + `msg`/`lbl` are clamped on parse (`AgentStatus::new_clamped`) so a hostile pane + can't pin unbounded memory (custom ≤ a few KB, labels bounded) — mirrors the + OSC 99 caps. +- `lbl=` reserves three keys — `agent`, `session_id`, `transcript_path`. With an + `agent` id + a UUID-shaped `session_id`, they're captured into a **sticky** + `okena_core::agent_session::AgentSession` on `Terminal.agent_session` (read via + `Terminal::agent_session()`). Unlike `agent_status` it survives `st=clear` + (it's the pane's session identity for resume + transcript stats, persisted by + the app layer), and a change sets the `agent_session_dirty` edge (drained via + `take_agent_session_dirty`). Per-harness resume/transcript logic is dispatched + by `agent` id through the gpui-free `okena_core::agent_harness` registry + (impls live in the `okena-ext-*` crates). A non-UUID `session_id` is dropped. +- A change stores into the shared one-shot `remote_dirty` edge (drained via + `take_remote_dirty`), which the PTY event loop consumes + (`Okena::process_remote_dirty`) to bump the remote `state_version`. This edge + is **generic**, not agent-specific: any runtime-only signal that remote + clients should see reuses it rather than adding its own changed-edge + + per-feature drain. A transition into `blocked`/`done` also queues a + `TerminalNotification` (reusing the OSC 9 notification path + focus + suppression). +- `pty_manager.rs` exports `OKENA_TTY` (the pane's slave pty path, from + `ptsname` on the master fd) into the pane env so a process **without a + controlling terminal** — e.g. a Claude Code hook — can emit this OSC by + writing to `$OKENA_TTY` (writing to the slave reaches Okena's master reader + even through a nested dtach/tmux pty). `/dev/tty` only works for interactive + processes, not hooks. Captured at first spawn via `cmd.env`, so it goes + **stale on reattach** to a persistent dtach/tmux session (not yet refreshed + per-attach — known follow-up). + ## Threading Model Three execution contexts access `Terminal`: diff --git a/crates/okena-terminal/src/input.rs b/crates/okena-terminal/src/input.rs index bcdd0188..aac6de3c 100644 --- a/crates/okena-terminal/src/input.rs +++ b/crates/okena-terminal/src/input.rs @@ -19,12 +19,34 @@ pub struct KeyEvent { pub modifiers: KeyModifiers, } +/// Active kitty keyboard protocol enhancement flags (read from the terminal mode). +/// Only `disambiguate_escape_codes` is honored today; the other progressive- +/// enhancement levels are a follow-up, so this struct intentionally carries +/// just that one flag for now. +#[derive(Clone, Copy, Debug, Default)] +pub struct KittyKeyboardFlags { + pub disambiguate_escape_codes: bool, +} + /// Convert a key event to terminal input bytes. /// /// `app_cursor_mode`: When true, arrow keys send SS3 sequences (\x1bOA) instead of CSI (\x1b[A). /// This should be true when the terminal is in application cursor keys mode (DECCKM), /// which is used by applications like less, vim, htop, etc. -pub fn key_to_bytes(event: &KeyEvent, app_cursor_mode: bool) -> Option> { +/// +/// `kitty`: Active kitty keyboard protocol flags. When `disambiguate_escape_codes` +/// is set, the ambiguous keys (Esc, ctrl/alt+key, modified Enter/Tab/Backspace) are +/// reported as `CSI u` sequences before the legacy logic runs. +pub fn key_to_bytes( + event: &KeyEvent, + app_cursor_mode: bool, + kitty: KittyKeyboardFlags, +) -> Option> { + if kitty.disambiguate_escape_codes + && let Some(bytes) = kitty_disambiguate_bytes(event) { + return Some(bytes); + } + let mods = &event.modifiers; // Handle Ctrl+key combinations for letters (produces control characters) @@ -169,3 +191,175 @@ pub fn key_to_bytes(event: &KeyEvent, app_cursor_mode: bool) -> Option> log::warn!("No input generated for key: {:?}", event.key); None } + +/// Encode a key as a `CSI u` sequence: `CSI code u` when unmodified, or +/// `CSI code ; kmod u` when modifiers are held. +fn csi_u(code: u32, kmod: u32) -> Vec { + if kmod == 1 { + format!("\x1b[{code}u").into_bytes() + } else { + format!("\x1b[{code};{kmod}u").into_bytes() + } +} + +/// Kitty keyboard protocol level 1 ("Disambiguate escape codes"). +/// +/// Returns the `CSI u` encoding for the keys the disambiguate level rewrites: +/// Esc (always), modified Enter/Tab/Backspace, and ctrl/alt printable keys. +/// Returns `None` for everything else so the caller falls through to the +/// legacy logic, which already matches kitty for arrows/Home/End and produces +/// text for plain keys. +fn kitty_disambiguate_bytes(event: &KeyEvent) -> Option> { + let mods = &event.modifiers; + // Kitty modifier value: 1 + bitmask (shift=1, alt=2, ctrl=4, super=8). + let kmod = 1 + + (if mods.shift { 1 } else { 0 }) + + (if mods.alt { 2 } else { 0 }) + + (if mods.control { 4 } else { 0 }) + + (if mods.platform { 8 } else { 0 }); + let has_mods = kmod > 1; + + match event.key.as_str() { + // Plain Esc is reported as `CSI 27 u` so apps can tell it from the + // start of an escape sequence. + "escape" => Some(csi_u(27, kmod)), + // Plain Enter stays legacy `\r`; modified Enter is disambiguated. + "enter" | "return" | "kp_enter" => has_mods.then(|| csi_u(13, kmod)), + // Plain Tab stays legacy `\t`; Shift+Tab → `\x1b[9;2u`. + "tab" => has_mods.then(|| csi_u(9, kmod)), + // Plain Backspace stays legacy; modified Backspace is disambiguated. + "backspace" => has_mods.then(|| csi_u(127, kmod)), + key => { + // ctrl/alt + a single printable char (ctrl+letter, alt+key, + // ctrl+alt+key, shift+alt+key). Plain super+key is a higher level. + if (mods.control || mods.alt) + && key.chars().count() == 1 + && let Some(c) = key.chars().next() + && !c.is_control() + { + let cp = if c.is_ascii_alphabetic() { + c.to_ascii_lowercase() as u32 + } else { + c as u32 + }; + Some(csi_u(cp, kmod)) + } else { + None + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ev(key: &str, key_char: Option<&str>, mods: KeyModifiers) -> KeyEvent { + KeyEvent { + key: key.to_string(), + key_char: key_char.map(|s| s.to_string()), + modifiers: mods, + } + } + + fn ctrl() -> KeyModifiers { + KeyModifiers { control: true, ..Default::default() } + } + + fn shift() -> KeyModifiers { + KeyModifiers { shift: true, ..Default::default() } + } + + fn on() -> KittyKeyboardFlags { + KittyKeyboardFlags { disambiguate_escape_codes: true } + } + + #[test] + fn flag_off_keeps_legacy_bytes() { + let off = KittyKeyboardFlags::default(); + assert_eq!( + key_to_bytes(&ev("a", None, ctrl()), false, off), + Some(vec![0x01]) + ); + assert_eq!( + key_to_bytes(&ev("tab", None, KeyModifiers::default()), false, off), + Some(b"\t".to_vec()) + ); + assert_eq!( + key_to_bytes(&ev("escape", None, KeyModifiers::default()), false, off), + Some(b"\x1b".to_vec()) + ); + } + + #[test] + fn escape_is_disambiguated() { + assert_eq!( + key_to_bytes(&ev("escape", None, KeyModifiers::default()), false, on()), + Some(b"\x1b[27u".to_vec()) + ); + assert_eq!( + key_to_bytes(&ev("escape", None, ctrl()), false, on()), + Some(b"\x1b[27;5u".to_vec()) + ); + } + + #[test] + fn ctrl_letters_are_disambiguated() { + assert_eq!( + key_to_bytes(&ev("i", None, ctrl()), false, on()), + Some(b"\x1b[105;5u".to_vec()) + ); + assert_eq!( + key_to_bytes(&ev("a", None, ctrl()), false, on()), + Some(b"\x1b[97;5u".to_vec()) + ); + } + + #[test] + fn tab_disambiguation() { + assert_eq!( + key_to_bytes(&ev("tab", None, shift()), false, on()), + Some(b"\x1b[9;2u".to_vec()) + ); + assert_eq!( + key_to_bytes(&ev("tab", None, KeyModifiers::default()), false, on()), + Some(b"\t".to_vec()) + ); + } + + #[test] + fn enter_disambiguation() { + assert_eq!( + key_to_bytes(&ev("enter", None, ctrl()), false, on()), + Some(b"\x1b[13;5u".to_vec()) + ); + assert_eq!( + key_to_bytes(&ev("enter", None, KeyModifiers::default()), false, on()), + Some(b"\r".to_vec()) + ); + } + + #[test] + fn ctrl_backspace_is_disambiguated() { + assert_eq!( + key_to_bytes(&ev("backspace", None, ctrl()), false, on()), + Some(b"\x1b[127;5u".to_vec()) + ); + } + + #[test] + fn plain_char_falls_through_to_text() { + assert_eq!( + key_to_bytes(&ev("a", Some("a"), KeyModifiers::default()), false, on()), + None + ); + } + + #[test] + fn plain_arrow_is_not_stolen() { + assert_eq!( + key_to_bytes(&ev("up", None, KeyModifiers::default()), false, on()), + Some(b"\x1b[A".to_vec()) + ); + } +} diff --git a/crates/okena-terminal/src/pty_manager.rs b/crates/okena-terminal/src/pty_manager.rs index b9f0c1b4..5be16653 100644 --- a/crates/okena-terminal/src/pty_manager.rs +++ b/crates/okena-terminal/src/pty_manager.rs @@ -363,6 +363,27 @@ impl PtyManager { } } + // Expose the pane's slave pty path so processes that lack a controlling + // terminal can still write in-band escape sequences to this pane. A + // Claude Code hook (and any agent hook) runs with no `/dev/tty`, so it + // can't address the terminal the usual way; it opens `$OKENA_TTY` + // instead. Writing to the slave reaches Okena's master reader, so it + // works through a session backend (dtach/tmux) that nests another pty on + // first launch. Set via the same `cmd.env` channel as + // `OKENA_TERMINAL_ID`. + // + // CAVEAT: like `OKENA_TERMINAL_ID`, this is captured into the shell's + // environment at first spawn, not refreshed per-attach. On *reattach* to + // a pre-existing dtach/tmux session the already-running shell keeps the + // original value while Okena has opened a new slave pty — `$OKENA_TTY` + // then points at the old (closed) device and the hook's write is a + // silent no-op. Refreshing it on each attach (e.g. tmux + // `set-environment`) is a known follow-up; see docs/agent-status.md. + #[cfg(unix)] + if let Some(path) = slave_pty_path(pair.master.as_ref()) { + cmd.env("OKENA_TTY", path); + } + // Spawn the process let child = pair.slave.spawn_command(cmd)?; @@ -1391,3 +1412,23 @@ fn first_proc_child(pid: u32) -> Option { fn first_proc_child(_pid: u32) -> Option { None } + +/// Resolve the slave device path (e.g. `/dev/pts/3`) for a pty from its master +/// fd, via `ptsname`. Used to export `OKENA_TTY` so hooks without a controlling +/// terminal can still write to the pane. +#[cfg(unix)] +fn slave_pty_path(master: &dyn MasterPty) -> Option { + let fd = master.as_raw_fd()?; + // SAFETY: `fd` is the live pty master. `ptsname` returns a pointer into a + // static buffer valid until the next `ptsname` call; terminal creation is + // serialized on the GPUI thread, so nothing can race that buffer here. + let ptr = unsafe { libc::ptsname(fd) }; + if ptr.is_null() { + return None; + } + let path = unsafe { std::ffi::CStr::from_ptr(ptr) } + .to_str() + .ok()? + .to_string(); + Some(path) +} diff --git a/crates/okena-terminal/src/terminal/event_listener.rs b/crates/okena-terminal/src/terminal/event_listener.rs index a54c207e..c35e9e19 100644 --- a/crates/okena-terminal/src/terminal/event_listener.rs +++ b/crates/okena-terminal/src/terminal/event_listener.rs @@ -4,6 +4,40 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use super::transport::TerminalTransport; +use super::types::{ClipboardReadResponder, ResizeState}; + +/// The two OSC 52 clipboard queues the listener shares with `Terminal`. +/// +/// Both are `Arc`-shared with the owning `Terminal` (which holds the same +/// `Arc`s as separate fields) and pushed here during `process_output`, then +/// drained on the GPUI thread. They are grouped into one struct so the +/// listener constructor stays compact as more shared state is added. +pub(super) struct ClipboardQueues { + /// Pending OSC 52 clipboard writes to be picked up by the GPUI thread. + pub writes: Arc>>, + /// Pending OSC 52 clipboard *read* requests (`OSC 52 ; c ; ?`), each + /// carrying a formatter that turns clipboard text into the PTY reply. + /// Drained on the GPUI thread, where the system clipboard and the opt-in + /// setting gating reads are reachable. + pub reads: Arc>>, +} + +/// The current terminal state the listener reads to answer terminal queries. +/// +/// Both fields are `Arc`-shared with the owning `Terminal` (which holds the +/// same `Arc`s) and read here during `process_output` to compose replies: +/// `palette` answers OSC 10/11/12/4 color queries, `resize_state` answers the +/// `CSI 14 t` (XTWINOPS) text-area-size-in-pixels query. They are grouped into +/// one struct so the listener constructor stays compact as more shared state +/// is added. +pub(super) struct CurrentState { + /// Current theme palette, pushed from the GPUI thread on each render. + /// Used to answer OSC 10/11/12/4 color queries from apps. + pub palette: Arc>>, + /// Current terminal size, kept up to date by `resize`. Read to answer the + /// `CSI 14 t` XTWINOPS query (report text-area size in pixels). + pub resize_state: Arc>, +} /// Event listener for alacritty_terminal that captures title changes, bell, and PTY write requests pub struct ZedEventListener { @@ -15,11 +49,11 @@ pub struct ZedEventListener { /// event loop to fire a desktop notification exactly once per bell rather /// than on every batch while `has_bell` stays set. bell_pending: Arc, - /// Pending OSC 52 clipboard writes to be picked up by the GPUI thread - pending_clipboard: Arc>>, - /// Current theme palette, pushed from the GPUI thread on each render. - /// Used to answer OSC 10/11/12/4 color queries from apps. - palette: Arc>>, + /// Pending OSC 52 clipboard write/read queues, shared with `Terminal`. + clipboard: ClipboardQueues, + /// Current state the listener reads to answer terminal queries (theme + /// palette for color queries, size for the XTWINOPS size query). + state: CurrentState, /// Transport for writing responses back to the terminal transport: Arc, /// Terminal ID for PTY write operations @@ -27,16 +61,24 @@ pub struct ZedEventListener { } impl ZedEventListener { - pub fn new( + pub(super) fn new( title: Arc>>, has_bell: Arc>, bell_pending: Arc, - pending_clipboard: Arc>>, - palette: Arc>>, + clipboard: ClipboardQueues, + state: CurrentState, transport: Arc, terminal_id: String, ) -> Self { - Self { title, has_bell, bell_pending, pending_clipboard, palette, transport, terminal_id } + Self { + title, + has_bell, + bell_pending, + clipboard, + state, + transport, + terminal_id, + } } /// Resolve a color index (as passed by alacritty on a color query) to an @@ -58,7 +100,7 @@ impl ZedEventListener { return Some(xterm_256_grayscale_rgb(index)); } - let palette = self.palette.lock(); + let palette = self.state.palette.lock(); let colors = palette.as_ref()?; let hex = match index { 0 => colors.term_black, @@ -119,7 +161,17 @@ impl EventListener for ZedEventListener { self.bell_pending.store(true, Ordering::Relaxed); } TermEvent::ClipboardStore(_, text) => { - self.pending_clipboard.lock().push(text); + self.clipboard.writes.lock().push(text); + } + TermEvent::ClipboardLoad(_ty, formatter) => { + // An app asked to READ the clipboard (`OSC 52 ; c ; ?`). We + // can't read the system clipboard here (no `cx` on this + // thread), so queue the formatter; the GPUI thread drains it + // later, gated behind the opt-in `allow_clipboard_read` + // setting. The `ClipboardType` is ignored — primary-selection + // requests are answered from the regular clipboard like the + // rest, which matches what most terminals do. + self.clipboard.reads.lock().push(formatter); } TermEvent::ColorRequest(index, response_fn) => { if let Some((r, g, b)) = self.resolve_color(index) { @@ -128,6 +180,21 @@ impl EventListener for ZedEventListener { self.transport.send_input(&self.terminal_id, reply.as_bytes()); } } + TermEvent::TextAreaSizeRequest(formatter) => { + // Answer `CSI 14 t` (report text-area size in pixels). alacritty + // hands us the formatter; we supply the current geometry. Cell + // dims are f32 pixels in TerminalSize; round to the nearest whole + // pixel (min 1) for the u16 WindowSize. + let size = self.state.resize_state.lock().size; + let window_size = alacritty_terminal::event::WindowSize { + num_lines: size.rows, + num_cols: size.cols, + cell_width: (size.cell_width.round() as u16).max(1), + cell_height: (size.cell_height.round() as u16).max(1), + }; + let reply = formatter(window_size); + self.transport.send_input(&self.terminal_id, reply.as_bytes()); + } TermEvent::PtyWrite(data) => { // Write response back to PTY (e.g., cursor position report) log::debug!("PtyWrite event: {:?}", data); diff --git a/crates/okena-terminal/src/terminal/io.rs b/crates/okena-terminal/src/terminal/io.rs index 711f37c1..bffc7abd 100644 --- a/crates/okena-terminal/src/terminal/io.rs +++ b/crates/okena-terminal/src/terminal/io.rs @@ -51,6 +51,7 @@ impl Terminal { // New output disengages the prompt-jump walker so the next // Above jump starts from the newest prompt again. *self.prompt_jump_index.lock() = None; + *self.failed_jump_index.lock() = None; self.dirty.store(true, Ordering::Relaxed); self.content_generation.fetch_add(1, Ordering::Relaxed); @@ -205,6 +206,45 @@ impl Terminal { self.transport.send_input(&self.terminal_id, data); } + /// Send a named special key (Esc / Tab / Shift+Tab) the way the running app + /// expects, honoring app-cursor and kitty-keyboard modes. + /// + /// These keys are delivered via dedicated GPUI actions (so they take part + /// in context routing — e.g. Esc closes search in the search bar but goes + /// to the PTY here) instead of the raw `on_key_down` path, so they bypass + /// the encoder unless routed back through it here. Reusing `key_to_bytes` + /// keeps them in lockstep with normal key handling (notably the kitty + /// `CSI u` disambiguation: `Esc` → `CSI 27 u`, `Shift+Tab` → `CSI 9 ; 2 u`). + fn send_named_key(&self, key: &str, shift: bool) { + let event = crate::input::KeyEvent { + key: key.to_string(), + key_char: None, + modifiers: crate::input::KeyModifiers { shift, ..Default::default() }, + }; + if let Some(bytes) = crate::input::key_to_bytes( + &event, + self.is_app_cursor_mode(), + self.kitty_keyboard_flags(), + ) { + self.send_bytes(&bytes); + } + } + + /// Send the Escape key (kitty-aware: `CSI 27 u` in disambiguate mode). + pub fn send_escape(&self) { + self.send_named_key("escape", false); + } + + /// Send the Tab key (plain `\t`; unchanged by kitty level 1). + pub fn send_tab(&self) { + self.send_named_key("tab", false); + } + + /// Send Shift+Tab / backtab (kitty-aware: `CSI 9 ; 2 u` in disambiguate mode). + pub fn send_backtab(&self) { + self.send_named_key("tab", true); + } + /// Clear the terminal screen by sending the clear sequence pub fn clear(&self) { // Send ANSI escape sequence to clear screen and move cursor to home diff --git a/crates/okena-terminal/src/terminal/meta.rs b/crates/okena-terminal/src/terminal/meta.rs index b57e7445..e96ac648 100644 --- a/crates/okena-terminal/src/terminal/meta.rs +++ b/crates/okena-terminal/src/terminal/meta.rs @@ -19,6 +19,37 @@ impl Terminal { std::mem::take(&mut *self.pending_clipboard.lock()) } + /// Whether the running app has queued any OSC 52 clipboard *read* requests + /// (`OSC 52 ; c ; ?`) since the last drain. The PTY event loop checks this + /// per dirty terminal before deciding whether to read the system clipboard + /// (only when the opt-in setting is on) or silently drop the requests. + pub fn has_pending_clipboard_reads(&self) -> bool { + !self.pending_clipboard_reads.lock().is_empty() + } + + /// Answer all queued OSC 52 clipboard *read* requests with `content`, + /// draining the queue. For each queued formatter we build the reply + /// (`OSC 52 ; c ; ST`) and write it straight back to the PTY via + /// the transport — not `send_bytes` — so a clipboard read doesn't set + /// `had_user_input` or scroll the view, mirroring how color-query replies + /// are written directly in the event listener. Only call this when the + /// user has opted into clipboard reads. + pub fn answer_clipboard_reads(&self, content: &str) { + let responders = std::mem::take(&mut *self.pending_clipboard_reads.lock()); + for responder in responders { + let reply = responder(content); + self.transport.send_input(&self.terminal_id, reply.as_bytes()); + } + } + + /// Drop all queued OSC 52 clipboard *read* requests without replying. + /// Used when the `allow_clipboard_read` setting is off: the request is + /// silently denied (the app gets no response), but the queue is still + /// cleared so it stays bounded across batches. + pub fn drop_clipboard_reads(&self) { + self.pending_clipboard_reads.lock().clear(); + } + /// Take any pending `OSC 9` / `OSC 777` notifications. The GPUI thread /// drains these in the PTY event loop to surface native desktop /// notifications for background panes whose command finished or needs @@ -27,6 +58,46 @@ impl Terminal { std::mem::take(&mut *self.pending_notifications.lock()) } + /// Active `OSC 9 ; 4` (ConEmu / Windows Terminal) progress report, or + /// `None` when the running program isn't reporting progress (it never + /// started one, or sent `st=0` to clear it). Read each render to drive a + /// per-tab / per-pane progress indicator. + pub fn progress(&self) -> Option { + *self.progress.lock() + } + + /// Latest agent status reported via the agent-status OSC (`OSC 9001`), or + /// `None` when no agent has reported one (or it was cleared). Read each + /// render to drive the per-tab indicator and the sidebar "Agents" section. + pub fn agent_status(&self) -> Option { + self.agent_status.lock().clone() + } + + /// Consume the one-shot "remote-visible state changed since last drain" + /// edge. Returns true if anything marked it since the previous call, then + /// resets it. The PTY event loop uses this to bump the remote + /// `state_version` so remote / mobile clients re-fetch. + pub fn take_remote_dirty(&self) -> bool { + self.remote_dirty + .swap(false, std::sync::atomic::Ordering::Relaxed) + } + + /// The durable agent session captured for this pane (`agent` + `session_id` + /// + optional `transcript_path`), or `None` if none has been reported. + /// Unlike [`agent_status`](Self::agent_status) this is **sticky** — it + /// survives `st=clear` — since it's the identity used for resume + transcript + /// stats, persisted into `workspace.json` by the app layer. + pub fn agent_session(&self) -> Option { + self.agent_session.lock().clone() + } + + /// Consume the one-shot "agent session changed since last drain" edge. The + /// PTY event loop uses this to persist the new session into `workspace.json`. + pub fn take_agent_session_dirty(&self) -> bool { + self.agent_session_dirty + .swap(false, std::sync::atomic::Ordering::Relaxed) + } + /// Push the active theme palette so the event listener can answer /// OSC 10/11/12/4 color queries with real theme colors. Called from the /// render loop on every frame; writes are cheap and uncontested. diff --git a/crates/okena-terminal/src/terminal/mod.rs b/crates/okena-terminal/src/terminal/mod.rs index c473bd5a..0e7ce45d 100644 --- a/crates/okena-terminal/src/terminal/mod.rs +++ b/crates/okena-terminal/src/terminal/mod.rs @@ -1,6 +1,8 @@ use alacritty_terminal::term::test::TermSize; use alacritty_terminal::term::{Config as TermConfig, Term}; use alacritty_terminal::vte::ansi::{CursorShape as VteCursorShape, CursorStyle as VteCursorStyle, Processor}; +use okena_core::agent_session::AgentSession; +use okena_core::agent_status::AgentStatus; use parking_lot::Mutex; use std::sync::atomic::{AtomicBool, AtomicU64}; use std::sync::Arc; @@ -39,13 +41,13 @@ pub use resize_authority::{ }; pub use transport::TerminalTransport; pub use types::{ - AppCursorShape, DetectedLink, PromptMark, PromptMarkKind, ResizeState, SelectionState, - TerminalSize, + AppCursorShape, ClipboardReadResponder, DetectedLink, PromptMark, PromptMarkKind, ResizeState, + SelectionState, TerminalProgress, TerminalProgressState, TerminalSize, }; pub use osc_sidecar::TerminalNotification; -use event_listener::ZedEventListener; +use event_listener::{ClipboardQueues, CurrentState, ZedEventListener}; use osc_sidecar::OscSidecar; use prompt_marks::{PromptSidecar, PromptTracker}; use types::FocusReportState; @@ -164,6 +166,15 @@ pub struct Terminal { /// GPUI thread only. pub(super) pending_clipboard: Arc>>, + /// Pending OSC 52 clipboard *read* requests (`OSC 52 ; c ; ?`) from the + /// running app, each carrying the formatter that turns clipboard text + /// into the PTY reply. `Arc` shared with `ZedEventListener`: pushed on + /// `ClipboardLoad` during `process_output`, drained on the GPUI thread + /// (in the PTY event loop, where the settings gate and the system + /// clipboard are reachable) via `answer_clipboard_reads` / + /// `drop_clipboard_reads`. GPUI thread only. + pub(super) pending_clipboard_reads: Arc>>, + /// Theme palette used to answer OSC 10/11/12/4 color queries from /// terminal apps. `Arc` shared with `ZedEventListener`: the render path /// pushes the current theme via `push_palette`, and the listener reads @@ -182,6 +193,41 @@ pub struct Terminal { /// thread only. pub(super) pending_notifications: Arc>>, + /// Active `OSC 9 ; 4` (ConEmu / Windows Terminal) progress report, or + /// `None` when no progress is being shown. `Arc` shared with `OscSidecar`: + /// the sidecar overwrites it on each `OSC 9 ; 4` (and clears it to `None` + /// on `st=0`), GPUI reads via `progress`. GPUI thread only. + pub(super) progress: Arc>>, + + /// Latest agent status reported via the agent-status OSC (`OSC 9001`), or + /// `None` when never set / cleared. `Arc` shared with `OscSidecar`: the + /// sidecar overwrites it on each sequence, GPUI reads via `agent_status`. + /// Runtime-only (never persisted). GPUI thread only. + pub(super) agent_status: Arc>>, + + /// One-shot "remote-visible state changed since last drain" edge. `Arc` + /// shared with `OscSidecar`: any runtime-only signal whose change remote + /// clients should see (currently agent status, `OSC 9001`) stores into it, + /// and the PTY event loop consumes it via `take_remote_dirty` to bump the + /// remote `state_version` so remote / mobile clients re-fetch. Generic by + /// design — a new such signal (e.g. progress, `OSC 9;4`) sets this same flag + /// instead of growing its own changed-edge + per-feature drain in + /// `okena-app`. Mirrors `bell_pending`. + pub(super) remote_dirty: Arc, + + /// Durable agent session captured from the agent-status OSC `lbl=` (`agent` + /// + `session_id` + `transcript_path`). `Arc` shared with `OscSidecar`. + /// Unlike `agent_status` this is **sticky** — it survives `st=clear` — + /// because it's the pane's session identity used for resume + transcript + /// stats, and is persisted into `workspace.json` by the app layer (so, + /// unlike agent status, it is *not* runtime-only). + pub(super) agent_session: Arc>>, + + /// One-shot "agent session changed since last drain" edge, shared with + /// `OscSidecar`. Set when `agent_session` changes; the PTY event loop drains + /// it via `take_agent_session_dirty` to persist the new session. + pub(super) agent_session_dirty: Arc, + /// Per-renderer focus state for DEC focus reports. A terminal can appear /// in multiple windows, so focus reports are derived from the aggregate /// instead of whichever view rendered last. @@ -218,6 +264,14 @@ pub struct Terminal { /// GPUI thread only. pub(super) prompt_jump_index: Mutex>, + /// Reverse index into the current list of prompts that produced a + /// non-zero exit code (0 = newest failure). `Some` while the user is + /// walking through failed commands with + /// `jump_to_prev/next_failed_command`; reset to `None` on any output or + /// scroll so the next walk starts from the most recent failure again. + /// GPUI thread only. + pub(super) failed_jump_index: Mutex>, + /// Shell process PID. Set by `set_shell_pid` (called from GPUI thread /// after PTY spawn), read by `shell_pid` and `has_running_child`. /// GPUI thread only. @@ -305,6 +359,21 @@ impl Terminal { shape: VteCursorShape::HollowBlock, blinking: false, }, + // Enable the kitty keyboard protocol. alacritty gates ALL of its + // keyboard-mode handling (push/pop/set/report of `CSI u` mode + // sequences) behind this flag — with it off, an app's request to + // enable the protocol is silently ignored and `term.mode()` never + // reflects it, so `kitty_keyboard_flags()` would always read false + // and our encoder (see `input::key_to_bytes`) would never fire. + kitty_keyboard: true, + // Accept OSC 52 *read* (paste) sequences in addition to writes. + // alacritty's default `OnlyCopy` silently drops `OSC 52 ; c ; ?` + // and never emits `ClipboardLoad`; `CopyPaste` makes it emit the + // event so Okena can queue the request and decide whether to + // answer it based on the opt-in `allow_clipboard_read` setting + // (off by default). The actual security gate lives in Okena, not + // here — alacritty just hands us the request. + osc52: alacritty_terminal::term::Osc52::CopyPaste, ..TermConfig::default() }; let term_size = TermSize::new(size.cols as usize, size.rows as usize); @@ -314,13 +383,21 @@ impl Terminal { let has_bell = Arc::new(Mutex::new(false)); let bell_pending = Arc::new(AtomicBool::new(false)); let pending_clipboard = Arc::new(Mutex::new(Vec::new())); + let pending_clipboard_reads = Arc::new(Mutex::new(Vec::new())); let palette = Arc::new(Mutex::new(None)); + let resize_state = Arc::new(Mutex::new(ResizeState::new(size))); let event_listener = ZedEventListener::new( title.clone(), has_bell.clone(), bell_pending.clone(), - pending_clipboard.clone(), - palette.clone(), + ClipboardQueues { + writes: pending_clipboard.clone(), + reads: pending_clipboard_reads.clone(), + }, + CurrentState { + palette: palette.clone(), + resize_state: resize_state.clone(), + }, transport.clone(), terminal_id.clone(), ); @@ -328,9 +405,19 @@ impl Terminal { let reported_cwd = Arc::new(Mutex::new(None)); let pending_notifications = Arc::new(Mutex::new(Vec::new())); + let progress = Arc::new(Mutex::new(None)); + let agent_status = Arc::new(Mutex::new(None)); + let remote_dirty = Arc::new(AtomicBool::new(false)); + let agent_session = Arc::new(Mutex::new(None)); + let agent_session_dirty = Arc::new(AtomicBool::new(false)); let osc_sidecar = Mutex::new(OscSidecar::new( reported_cwd.clone(), pending_notifications.clone(), + progress.clone(), + agent_status.clone(), + remote_dirty.clone(), + agent_session.clone(), + agent_session_dirty.clone(), transport.clone(), terminal_id.clone(), )); @@ -339,7 +426,7 @@ impl Terminal { term: Arc::new(Mutex::new(term)), processor: Mutex::new(Processor::new()), terminal_id, - resize_state: Arc::new(Mutex::new(ResizeState::new(size))), + resize_state, transport, selection_state: Mutex::new(SelectionState::default()), scroll_offset: Mutex::new(0), @@ -348,6 +435,7 @@ impl Terminal { bell_pending, has_notification: AtomicBool::new(false), pending_clipboard, + pending_clipboard_reads, palette, pending_output: Mutex::new(Vec::new()), dirty: AtomicBool::new(false), @@ -355,12 +443,18 @@ impl Terminal { initial_cwd, reported_cwd, pending_notifications, + progress, + agent_status, + remote_dirty, + agent_session, + agent_session_dirty, focus_report_state: Mutex::new(FocusReportState::default()), osc_sidecar, prompt_sidecar: Mutex::new(PromptSidecar::new()), prompt_tracker: Mutex::new(PromptTracker::new()), command_finished_pending: AtomicBool::new(false), prompt_jump_index: Mutex::new(None), + failed_jump_index: Mutex::new(None), last_output_time: Arc::new(Mutex::new(Instant::now())), shell_pid: Mutex::new(None), waiting_for_input: AtomicBool::new(false), diff --git a/crates/okena-terminal/src/terminal/modes.rs b/crates/okena-terminal/src/terminal/modes.rs index 381b083a..898169b8 100644 --- a/crates/okena-terminal/src/terminal/modes.rs +++ b/crates/okena-terminal/src/terminal/modes.rs @@ -25,6 +25,19 @@ impl Terminal { term.mode().contains(TermMode::APP_CURSOR) } + /// Active kitty keyboard protocol enhancement flags requested by the app. + /// Only the level-1 "disambiguate escape codes" flag is honored today; the + /// higher progressive-enhancement levels are a follow-up. + pub fn kitty_keyboard_flags(&self) -> crate::input::KittyKeyboardFlags { + crate::input::KittyKeyboardFlags { + disambiguate_escape_codes: self + .term + .lock() + .mode() + .contains(TermMode::DISAMBIGUATE_ESC_CODES), + } + } + /// Check if terminal is using the alternate screen buffer. /// TUI apps (vim, less, htop, Claude Code CLI) use alternate screen. pub fn is_alt_screen(&self) -> bool { diff --git a/crates/okena-terminal/src/terminal/osc_sidecar.rs b/crates/okena-terminal/src/terminal/osc_sidecar.rs index efde4124..56612bf6 100644 --- a/crates/okena-terminal/src/terminal/osc_sidecar.rs +++ b/crates/okena-terminal/src/terminal/osc_sidecar.rs @@ -1,11 +1,15 @@ use alacritty_terminal::vte::Perform; use base64::Engine as _; +use okena_core::agent_session::AgentSession; +use okena_core::agent_status::{AgentLifecycle, AgentStatus}; use parking_lot::Mutex; use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use super::app_version::app_version; use super::transport::TerminalTransport; +use super::types::{TerminalProgress, TerminalProgressState}; /// A desktop notification requested by the shell via an OSC escape. /// @@ -54,6 +58,11 @@ impl OscSidecar { pub(super) fn new( reported_cwd: Arc>>, pending_notifications: Arc>>, + progress: Arc>>, + agent_status: Arc>>, + remote_dirty: Arc, + agent_session: Arc>>, + agent_session_dirty: Arc, transport: Arc, terminal_id: String, ) -> Self { @@ -62,6 +71,11 @@ impl OscSidecar { perform: SidecarPerform { reported_cwd, pending_notifications, + progress, + agent_status, + remote_dirty, + agent_session, + agent_session_dirty, transport, terminal_id, osc99_pending: HashMap::new(), @@ -79,6 +93,28 @@ struct SidecarPerform { /// `OSC 9` / `OSC 777` / `OSC 99` notifications, drained by the GPUI thread /// in the PTY event loop (same model as `pending_clipboard`). pending_notifications: Arc>>, + /// Active `OSC 9 ; 4` (ConEmu / Windows Terminal) progress report, or + /// `None` when cleared (`st=0`). Overwritten on each progress sequence and + /// read by the GPUI thread via `Terminal::progress`. + progress: Arc>>, + /// Latest agent status reported via the agent-status OSC, or `None` when + /// never set / cleared (`st=clear`). Overwritten on each sequence and read + /// by the GPUI thread via `Terminal::agent_status`. + agent_status: Arc>>, + /// One-shot "remote-visible state changed since last drain" edge, shared + /// with `Terminal`. Set whenever a runtime-only signal here changes state + /// remote clients should see (currently agent status), consumed by the PTY + /// event loop (`take_remote_dirty`) to bump the remote `state_version` so + /// remote / mobile clients re-fetch. Generic by design — not agent-specific. + /// Mirrors `bell_pending`. + remote_dirty: Arc, + /// Sticky agent session identity (`agent` + `session_id` + `transcript_path`) + /// captured from the `lbl=` of an agent-status OSC. Unlike `agent_status` + /// it survives `st=clear`; the app layer persists it for resume + stats. + agent_session: Arc>>, + /// One-shot edge set when `agent_session` changes; drained by the PTY event + /// loop to persist the session. Mirrors `remote_dirty`. + agent_session_dirty: Arc, transport: Arc, terminal_id: String, /// In-progress `OSC 99` notifications keyed by `i=` id, awaiting their @@ -195,6 +231,222 @@ impl SidecarPerform { }; self.pending_notifications.lock().push(notification); } + + /// Handle the ConEmu / Windows Terminal progress protocol + /// `OSC 9 ; 4 ; st ; pr` (also spoken by WezTerm, Ghostty, Kitty, …). + /// + /// `st` selects the state and `pr` an optional 0..=100 percentage: + /// - `st=0` → clear progress (`pr` ignored). + /// - `st=1` → [`TerminalProgressState::Normal`] at `pr` percent + /// (clamped to `0..=100`). + /// - `st=2` → [`TerminalProgressState::Error`]; `pr` optional, keeping the + /// previous percent when absent. + /// - `st=3` → [`TerminalProgressState::Indeterminate`] (`pr` ignored). + /// - `st=4` → [`TerminalProgressState::Paused`]; `pr` optional like `st=2`. + /// + /// A missing or unparseable `st` (or any value outside `0..=4`) leaves the + /// current progress untouched, so a malformed sequence can never panic or + /// spuriously clear an active bar. `params[2]` is `st`, `params[3]` is `pr`. + fn handle_osc9_progress(&mut self, params: &[&[u8]]) { + let Some(st) = params + .get(2) + .and_then(|p| std::str::from_utf8(p).ok()) + .and_then(|s| s.trim().parse::().ok()) + else { + return; + }; + // `pr` is optional and only meaningful for some states. + let pr = params + .get(3) + .and_then(|p| std::str::from_utf8(p).ok()) + .and_then(|s| s.trim().parse::().ok()); + + let mut slot = self.progress.lock(); + let previous = slot.map(|p| p.value).unwrap_or(0); + *slot = match st { + // `st=0` removes the progress entirely. + 0 => None, + 1 => Some(TerminalProgress { + state: TerminalProgressState::Normal, + value: pr.unwrap_or(0).min(100), + }), + // Error / paused keep the previous percent when `pr` is omitted. + 2 => Some(TerminalProgress { + state: TerminalProgressState::Error, + value: pr.unwrap_or(previous).min(100), + }), + 3 => Some(TerminalProgress { + state: TerminalProgressState::Indeterminate, + value: 0, + }), + 4 => Some(TerminalProgress { + state: TerminalProgressState::Paused, + value: pr.unwrap_or(previous).min(100), + }), + // Unknown state — ignore gracefully, leaving the bar as-is. + _ => return, + }; + } + + /// Handle Okena's agent-status protocol + /// `OSC 9001 ; st= [; msg=] [; lbl=]`. + /// + /// An AI coding agent (or a thin hook) pushes its own lifecycle here: + /// - `st=working|blocked|done|idle` sets the lifecycle. + /// - `st=clear` removes the status entirely. + /// - `msg=` carries base64(UTF-8) free-form text (e.g. "running tests 3/5"). + /// - `lbl=` carries base64 of a flat JSON `{"k":"v"}` object of extras. + /// + /// `msg`/`lbl` are base64-encoded so their values stay `;`/ST-safe (the VTE + /// parser splits OSC params on `;`). A missing or unknown `st` leaves the + /// current status untouched — a malformed sequence can never panic or + /// spuriously clear an active status. + /// + /// On a transition *into* a notifying state (`blocked` / `done`) a + /// [`TerminalNotification`] is queued so the GPUI layer raises a desktop + /// notification — reusing the same drain + focused-pane suppression as + /// `OSC 9` notifications. + fn handle_agent_status(&mut self, params: &[&[u8]]) { + // params[0] is the OSC number; the rest are `key=value` pairs. + let mut st: Option<&str> = None; + let mut msg_b64: Option<&str> = None; + let mut lbl_b64: Option<&str> = None; + for kv in ¶ms[1..] { + let Ok(s) = std::str::from_utf8(kv) else { + continue; + }; + let mut it = s.splitn(2, '='); + let key = it.next().unwrap_or("").trim(); + let val = it.next().unwrap_or("").trim(); + match key { + "st" => st = Some(val), + "msg" => msg_b64 = Some(val), + "lbl" => lbl_b64 = Some(val), + _ => {} + } + } + + let Some(st) = st else { + log::debug!( + "agent-status[{}]: OSC 9001 with no st= field — ignored", + self.terminal_id + ); + return; // no state field — nothing to do + }; + + if st == "clear" { + let mut slot = self.agent_status.lock(); + let changed = slot.is_some(); + *slot = None; + drop(slot); + if changed { + self.remote_dirty.store(true, Ordering::Relaxed); + } + log::debug!( + "agent-status[{}]: clear (changed={changed})", + self.terminal_id + ); + return; + } + let Some(lifecycle) = AgentLifecycle::from_token(st) else { + log::debug!( + "agent-status[{}]: unknown st={st:?} — ignored, status left as-is", + self.terminal_id + ); + return; // unknown state — ignore, leave current status as-is + }; + + // Decode the agent-supplied fields BEFORE taking the lock — base64 of a + // hostile multi-MB payload must not run while holding `agent_status`. + // `new_clamped` bounds the decoded sizes (a pane could otherwise pin + // unbounded memory we then re-serialize to every remote client) and + // maps an empty `msg=` to `None` so a notifying state keeps its default + // body instead of an empty string. + let custom = msg_b64.and_then(decode_osc_base64); + let labels = lbl_b64 + .and_then(decode_osc_base64) + .map(|json| okena_core::agent_status::parse_labels_json(&json)) + .unwrap_or_default(); + let new_status = AgentStatus::new_clamped(lifecycle, custom, labels); + + // Durable session capture (resume + transcript stats). The `agent` + + // `session_id` labels are the pane's *sticky* session identity, NOT part + // of the ephemeral status — record them on a separate field that + // survives `st=clear`, so the pane can later offer to resume or show + // stats. Require both (without an `agent` id we don't know which harness + // could resume it) and validate the id looks like a UUID: it's + // untrusted in-band data that may reach a resume command. + if let (Some(agent), Some(sid)) = ( + new_status.labels.get("agent"), + new_status.labels.get("session_id"), + ) { + if okena_core::agent_session::is_uuid_like(sid) { + let session = AgentSession { + agent: agent.clone(), + session_id: sid.clone(), + transcript_path: new_status.labels.get("transcript_path").cloned(), + }; + let mut s = self.agent_session.lock(); + if s.as_ref() != Some(&session) { + *s = Some(session); + self.agent_session_dirty.store(true, Ordering::Relaxed); + } + } + } + + // Notify only on a *transition* into a notifying state, so repeated + // identical reports don't ping. Decide while holding the slot (we need + // the previous value), then release it before touching the notification + // queue to keep lock scopes from overlapping. + let mut slot = self.agent_status.lock(); + let previous_lifecycle = slot.as_ref().map(|s| s.lifecycle); + let notify_body = (previous_lifecycle != Some(lifecycle) && lifecycle.notifies()) + .then(|| new_status.custom.clone().unwrap_or_else(|| agent_default_body(lifecycle))); + // Any change to the stored status (lifecycle, custom text, or labels) + // is worth pushing to remote clients. + let changed = slot.as_ref() != Some(&new_status); + *slot = Some(new_status); + drop(slot); + + log::debug!( + "agent-status[{}]: {:?} -> {:?} (changed={changed}, notify={})", + self.terminal_id, + previous_lifecycle, + lifecycle, + notify_body.is_some() + ); + + if changed { + self.remote_dirty.store(true, Ordering::Relaxed); + } + if let Some(body) = notify_body { + self.pending_notifications + .lock() + .push(TerminalNotification { title: None, body }); + } + } +} + +/// Decode a base64(UTF-8) OSC field, tolerating a missing-pad variant (same +/// leniency as the `OSC 99` payload decoder). Returns `None` on undecodable or +/// non-UTF-8 input so a malformed field is simply dropped. +fn decode_osc_base64(s: &str) -> Option { + let decoded = base64::engine::general_purpose::STANDARD + .decode(s.as_bytes()) + .or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(s.as_bytes())) + .ok()?; + String::from_utf8(decoded).ok() +} + +/// Default notification body when an agent reports a notifying state without +/// its own `msg=` text. +fn agent_default_body(lifecycle: AgentLifecycle) -> String { + match lifecycle { + AgentLifecycle::Blocked => "Agent needs your input".to_string(), + AgentLifecycle::Done => "Agent finished".to_string(), + // Non-notifying states never reach here (guarded by `notifies()`). + AgentLifecycle::Working | AgentLifecycle::Idle => String::new(), + } } impl Perform for SidecarPerform { @@ -217,11 +469,40 @@ impl Perform for SidecarPerform { *self.reported_cwd.lock() = Some(path); } } + b"1337" => { + // iTerm2's proprietary `OSC 1337 ; key=value` channel. We only + // honour `CurrentDir=`, which some shell integrations emit + // instead of `OSC 7`; it feeds the *same* `reported_cwd` so the + // sidebar / "new tab here" / cwd tracking work regardless of + // which sequence the shell speaks. All other 1337 subcommands + // (RemoteHost, SetUserVar, File, …) have no consumer here and + // are deliberately ignored rather than parsed into dead code. + // + // Unlike OSC 7, the value is a raw filesystem path — not a + // `file://` URI and not percent-encoded. A path may legitimately + // contain `;`, so rejoin the split tail like the OSC 7 / 777 + // arms in case the parser broke the value apart. + let payload: String = params[1..] + .iter() + .filter_map(|p| std::str::from_utf8(p).ok()) + .collect::>() + .join(";"); + if let Some(value) = payload.strip_prefix("CurrentDir=") { + let path = value.trim(); + if !path.is_empty() { + *self.reported_cwd.lock() = Some(path.to_string()); + } + } + } b"9" => { - // iTerm2-style notification: `OSC 9 ; `. ConEmu's - // `OSC 9 ; 4 ; state ; progress` progress-bar subtype is - // treated as a plain-text message for now — we can split - // off subtypes when there's a UI for them. + // `OSC 9` is overloaded. The `OSC 9 ; 4 ; st ; pr` subtype is + // ConEmu's / Windows Terminal's progress-bar protocol; route it + // to the progress handler. Everything else is an iTerm2-style + // notification: `OSC 9 ; `. + if params.get(1).copied() == Some(b"4".as_slice()) { + self.handle_osc9_progress(params); + return; + } let message: String = params[1..] .iter() .filter_map(|p| std::str::from_utf8(p).ok()) @@ -265,6 +546,8 @@ impl Perform for SidecarPerform { } } b"99" => self.handle_osc99(params), + // Okena's private agent-status protocol; see `handle_agent_status`. + b"9001" => self.handle_agent_status(params), _ => {} } } diff --git a/crates/okena-terminal/src/terminal/prompt_jump.rs b/crates/okena-terminal/src/terminal/prompt_jump.rs index 7e4322f8..95b5402d 100644 --- a/crates/okena-terminal/src/terminal/prompt_jump.rs +++ b/crates/okena-terminal/src/terminal/prompt_jump.rs @@ -63,11 +63,85 @@ impl Terminal { }; let target = prompts[prompts.len() - 1 - new_index]; + self.scroll_to_prompt_mark(target); + true + } + + /// Scroll the viewport so the next older prompt whose command finished + /// with a non-zero exit code lands at visual row 0. Mirrors + /// [`Terminal::jump_to_prompt_above`] but only visits failures. + pub fn jump_to_prev_failed_command(&self) -> bool { + self.jump_to_failed(JumpDirection::Above) + } + + /// Reverse of [`Terminal::jump_to_prev_failed_command`]: walks one + /// failure forward toward the live bottom. Returns `false` when the + /// walker is already sitting on the newest failure or hasn't started + /// walking yet. + pub fn jump_to_next_failed_command(&self) -> bool { + self.jump_to_failed(JumpDirection::Below) + } + + fn jump_to_failed(&self, direction: JumpDirection) -> bool { + let marks = self.prompt_tracker.lock().snapshot(); + // Build the ordered list of prompts that produced a failure: walk + // marks chronologically tracking the most recent `PromptStart`, and + // whenever a `CommandFinished` reports a non-zero exit code, record + // the prompt that started that command. Marks with an unknown exit + // code (`None`) or a zero exit are not failures. + let mut last_prompt: Option<&PromptMark> = None; + let mut prompts: Vec<&PromptMark> = Vec::new(); + for mark in &marks { + match mark.kind { + PromptMarkKind::PromptStart => last_prompt = Some(mark), + PromptMarkKind::CommandFinished { + exit_code: Some(code), + } if code != 0 => { + if let Some(prompt) = last_prompt { + prompts.push(prompt); + } + } + _ => {} + } + } + if prompts.is_empty() { + return false; + } + + // `failed_jump_index` is a reverse index into `prompts`: 0 = newest + // failure, 1 = one older, etc. `None` means "walker is not engaged; + // an Above jump should land on the newest failure". Storing a + // reverse index keeps the walk scroll-invariant — scrolling rebases + // line values on every mark, but the relative order and count don't + // change. + let new_index: usize = { + let mut state = self.failed_jump_index.lock(); + let next = match (direction, *state) { + (JumpDirection::Above, None) => 0, + (JumpDirection::Above, Some(n)) => { + if n + 1 >= prompts.len() { + return false; + } + n + 1 + } + (JumpDirection::Below, Some(n)) if n > 0 => n - 1, + (JumpDirection::Below, _) => return false, + }; + *state = Some(next); + next + }; + + let target = prompts[prompts.len() - 1 - new_index]; + self.scroll_to_prompt_mark(target); + true + } + + /// Scroll the viewport so `target` lands at visual row 0, bypassing + /// `self.scroll` so the jump walker state isn't cleared — `self.scroll` + /// is reserved for externally-driven scrolling which resets the walker. + fn scroll_to_prompt_mark(&self, target: &PromptMark) { let target_offset = (-target.line).max(0); - // Scroll inline (bypassing self.scroll) so the jump walker state - // isn't cleared — self.scroll() is reserved for externally-driven - // scrolling which resets the walker. let mut term = self.term.lock(); let current = term.grid().display_offset() as i32; let delta = target_offset - current; @@ -77,6 +151,5 @@ impl Terminal { *self.scroll_offset.lock() += delta; self.content_generation.fetch_add(1, Ordering::Relaxed); } - true } } diff --git a/crates/okena-terminal/src/terminal/scroll.rs b/crates/okena-terminal/src/terminal/scroll.rs index 8d3f782a..217bb13c 100644 --- a/crates/okena-terminal/src/terminal/scroll.rs +++ b/crates/okena-terminal/src/terminal/scroll.rs @@ -37,6 +37,7 @@ impl Terminal { // implicit reference point has moved, so the next Above jump // should start over from the newest prompt. *self.prompt_jump_index.lock() = None; + *self.failed_jump_index.lock() = None; } /// Scroll up by lines diff --git a/crates/okena-terminal/src/terminal/tests/kitty.rs b/crates/okena-terminal/src/terminal/tests/kitty.rs new file mode 100644 index 00000000..7b66a3bf --- /dev/null +++ b/crates/okena-terminal/src/terminal/tests/kitty.rs @@ -0,0 +1,98 @@ +//! Integration tests for the kitty keyboard protocol: the full chain from an +//! app pushing a mode (`CSI > flags u`) through alacritty's `TermMode` to +//! `Terminal::kitty_keyboard_flags()` and on into the key encoder. The +//! `input::tests` unit tests exercise the encoder with hand-built flags; these +//! tests guard the wiring those bypass — in particular that +//! `TermConfig::kitty_keyboard` stays enabled (without it alacritty silently +//! ignores every keyboard-mode sequence and the flag never flips). + +use super::super::Terminal; +use super::super::types::TerminalSize; +use super::{CapturingTransport, NullTransport}; +use crate::input::{KeyEvent, KeyModifiers, key_to_bytes}; +use std::sync::Arc; + +fn term() -> Terminal { + Terminal::new( + "test-id".to_string(), + TerminalSize::default(), + Arc::new(NullTransport), + "/tmp".to_string(), + ) +} + +#[test] +fn push_and_pop_toggle_disambiguate_flag() { + let t = term(); + assert!( + !t.kitty_keyboard_flags().disambiguate_escape_codes, + "disambiguate must start off" + ); + + // `CSI > 1 u` — push the disambiguate-escape-codes flag. + t.process_output(b"\x1b[>1u"); + assert!( + t.kitty_keyboard_flags().disambiguate_escape_codes, + "push must set the flag (regression guard for TermConfig.kitty_keyboard)" + ); + + // `CSI < u` — pop one level off the stack, back to no modes. + t.process_output(b"\x1b[ 1 u`: the encoder reads the live flag and emits `CSI 27 u`. + t.process_output(b"\x1b[>1u"); + assert_eq!( + key_to_bytes(&esc, false, t.kitty_keyboard_flags()), + Some(b"\x1b[27u".to_vec()) + ); +} + +#[test] +fn send_escape_and_backtab_actions_are_kitty_aware() { + // Esc / Tab / Shift+Tab arrive via dedicated GPUI actions that bypass the + // on_key_down path; `Terminal::send_escape` / `send_backtab` route them back + // through the encoder. Guard that they honor the live kitty flag. + let transport = Arc::new(CapturingTransport::new()); + let t = Terminal::new( + "test-id".to_string(), + TerminalSize::default(), + transport.clone(), + "/tmp".to_string(), + ); + + // Legacy before the protocol is enabled. + t.send_escape(); + t.send_backtab(); + assert_eq!(transport.writes(), vec![b"\x1b".to_vec(), b"\x1b[Z".to_vec()]); + + // After the app pushes disambiguate, the same actions emit CSI u. + t.process_output(b"\x1b[>1u"); + t.send_escape(); + t.send_backtab(); + let writes = transport.writes(); + assert_eq!(&writes[2..], &[b"\x1b[27u".to_vec(), b"\x1b[9;2u".to_vec()]); + + // Plain Tab is never disambiguated at level 1. + t.send_tab(); + assert_eq!(transport.writes().last().unwrap(), b"\t"); +} diff --git a/crates/okena-terminal/src/terminal/tests/mod.rs b/crates/okena-terminal/src/terminal/tests/mod.rs index aff20b0c..3863b129 100644 --- a/crates/okena-terminal/src/terminal/tests/mod.rs +++ b/crates/okena-terminal/src/terminal/tests/mod.rs @@ -1,5 +1,6 @@ mod focus_report; mod helpers; +mod kitty; mod osc; mod prompt_jump; mod resize_authority; diff --git a/crates/okena-terminal/src/terminal/tests/osc.rs b/crates/okena-terminal/src/terminal/tests/osc.rs index bc56cbc9..efa40556 100644 --- a/crates/okena-terminal/src/terminal/tests/osc.rs +++ b/crates/okena-terminal/src/terminal/tests/osc.rs @@ -2,7 +2,9 @@ use super::super::Terminal; use super::super::TerminalNotification; use super::super::app_version::set_app_version; use super::super::osc_sidecar::parse_osc7_file_uri; -use super::super::types::{PromptMarkKind, TerminalSize}; +use super::super::types::{ + PromptMarkKind, TerminalProgress, TerminalProgressState, TerminalSize, +}; use super::{CapturingTransport, NullTransport}; use std::sync::Arc; @@ -161,6 +163,79 @@ fn test_osc7_invalid_scheme_ignored() { assert_eq!(terminal.reported_cwd(), None); } +#[test] +fn test_osc1337_current_dir_bel_terminated() { + let transport = Arc::new(NullTransport); + let terminal = Terminal::new( + "t".into(), + TerminalSize::default(), + transport, + "/tmp".into(), + ); + + assert_eq!(terminal.reported_cwd(), None); + + // iTerm2 shell integration: OSC 1337 ; CurrentDir= BEL. The path + // is a plain filesystem path — no file:// scheme, no percent-encoding. + terminal.process_output(b"\x1b]1337;CurrentDir=/home/matej/projects/okena\x07"); + + assert_eq!( + terminal.reported_cwd().as_deref(), + Some("/home/matej/projects/okena"), + ); + assert_eq!(terminal.current_cwd(), "/home/matej/projects/okena"); +} + +#[test] +fn test_osc1337_current_dir_st_terminated() { + let transport = Arc::new(NullTransport); + let terminal = Terminal::new( + "t".into(), + TerminalSize::default(), + transport, + "/tmp".into(), + ); + + // ST-terminated form (ESC \) is equally valid. + terminal.process_output(b"\x1b]1337;CurrentDir=/var/www\x1b\\"); + + assert_eq!(terminal.reported_cwd().as_deref(), Some("/var/www")); +} + +#[test] +fn test_osc1337_empty_current_dir_ignored() { + let transport = Arc::new(NullTransport); + let terminal = Terminal::new( + "t".into(), + TerminalSize::default(), + transport, + "/tmp".into(), + ); + + // An empty / whitespace-only path must not overwrite the cwd. + terminal.process_output(b"\x1b]1337;CurrentDir=\x07"); + terminal.process_output(b"\x1b]1337;CurrentDir= \x07"); + + assert_eq!(terminal.reported_cwd(), None); +} + +#[test] +fn test_osc1337_non_current_dir_subcommand_ignored() { + let transport = Arc::new(NullTransport); + let terminal = Terminal::new( + "t".into(), + TerminalSize::default(), + transport, + "/tmp".into(), + ); + + // 1337 carries many subcommands; only CurrentDir is ours. RemoteHost (and + // the rest) must leave the cwd untouched. + terminal.process_output(b"\x1b]1337;RemoteHost=matej@myhost\x07"); + + assert_eq!(terminal.reported_cwd(), None); +} + #[test] fn test_parse_osc7_file_uri() { assert_eq!( @@ -297,6 +372,128 @@ fn test_osc9_st_terminator() { ); } +#[test] +fn test_osc9_4_st1_sets_normal_progress() { + let transport = Arc::new(NullTransport); + let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + + assert_eq!(terminal.progress(), None); + + // OSC 9 ; 4 ; 1 ; 42 → normal progress at 42%. + terminal.process_output(b"\x1b]9;4;1;42\x07"); + + assert_eq!( + terminal.progress(), + Some(TerminalProgress { state: TerminalProgressState::Normal, value: 42 }), + ); + // Progress is sticky (not drained on read). + assert_eq!( + terminal.progress(), + Some(TerminalProgress { state: TerminalProgressState::Normal, value: 42 }), + ); +} + +#[test] +fn test_osc9_4_st0_clears_progress() { + let transport = Arc::new(NullTransport); + let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + + terminal.process_output(b"\x1b]9;4;1;50\x07"); + assert!(terminal.progress().is_some()); + + // st=0 removes the bar; pr is ignored. + terminal.process_output(b"\x1b]9;4;0;\x07"); + assert_eq!(terminal.progress(), None); +} + +#[test] +fn test_osc9_4_st3_indeterminate_ignores_value() { + let transport = Arc::new(NullTransport); + let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + + // st=3 is a spinner — pr is meaningless and normalised to 0. + terminal.process_output(b"\x1b]9;4;3;77\x07"); + + assert_eq!( + terminal.progress(), + Some(TerminalProgress { state: TerminalProgressState::Indeterminate, value: 0 }), + ); +} + +#[test] +fn test_osc9_4_clamps_value_over_100() { + let transport = Arc::new(NullTransport); + let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + + // pr above 100 is clamped to 100. + terminal.process_output(b"\x1b]9;4;1;255\x07"); + + assert_eq!( + terminal.progress(), + Some(TerminalProgress { state: TerminalProgressState::Normal, value: 100 }), + ); +} + +#[test] +fn test_osc9_4_st2_error_keeps_previous_value() { + let transport = Arc::new(NullTransport); + let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + + // Set a baseline, then signal an error without an explicit percent. + terminal.process_output(b"\x1b]9;4;1;30\x07"); + terminal.process_output(b"\x1b]9;4;2;\x07"); + + assert_eq!( + terminal.progress(), + Some(TerminalProgress { state: TerminalProgressState::Error, value: 30 }), + ); + + // An explicit pr on the error state overrides the kept value. + terminal.process_output(b"\x1b]9;4;2;80\x07"); + assert_eq!( + terminal.progress(), + Some(TerminalProgress { state: TerminalProgressState::Error, value: 80 }), + ); +} + +#[test] +fn test_osc9_4_garbage_st_ignored() { + let transport = Arc::new(NullTransport); + let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + + terminal.process_output(b"\x1b]9;4;1;25\x07"); + // A non-numeric / out-of-range st must leave the current bar untouched and + // must never produce a notification. + terminal.process_output(b"\x1b]9;4;abc;5\x07"); + terminal.process_output(b"\x1b]9;4;9;5\x07"); + + assert_eq!( + terminal.progress(), + Some(TerminalProgress { state: TerminalProgressState::Normal, value: 25 }), + ); + assert!(terminal.take_pending_notifications().is_empty()); +} + +#[test] +fn test_osc9_4_does_not_produce_notification() { + let transport = Arc::new(NullTransport); + let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + + // The progress subtype must NOT be treated as notification text... + terminal.process_output(b"\x1b]9;4;1;42\x07"); + assert!(terminal.take_pending_notifications().is_empty()); + assert!(terminal.progress().is_some()); + + // ...while a plain OSC 9 message still produces a notification and leaves + // progress untouched (no regression). + terminal.process_output(b"\x1b]9;Build complete\x07"); + assert_eq!(terminal.take_pending_notifications(), vec![body("Build complete")]); + assert_eq!( + terminal.progress(), + Some(TerminalProgress { state: TerminalProgressState::Normal, value: 42 }), + ); +} + #[test] fn test_osc777_notify_title_and_body() { let transport = Arc::new(NullTransport); @@ -435,6 +632,153 @@ fn test_bell_edge_is_one_shot() { assert!(terminal.has_bell(), "has_bell stays set until focus clears it"); } +#[test] +fn test_osc52_read_request_queues_responder() { + let transport = Arc::new(CapturingTransport::new()); + let terminal = Terminal::new( + "t".into(), + TerminalSize::default(), + transport.clone(), + "/tmp".into(), + ); + + assert!(!terminal.has_pending_clipboard_reads(), "nothing queued yet"); + + // OSC 52 ; c ; ? — the app asks to READ the clipboard. + terminal.process_output(b"\x1b]52;c;?\x07"); + + assert!( + terminal.has_pending_clipboard_reads(), + "a read request must be queued", + ); + // Queuing the request must not write anything to the PTY on its own — + // the reply is only sent once answered with clipboard contents. + assert!( + transport.writes().is_empty(), + "no PTY reply until answered: {:?}", + transport.writes(), + ); +} + +#[test] +fn test_osc52_answer_clipboard_reads_replies_and_drains() { + let transport = Arc::new(CapturingTransport::new()); + let terminal = Terminal::new( + "t".into(), + TerminalSize::default(), + transport.clone(), + "/tmp".into(), + ); + + terminal.process_output(b"\x1b]52;c;?\x07"); + assert!(terminal.has_pending_clipboard_reads()); + + terminal.answer_clipboard_reads("hi"); + + // The queue is drained once answered. + assert!( + !terminal.has_pending_clipboard_reads(), + "queue must be empty after answering", + ); + // A non-empty OSC 52 response was written back to the PTY. + let writes = transport.writes(); + assert_eq!(writes.len(), 1, "expected exactly one PTY reply"); + assert!(!writes[0].is_empty(), "reply must not be empty"); + let body = std::str::from_utf8(&writes[0]).unwrap(); + assert!(body.contains("52;"), "reply should be an OSC 52 sequence: {body:?}"); +} + +#[test] +fn test_osc52_drop_clipboard_reads_clears_without_reply() { + let transport = Arc::new(CapturingTransport::new()); + let terminal = Terminal::new( + "t".into(), + TerminalSize::default(), + transport.clone(), + "/tmp".into(), + ); + + terminal.process_output(b"\x1b]52;c;?\x07"); + assert!(terminal.has_pending_clipboard_reads()); + + // Silent deny: drop the request without writing anything to the PTY. + terminal.drop_clipboard_reads(); + + assert!(!terminal.has_pending_clipboard_reads(), "queue must be cleared"); + assert!( + transport.writes().is_empty(), + "dropping must not reply: {:?}", + transport.writes(), + ); +} + +#[test] +fn test_osc52_write_does_not_enqueue_read() { + let transport = Arc::new(NullTransport); + let terminal = Terminal::new( + "t".into(), + TerminalSize::default(), + transport, + "/tmp".into(), + ); + + // A plain OSC 52 *write* (base64 "hi" == "aGk=") stores clipboard text and + // must NOT enqueue a read request. + terminal.process_output(b"\x1b]52;c;aGk=\x07"); + + assert!( + !terminal.has_pending_clipboard_reads(), + "a write must not enqueue a read", + ); + assert_eq!( + terminal.take_pending_clipboard_writes(), + vec!["hi".to_string()], + "the write text must reach the clipboard-write queue", + ); +} + +#[test] +fn test_xtwinops_14t_reports_text_area_size_in_pixels() { + // Explicit size so the pixel math is unambiguous: 80 cols x 24 rows at + // 8x16 px cells → text area is 640 px wide and 384 px tall. + let size = TerminalSize { + cols: 80, + rows: 24, + cell_width: 8.0, + cell_height: 16.0, + }; + let transport = Arc::new(CapturingTransport::new()); + let terminal = Terminal::new("t".into(), size, transport.clone(), "/tmp".into()); + + // CSI 14 t — report text-area size in pixels. + terminal.process_output(b"\x1b[14t"); + + let writes = transport.writes(); + assert_eq!(writes.len(), 1, "expected exactly one PTY reply"); + // Reply is `CSI 4 ; ; t` = rows*cell_height ; cols*cell_width. + assert_eq!(writes[0], b"\x1b[4;384;640t"); +} + +#[test] +fn test_xtwinops_18t_still_reports_size_in_cells() { + // No regression: CSI 18 t (size in cells) keeps replying via PtyWrite. + let size = TerminalSize { + cols: 80, + rows: 24, + cell_width: 8.0, + cell_height: 16.0, + }; + let transport = Arc::new(CapturingTransport::new()); + let terminal = Terminal::new("t".into(), size, transport.clone(), "/tmp".into()); + + terminal.process_output(b"\x1b[18t"); + + let writes = transport.writes(); + assert_eq!(writes.len(), 1, "expected exactly one PTY reply"); + // Reply is `CSI 8 ; ; t`. + assert_eq!(writes[0], b"\x1b[8;24;80t"); +} + #[test] fn test_xtversion_responds_with_okena_name() { set_app_version("0.20.0-test"); @@ -699,3 +1043,269 @@ fn test_parse_osc133_kind() { ); assert_eq!(parse_osc133_kind(b'Z', &[]), None); } + +// ── Agent status (OSC 9001) ────────────────────────────────────────────── + +fn b64(s: &str) -> String { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD.encode(s) +} + +#[test] +fn test_agent_status_sets_lifecycle_without_notification() { + use okena_core::agent_status::{AgentLifecycle, AgentStatus}; + let transport = Arc::new(NullTransport); + let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + + assert_eq!(terminal.agent_status(), None); + + terminal.process_output(b"\x1b]9001;st=working\x07"); + + assert_eq!( + terminal.agent_status(), + Some(AgentStatus::new(AgentLifecycle::Working)), + ); + // `working` is a non-notifying state. + assert!(terminal.take_pending_notifications().is_empty()); +} + +#[test] +fn test_agent_status_done_with_custom_message_notifies() { + use okena_core::agent_status::AgentLifecycle; + let transport = Arc::new(NullTransport); + let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + + let msg = "running tests 3/5"; + terminal.process_output(format!("\x1b]9001;st=done;msg={}\x07", b64(msg)).as_bytes()); + + let status = terminal.agent_status().expect("status set"); + assert_eq!(status.lifecycle, AgentLifecycle::Done); + assert_eq!(status.custom.as_deref(), Some(msg)); + + // A transition into `done` queues a notification carrying the custom text. + assert_eq!(terminal.take_pending_notifications(), vec![body(msg)]); +} + +#[test] +fn test_agent_status_blocked_without_message_uses_default_body() { + let transport = Arc::new(NullTransport); + let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + + terminal.process_output(b"\x1b]9001;st=blocked\x07"); + + assert_eq!( + terminal.take_pending_notifications(), + vec![body("Agent needs your input")], + ); +} + +#[test] +fn test_agent_status_notifies_only_on_transition() { + let transport = Arc::new(NullTransport); + let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + + terminal.process_output(b"\x1b]9001;st=working\x07"); + assert!(terminal.take_pending_notifications().is_empty()); + + // First `done` notifies... + terminal.process_output(b"\x1b]9001;st=done\x07"); + assert_eq!(terminal.take_pending_notifications(), vec![body("Agent finished")]); + + // ...a repeated identical `done` does not (no transition). + terminal.process_output(b"\x1b]9001;st=done\x07"); + assert!(terminal.take_pending_notifications().is_empty()); +} + +#[test] +fn test_agent_status_clear_removes_status() { + let transport = Arc::new(NullTransport); + let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + + terminal.process_output(b"\x1b]9001;st=working\x07"); + assert!(terminal.agent_status().is_some()); + + terminal.process_output(b"\x1b]9001;st=clear\x07"); + assert_eq!(terminal.agent_status(), None); +} + +#[test] +fn test_agent_status_unknown_state_left_untouched() { + use okena_core::agent_status::{AgentLifecycle, AgentStatus}; + let transport = Arc::new(NullTransport); + let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + + terminal.process_output(b"\x1b]9001;st=working\x07"); + // An unknown / missing state must not clear or change the current status, + // and must never produce a notification. + terminal.process_output(b"\x1b]9001;st=bogus\x07"); + terminal.process_output(b"\x1b]9001;msg=abc\x07"); + + assert_eq!( + terminal.agent_status(), + Some(AgentStatus::new(AgentLifecycle::Working)), + ); + assert!(terminal.take_pending_notifications().is_empty()); +} + +#[test] +fn test_agent_status_parses_labels_json() { + let transport = Arc::new(NullTransport); + let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + + let lbl = b64(r#"{"stage":"verify","eta":"5m"}"#); + terminal.process_output(format!("\x1b]9001;st=working;lbl={lbl}\x07").as_bytes()); + + let status = terminal.agent_status().expect("status set"); + assert_eq!(status.labels.get("stage").map(String::as_str), Some("verify")); + assert_eq!(status.labels.get("eta").map(String::as_str), Some("5m")); +} + +#[test] +fn test_agent_status_malformed_base64_drops_field_but_keeps_lifecycle() { + use okena_core::agent_status::AgentLifecycle; + let transport = Arc::new(NullTransport); + let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + + // `***` is not valid base64 — the custom text is dropped, but the + // lifecycle still applies. + terminal.process_output(b"\x1b]9001;st=working;msg=***\x07"); + + let status = terminal.agent_status().expect("status set"); + assert_eq!(status.lifecycle, AgentLifecycle::Working); + assert_eq!(status.custom, None); +} + +#[test] +fn test_agent_status_st_terminator() { + use okena_core::agent_status::{AgentLifecycle, AgentStatus}; + let transport = Arc::new(NullTransport); + let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + + // ST-terminated form (ESC \) is equally valid. + terminal.process_output(b"\x1b]9001;st=idle\x1b\\"); + + assert_eq!( + terminal.agent_status(), + Some(AgentStatus::new(AgentLifecycle::Idle)), + ); +} + +#[test] +fn test_agent_status_empty_message_uses_default_body() { + // A `msg=` that decodes to "" must not produce an empty notification (or an + // empty rendered custom) — it falls back to the lifecycle default, same as + // omitting `msg=` entirely. + let transport = Arc::new(NullTransport); + let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + + terminal.process_output(b"\x1b]9001;st=blocked;msg=\x07"); + + let status = terminal.agent_status().expect("status set"); + assert_eq!(status.custom, None); + assert_eq!( + terminal.take_pending_notifications(), + vec![body("Agent needs your input")], + ); +} + +#[test] +fn test_agent_status_oversized_fields_are_bounded() { + use okena_core::agent_status::{MAX_CUSTOM_LEN, MAX_LABELS}; + let transport = Arc::new(NullTransport); + let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + + // A hostile pane pushes a multi-MB custom string and a huge labels map. + // Both must be clamped (not stored verbatim) and must not panic. + let huge = "A".repeat(2_000_000); + let mut json = String::from("{"); + for i in 0..200 { + if i > 0 { + json.push(','); + } + json.push_str(&format!("\"k{i}\":\"v\"")); + } + json.push('}'); + terminal.process_output( + format!("\x1b]9001;st=working;msg={};lbl={}\x07", b64(&huge), b64(&json)).as_bytes(), + ); + + let status = terminal.agent_status().expect("status set"); + assert!(status.custom.as_ref().expect("custom set").len() <= MAX_CUSTOM_LEN); + assert!(status.labels.len() <= MAX_LABELS); +} + +#[test] +fn test_agent_status_marks_remote_dirty_on_change_only() { + // `remote_dirty` is the only signal that pushes agent status to remote + // clients (the PTY loop drains it to bump state_version), so guard its + // edges: a first status, a custom-text-only change at the same lifecycle, + // and clear-from-set are all dirty; an identical repeat and clear-from-none + // are not. + let transport = Arc::new(NullTransport); + let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + + terminal.process_output(b"\x1b]9001;st=working\x07"); + assert!(terminal.take_remote_dirty()); + assert!(!terminal.take_remote_dirty()); // one-shot + + // Custom-text-only change at the SAME lifecycle still counts. + terminal.process_output(format!("\x1b]9001;st=working;msg={}\x07", b64("step 1")).as_bytes()); + assert!(terminal.take_remote_dirty()); + terminal.process_output(format!("\x1b]9001;st=working;msg={}\x07", b64("step 2")).as_bytes()); + assert!(terminal.take_remote_dirty()); + + // A byte-identical repeat is not a change. + terminal.process_output(format!("\x1b]9001;st=working;msg={}\x07", b64("step 2")).as_bytes()); + assert!(!terminal.take_remote_dirty()); + + // clear with a prior status is a change; clear with none is not. + terminal.process_output(b"\x1b]9001;st=clear\x07"); + assert!(terminal.take_remote_dirty()); + terminal.process_output(b"\x1b]9001;st=clear\x07"); + assert!(!terminal.take_remote_dirty()); +} + +#[test] +fn test_agent_session_captured_from_label_and_survives_clear() { + let transport = Arc::new(NullTransport); + let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + + assert_eq!(terminal.agent_session(), None); + + let uuid = "3b9c1f2a-4d5e-6f70-8a9b-0c1d2e3f4a5b"; + let lbl = b64(&format!( + r#"{{"agent":"claude-code","session_id":"{uuid}","transcript_path":"/tmp/t.jsonl"}}"# + )); + terminal.process_output(format!("\x1b]9001;st=working;lbl={lbl}\x07").as_bytes()); + + let session = terminal.agent_session().expect("session captured"); + assert_eq!(session.agent, "claude-code"); + assert_eq!(session.session_id, uuid); + assert_eq!(session.transcript_path.as_deref(), Some("/tmp/t.jsonl")); + assert!(terminal.take_agent_session_dirty()); + assert!(!terminal.take_agent_session_dirty()); // one-shot + + // Sticky: a `clear` drops the ephemeral status but KEEPS the session, so the + // pane can still offer to resume after the agent ends. + terminal.process_output(b"\x1b]9001;st=clear\x07"); + assert_eq!(terminal.agent_status(), None); + assert_eq!( + terminal.agent_session().map(|s| s.session_id), + Some(uuid.to_string()), + ); +} + +#[test] +fn test_agent_session_rejects_non_uuid_session_id() { + // A hostile / malformed session id is ignored — never stored, so it can + // never reach a resume command. The lifecycle still applies. + let transport = Arc::new(NullTransport); + let terminal = Terminal::new("t".into(), TerminalSize::default(), transport, "/tmp".into()); + + let lbl = b64(r#"{"agent":"claude-code","session_id":"$(rm -rf ~)"}"#); + terminal.process_output(format!("\x1b]9001;st=working;lbl={lbl}\x07").as_bytes()); + + assert!(terminal.agent_status().is_some()); + assert_eq!(terminal.agent_session(), None); + assert!(!terminal.take_agent_session_dirty()); +} diff --git a/crates/okena-terminal/src/terminal/tests/prompt_jump.rs b/crates/okena-terminal/src/terminal/tests/prompt_jump.rs index e1a107a2..77a4a091 100644 --- a/crates/okena-terminal/src/terminal/tests/prompt_jump.rs +++ b/crates/okena-terminal/src/terminal/tests/prompt_jump.rs @@ -163,3 +163,85 @@ fn test_jump_to_prompt_ignores_non_prompt_kinds() { assert!(!terminal.jump_to_prompt_above()); } + +#[test] +fn test_jump_to_failed_returns_false_without_failures() { + let size = TerminalSize { + cols: 20, + rows: 5, + cell_width: 8.0, + cell_height: 16.0, + }; + let transport = Arc::new(NullTransport); + let terminal = Terminal::new("t".into(), size, transport, "/tmp".into()); + + // Two prompts that both succeed (exit code 0) — no failures to visit. + terminal.process_output(b"\x1b]133;A\x1b\\$ a\r\nout\r\n\x1b]133;D;0\x1b\\"); + terminal.process_output(b"\x1b]133;A\x1b\\$ b\r\nout\r\n\x1b]133;D;0\x1b\\"); + + assert!(!terminal.jump_to_prev_failed_command()); + assert!(!terminal.jump_to_next_failed_command()); +} + +#[test] +fn test_jump_to_failed_jumps_to_failure() { + let size = TerminalSize { + cols: 20, + rows: 5, + cell_width: 8.0, + cell_height: 16.0, + }; + let transport = Arc::new(NullTransport); + let terminal = Terminal::new("t".into(), size, transport, "/tmp".into()); + + // A single prompt whose command fails (exit code 1), then a fresh + // prompt with enough output to scroll the failure into history. + terminal.process_output( + b"\x1b]133;A\x1b\\$ boom\r\nerr\r\nmore\r\n\x1b]133;D;1\x1b\\", + ); + terminal.process_output(b"\x1b]133;A\x1b\\$ ok\r\n"); + + // First Above press engages the walker and lands on the failure. + assert!(terminal.jump_to_prev_failed_command()); + // Nothing older to visit. + assert!(!terminal.jump_to_prev_failed_command()); +} + +#[test] +fn test_jump_to_failed_visits_only_failures() { + let size = TerminalSize { + cols: 20, + rows: 5, + cell_width: 8.0, + cell_height: 16.0, + }; + let transport = Arc::new(NullTransport); + let terminal = Terminal::new("t".into(), size, transport, "/tmp".into()); + + // Three commands: fail, succeed, fail. Only the two failures are + // walkable — a third Above press must fail. + terminal.process_output(b"\x1b]133;A\x1b\\$ a\r\nout\r\n\x1b]133;D;2\x1b\\"); + terminal.process_output(b"\x1b]133;A\x1b\\$ b\r\nout\r\n\x1b]133;D;0\x1b\\"); + terminal.process_output(b"\x1b]133;A\x1b\\$ c\r\nout\r\n\x1b]133;D;1\x1b\\"); + + assert!(terminal.jump_to_prev_failed_command()); // newest failure (c) + assert!(terminal.jump_to_prev_failed_command()); // older failure (a) + assert!(!terminal.jump_to_prev_failed_command()); // success (b) skipped, no more +} + +#[test] +fn test_jump_to_failed_ignores_zero_exit() { + let size = TerminalSize { + cols: 20, + rows: 5, + cell_width: 8.0, + cell_height: 16.0, + }; + let transport = Arc::new(NullTransport); + let terminal = Terminal::new("t".into(), size, transport, "/tmp".into()); + + // Exit code 0 is success, not a failure — jumping must be a no-op. + terminal.process_output(b"\x1b]133;A\x1b\\$ a\r\nout\r\n\x1b]133;D;0\x1b\\"); + + assert!(!terminal.jump_to_prev_failed_command()); +} diff --git a/crates/okena-terminal/src/terminal/types.rs b/crates/okena-terminal/src/terminal/types.rs index 6f55e06a..c31fd28d 100644 --- a/crates/okena-terminal/src/terminal/types.rs +++ b/crates/okena-terminal/src/terminal/types.rs @@ -1,3 +1,14 @@ +/// Formatter for an OSC 52 clipboard *read* request. +/// +/// `alacritty_terminal` hands us this closure with the +/// `Event::ClipboardLoad` event: given the current clipboard text, it +/// returns the full escape sequence (`OSC 52 ; c ; ST`) to write +/// back to the PTY so the requesting app receives the contents. We can't +/// run it inline (the listener has no access to the system clipboard on the +/// GPUI thread), so the closure is queued and invoked later once the +/// clipboard has been read with a `cx`. +pub type ClipboardReadResponder = std::sync::Arc String + Send + Sync>; + /// Terminal size in cells and pixels #[derive(Clone, Copy, Debug)] pub struct TerminalSize { @@ -63,6 +74,39 @@ pub struct PromptMark { pub column: usize, } +/// Progress state reported via the ConEmu / Windows Terminal protocol +/// `OSC 9 ; 4 ; st ; pr` (also spoken by WezTerm, Ghostty, Kitty, …). +/// +/// Programs like npm, cargo, and downloaders emit this to drive a taskbar / +/// tab progress indicator. The `st` field selects the variant; `pr` carries +/// the 0..=100 percentage where applicable (see [`TerminalProgress`]). The +/// `st=0` "remove" state has no variant here — it clears the progress to +/// `None` rather than being represented as a state. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TerminalProgressState { + /// `st=1` — a normal, determinate progress bar at `value` percent. + Normal, + /// `st=2` — an error occurred; `value` keeps the last/explicit percent. + Error, + /// `st=3` — indeterminate work (a spinner); `value` is meaningless. + Indeterminate, + /// `st=4` — paused or warning; `value` keeps the last/explicit percent. + Paused, +} + +/// Active progress reported by the running program via `OSC 9 ; 4`. +/// +/// The owning [`Terminal`](super::Terminal) holds `Option`: +/// `None` means no progress is being reported (the program never started one +/// or sent `st=0` to clear it). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TerminalProgress { + pub state: TerminalProgressState, + /// Percentage in `0..=100`. Ignored for + /// [`TerminalProgressState::Indeterminate`]. + pub value: u8, +} + /// Cursor shape requested by the terminal application via DECSCUSR. /// /// Maps onto the three shapes Okena's renderer knows how to paint. diff --git a/crates/okena-transport/src/client/state.rs b/crates/okena-transport/src/client/state.rs index 223ffb82..8f31b7a2 100644 --- a/crates/okena-transport/src/client/state.rs +++ b/crates/okena-transport/src/client/state.rs @@ -202,6 +202,7 @@ mod tests { show_in_overview: true, layout, terminal_names: Default::default(), + terminal_agent_status: Default::default(), git_status: None, folder_color: Default::default(), services: vec![], @@ -274,6 +275,7 @@ mod tests { ], }), terminal_names: Default::default(), + terminal_agent_status: Default::default(), git_status: None, folder_color: FolderColor::default(), services: Vec::new(), diff --git a/crates/okena-views-sidebar/src/agents_list.rs b/crates/okena-views-sidebar/src/agents_list.rs new file mode 100644 index 00000000..32f5261f --- /dev/null +++ b/crates/okena-views-sidebar/src/agents_list.rs @@ -0,0 +1,195 @@ +//! The sidebar "AGENTS" section — a flat, cross-project list of panes running +//! an AI coding agent that reports its status via `OSC 9001`. +//! +//! This is the multi-agent "mission control": every active agent at a glance, +//! sorted by attention (blocked → done → working → idle), regardless of which +//! project it lives in. Clicking a row jumps straight to that pane. The section +//! hides itself when no agent is active. Per-pane indicators still live on the +//! tab itself (see `okena-views-terminal`). + +use okena_core::agent_status::AgentStatus; +use okena_ui::theme::theme; +use okena_ui::tokens::{ui_text_md, ui_text_ms, ui_text_sm}; +use gpui::*; +use gpui_component::tooltip::Tooltip; + +use crate::sidebar::Sidebar; + +/// One running agent, projected for rendering (owned so we don't hold the +/// workspace / terminal-registry locks while building elements). +struct SidebarAgentInfo { + terminal_id: String, + project_id: String, + display_name: String, + project_name: String, + status: AgentStatus, +} + +impl Sidebar { + /// Render the AGENTS section (header + one row per active agent), or an + /// empty element when no agent is reporting a status. + pub fn render_agents_section(&self, cx: &mut Context) -> impl IntoElement { + let agents = self.collect_agents(cx); + if agents.is_empty() { + return div().into_any_element(); + } + + let mut children: Vec = Vec::new(); + children.push(self.render_agents_header(agents.len(), cx).into_any_element()); + for agent in &agents { + children.push(self.render_agent_row(agent, cx).into_any_element()); + } + div().children(children).into_any_element() + } + + /// Collect every non-hook terminal across all projects that currently has an + /// agent status, sorted by lifecycle priority (blocked first) then name. + fn collect_agents(&self, cx: &mut Context) -> Vec { + let workspace = self.workspace.read(cx); + let terminals = self.terminals.lock(); + let mut agents: Vec = Vec::new(); + for project in workspace.projects() { + let Some(layout) = project.layout.as_ref() else { + continue; + }; + for tid in layout.collect_terminal_ids() { + // Hook terminals have their own panel. + if project.hook_terminals.contains_key(&tid) { + continue; + } + let Some(term) = terminals.get(tid.as_str()) else { + continue; + }; + let Some(status) = term.agent_status() else { + continue; + }; + let display_name = project.terminal_display_name(&tid, term.title()); + agents.push(SidebarAgentInfo { + terminal_id: tid, + project_id: project.id.clone(), + display_name, + project_name: project.name.clone(), + status, + }); + } + } + agents.sort_by(|a, b| { + b.status + .lifecycle + .priority() + .cmp(&a.status.lifecycle.priority()) + .then_with(|| a.display_name.cmp(&b.display_name)) + }); + agents + } + + fn render_agents_header(&self, count: usize, cx: &mut Context) -> impl IntoElement { + let t = theme(cx); + div() + .h(px(28.0)) + .px(px(12.0)) + .mt(px(8.0)) + .flex() + .items_center() + .gap(px(6.0)) + .child( + div() + .text_size(ui_text_ms(cx)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(t.text_secondary)) + .child("AGENTS"), + ) + .child( + div() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(count.to_string()), + ) + } + + fn render_agent_row(&self, agent: &SidebarAgentInfo, cx: &mut Context) -> impl IntoElement { + let t = theme(cx); + let dot_color = rgb(agent.status.lifecycle.theme_color(&t)); + let project_id = agent.project_id.clone(); + let terminal_id = agent.terminal_id.clone(); + let custom = agent.status.custom.clone().filter(|c| !c.is_empty()); + + // Two-line layout: the terminal title (usually the agent's task) on top, + // the project — plus any free-form status the agent reports — muted + // underneath, so a long title can no longer crowd out the project the + // pane belongs to. Both lines ellipsize independently. + let title = agent.display_name.clone(); + // Hover reveals whatever the lines truncate. + let tooltip = match &custom { + Some(c) => format!("{title} · {c}"), + None => title.clone(), + }; + // Second line: "project" or "project · custom status". + let subtitle = match &custom { + Some(c) => format!("{} · {c}", agent.project_name), + None => agent.project_name.clone(), + }; + + div() + .id(ElementId::Name(format!("agent-row-{}", agent.terminal_id).into())) + .px(px(12.0)) + .py(px(5.0)) + .flex() + .items_start() + .gap(px(6.0)) + .cursor_pointer() + .hover(|s| s.bg(rgb(t.bg_hover))) + .on_click(cx.listener(move |this, _, _window, cx| { + this.cursor_index = None; + let workspace = this.workspace.clone(); + this.focus_manager.update(cx, |fm, cx| { + workspace.update(cx, |ws, cx| { + ws.focus_terminal_by_id(fm, &project_id, &terminal_id, cx); + }); + cx.notify(); + }); + })) + // Lifecycle status dot, nudged down to sit on the title line. + .child( + div() + .mt(px(4.0)) + .w(px(8.0)) + .h(px(8.0)) + .rounded_full() + .bg(dot_color) + .flex_shrink_0(), + ) + // Text column: title over project, each ellipsized. + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap(px(1.0)) + // Line 1 — terminal title / agent task. + .child( + div() + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .text_size(ui_text_md(cx)) + .text_color(rgb(t.text_primary)) + .child(title), + ) + // Line 2 — project (+ optional free-form status). + .child( + div() + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(subtitle), + ), + ) + .tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx)) + } +} diff --git a/crates/okena-views-sidebar/src/lib.rs b/crates/okena-views-sidebar/src/lib.rs index 3020a4d6..845c11ff 100644 --- a/crates/okena-views-sidebar/src/lib.rs +++ b/crates/okena-views-sidebar/src/lib.rs @@ -1,6 +1,7 @@ #![cfg_attr(not(test), warn(clippy::unwrap_used, clippy::expect_used))] pub mod activity_order; +pub mod agents_list; pub mod sidebar; pub mod project_list; pub mod folder_list; diff --git a/crates/okena-views-sidebar/src/sidebar/from_project_test.rs b/crates/okena-views-sidebar/src/sidebar/from_project_test.rs index d70122e8..06c57cae 100644 --- a/crates/okena-views-sidebar/src/sidebar/from_project_test.rs +++ b/crates/okena-views-sidebar/src/sidebar/from_project_test.rs @@ -25,6 +25,7 @@ fn make_project(id: &str) -> ProjectData { is_remote: false, connection_id: None, service_terminals: HashMap::new(), + agent_sessions: Default::default(), default_shell: None, hook_terminals: HashMap::new(), pinned: false, diff --git a/crates/okena-views-sidebar/src/sidebar/render.rs b/crates/okena-views-sidebar/src/sidebar/render.rs index 42cef978..7c4fb85c 100644 --- a/crates/okena-views-sidebar/src/sidebar/render.rs +++ b/crates/okena-views-sidebar/src/sidebar/render.rs @@ -110,6 +110,7 @@ impl Sidebar { } })) .children(flat_elements) + .child(self.render_agents_section(cx)) .child(self.render_remote_section(cx)), ) } diff --git a/crates/okena-views-terminal/src/actions.rs b/crates/okena-views-terminal/src/actions.rs index aa64ac1c..d62ef20e 100644 --- a/crates/okena-views-terminal/src/actions.rs +++ b/crates/okena-views-terminal/src/actions.rs @@ -35,5 +35,7 @@ gpui::actions!( FullscreenPrevTerminal, JumpToPreviousPrompt, JumpToNextPrompt, + JumpToPreviousFailedCommand, + JumpToNextFailedCommand, ] ); diff --git a/crates/okena-views-terminal/src/layout/tabs/mod.rs b/crates/okena-views-terminal/src/layout/tabs/mod.rs index 258e1114..4fe6aa01 100644 --- a/crates/okena-views-terminal/src/layout/tabs/mod.rs +++ b/crates/okena-views-terminal/src/layout/tabs/mod.rs @@ -12,9 +12,11 @@ use okena_ui::header_buttons::{header_button_base, ButtonSize, HeaderAction}; use crate::layout::layout_container::{LayoutContainer, is_renaming, rename_input}; use crate::layout::pane_drag::{PaneDrag, PaneDragView}; use crate::simple_input::SimpleInput; +use okena_terminal::terminal::TerminalProgressState; use okena_workspace::state::{LayoutNode, SplitDirection}; use gpui::*; use gpui_component::{h_flex, v_flex}; +use gpui_component::tooltip::Tooltip; use gpui::prelude::*; use std::collections::HashSet; @@ -380,15 +382,29 @@ impl LayoutContainer { _ => None, }; - let (is_waiting, idle_label) = terminal_id.as_ref().map_or((false, None), |tid| { - let guard = terminals.lock(); - guard.get(tid).map_or((false, None), |t| { - if t.is_waiting_for_input() { - (true, Some(t.idle_duration_display())) - } else { - (false, None) - } - }) + let (is_waiting, idle_label, progress, agent_status) = + terminal_id.as_ref().map_or((false, None, None, None), |tid| { + let guard = terminals.lock(); + guard.get(tid).map_or((false, None, None, None), |t| { + let progress = t.progress(); + let agent_status = t.agent_status(); + if t.is_waiting_for_input() { + (true, Some(t.idle_duration_display()), progress, agent_status) + } else { + (false, None, progress, agent_status) + } + }) + }); + + // Agent lifecycle (if the pane is running an agent that reports via + // OSC 9001) takes precedence over the generic idle/hook coloring, + // and its free-form text shows in the tab tooltip. + let agent_tooltip: Option = agent_status.as_ref().map(|a| { + let life = a.lifecycle.label(); + match a.custom.as_deref() { + Some(c) if !c.is_empty() => format!("{life} · {c}"), + _ => format!("agent {life}"), + } }); let is_hook = terminal_id.as_ref().is_some_and(|tid| { @@ -415,6 +431,9 @@ impl LayoutContainer { div() .id(ElementId::Name(format!("tab-{}-{:?}", i, layout_path).into())) .cursor_pointer() + .when_some(agent_tooltip, |d, text| { + d.tooltip(move |window, cx| Tooltip::new(text.clone()).build(window, cx)) + }) .relative() .flex_shrink_0() .max_w(px(200.0)) @@ -483,7 +502,17 @@ impl LayoutContainer { })) .into_any_element() } else { - let icon_color = if is_hook { rgb(t.term_yellow) } else if is_waiting { rgb(t.border_idle) } else if is_active { rgb(t.success) } else { rgb(t.text_muted) }; + let icon_color = if let Some(a) = agent_status.as_ref() { + rgb(a.lifecycle.theme_color(&t)) + } else if is_hook { + rgb(t.term_yellow) + } else if is_waiting { + rgb(t.border_idle) + } else if is_active { + rgb(t.success) + } else { + rgb(t.text_muted) + }; h_flex() .gap(px(6.0)) .overflow_hidden() @@ -493,9 +522,57 @@ impl LayoutContainer { .children(idle_label.as_ref().map(|d| { div().text_size(ui_text_sm(cx)).text_color(rgb(t.border_idle)).child(d.clone()) })) + // Determinate OSC 9;4 progress also shows a percentage in + // the label, so it reads even when the bar is a thin sliver. + // Indeterminate progress has no meaningful value, so it's + // bar-only. + .children( + progress + .and_then(|p| match p.state { + TerminalProgressState::Indeterminate => None, + _ => Some(p.value.min(100)), + }) + .map(|pct| { + div() + .flex_shrink_0() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(format!("{pct}%")) + }), + ) .into_any_element() } }) + .children(progress.map(|p| { + // Color the bar by reported progress state. + let fill_color = match p.state { + TerminalProgressState::Normal => t.border_active, + TerminalProgressState::Error => t.error, + TerminalProgressState::Paused => t.warning, + TerminalProgressState::Indeterminate => t.border_active, + }; + // Determinate states fill `value`%; an indeterminate "busy" + // bar is rendered full width at a reduced alpha (no animation). + let (fill_fraction, fill_alpha) = match p.state { + TerminalProgressState::Indeterminate => (1.0_f32, 0.5_f32), + _ => (p.value.min(100) as f32 / 100.0, 1.0_f32), + }; + // Absolutely-positioned track pinned to the bottom edge, + // overlaying the tab's bottom border. + div() + .absolute() + .bottom_0() + .left_0() + .right_0() + .h(px(3.0)) + .bg(with_alpha(t.border_active, 0.15)) + .child( + div() + .h_full() + .w(relative(fill_fraction)) + .bg(with_alpha(fill_color, fill_alpha)), + ) + })) .on_mouse_down(MouseButton::Right, { let project_id = project_id.clone(); let layout_path = layout_path.clone(); diff --git a/crates/okena-views-terminal/src/layout/terminal_pane/actions.rs b/crates/okena-views-terminal/src/layout/terminal_pane/actions.rs index d81ae1c2..90311f84 100644 --- a/crates/okena-views-terminal/src/layout/terminal_pane/actions.rs +++ b/crates/okena-views-terminal/src/layout/terminal_pane/actions.rs @@ -162,6 +162,20 @@ impl TerminalPane { } } + pub(super) fn handle_jump_prev_failed(&mut self, cx: &mut Context) { + if let Some(ref terminal) = self.terminal + && terminal.jump_to_prev_failed_command() { + cx.notify(); + } + } + + pub(super) fn handle_jump_next_failed(&mut self, cx: &mut Context) { + if let Some(ref terminal) = self.terminal + && terminal.jump_to_next_failed_command() { + cx.notify(); + } + } + pub(super) fn handle_file_drop(&mut self, paths: &ExternalPaths, _cx: &mut Context) { let Some(ref terminal) = self.terminal else { return; diff --git a/crates/okena-views-terminal/src/layout/terminal_pane/mod.rs b/crates/okena-views-terminal/src/layout/terminal_pane/mod.rs index 406590c8..e765962d 100644 --- a/crates/okena-views-terminal/src/layout/terminal_pane/mod.rs +++ b/crates/okena-views-terminal/src/layout/terminal_pane/mod.rs @@ -358,6 +358,28 @@ impl TerminalPane { &settings.default_shell, ); + // On restore, optionally resume the pane's captured AI agent session. + // This function only runs when the terminal wasn't already in the + // registry (a fresh reconnect after restart), so resume can't fire + // mid-session. Gated by the opt-in setting and dispatched to the right + // harness by the session's `agent` id. The persisted session — and the + // terminal_id it's keyed by — only survives a restart with a session + // backend, so this is a no-op under `session_backend = none`. + let resume_line: Option = if settings.auto_resume_agent_sessions { + ws.agent_session(&self.project_id, &terminal_id) + .and_then(|session| { + okena_core::agent_harness::for_agent(&session.agent).and_then(|harness| { + harness.resume_command( + &session.session_id, + std::path::Path::new(&self.project_path), + ) + }) + }) + .map(|argv| argv.join(" ")) + } else { + None + }; + match self .backend .reconnect_terminal(&terminal_id, &self.project_path, Some(&shell)) @@ -368,6 +390,18 @@ impl TerminalPane { } } + // Type the resume command once the freshly-reconnected shell has settled. + if let Some(line) = resume_line { + let transport = self.backend.transport(); + let tid = terminal_id.clone(); + let payload = format!("{line}\n"); + cx.spawn(async move |_this: WeakEntity>, _cx| { + smol::Timer::after(std::time::Duration::from_millis(1500)).await; + transport.send_input(&tid, payload.as_bytes()); + }) + .detach(); + } + let size = TerminalSize::default(); let terminal = Arc::new(Terminal::new(terminal_id.clone(), size, self.backend.transport(), self.project_path.clone())); if let Some(pid) = self.backend.get_foreground_shell_pid(&terminal_id) { diff --git a/crates/okena-views-terminal/src/layout/terminal_pane/navigation.rs b/crates/okena-views-terminal/src/layout/terminal_pane/navigation.rs index ae0d5b86..976beb7f 100644 --- a/crates/okena-views-terminal/src/layout/terminal_pane/navigation.rs +++ b/crates/okena-views-terminal/src/layout/terminal_pane/navigation.rs @@ -210,6 +210,7 @@ impl TerminalPane { } let app_cursor_mode = terminal.is_app_cursor_mode(); + let kitty = terminal.kitty_keyboard_flags(); let key_event = KeyEvent { key: event.keystroke.key.clone(), key_char: event.keystroke.key_char.clone(), @@ -220,7 +221,7 @@ impl TerminalPane { platform: event.keystroke.modifiers.platform, }, }; - if let Some(input) = key_to_bytes(&key_event, app_cursor_mode) { + if let Some(input) = key_to_bytes(&key_event, app_cursor_mode, kitty) { terminal.send_bytes(&input); } } diff --git a/crates/okena-views-terminal/src/layout/terminal_pane/render.rs b/crates/okena-views-terminal/src/layout/terminal_pane/render.rs index 48cdb643..355857c2 100644 --- a/crates/okena-views-terminal/src/layout/terminal_pane/render.rs +++ b/crates/okena-views-terminal/src/layout/terminal_pane/render.rs @@ -5,7 +5,8 @@ use okena_core::api::ActionRequest; use crate::actions::{ AddTab, CloseSearch, CloseTerminal, Copy, FocusDown, FocusLeft, FocusNextTerminal, FocusPrevTerminal, FocusRight, FocusUp, FullscreenNextTerminal, FullscreenPrevTerminal, - JumpToNextPrompt, JumpToPreviousPrompt, MinimizeTerminal, Paste, ResetZoom, Search, + JumpToNextFailedCommand, JumpToNextPrompt, JumpToPreviousFailedCommand, JumpToPreviousPrompt, + MinimizeTerminal, Paste, ResetZoom, Search, SearchNext, SearchPrev, SendBacktab, SendEscape, SendTab, SplitHorizontal, SplitVertical, ToggleFullscreen, ZoomIn, ZoomOut, }; @@ -143,15 +144,17 @@ impl Render for TerminalPane { .on_action(cx.listener(|this, _: &SearchPrev, _window, cx| { this.prev_match(cx); })) .on_action(cx.listener(|this, _: &JumpToPreviousPrompt, _window, cx| { this.handle_jump_prev_prompt(cx); })) .on_action(cx.listener(|this, _: &JumpToNextPrompt, _window, cx| { this.handle_jump_next_prompt(cx); })) + .on_action(cx.listener(|this, _: &JumpToPreviousFailedCommand, _window, cx| { this.handle_jump_prev_failed(cx); })) + .on_action(cx.listener(|this, _: &JumpToNextFailedCommand, _window, cx| { this.handle_jump_next_failed(cx); })) .on_action(cx.listener(|this, _: &FocusLeft, window, cx| { this.handle_navigation(NavigationDirection::Left, window, cx); })) .on_action(cx.listener(|this, _: &FocusRight, window, cx| { this.handle_navigation(NavigationDirection::Right, window, cx); })) .on_action(cx.listener(|this, _: &FocusUp, window, cx| { this.handle_navigation(NavigationDirection::Up, window, cx); })) .on_action(cx.listener(|this, _: &FocusDown, window, cx| { this.handle_navigation(NavigationDirection::Down, window, cx); })) .on_action(cx.listener(|this, _: &FocusNextTerminal, window, cx| { this.handle_sequential_navigation(true, window, cx); })) .on_action(cx.listener(|this, _: &FocusPrevTerminal, window, cx| { this.handle_sequential_navigation(false, window, cx); })) - .on_action(cx.listener(|this, _: &SendTab, _window, _cx| { if let Some(ref terminal) = this.terminal { terminal.send_bytes(b"\t"); } })) - .on_action(cx.listener(|this, _: &SendBacktab, _window, _cx| { if let Some(ref terminal) = this.terminal { terminal.send_bytes(b"\x1b[Z"); } })) - .on_action(cx.listener(|this, _: &SendEscape, _window, _cx| { if let Some(ref terminal) = this.terminal { terminal.send_bytes(b"\x1b"); } })) + .on_action(cx.listener(|this, _: &SendTab, _window, _cx| { if let Some(ref terminal) = this.terminal { terminal.send_tab(); } })) + .on_action(cx.listener(|this, _: &SendBacktab, _window, _cx| { if let Some(ref terminal) = this.terminal { terminal.send_backtab(); } })) + .on_action(cx.listener(|this, _: &SendEscape, _window, _cx| { if let Some(ref terminal) = this.terminal { terminal.send_escape(); } })) .on_action(cx.listener(|this, _: &ZoomIn, _window, cx| { let current = this.workspace.read(cx).get_terminal_zoom(&this.project_id, &this.layout_path); let new_zoom = (current + 0.1).clamp(0.5, 3.0); diff --git a/crates/okena-views-terminal/src/lib.rs b/crates/okena-views-terminal/src/lib.rs index 8d6aafc5..78b9ffa1 100644 --- a/crates/okena-views-terminal/src/lib.rs +++ b/crates/okena-views-terminal/src/lib.rs @@ -88,6 +88,10 @@ pub struct TerminalViewSettings { /// When true, Ctrl+C copies the active selection (and clears it) instead of sending SIGINT. /// Ctrl+C without a selection always sends SIGINT. pub ctrl_c_copies_selection: bool, + /// Auto-resume a pane's captured AI agent session (`claude --resume `, + /// …) when reconnecting it on restore. Opt-in; off by default. + #[serde(default)] + pub auto_resume_agent_sessions: bool, } /// Read current terminal view settings from ExtensionSettingsStore. @@ -110,6 +114,7 @@ pub fn terminal_view_settings(cx: &gpui::App) -> TerminalViewSettings { default_shell: okena_terminal::shell_config::ShellType::Default, hooks: Default::default(), ctrl_c_copies_selection: false, + auto_resume_agent_sessions: false, }) } diff --git a/crates/okena-views-terminal/src/overlays/terminal_overlay_utils.rs b/crates/okena-views-terminal/src/overlays/terminal_overlay_utils.rs index 6418707b..83b00b6b 100644 --- a/crates/okena-views-terminal/src/overlays/terminal_overlay_utils.rs +++ b/crates/okena-views-terminal/src/overlays/terminal_overlay_utils.rs @@ -6,7 +6,7 @@ //! - Key input handling //! - Focus management -use okena_terminal::input::{KeyEvent, KeyModifiers, key_to_bytes}; +use okena_terminal::input::{KeyEvent, KeyModifiers, KittyKeyboardFlags, key_to_bytes}; use okena_terminal::terminal::{Terminal, TerminalSize, TerminalTransport}; use okena_terminal::TerminalsRegistry; use crate::layout::terminal_pane::TerminalContent; @@ -15,7 +15,11 @@ use gpui::*; use std::sync::Arc; /// Convert a GPUI key event to terminal input bytes. -fn gpui_key_to_bytes(event: &KeyDownEvent, app_cursor_mode: bool) -> Option> { +fn gpui_key_to_bytes( + event: &KeyDownEvent, + app_cursor_mode: bool, + kitty: KittyKeyboardFlags, +) -> Option> { let key_event = KeyEvent { key: event.keystroke.key.clone(), key_char: event.keystroke.key_char.clone(), @@ -26,7 +30,7 @@ fn gpui_key_to_bytes(event: &KeyDownEvent, app_cursor_mode: bool) -> Option( /// Returns true if input was sent. pub fn handle_terminal_key_input(terminal: &Terminal, event: &KeyDownEvent) -> bool { let app_cursor_mode = terminal.is_app_cursor_mode(); - if let Some(input) = gpui_key_to_bytes(event, app_cursor_mode) { + let kitty = terminal.kitty_keyboard_flags(); + if let Some(input) = gpui_key_to_bytes(event, app_cursor_mode, kitty) { terminal.send_bytes(&input); true } else { diff --git a/crates/okena-workspace/src/actions/focus.rs b/crates/okena-workspace/src/actions/focus.rs index 8c8f0949..0b878ee3 100644 --- a/crates/okena-workspace/src/actions/focus.rs +++ b/crates/okena-workspace/src/actions/focus.rs @@ -200,9 +200,16 @@ impl Workspace { cx.notify(); } - /// Focus a terminal by its ID (finds path automatically) + /// Focus a terminal by its ID, *revealing* it first (finds path automatically). /// - /// This is a convenience method that looks up the layout path and calls set_focused_terminal. + /// This is the shared "navigate to this terminal" primitive behind agent-row + /// / sidebar / notification clicks and remote focus requests. It activates + /// the tabs along the path, then calls [`FocusManager::reveal_terminal`] so + /// the target becomes visible even when the view is currently zoomed into + /// another project or has a different terminal fullscreened — retargeting + /// the active view mode onto the target rather than focusing a pane the + /// current view is hiding. (Use `set_focused_terminal` for clicks on a pane + /// that's already on screen.) pub fn focus_terminal_by_id( &mut self, focus_manager: &mut FocusManager, @@ -219,8 +226,19 @@ impl Workspace { layout.activate_tabs_along_path(&path); } self.notify_data(cx); - // Focus the terminal without changing which projects are shown - self.set_focused_terminal(focus_manager, project_id.to_string(), path, cx); + // Reveal: retarget the active zoom/fullscreen onto the target + // so it's actually visible, then focus it. + focus_manager.reveal_terminal( + project_id.to_string(), + path, + terminal_id.to_string(), + ); + // Record access time for recency sorting + stamp activity so + // the activity-sorted sidebar surfaces the project just moved + // into. `bump_activity` notifies + persists (debounced). + self.touch_project(project_id); + self.bump_activity(project_id, cx); + cx.notify(); } } } diff --git a/crates/okena-workspace/src/actions/folder.rs b/crates/okena-workspace/src/actions/folder.rs index 08d39968..14f8f834 100644 --- a/crates/okena-workspace/src/actions/folder.rs +++ b/crates/okena-workspace/src/actions/folder.rs @@ -187,6 +187,7 @@ mod tests { is_remote: false, connection_id: None, service_terminals: HashMap::new(), + agent_sessions: Default::default(), default_shell: None, hook_terminals: HashMap::new(), pinned: false, @@ -299,6 +300,7 @@ mod gpui_tests { is_remote: false, connection_id: None, service_terminals: HashMap::new(), + agent_sessions: Default::default(), default_shell: None, hook_terminals: HashMap::new(), pinned: false, diff --git a/crates/okena-workspace/src/actions/layout/tests_gpui.rs b/crates/okena-workspace/src/actions/layout/tests_gpui.rs index 30afcdaa..21cd24f9 100644 --- a/crates/okena-workspace/src/actions/layout/tests_gpui.rs +++ b/crates/okena-workspace/src/actions/layout/tests_gpui.rs @@ -30,6 +30,7 @@ fn make_project(id: &str) -> ProjectData { is_remote: false, connection_id: None, service_terminals: HashMap::new(), + agent_sessions: Default::default(), default_shell: None, hook_terminals: HashMap::new(), pinned: false, @@ -397,6 +398,7 @@ fn make_project_with_layout(id: &str, layout: LayoutNode) -> ProjectData { is_remote: false, connection_id: None, service_terminals: HashMap::new(), + agent_sessions: Default::default(), default_shell: None, hook_terminals: HashMap::new(), pinned: false, diff --git a/crates/okena-workspace/src/actions/project.rs b/crates/okena-workspace/src/actions/project.rs index ab5ab835..7e10ada1 100644 --- a/crates/okena-workspace/src/actions/project.rs +++ b/crates/okena-workspace/src/actions/project.rs @@ -194,6 +194,7 @@ impl Workspace { is_remote: false, connection_id: None, service_terminals: HashMap::new(), + agent_sessions: HashMap::new(), default_shell, hook_terminals: HashMap::new(), pinned: false, @@ -528,6 +529,7 @@ mod tests { is_remote: false, connection_id: None, service_terminals: HashMap::new(), + agent_sessions: Default::default(), default_shell: None, hook_terminals: HashMap::new(), pinned: false, @@ -747,6 +749,7 @@ mod gpui_tests { is_remote: false, connection_id: None, service_terminals: HashMap::new(), + agent_sessions: Default::default(), default_shell: None, hook_terminals: HashMap::new(), pinned: false, diff --git a/crates/okena-workspace/src/actions/soft_close.rs b/crates/okena-workspace/src/actions/soft_close.rs index 56d82ef7..00a3c246 100644 --- a/crates/okena-workspace/src/actions/soft_close.rs +++ b/crates/okena-workspace/src/actions/soft_close.rs @@ -293,6 +293,7 @@ mod tests { is_remote: false, connection_id: None, service_terminals: HashMap::new(), + agent_sessions: Default::default(), default_shell: None, hook_terminals: HashMap::new(), pinned: false, diff --git a/crates/okena-workspace/src/actions/worktree.rs b/crates/okena-workspace/src/actions/worktree.rs index b0483666..8e29748b 100644 --- a/crates/okena-workspace/src/actions/worktree.rs +++ b/crates/okena-workspace/src/actions/worktree.rs @@ -191,6 +191,7 @@ impl Workspace { is_remote: false, connection_id: None, service_terminals: HashMap::new(), + agent_sessions: HashMap::new(), default_shell: None, hook_terminals: HashMap::new(), pinned: false, @@ -337,6 +338,7 @@ impl Workspace { is_remote: false, connection_id: None, service_terminals: HashMap::new(), + agent_sessions: HashMap::new(), hook_terminals: HashMap::new(), pinned: false, last_activity_at: None, diff --git a/crates/okena-workspace/src/focus.rs b/crates/okena-workspace/src/focus.rs index 9fa0d2f4..e898f7f0 100644 --- a/crates/okena-workspace/src/focus.rs +++ b/crates/okena-workspace/src/focus.rs @@ -215,6 +215,12 @@ impl FocusManager { /// This is the primary method for focusing a terminal. It: /// - Updates the current focus target /// - Does NOT push to stack (direct user action) + /// + /// This is the "click an already-visible pane" path: it deliberately + /// preserves fullscreen (and then keeps the same fullscreened project, + /// only updating the layout path). To *navigate* to a terminal the current + /// view might be hiding — a sidebar/agent click, a notification, a remote + /// focus request — use [`reveal_terminal`](Self::reveal_terminal) instead. pub fn focus_terminal(&mut self, project_id: String, layout_path: Vec) { if self.context == FocusContext::Fullscreen { // Preserve fullscreen state — only update layout_path if same project @@ -227,6 +233,68 @@ impl FocusManager { self.context = FocusContext::Terminal; } + /// Reveal **and** focus a terminal as a navigation action — an agent-row or + /// sidebar click, a notification jump, a remote focus request. + /// + /// [`focus_terminal`](Self::focus_terminal) is for clicking a pane that's + /// already on screen, so it preserves fullscreen and ignores the requested + /// project; that makes it unable to reach a terminal the current view is + /// hiding (a different zoomed project, a fullscreened sibling). This instead + /// retargets whatever view mode is active onto the target so it actually + /// becomes visible, *without* widening the view the user chose: + /// + /// - **Fullscreen** → stay fullscreen, but on the target terminal and its + /// project (retargeted in place, no stack growth — same as the zoom-header + /// next/prev arrows). A jump from a zoomed terminal lands on the new one + /// instead of being swallowed by the old `layout_path`-only update. + /// - **Project zoom** (`focused_project_id` set) → switch the zoom to the + /// target's project. `compute_visible_projects`' focus override then + /// surfaces that project even past a folder filter or a hidden-set entry. + /// - **Overview** (no zoom, no fullscreen) → just move terminal focus; the + /// target's column is already on screen, so the multi-project overview is + /// left intact. + /// + /// `terminal_id` is the id of the terminal at `layout_path`; it's required + /// to retarget fullscreen (which tracks the focused terminal by id). + pub fn reveal_terminal( + &mut self, + project_id: String, + layout_path: Vec, + terminal_id: String, + ) { + if self.context == FocusContext::Fullscreen { + // Retarget the single zoomed pane onto the target, following it to + // its project so the one visible column is the target's. Mirrors the + // in-place swap `enter_fullscreen` does when already fullscreen. + self.current_focus = Some(FocusTarget::with_terminal( + project_id.clone(), + layout_path, + terminal_id, + )); + self.focused_project_id = Some(project_id); + return; + } + + // Not fullscreen. If a project is zoomed, follow the jump so the + // target's column is the one shown; leave overview untouched. Only when + // switching to a *different* project do we reset individual mode (a + // fresh zoom shows the project plus its worktree children) — revealing + // another terminal within the already-zoomed project must NOT clear + // individual mode, or it would re-expand worktree children the user has + // zoomed past. This primitive also backs pre-existing sidebar/cursor + // terminal clicks, so that regression would hit them too. + if self + .focused_project_id + .as_deref() + .is_some_and(|current| current != project_id) + { + self.focused_project_id = Some(project_id.clone()); + self.focus_project_individual = false; + } + self.current_focus = Some(FocusTarget::new(project_id, layout_path)); + self.context = FocusContext::Terminal; + } + /// Enter fullscreen mode, saving current focus for restoration. /// /// When entering fullscreen, the current focus and focused_project_id are @@ -571,6 +639,105 @@ mod tests { assert!(!fm.has_fullscreen()); } + #[test] + fn reveal_terminal_retargets_fullscreen_to_other_project() { + // A different terminal is fullscreened; revealing the target must follow + // it (terminal + project) while staying fullscreen, and must not grow + // the stack (so one exit still leaves fullscreen). This is the + // "terminal zoomed" half of the focus bug. + let mut fm = FocusManager::new(); + fm.enter_fullscreen("proj1".to_string(), vec![0], "term1".to_string()); + let stack_after_enter = fm.focus_stack.len(); + + fm.reveal_terminal("proj2".to_string(), vec![1, 0], "term2".to_string()); + + assert!(fm.has_fullscreen()); + assert!(fm.is_terminal_fullscreened("proj2", "term2")); + assert_eq!(fm.fullscreen_project_id(), Some("proj2")); + assert_eq!(fm.focused_project_id(), Some(&"proj2".to_string())); + assert_eq!(fm.focused_terminal_state().unwrap().layout_path, vec![1, 0]); + assert_eq!(fm.focus_stack.len(), stack_after_enter); + + // A single exit fully leaves fullscreen. + fm.exit_fullscreen(); + assert!(!fm.has_fullscreen()); + } + + #[test] + fn reveal_terminal_switches_zoom_to_target_project() { + // Zoomed into proj1; revealing a terminal in proj2 must switch the zoom + // to proj2 so its column is the one shown. This is the "different + // project zoomed" half of the focus bug. + let mut fm = FocusManager::new(); + fm.set_focused_project_id(Some("proj1".to_string())); + fm.focus_terminal("proj1".to_string(), vec![0]); + + fm.reveal_terminal("proj2".to_string(), vec![2], "term2".to_string()); + + assert_eq!(fm.focused_project_id(), Some(&"proj2".to_string())); + assert!(!fm.has_fullscreen()); + assert_eq!(*fm.context(), FocusContext::Terminal); + let state = fm.focused_terminal_state().unwrap(); + assert_eq!(state.project_id, "proj2"); + assert_eq!(state.layout_path, vec![2]); + } + + #[test] + fn reveal_terminal_in_overview_keeps_overview() { + // In overview (no zoom), revealing must not collapse the view into a + // single project — it only moves terminal focus. + let mut fm = FocusManager::new(); + fm.focus_terminal("proj1".to_string(), vec![0]); + + fm.reveal_terminal("proj2".to_string(), vec![1], "term2".to_string()); + + assert_eq!(fm.focused_project_id(), None); + assert!(!fm.has_fullscreen()); + let state = fm.focused_terminal_state().unwrap(); + assert_eq!(state.project_id, "proj2"); + assert_eq!(state.layout_path, vec![1]); + } + + #[test] + fn reveal_terminal_zoomed_same_project_updates_focus() { + // Revealing another terminal within the already-zoomed project keeps the + // zoom on that project and just moves focus. + let mut fm = FocusManager::new(); + fm.set_focused_project_id(Some("proj1".to_string())); + fm.focus_terminal("proj1".to_string(), vec![0]); + + fm.reveal_terminal("proj1".to_string(), vec![1], "term-b".to_string()); + + assert_eq!(fm.focused_project_id(), Some(&"proj1".to_string())); + let state = fm.focused_terminal_state().unwrap(); + assert_eq!(state.project_id, "proj1"); + assert_eq!(state.layout_path, vec![1]); + } + + #[test] + fn reveal_terminal_within_individual_zoom_preserves_individual_mode() { + // Zoomed *individual* into proj1 (worktree children hidden); revealing + // another terminal of proj1 must keep individual mode on — it must not + // re-expand the hidden children. Regression guard for the + // set_focused_terminal -> reveal_terminal swap, which previously cleared + // individual mode unconditionally (also hitting sidebar/cursor clicks). + let mut fm = FocusManager::new(); + fm.set_focused_project_id_individual(Some("proj1".to_string())); + fm.focus_terminal("proj1".to_string(), vec![0]); + assert!(fm.is_focus_individual()); + + fm.reveal_terminal("proj1".to_string(), vec![1], "term-b".to_string()); + + assert!(fm.is_focus_individual()); + assert_eq!(fm.focused_project_id(), Some(&"proj1".to_string())); + assert_eq!(fm.focused_terminal_state().unwrap().layout_path, vec![1]); + + // ...but switching to a DIFFERENT project still resets individual mode. + fm.reveal_terminal("proj2".to_string(), vec![0], "term-c".to_string()); + assert!(!fm.is_focus_individual()); + assert_eq!(fm.focused_project_id(), Some(&"proj2".to_string())); + } + #[test] fn clear_all_resets_everything() { let mut fm = FocusManager::new(); diff --git a/crates/okena-workspace/src/persistence.rs b/crates/okena-workspace/src/persistence.rs index 13f903c9..2f4558a2 100644 --- a/crates/okena-workspace/src/persistence.rs +++ b/crates/okena-workspace/src/persistence.rs @@ -643,6 +643,7 @@ pub fn default_workspace() -> WorkspaceData { is_remote: false, connection_id: None, service_terminals: HashMap::new(), + agent_sessions: HashMap::new(), default_shell: None, hook_terminals: HashMap::new(), pinned: false, @@ -677,6 +678,7 @@ mod tests { is_remote: false, connection_id: None, service_terminals: HashMap::new(), + agent_sessions: HashMap::new(), default_shell: None, hook_terminals: HashMap::new(), pinned: false, diff --git a/crates/okena-workspace/src/remote_apply.rs b/crates/okena-workspace/src/remote_apply.rs index b617895d..1add24d2 100644 --- a/crates/okena-workspace/src/remote_apply.rs +++ b/crates/okena-workspace/src/remote_apply.rs @@ -190,6 +190,7 @@ pub fn apply_remote_snapshot( is_remote: true, connection_id: Some(conn_id_owned), service_terminals: HashMap::new(), + agent_sessions: HashMap::new(), default_shell: None, hook_terminals: HashMap::new(), pinned: false, @@ -387,6 +388,7 @@ mod tests { show_in_overview: true, layout, terminal_names: HashMap::new(), + terminal_agent_status: HashMap::new(), git_status: None, folder_color: FolderColor::Default, services: Vec::new(), diff --git a/crates/okena-workspace/src/settings.rs b/crates/okena-workspace/src/settings.rs index 49d35ba8..d59326f1 100644 --- a/crates/okena-workspace/src/settings.rs +++ b/crates/okena-workspace/src/settings.rs @@ -281,6 +281,14 @@ pub struct AppSettings { /// Session backend for terminal persistence (tmux/screen/none/auto) #[serde(default)] pub session_backend: SessionBackend, + /// Automatically resume a pane's AI agent session (e.g. `claude --resume + /// `) on restore, when one was captured before the restart. Opt-in + /// (default off). When off, the captured session is still persisted and + /// shown — just not auto-run. Note: terminal IDs (and thus the captured + /// session) only survive a restart when a session backend is configured, so + /// this is effectively a no-op with `session_backend = none`. + #[serde(default)] + pub auto_resume_agent_sessions: bool, // File opener settings /// Editor command to open file paths (e.g. "code", "cursor", "zed", "subl", "vim") @@ -388,6 +396,12 @@ pub struct AppSettings { /// (OSC 9/777 alerts and the bell). Opt-in — see [`NotificationSettings`]. #[serde(default)] pub notifications: NotificationSettings, + + /// Allow terminal apps to READ the system clipboard via OSC 52 (`OSC 52 ; c ; ?`). + /// Off by default: clipboard read lets a program exfiltrate whatever you have + /// copied. OSC 52 *write* is always allowed; only read is gated. + #[serde(default)] + pub allow_clipboard_read: bool, } impl Default for AppSettings { @@ -412,6 +426,7 @@ impl Default for AppSettings { default_shell: ShellType::default(), show_shell_selector: false, session_backend: SessionBackend::default(), + auto_resume_agent_sessions: false, file_opener: default_file_opener(), hooks: HooksConfig::default(), diff_view_mode: DiffViewMode::default(), @@ -439,6 +454,7 @@ impl Default for AppSettings { file_finder: FileFinderSettings::default(), header_density: HeaderDensity::default(), notifications: NotificationSettings::default(), + allow_clipboard_read: false, } } } diff --git a/crates/okena-workspace/src/state.rs b/crates/okena-workspace/src/state.rs index 0ddf87fc..b40bb07a 100644 --- a/crates/okena-workspace/src/state.rs +++ b/crates/okena-workspace/src/state.rs @@ -518,6 +518,51 @@ impl Workspace { } } + /// Persist the AI agent session captured for a terminal (agent-status OSC + /// `lbl=`). Called from the PTY event loop when + /// `Terminal::take_agent_session_dirty` fires. Idempotent — no save when + /// unchanged. + pub fn set_agent_session( + &mut self, + project_id: &str, + terminal_id: &str, + session: okena_core::agent_session::AgentSession, + cx: &mut Context, + ) { + if let Some(project) = self.project_mut(project_id) + && project.agent_sessions.get(terminal_id) != Some(&session) + { + project.agent_sessions.insert(terminal_id.to_string(), session); + self.notify_data(cx); + } + } + + /// The persisted agent session for a terminal, if any. Read on restore to + /// decide whether to offer / auto-run a resume. + pub fn agent_session( + &self, + project_id: &str, + terminal_id: &str, + ) -> Option { + self.project(project_id) + .and_then(|p| p.agent_sessions.get(terminal_id).cloned()) + } + + /// Drop a terminal's persisted agent session (the terminal was permanently + /// closed), so `workspace.json` doesn't accumulate orphans. + pub fn remove_agent_session( + &mut self, + project_id: &str, + terminal_id: &str, + cx: &mut Context, + ) { + if let Some(project) = self.project_mut(project_id) + && project.agent_sessions.remove(terminal_id).is_some() + { + self.notify_data(cx); + } + } + pub fn register_hook_terminal( &mut self, project_id: &str, @@ -947,6 +992,7 @@ mod workspace_tests { is_remote: false, connection_id: None, service_terminals: HashMap::new(), + agent_sessions: Default::default(), default_shell: None, hook_terminals: HashMap::new(), pinned: false, @@ -1710,6 +1756,7 @@ mod gpui_tests { is_remote: false, connection_id: None, service_terminals: HashMap::new(), + agent_sessions: Default::default(), default_shell: None, hook_terminals: HashMap::new(), pinned: false, diff --git a/crates/okena-workspace/src/visibility.rs b/crates/okena-workspace/src/visibility.rs index 4c0caaee..47729d56 100644 --- a/crates/okena-workspace/src/visibility.rs +++ b/crates/okena-workspace/src/visibility.rs @@ -233,6 +233,7 @@ mod tests { is_remote: false, connection_id: None, service_terminals: HashMap::new(), + agent_sessions: Default::default(), default_shell: None, hook_terminals: HashMap::new(), pinned: false, diff --git a/docs/agent-status.md b/docs/agent-status.md new file mode 100644 index 00000000..08a44344 --- /dev/null +++ b/docs/agent-status.md @@ -0,0 +1,231 @@ +# Agent Status + +Okena can show what an AI coding agent (Claude Code, Codex, …) is doing in each +terminal pane: a per-tab indicator, a dedicated **Agents** section in the sidebar +that lists every active agent across all projects, a matching field in the +[remote API](remote.md), and a desktop notification when an agent finishes or +gets blocked. + +The model is **push-based and open**: the agent reports its own state by writing +a small escape sequence to its terminal. Okena never scrapes the agent's output +or reads its private files — an agent (or a thin hook) tells Okena directly. A +small fixed set of lifecycle states drives color, sort order, and notifications; +a free-form message and optional labels carry whatever the agent wants and are +shown verbatim. + +## What you see + +- **Tab** — the pane's icon is recolored by lifecycle (blocked = red, working = + yellow, done = green, idle = muted). Hovering the tab shows the agent's + free-form status text. +- **Sidebar → AGENTS** — a flat, cross-project list of every pane currently + reporting a status, sorted by attention (blocked → done → working → idle). + Each row shows the lifecycle dot, the terminal name, its project, and the + free-form text. Click a row to jump straight to that pane. The section hides + itself when no agent is active. +- **Notification** — entering `blocked` or `done` raises a desktop notification + (+ sound), suppressed for the pane you're actively looking at. Gated by the + normal notification settings. +- **Remote** — `GET /v1/state` includes `terminal_agent_status` per project, and + a status change bumps `state_version` so subscribed clients re-fetch. + +Agent status is **runtime-only** — it is never written to `workspace.json` and +does not survive a restart. + +## The data model + +| Field | Meaning | +|-------|---------| +| `lifecycle` | One of `working`, `blocked`, `done`, `idle`. Drives color / sort / notifications. | +| `custom` | Optional free-form text, e.g. `"running tests 3/5"`. Rendered verbatim. | +| `labels` | Optional flat `{ "key": "value" }` map of extras. | + +## The wire format (OSC 9001) + +An agent reports its state by writing this OSC sequence to its terminal: + +``` +ESC ] 9001 ; st= [ ; msg= ] [ ; lbl= ] ST +``` + +- `ESC` is `\033` (0x1B); `ST` is the string terminator `ESC \` (`\033\\`). A + `BEL` (`\007`) terminator is also accepted. +- `st=` — `working` | `blocked` | `done` | `idle`, or `clear` to remove any + status. An unknown/missing `st` leaves the current status untouched. +- `msg=` — base64(UTF-8) of the free-form `custom` text. Base64 keeps the value + `;`/`ST`-safe. +- `lbl=` — base64(UTF-8) of a flat JSON object, e.g. `{"stage":"verify"}`. + Three keys are **reserved**: `agent` (harness id, e.g. `claude-code`), + `session_id`, and `transcript_path`. When `agent` + a UUID-shaped `session_id` + are present, Okena captures them into the pane's *agent session* — a sticky + record (it survives `st=clear`) that is the basis for resuming the session and + showing transcript stats. A non-UUID `session_id` is ignored (it's untrusted + in-band data that may reach a resume command). All other keys are free-form. + +For example, to report "done" with a message, from inside the pane: + +```sh +printf '\033]9001;st=done;msg=%s\033\\' "$(printf 'all tests passed' | base64 | tr -d '\n')" > /dev/tty +``` + +This is the same family of in-band signals Okena already understands +(`OSC 9;4` progress, `OSC 133` shell integration); see the contract note in +`crates/okena-terminal/CLAUDE.md`. + +## Session resume + +The reserved `agent` + `session_id` (+ optional `transcript_path`) labels let +Okena remember which AI session a pane is running and bring it back after a +restart: + +- **Captured** in-band from `OSC 9001` `lbl=` (see above), validated as a UUID, + and kept on the pane as a *sticky* record that survives `st=clear`. +- **Persisted** per terminal in `workspace.json` (`project.agent_sessions`), so + it outlives the process. +- **Resumed** on restore when the **`auto_resume_agent_sessions`** setting is on: + Okena types the harness's resume command (for Claude Code, `claude --resume + `) into the reconnected pane after a short delay. Off by default — when + off, the session is still captured, persisted, and shown, just not auto-run. + +Which command resumes a session is **per-harness** (Claude Code, Codex, …), +selected by the `agent` id through the harness registry — adding a new agent is +additive, with no core change. + +> **Requires a session backend.** A pane's `terminal_id` (the key the session is +> stored under) only survives a restart when a session backend (`tmux` / `dtach` +> / `screen`) is configured. With `session_backend = none`, terminal IDs are +> regenerated on load, so the persisted session no longer matches and auto-resume +> is a no-op. + +## Claude Code integration + +The easiest way is the bundled **Claude Code plugin**, which wires up the +lifecycle hooks for you — no editing of `settings.json`, versioned and cleanly +uninstallable. The [`integrations/claude-code/`](../integrations/claude-code/) +directory is a Claude Code plugin marketplace. + +From a clone of this repo: + +``` +/plugin marketplace add ./integrations/claude-code +/plugin install okena-lifecycle@okena +``` + +Or enable it non-interactively in `~/.claude/settings.json`: + +```json +{ "enabledPlugins": { "okena-lifecycle@okena": true } } +``` + +Then run `claude` inside an Okena pane and watch the tab + AGENTS section react. + +The plugin maps Claude Code's lifecycle hooks to agent states: + +| Claude Code hook | State | When | +|------------------|-------|------| +| `UserPromptSubmit` | `working` | You submit a prompt — the agent starts working. | +| `PreToolUse` | `working` | The agent is about to run a tool — work resumes. | +| `PostToolUse` | `working` | A tool finished — work continues. | +| `Notification` | `blocked` | Claude needs permission or input. | +| `Stop` | `done` | The agent finished its turn. | +| `SessionStart` | `clear` | A new/resumed session — reset any stale status. | +| `SessionEnd` | `clear` | The agent exited — drop it from the Agents list. | + +`PreToolUse` / `PostToolUse` are the recovery edges that the obvious four-hook +mapping is missing: when Claude is `blocked` waiting on you and you answer +(e.g. a permission grant, or answering a question mid-turn), **no +`UserPromptSubmit` fires** — that only fires for a fresh prompt. Without a +"work resumed" signal the pane stays stuck on `blocked` even though the agent is +busy again. Running a tool fires `PreToolUse` (and later `PostToolUse`), which +flips it back to `working`. Ordering is safe: for a permission-gated tool the +sequence is `PreToolUse` (working) → `Notification` (blocked) → you approve → +tool runs → `PostToolUse` (working), and hooks are awaited so the writes never +race — the pane correctly shows `blocked` while you're being asked. + +The plugin sets `OKENA_AGENT=claude-code` on each hook command, and the script +mines the hook's stdin event JSON for `session_id` / `transcript_path` (a small +`sed` extraction, no `jq` dependency) and forwards them in the reserved `lbl=` +keys above — that's how Okena learns the pane's Claude session. + +It bundles `okena-lifecycle/scripts/okena-agent-status.sh`, invoked via +`${CLAUDE_PLUGIN_ROOT}`. Hooks run as subprocesses with **no controlling +terminal**, so `/dev/tty` is unavailable to them — Okena exports `OKENA_TTY` +(the pane's slave pty path) into the pane environment and the script writes +there instead (falling back to `/dev/tty` for interactive use). It's a silent +no-op when there's no device to write to. + +> **Caveat — persistent sessions.** `OKENA_TTY` is captured into the shell's +> environment when the pane is **first launched**, not refreshed per-attach. If +> you *reattach* to a pre-existing `dtach`/`tmux`/`screen` session, the +> already-running shell keeps the original value while Okena has opened a new +> pty, so `$OKENA_TTY` points at the old device and the indicator can go silent +> until the session is restarted. (Known limitation — the env isn't yet +> refreshed on reattach.) + +### Manual (without the plugin) + +If you'd rather not use the plugin, register the hooks yourself. Copy the script +somewhere on your `PATH`: + +```sh +install -m 0755 integrations/claude-code/okena-lifecycle/scripts/okena-agent-status.sh ~/.local/bin/okena-agent-status +``` + +…then add to `~/.claude/settings.json`: + +```json +{ + "hooks": { + "UserPromptSubmit": [ + { "hooks": [ { "type": "command", "command": "okena-agent-status working" } ] } + ], + "PreToolUse": [ + { "hooks": [ { "type": "command", "command": "okena-agent-status working" } ] } + ], + "PostToolUse": [ + { "hooks": [ { "type": "command", "command": "okena-agent-status working" } ] } + ], + "Notification": [ + { "hooks": [ { "type": "command", "command": "okena-agent-status blocked" } ] } + ], + "Stop": [ + { "hooks": [ { "type": "command", "command": "okena-agent-status done" } ] } + ], + "SessionStart": [ + { "hooks": [ { "type": "command", "command": "okena-agent-status clear" } ] } + ], + "SessionEnd": [ + { "hooks": [ { "type": "command", "command": "okena-agent-status clear" } ] } + ] + } +} +``` + +### Debugging + +If a pane's status looks wrong (stale `blocked`, nothing showing), make the +whole path observable from both ends: + +- **The hook end** — set `OKENA_AGENT_STATUS_LOG` to a writable file in the + pane's environment. The script then appends one line per invocation recording + the state, the target device (`$OKENA_TTY`), and whether the write actually + succeeded: + + ``` + 2026-06-23T11:30:01+0200 pid=12345 state=working tty=/dev/pts/7 msglen=0 write=ok + ``` + + A `write=failed` line means the OSC never reached Okena (wrong/missing + `OKENA_TTY`); no line at all means the hook didn't fire. + +- **The Okena end** — `okena_terminal::terminal::osc_sidecar` logs every parsed + `OSC 9001` at `debug` level (`agent-status[]: -> + (changed=…, notify=…)`), including ignored/unknown/clear cases, so you can see + what Okena received and decided. Raise the log filter to `debug` to see them. + +### Other agents + +The script is agent-agnostic: anything that can run a command (Codex, a +Makefile, your own tooling) can call +`okena-lifecycle/scripts/okena-agent-status.sh [message]` to report into +Okena. diff --git a/integrations/claude-code/.claude-plugin/marketplace.json b/integrations/claude-code/.claude-plugin/marketplace.json new file mode 100644 index 00000000..45e451d0 --- /dev/null +++ b/integrations/claude-code/.claude-plugin/marketplace.json @@ -0,0 +1,15 @@ +{ + "name": "okena", + "owner": { + "name": "Okena" + }, + "description": "Okena integrations for Claude Code.", + "plugins": [ + { + "name": "okena-lifecycle", + "source": "./okena-lifecycle", + "description": "Report Claude Code lifecycle to Okena (tab indicator, sidebar Agents section, desktop notifications) via OSC 9001.", + "version": "0.2.0" + } + ] +} diff --git a/integrations/claude-code/okena-lifecycle/.claude-plugin/plugin.json b/integrations/claude-code/okena-lifecycle/.claude-plugin/plugin.json new file mode 100644 index 00000000..2de6a262 --- /dev/null +++ b/integrations/claude-code/okena-lifecycle/.claude-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "okena-lifecycle", + "description": "Report Claude Code lifecycle to Okena via OSC 9001 — drives the tab indicator, the sidebar Agents section, and desktop notifications.", + "version": "0.2.0", + "author": { + "name": "Okena" + }, + "homepage": "https://github.com/contember/okena", + "license": "MIT", + "keywords": ["okena", "terminal", "agent-status"] +} diff --git a/integrations/claude-code/okena-lifecycle/README.md b/integrations/claude-code/okena-lifecycle/README.md new file mode 100644 index 00000000..3df7b270 --- /dev/null +++ b/integrations/claude-code/okena-lifecycle/README.md @@ -0,0 +1,47 @@ +# okena-lifecycle — Claude Code plugin + +Reports Claude Code's lifecycle to [Okena](https://github.com/contember/okena) so +the pane's tab, the sidebar **Agents** section, and desktop notifications reflect +what the agent is doing. It does this by emitting Okena's agent-status escape +sequence (`OSC 9001`) to the terminal on lifecycle events — no network, no +config files written, works only inside an Okena pane (a silent no-op elsewhere). + +## Install + +From a clone of the okena repo (the `integrations/claude-code` dir is the +marketplace): + +``` +/plugin marketplace add ./integrations/claude-code +/plugin install okena-lifecycle@okena +``` + +Or enable it non-interactively in `~/.claude/settings.json`: + +```json +{ + "enabledPlugins": { "okena-lifecycle@okena": true } +} +``` + +## What it maps + +| Claude Code hook | Reported state | +|------------------|----------------| +| `UserPromptSubmit` | `working` | +| `PreToolUse` | `working` (about to run a tool) | +| `PostToolUse` | `working` (tool finished — work resumes) | +| `Notification` | `blocked` (needs permission / input) | +| `Stop` | `done` | +| `SessionStart` | `clear` (reset stale status) | +| `SessionEnd` | `clear` (agent exited) | + +`PreToolUse` / `PostToolUse` are the recovery edges: when you answer a blocked +agent (permission grant, or a question mid-turn) Claude Code does **not** fire +`UserPromptSubmit`, so without them the pane stays stuck on `blocked` while the +agent is actually busy again. + +See [`docs/agent-status.md`](../../../docs/agent-status.md) for the full model, +the `OSC 9001` wire format, and debugging (the `OKENA_AGENT_STATUS_LOG` env var). +The bundled `scripts/okena-agent-status.sh` is agent-agnostic — anything that can +run a command can call it directly. diff --git a/integrations/claude-code/okena-lifecycle/hooks/hooks.json b/integrations/claude-code/okena-lifecycle/hooks/hooks.json new file mode 100644 index 00000000..74f52942 --- /dev/null +++ b/integrations/claude-code/okena-lifecycle/hooks/hooks.json @@ -0,0 +1,74 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "OKENA_AGENT=claude-code \"${CLAUDE_PLUGIN_ROOT}\"/scripts/okena-agent-status.sh clear" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "OKENA_AGENT=claude-code \"${CLAUDE_PLUGIN_ROOT}\"/scripts/okena-agent-status.sh working" + } + ] + } + ], + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "OKENA_AGENT=claude-code \"${CLAUDE_PLUGIN_ROOT}\"/scripts/okena-agent-status.sh working" + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "OKENA_AGENT=claude-code \"${CLAUDE_PLUGIN_ROOT}\"/scripts/okena-agent-status.sh working" + } + ] + } + ], + "Notification": [ + { + "hooks": [ + { + "type": "command", + "command": "OKENA_AGENT=claude-code \"${CLAUDE_PLUGIN_ROOT}\"/scripts/okena-agent-status.sh blocked" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "OKENA_AGENT=claude-code \"${CLAUDE_PLUGIN_ROOT}\"/scripts/okena-agent-status.sh done" + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "OKENA_AGENT=claude-code \"${CLAUDE_PLUGIN_ROOT}\"/scripts/okena-agent-status.sh clear" + } + ] + } + ] + } +} diff --git a/integrations/claude-code/okena-lifecycle/scripts/okena-agent-status.sh b/integrations/claude-code/okena-lifecycle/scripts/okena-agent-status.sh new file mode 100755 index 00000000..2755a631 --- /dev/null +++ b/integrations/claude-code/okena-lifecycle/scripts/okena-agent-status.sh @@ -0,0 +1,118 @@ +#!/bin/sh +# okena-agent-status — report an AI agent's lifecycle to Okena. +# +# Writes Okena's agent-status OSC (OSC 9001) to the controlling terminal so the +# Okena tab + the sidebar "Agents" section reflect what the agent is doing, and +# so Okena raises a desktop notification when the agent finishes or gets blocked. +# +# Usage: +# okena-agent-status [message] +# +# Designed to be wired up as a Claude Code hook (see docs/agent-status.md), but +# it's agent-agnostic — anything that can run a command can call it. +# +# Output device: a hook runs as a subprocess with NO controlling terminal, so +# `/dev/tty` is unavailable to it. Okena therefore exports `OKENA_TTY` (the +# pane's slave pty path) into the pane's environment; we write there. Writing to +# the slave reaches Okena's reader even through a nested session backend +# (dtach/tmux). Falls back to `/dev/tty` for the interactive case. It drains +# stdin so a hook feeding event JSON on the pipe never blocks, and is a silent +# no-op when there's no device to write to — safe to call from anywhere. +# +# Debugging: set OKENA_AGENT_STATUS_LOG=/path/to/log to append one line per +# invocation recording the state, the target device, and whether the write +# actually succeeded. This makes the whole path observable — pair it with +# Okena's own `okena_terminal::terminal::osc_sidecar` debug logs (the receiving +# end) to see where a status update is lost. Unset → zero overhead, no file. + +state="${1:-}" +message="${2:-}" +# Harness id (e.g. "claude-code", "codex"), set by the per-agent hook glue. Used +# only to tag the captured session in the optional lbl= field; empty is fine. +agent="${OKENA_AGENT:-}" + +tty_dev="${OKENA_TTY:-/dev/tty}" + +# Append a debug line when OKENA_AGENT_STATUS_LOG points somewhere writable; +# a silent no-op otherwise. Never fails the hook. +log() { + [ -n "${OKENA_AGENT_STATUS_LOG:-}" ] || return 0 + ts=$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null || echo '????') + printf '%s pid=%s state=%s tty=%s %s\n' \ + "$ts" "$$" "${state:-}" "$tty_dev" "$1" \ + >>"$OKENA_AGENT_STATUS_LOG" 2>/dev/null || true +} + +# Capture any hook event JSON on stdin (Claude Code & co. feed it there) so the +# writer never blocks, then mine it for the agent's session id / transcript path +# to forward to Okena. No `jq` dependency — a narrow regex over the +# machine-generated JSON, with a clean fallback to "no session label" when the +# fields aren't present. +event="" +if [ ! -t 0 ]; then + event=$(cat 2>/dev/null || true) +fi + +# Print the first string value of JSON key $1 found in $event, or nothing. +json_str() { + printf '%s' "$event" | sed -n \ + "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -n1 +} +session_id=$(json_str session_id) +transcript_path=$(json_str transcript_path) + +# Assemble the optional lbl= JSON object only when we actually have a session id +# (the durable bit Okena persists). Values are JSON-escaped (\\ then "). +lbl_json="" +if [ -n "$session_id" ]; then + json_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; } + add_kv() { + [ -n "$2" ] || return 0 + ev=$(json_escape "$2") + if [ -n "$lbl_json" ]; then + lbl_json="$lbl_json,\"$1\":\"$ev\"" + else + lbl_json="\"$1\":\"$ev\"" + fi + } + add_kv agent "$agent" + add_kv session_id "$session_id" + add_kv transcript_path "$transcript_path" +fi + +# Nothing to do without a state. +if [ -z "$state" ]; then + log "skip=no-state" + exit 0 +fi + +# Assemble OSC 9001 params: `st` is required; `msg`/`lbl` are optional and +# base64-encoded so their values stay ';'/ST-safe (the VTE parser splits OSC +# params on ';'). +params="st=$state" +if [ -n "$message" ]; then + msg_b64=$(printf '%s' "$message" | base64 | tr -d '\n') + params="$params;msg=$msg_b64" + msg_info="msglen=${#message}" +else + msg_info="msglen=0" +fi +if [ -n "$lbl_json" ]; then + lbl_b64=$(printf '{%s}' "$lbl_json" | base64 | tr -d '\n') + params="$params;lbl=$lbl_b64" + msg_info="$msg_info sid=$session_id" +fi +seq=$(printf '\033]9001;%s\033\\' "$params") + +# Write to the device, recording success/failure for the debug log. The write is +# allowed to fail silently (no device, not in Okena) — that's a clean no-op. +# `2>/dev/null` comes first so a failed-to-open redirection (e.g. no such +# device) is suppressed too: shell redirections apply left to right, so stderr +# must already point at /dev/null before the `>"$tty_dev"` open is attempted. +if printf '%s' "$seq" 2>/dev/null >"$tty_dev"; then + log "$msg_info write=ok" +else + log "$msg_info write=failed (device unwritable: $tty_dev)" +fi + +exit 0 diff --git a/src/main.rs b/src/main.rs index b355dd0d..0c468d49 100644 --- a/src/main.rs +++ b/src/main.rs @@ -328,6 +328,17 @@ fn run_headless(listen_addr: IpAddr) { }); } +/// Build and install the agent-harness registry — one entry per AI agent +/// harness, keyed by agent id. Each `okena-ext-*` crate contributes how to +/// resume its sessions / parse its transcripts; the core/app stay +/// harness-agnostic and dispatch by the `agent` id captured via OSC 9001. +fn init_agent_harnesses() { + let mut registry = okena_core::agent_harness::AgentHarnessRegistry::new(); + registry.register(okena_ext_claude::register_harness()); + registry.register(okena_ext_codex::register_harness()); + okena_core::agent_harness::init(registry); +} + fn main() { // Handle --version before initializing anything (used by updater validation) if std::env::args().any(|a| a == "--version") { @@ -502,6 +513,11 @@ fn main() { } }; + // Install the per-harness agent-session registry (resume command + transcript + // parsing, dispatched by agent id). gpui-free, so it's shared by BOTH the + // desktop and headless paths — do it before the branch below. + init_agent_harnesses(); + if headless { let Some(addr) = listen_addr else { eprintln!("Headless mode requires --listen , e.g. --headless --listen 0.0.0.0"); @@ -581,6 +597,7 @@ fn main() { default_shell: s.settings.default_shell.clone(), hooks: s.settings.hooks.clone(), ctrl_c_copies_selection: s.settings.terminal_ctrl_c_copies_selection, + auto_resume_agent_sessions: s.settings.auto_resume_agent_sessions, }).ok() } "git" => { diff --git a/src/smoke_tests.rs b/src/smoke_tests.rs index 47c03b3a..0a380634 100644 --- a/src/smoke_tests.rs +++ b/src/smoke_tests.rs @@ -52,6 +52,7 @@ mod tests { default_shell: s.settings.default_shell.clone(), hooks: s.settings.hooks.clone(), ctrl_c_copies_selection: s.settings.terminal_ctrl_c_copies_selection, + auto_resume_agent_sessions: s.settings.auto_resume_agent_sessions, }).ok(), "git" => serde_json::to_value(&okena_views_git::settings::GitViewSettings { diff_view_mode: s.settings.diff_view_mode,