Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
10 changes: 10 additions & 0 deletions app/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1441,6 +1441,7 @@ fn initialize_app(
} else {
log::info!("Home directory not found; skipping HomeDirectoryWatcher registration");
}
ctx.add_singleton_model(crate::terminal::shell_history_watcher::ShellHistoryWatcher::new);
}

#[cfg(feature = "local_fs")]
Expand Down Expand Up @@ -1494,6 +1495,15 @@ fn initialize_app(

ctx.add_singleton_model(move |_| History::new(command_history));

// GH-3422: subscribe History to ShellHistoryWatcher so live updates from
// other terminals (when the user has opted in via
// `terminal.live_sync_os_shell_history`) flow into Warp's autocomplete.
// Idempotent — safe to call once at app startup.
#[cfg(not(target_family = "wasm"))]
crate::terminal::history::History::handle(ctx).update(ctx, |history, ctx| {
history.set_up_external_history_sync(ctx);
});

ctx.add_singleton_model(CustomSecretRegexUpdater::new);

// Register the `TelemetryCollection` singleton model.
Expand Down
6 changes: 6 additions & 0 deletions app/src/search/command_search/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,12 @@ impl CommandSearchView {
ctx.notify();
}
}
HistoryEvent::ExternalHistoryUpdated { .. } => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] The history data source is a snapshot created when the mixer is reset; this callback is only installed in the !is_queryable branch and ctx.notify() does not rebuild the source or rerun the current query, so command search stays stale for already-queryable sessions after live history updates.

// Live OS-shell-history sync (GH-3422). The mixer's
// history source re-queries on its own debounce, so
// we just trigger a notify to refresh visible state.
ctx.notify();
}
}
});
}
Expand Down
5 changes: 3 additions & 2 deletions app/src/settings/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ use super::{
AliasExpansionSettings, AppEditorSettings, BlockVisibilitySettings, ChangelogSettings,
CodeSettings, DebugSettings, EmacsBindingsSettings, FontSettings, FontSettingsChangedEvent,
GPUSettings, InputBoxType, InputModeSettings, InputSettings, PaneSettings,
SameLinePromptBlockSettings, ScrollSettings, SelectionSettings, SshSettings, ThemeSettings,
VimBannerSettings, WarpDrivePrivacySettings,
SameLinePromptBlockSettings, ScrollSettings, SelectionSettings, ShellHistorySyncSettings,
SshSettings, ThemeSettings, VimBannerSettings, WarpDrivePrivacySettings,
};

