Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions crates/okena-app-core/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>) {
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>) {
self.settings.file_finder.show_ignored = value;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions crates/okena-app/src/app/headless.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,9 @@ impl HeadlessApp {

// Collect exit events for service manager processing
let mut exit_events: Vec<(String, Option<u32>)> = 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<String> = Vec::new();

// Process first event (broadcasting handled by PtyOutputSink in reader threads)
match &event {
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions crates/okena-app/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
138 changes: 138 additions & 0 deletions crates/okena-app/src/app/notifications.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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<Self>,
) {
// 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<Self>,
) {
// 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<String> = {
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.
Expand Down
23 changes: 23 additions & 0 deletions crates/okena-app/src/app/remote_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, okena_core::agent_status::AgentStatus> = {
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();
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions crates/okena-app/src/keybindings/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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![
Expand Down
20 changes: 19 additions & 1 deletion crates/okena-app/src/keybindings/descriptions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions crates/okena-app/src/keybindings/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)),
Expand Down
1 change: 1 addition & 0 deletions crates/okena-app/src/views/overlays/project_switcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,7 @@ mod tests {
is_remote: false,
connection_id: None,
service_terminals: HashMap::new(),
agent_sessions: Default::default(),
default_shell: None::<ShellType>,
hook_terminals: HashMap::<String, HookTerminalEntry>::new(),
pinned: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions crates/okena-cli/src/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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![],
Expand Down
Loading
Loading