pub struct UserDefaultsOnStartup {
Expand Down Expand Up @@ -91,6 +91,7 @@ pub fn register_all_settings(ctx: &mut AppContext) {
AltScreenReporting::register(ctx);
UndoCloseSettings::register(ctx);
SshSettings::register(ctx);
ShellHistorySyncSettings::register(ctx);
VimBannerSettings::register(ctx);
SharedSessionSettings::register(ctx);
WarpDriveSettings::register(ctx);
Expand Down
2 changes: 2 additions & 0 deletions app/src/settings/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ mod privacy;
mod same_line_prompt_block;
mod scroll;
mod select;
mod shell_history_sync;
mod ssh;
mod theme;
mod vim_banner;
Expand Down Expand Up @@ -61,6 +62,7 @@ pub use privacy::*;
pub use same_line_prompt_block::*;
pub use scroll::*;
pub use select::*;
pub use shell_history_sync::*;
pub use ssh::*;
pub use theme::*;
pub use vim_banner::*;
Expand Down
18 changes: 18 additions & 0 deletions app/src/settings/shell_history_sync.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
use settings::{
macros::define_settings_group, RespectUserSyncSetting, SupportedPlatforms, SyncToCloud,
};

define_settings_group!(ShellHistorySyncSettings, settings: [
live_sync_os_shell_history: LiveSyncOsShellHistoryEnabled {
type: bool,
default: false,
supported_platforms: SupportedPlatforms::ALL,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: false,
toml_path: "terminal.live_sync_os_shell_history",
description: "When enabled, Warp watches the active shell's history file (~/.zsh_history, \
~/.bash_history, fish, PSReadLine) for changes made by other terminals and \
merges new commands into Warp's autocomplete in real time. Off by default. \
Tracks GH-3422.",
},
]);
243 changes: 241 additions & 2 deletions app/src/terminal/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,26 @@ use futures::Future;
use serde::{Deserialize, Serialize};
use std::{
collections::{HashMap, HashSet},
path::PathBuf,
sync::Arc,
};

use settings::Setting as _;
use warp_core::command::ExitCode;
use warpui::{AppContext, Entity, ModelContext, SingletonEntity};

use super::{
model::block::{AgentInteractionMetadata, Block, SerializedAIMetadata, SerializedBlock},
shell::ShellType,
shell_history_watcher::{ShellHistoryWatcher, ShellHistoryWatcherEvent},
};
use crate::{
cloud_object::{
model::{persistence::CloudModel, view::CloudViewModel},
Space,
},
server::ids::{ClientId, HashableId as _, SyncId},
settings::ShellHistorySyncSettings,
terminal::model::session::{Session, SessionId},
util::dedupe_from_last,
workflows::{
Expand Down Expand Up @@ -162,6 +166,14 @@ enum ReadHistoryFileState {
pub enum HistoryEvent {
/// History has been initialized for the session with the contained ID.
Initialized(SessionId),
/// External history file (e.g. `~/.zsh_history`) was modified by another
/// terminal and `num_appended` new entries were merged into
/// `history_file_commands` for `host`. Listeners that cache history-derived
/// state (autocomplete index, suggestion bar) should re-query.
///
/// Only emitted when the user has opted into
/// `terminal.live_sync_os_shell_history` (GH-3422).
ExternalHistoryUpdated { host: ShellHost, num_appended: usize },
}

/// This holds the aggregated data from the "commands" table in sqlite. We aggregate as a means of
Expand Down Expand Up @@ -191,8 +203,12 @@ pub struct History {
/// execution metadata from the most recent run.
persisted_commands_summary: HashMap<ShellHost, HashMap<String, CommandHistorySummary>>,

/// Entries from the history file for the host. Immutable once loaded and
/// shared between sessions.
/// Entries from the history file for the host. Loaded once at session-init
/// and shared between sessions. When the user enables
/// `terminal.live_sync_os_shell_history` (GH-3422) this map is also
/// append-only updated by [`Self::apply_external_history_lines`] whenever
/// the underlying histfile is modified by another terminal — see
/// [`Self::set_up_external_history_sync`].
history_file_commands: HashMap<ShellHost, Vec<Arc<HistoryEntry>>>,

/// Global history entries across all sessions for each host. Only grows. Deduping
Expand All @@ -211,6 +227,18 @@ pub struct History {
read_history_file_state: HashMap<ShellHost, ReadHistoryFileState>,

session_id_to_shell_host: HashMap<SessionId, ShellHost>,

/// For live OS-shell-history sync (GH-3422). Map from histfile path to the
/// set of hosts whose `history_file_commands` should be re-merged when
/// that path changes on disk. Populated by [`Self::maybe_register_live_sync`]
/// at session init (only when the live-sync setting is on); consulted by
/// [`Self::handle_shell_history_watcher_event`] when watcher events arrive.
live_sync_paths: HashMap<PathBuf, HashSet<ShellHost>>,

/// Set to `true` once [`Self::set_up_external_history_sync`] has installed
/// the watcher subscription. The subscription is global and idempotent so
/// we want to install it exactly once.
external_sync_subscribed: bool,
}

#[derive(Clone, Debug)]
Expand Down Expand Up @@ -568,6 +596,12 @@ impl History {
self.session_id_to_shell_host
.insert(session_id, host.clone());

// GH-3422: when the user has opted into live shell-history sync, register
// this host's histfile path(s) with `ShellHistoryWatcher` so subsequent
// changes by other terminals are merged into `history_file_commands`.
// No-op when the setting is off.
self.maybe_register_live_sync(&host, ctx);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] This registers live sync for every session, including WarpifiedRemote; maybe_register_live_sync resolves dirs::home_dir() and reads local histfiles, so local shell history can be merged into a remote session's autocomplete.


match self.read_history_file_state.get_mut(&host) {
None => {
let mut session_ids = HashSet::new();
Expand Down Expand Up @@ -964,6 +998,211 @@ impl History {
}
}
}

// ---------------------------------------------------------------------
// Live OS-shell-history sync (GH-3422).
//
// When the `terminal.live_sync_os_shell_history` setting is on, the
// `History` model subscribes to [`ShellHistoryWatcher`] events. When
// another terminal appends to the user's `~/.zsh_history` (or other
// shell histfile), the watcher fires, we re-read the file, parse it
// with the existing per-shell parser, and append the new commands to
// `history_file_commands` so they show up in Warp's autocomplete
// immediately. No write-back to disk happens in this code path —
// see GH-3422 follow-up.
// ---------------------------------------------------------------------

/// Subscribe to [`ShellHistoryWatcher`] events. Idempotent. Should be
/// called once at app startup (from `lib.rs`) after `History` is
/// registered as a singleton.
pub fn set_up_external_history_sync(&mut self, ctx: &mut ModelContext<Self>) {
if self.external_sync_subscribed {
return;
}
self.external_sync_subscribed = true;
let watcher_handle = ShellHistoryWatcher::handle(ctx);
ctx.subscribe_to_model(&watcher_handle, |me, event, ctx| {
me.handle_shell_history_watcher_event(event, ctx);
});
}

/// Handler for [`ShellHistoryWatcherEvent::HistfilesChanged`]. For each
/// changed path that we registered in [`Self::maybe_register_live_sync`],
/// kick off an async re-read of the file and dispatch the parsed lines
/// to [`Self::apply_external_history_lines`].
fn handle_shell_history_watcher_event(
&mut self,
event: &ShellHistoryWatcherEvent,
ctx: &mut ModelContext<Self>,
) {
let ShellHistoryWatcherEvent::HistfilesChanged(fs_event) = event;
for path in fs_event.added_or_updated_iter() {
// Snapshot the host set under this path so we can drop the
// borrow on `self` before spawning.
let Some(hosts) = self.live_sync_paths.get(path).cloned() else {
continue;
};
for host in hosts {
let path_for_read = path.clone();
let shell_type = host.shell_type;
let host_for_apply = host.clone();
ctx.spawn(
async move {
async_fs::read(&path_for_read)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] This bypasses the existing PowerShell-on-Windows history reader that shells out when Kaspersky is running, so enabling live sync can reintroduce the async_fs::read path that initial history loading deliberately avoids.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] This bypasses the existing PowerShell/Kaspersky read_history_contents fallback and always uses async_fs::read, so PSReadLine live sync can reintroduce the Windows antivirus failure path. Reuse the same reader as Session::read_history_for_local_session.

.await
.ok()
.map(|bytes| shell_type.parse_history(&bytes))
},
move |me, lines_opt, ctx| {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] Multiple watcher reads can complete out of order, and each callback replaces the cached histfile; a slower stale read can roll back commands from a newer event. Add a per-path/host generation check or otherwise discard stale read results before applying.

if let Some(lines) = lines_opt {
me.apply_external_history_lines(host_for_apply, lines, ctx);
}
},
);
}
}
}

/// Merge `new_lines` (the freshly-re-read histfile contents for `host`)
/// into `history_file_commands[host]`. Appends any commands not already
/// present, shifts session-index bookkeeping for sessions on the same
/// host, and emits [`HistoryEvent::ExternalHistoryUpdated`].
fn apply_external_history_lines(
&mut self,
host: ShellHost,
new_lines: Vec<String>,
ctx: &mut ModelContext<Self>,
) {
let new_deduped = dedupe_from_last(new_lines);

let existing_commands: HashSet<String> = self
.history_file_commands
.get(&host)
.map(|v| v.iter().map(|e| e.command.clone()).collect())
.unwrap_or_default();

let to_append: Vec<String> = new_deduped
.into_iter()
.filter(|cmd| !existing_commands.contains(cmd))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] Filtering against all existing command strings makes a full reread a no-op when another terminal reruns an existing command, so dedupe-from-last recency/order is never updated for duplicates.

.collect();

if to_append.is_empty() {
return;
}
let n = to_append.len();

let new_entries: Vec<Arc<HistoryEntry>> = to_append
.into_iter()
.map(|command| {
self.persisted_commands_summary
.get(&host)
.and_then(|summaries| summaries.get(&command))
.map(|summary| summary.most_recent_entry.clone())
.unwrap_or_else(|| HistoryEntry::command_only(command))
})
.map(Arc::new)
.collect();

// Capture old boundary BEFORE extending so the index shift below is correct.
let old_history_file_len = self
.history_file_commands
.get(&host)
.map(|v| v.len())
.unwrap_or(0);

self.history_file_commands
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] Replacing the histfile commands without re-deduping against session_commands means a command written by Warp's own shell, or any external command matching a session command, appears twice because the skip indices are only shifted. Recompute skips after the replacement or mark duplicates like append_commands does.

.entry(host.clone())
.or_default()
.extend(new_entries);

// The render-space history list for a host is
// history_file_commands[host] ++ session_commands[host]
// (see the doc comment on `session_skip_indices`). We just inserted
// `n` entries at position `old_history_file_len`, which shifts every
// index currently >= that boundary up by `n`.
let on_host_session_ids: Vec<SessionId> = self
.session_id_to_shell_host
.iter()
.filter(|(_, h)| **h == host)
.map(|(id, _)| *id)
.collect();

for session_id in &on_host_session_ids {
if let Some(start) = self.session_start_indices.get_mut(session_id) {
if *start >= old_history_file_len {
*start += n;
}
}
if let Some(skips) = self.session_skip_indices.get_mut(session_id) {
*skips = skips
.iter()
.map(|&i| if i >= old_history_file_len { i + n } else { i })
.collect();
}
}

ctx.emit(HistoryEvent::ExternalHistoryUpdated {
host,
num_appended: n,
});
}

/// Helper called from [`Self::init_session_with`] to register the
/// active session's histfile path(s) with [`ShellHistoryWatcher`] when
/// the live-sync setting is on. No-op when off.
///
/// Idempotent: registering the same `(path, host)` pair twice is safe —
/// the underlying watcher refcounts paths and the `live_sync_paths`
/// map is keyed by `HashSet<ShellHost>`.
fn maybe_register_live_sync(&mut self, host: &ShellHost, ctx: &mut ModelContext<Self>) {
let enabled = *ShellHistorySyncSettings::as_ref(ctx)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] The opt-in setting is only checked when registering; after a path is registered, disabling terminal.live_sync_os_shell_history does not unregister or suppress watcher events, so the feature keeps reading and merging until restart. Re-check the setting in the event path or subscribe to setting changes and unregister registered paths.

.live_sync_os_shell_history
.value();
if !enabled {
return;
}

let Some(home) = dirs::home_dir() else {
log::warn!(
"live_sync_os_shell_history is on but no home directory could be \
resolved; skipping live history watch registration"
);
return;
};

let candidate_paths: Vec<PathBuf> = host
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] Building watch paths from host.shell_type.history_files() ignores the actual session histfile; users with custom HISTFILE are initially loaded from that path but live sync watches the default file instead.

.shell_type
.history_files()
.into_iter()
.filter_map(|p| {
// `history_files()` returns `~/...`-prefixed strings; expand them.
let stripped = p.strip_prefix("~/").or_else(|| p.strip_prefix("~"))?;
Some(home.join(stripped))
})
.collect();

let watcher_handle = ShellHistoryWatcher::handle(ctx);
for path in candidate_paths {
// Only register paths that actually exist on disk. Watching a
// non-existent file would either fail or rely on the watcher's
// parent-directory fallback semantics (varies by OS), and the
// initial read at session-init already produces an empty list
// for missing histfiles.
if !path.exists() {
continue;
}
self.live_sync_paths
.entry(path.clone())
.or_default()
.insert(host.clone());
// Always call `register_histfile` — the watcher itself refcounts,
// so registering the same path for two sessions is safe and the
// first call is the one that actually drives a syscall.
watcher_handle.update(ctx, |watcher, ctx| {
watcher.register_histfile(&path, ctx);
});
}
}
}

#[cfg(test)]
Expand Down
Loading