aboutsummaryrefslogtreecommitdiffstats
path: root/crates
diff options
context:
space:
mode:
authorLucas Trzesniewski <lucas.trzesniewski@gmail.com>2026-02-12 01:06:11 +0100
committerGitHub <noreply@github.com>2026-02-11 16:06:11 -0800
commit6076b6b36bf1faffee41e958acdfc0a00bf3ecc3 (patch)
treeae9c3e4458aabbfb09872d30488a4162a4e05ad4 /crates
parentchore: update changelog (diff)
downloadatuin-6076b6b36bf1faffee41e958acdfc0a00bf3ecc3.zip
feat: `switch-context` (#3149)
This PR lets you change the current search context to the one of the currently selected command, which lets you easily see the surrounding commands of its session. It adds the following: - `switch-context` and `clear-context` actions - A `has-context` condition - `CTX:` and `C>` prefixes to show we're in another context The `switch-context` behavior is as follows: - The selected command defines the new context - The filter mode is automatically switched to SESSION - The search input is cleared, which gives you a full overview of the other commands executed in the same session - The command which triggered this mode keeps being selected to get a clear overview - The filter mode can be changed to modes such as DIRECTORY, but not to GLOBAL or SESSION+ as IMO those would be confusing in this mode This lets you easily navigate between modes and commands the way you prefer, for instance by switching the context through selected commands or switching back to the default mode with the same key. This could certainly still be improved (the docs are missing for instance), and if you have any feedback I can change the behavior as you see fit. I can add the docs when you'll approve the new names. Closes #2784 ## Checks - [x] I am happy for maintainers to push small adjustments to this PR, to speed up the review cycle - [x] I have checked that there are no existing pull requests for the same thing --------- Co-authored-by: Ellie Huxtable <ellie@elliehuxtable.com>
Diffstat (limited to 'crates')
-rw-r--r--crates/atuin-client/src/database.rs13
-rw-r--r--crates/atuin/src/command/client/search/engines.rs4
-rw-r--r--crates/atuin/src/command/client/search/interactive.rs93
-rw-r--r--crates/atuin/src/command/client/search/keybindings/actions.rs6
-rw-r--r--crates/atuin/src/command/client/search/keybindings/conditions.rs15
-rw-r--r--crates/atuin/src/command/client/search/keybindings/defaults.rs10
-rw-r--r--crates/atuin/src/command/client/search/keybindings/keymap.rs1
7 files changed, 132 insertions, 10 deletions
diff --git a/crates/atuin-client/src/database.rs b/crates/atuin-client/src/database.rs
index 28d6c0f0..7aa095f7 100644
--- a/crates/atuin-client/src/database.rs
+++ b/crates/atuin-client/src/database.rs
@@ -32,6 +32,7 @@ use super::{
settings::{FilterMode, SearchMode, Settings},
};
+#[derive(Clone)]
pub struct Context {
pub session: String,
pub cwd: String,
@@ -72,6 +73,18 @@ pub async fn current_context() -> eyre::Result<Context> {
})
}
+impl Context {
+ pub fn from_history(entry: &History) -> Self {
+ Context {
+ session: entry.session.to_string(),
+ cwd: entry.cwd.to_string(),
+ hostname: entry.hostname.to_string(),
+ host_id: String::new(),
+ git_root: utils::in_git_repo(entry.cwd.as_str()),
+ }
+ }
+}
+
fn get_session_start_time(session_id: &str) -> Option<i64> {
if let Ok(uuid) = Uuid::parse_str(session_id)
&& let Some(timestamp) = uuid.get_timestamp()
diff --git a/crates/atuin/src/command/client/search/engines.rs b/crates/atuin/src/command/client/search/engines.rs
index 95d6658b..5c53817e 100644
--- a/crates/atuin/src/command/client/search/engines.rs
+++ b/crates/atuin/src/command/client/search/engines.rs
@@ -1,7 +1,7 @@
use async_trait::async_trait;
use atuin_client::{
database::{Context, Database},
- history::History,
+ history::{History, HistoryId},
settings::{FilterMode, SearchMode, Settings},
};
use eyre::Result;
@@ -22,6 +22,7 @@ pub struct SearchState {
pub input: Cursor,
pub filter_mode: FilterMode,
pub context: Context,
+ pub custom_context: Option<HistoryId>,
}
impl SearchState {
@@ -44,6 +45,7 @@ impl SearchState {
fn filter_mode_available(&self, mode: FilterMode, settings: &Settings) -> bool {
match mode {
+ FilterMode::Global | FilterMode::SessionPreload => self.custom_context.is_none(),
FilterMode::Workspace => settings.workspaces && self.context.git_root.is_some(),
_ => true,
}
diff --git a/crates/atuin/src/command/client/search/interactive.rs b/crates/atuin/src/command/client/search/interactive.rs
index b5186706..c6a6064a 100644
--- a/crates/atuin/src/command/client/search/interactive.rs
+++ b/crates/atuin/src/command/client/search/interactive.rs
@@ -16,10 +16,11 @@ use super::{
history_list::{HistoryList, ListState},
};
use atuin_client::{
- database::{Database, current_context},
+ database::{Context, Database, current_context},
history::{History, HistoryId, HistoryStats, store::HistoryStore},
settings::{
- CursorStyle, ExitMode, KeymapMode, PreviewStrategy, SearchMode, Settings, UiColumn,
+ CursorStyle, ExitMode, FilterMode, KeymapMode, PreviewStrategy, SearchMode, Settings,
+ UiColumn,
},
};
@@ -59,6 +60,7 @@ pub enum InputAction {
ReturnQuery,
Continue,
Redraw,
+ SwitchContext(Option<usize>),
}
#[derive(Clone)]
@@ -304,6 +306,7 @@ impl State {
selected_index: self.results_state.selected(),
results_len: self.results_len,
original_input_empty: self.original_input_empty,
+ has_context: self.search.custom_context.is_some(),
};
// Convert KeyEvent to SingleKey
@@ -657,6 +660,10 @@ impl State {
self.engine = engines::engine(self.search_mode);
InputAction::Continue
}
+ Action::SwitchContext => {
+ InputAction::SwitchContext(Some(self.results_state.selected()))
+ }
+ Action::ClearContext => InputAction::SwitchContext(None),
Action::ToggleTab => {
self.tab_index = (self.tab_index + 1) % TAB_TITLES.len();
InputAction::Continue
@@ -916,6 +923,11 @@ impl State {
Compactness::Ultracompact => {
if self.switched_search_mode {
format!("S{}>", self.search_mode.as_str().chars().next().unwrap())
+ } else if self.search.custom_context.is_some() {
+ format!(
+ "C{}>",
+ self.search.filter_mode.as_str().chars().next().unwrap()
+ )
} else {
format!(
"{}> ",
@@ -1166,6 +1178,8 @@ impl State {
fn build_input(&self, style: StyleState, prefix_width: u16) -> Paragraph<'_> {
let (pref, mode) = if self.switched_search_mode {
(" SRCH:", self.search_mode.as_str())
+ } else if self.search.custom_context.is_some() {
+ (" CTX:", self.search.filter_mode.as_str())
} else {
("", self.search.filter_mode.as_str())
};
@@ -1372,7 +1386,7 @@ pub async fn history(
let update_needed = tokio::spawn(async move { settings2.needs_update().await }).fuse();
tokio::pin!(update_needed);
- let context = current_context().await?;
+ let initial_context = current_context().await?;
let history_count = db.history_count(false).await?;
let search_mode = if settings.shell_up_key_binding {
@@ -1382,6 +1396,10 @@ pub async fn history(
} else {
settings.search_mode
};
+ let default_filter_mode = settings
+ .filter_mode_shell_up_key_binding
+ .filter(|_| settings.shell_up_key_binding)
+ .unwrap_or_else(|| settings.default_filter_mode(initial_context.git_root.is_some()));
let mut app = State {
history_count,
results_state: ListState::default(),
@@ -1397,11 +1415,9 @@ pub async fn history(
keymaps: KeymapSet::from_settings(settings),
search: SearchState {
input,
- filter_mode: settings
- .filter_mode_shell_up_key_binding
- .filter(|_| settings.shell_up_key_binding)
- .unwrap_or_else(|| settings.default_filter_mode(context.git_root.is_some())),
- context,
+ filter_mode: default_filter_mode,
+ context: initial_context.clone(),
+ custom_context: None,
},
engine: engines::engine(search_mode),
results_len: 0,
@@ -1448,6 +1464,7 @@ pub async fn history(
let initial_input = app.search.input.as_str().to_owned();
let initial_filter_mode = app.search.filter_mode;
let initial_search_mode = app.search_mode;
+ let initial_custom_context = app.search.custom_context.clone();
let event_ready = tokio::task::spawn_blocking(|| event::poll(Duration::from_millis(250)));
@@ -1479,6 +1496,19 @@ pub async fn history(
app.tab_index = 0;
},
+ InputAction::SwitchContext(index) => {
+ if let Some(index) = index && let Some(entry) = results.get(index) {
+ app.search.custom_context = Some(entry.id.clone());
+ app.search.context = Context::from_history(entry);
+ app.search.filter_mode = FilterMode::Session;
+ app.search.input = Cursor::from(String::new());
+ app.results_state = ListState::default();
+ } else {
+ app.search.custom_context = None;
+ app.search.context = initial_context.clone();
+ app.search.filter_mode = default_filter_mode;
+ }
+ },
InputAction::Redraw => {
terminal.clear()?;
terminal.draw(|f| app.draw(f, &results, stats.clone(), inspecting.as_ref(), settings, theme))?;
@@ -1504,10 +1534,23 @@ pub async fn history(
if initial_input != app.search.input.as_str()
|| initial_filter_mode != app.search.filter_mode
|| initial_search_mode != app.search_mode
+ || initial_custom_context != app.search.custom_context
{
results = app.query_results(&mut db, settings.smart_sort).await?;
}
+ // In custom context mode, when no filter is applied, highlight the entry which was used
+ // to enter the context when changing modes. This helps to find your way around.
+ if app.search.custom_context.is_some()
+ && app.search.input.as_str().is_empty()
+ && (initial_custom_context != app.search.custom_context
+ || initial_filter_mode != app.search.filter_mode)
+ && let Some(history_id) = app.search.custom_context.clone()
+ && let Some(pos) = results.iter().position(|entry| entry.id == history_id)
+ {
+ app.results_state.select(pos);
+ }
+
let inspecting_id = app.inspecting_state.clone().current;
// If inspecting ID is not the current inspecting History, update it.
match inspecting_id {
@@ -1600,7 +1643,10 @@ pub async fn history(
// * out of bounds -> usually implies no selected entry so we return the input
Ok(app.search.input.into_inner())
}
- InputAction::Continue | InputAction::Redraw | InputAction::Delete(_) => {
+ InputAction::Continue
+ | InputAction::Redraw
+ | InputAction::Delete(_)
+ | InputAction::SwitchContext(_) => {
unreachable!("should have been handled!")
}
}
@@ -1827,6 +1873,7 @@ mod tests {
host_id: String::new(),
git_root: None,
},
+ custom_context: None,
},
engine: engines::engine(SearchMode::Fuzzy),
now: Box::new(OffsetDateTime::now_utc),
@@ -1881,6 +1928,7 @@ mod tests {
host_id: String::new(),
git_root: None,
},
+ custom_context: None,
},
engine: engines::engine(SearchMode::Fuzzy),
now: Box::new(OffsetDateTime::now_utc),
@@ -1999,6 +2047,7 @@ mod tests {
host_id: String::new(),
git_root: None,
},
+ custom_context: None,
},
engine: engines::engine(SearchMode::Fuzzy),
now: Box::new(OffsetDateTime::now_utc),
@@ -2057,6 +2106,7 @@ mod tests {
host_id: String::new(),
git_root: None,
},
+ custom_context: None,
},
engine: engines::engine(SearchMode::Fuzzy),
now: Box::new(OffsetDateTime::now_utc),
@@ -2111,6 +2161,7 @@ mod tests {
host_id: String::new(),
git_root: None,
},
+ custom_context: None,
},
engine: engines::engine(SearchMode::Fuzzy),
now: Box::new(OffsetDateTime::now_utc),
@@ -2161,6 +2212,7 @@ mod tests {
host_id: String::new(),
git_root: None,
},
+ custom_context: None,
},
engine: engines::engine(SearchMode::Fuzzy),
now: Box::new(OffsetDateTime::now_utc),
@@ -2220,6 +2272,7 @@ mod tests {
host_id: String::new(),
git_root: None,
},
+ custom_context: None,
},
engine: engines::engine(SearchMode::Fuzzy),
now: Box::new(OffsetDateTime::now_utc),
@@ -2280,6 +2333,7 @@ mod tests {
host_id: String::new(),
git_root: None,
},
+ custom_context: None,
},
engine: engines::engine(SearchMode::Fuzzy),
now: Box::new(OffsetDateTime::now_utc),
@@ -2490,6 +2544,26 @@ mod tests {
}
#[test]
+ fn execute_switch_context() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 7);
+ let settings = Settings::utc();
+ let result = state.execute_action(&Action::SwitchContext, &settings);
+ assert!(matches!(result, super::InputAction::SwitchContext(Some(7))));
+ }
+
+ #[test]
+ fn execute_clear_context() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 7);
+ let settings = Settings::utc();
+ let result = state.execute_action(&Action::ClearContext, &settings);
+ assert!(matches!(result, super::InputAction::SwitchContext(None)));
+ }
+
+ #[test]
fn execute_noop() {
use crate::command::client::search::keybindings::Action;
@@ -2638,6 +2712,7 @@ mod tests {
host_id: String::new(),
git_root: None,
},
+ custom_context: None,
},
engine: engines::engine(SearchMode::Fuzzy),
now: Box::new(OffsetDateTime::now_utc),
diff --git a/crates/atuin/src/command/client/search/keybindings/actions.rs b/crates/atuin/src/command/client/search/keybindings/actions.rs
index fae811d6..66e2709e 100644
--- a/crates/atuin/src/command/client/search/keybindings/actions.rs
+++ b/crates/atuin/src/command/client/search/keybindings/actions.rs
@@ -52,6 +52,8 @@ pub enum Action {
Redraw,
CycleFilterMode,
CycleSearchMode,
+ SwitchContext,
+ ClearContext,
ToggleTab,
// Mode changes
@@ -129,6 +131,8 @@ impl Action {
"redraw" => Ok(Action::Redraw),
"cycle-filter-mode" => Ok(Action::CycleFilterMode),
"cycle-search-mode" => Ok(Action::CycleSearchMode),
+ "switch-context" => Ok(Action::SwitchContext),
+ "clear-context" => Ok(Action::ClearContext),
"toggle-tab" => Ok(Action::ToggleTab),
"vim-enter-normal" => Ok(Action::VimEnterNormal),
@@ -193,6 +197,8 @@ impl Action {
Action::Redraw => "redraw".to_string(),
Action::CycleFilterMode => "cycle-filter-mode".to_string(),
Action::CycleSearchMode => "cycle-search-mode".to_string(),
+ Action::SwitchContext => "switch-context".to_string(),
+ Action::ClearContext => "clear-context".to_string(),
Action::ToggleTab => "toggle-tab".to_string(),
Action::VimEnterNormal => "vim-enter-normal".to_string(),
diff --git a/crates/atuin/src/command/client/search/keybindings/conditions.rs b/crates/atuin/src/command/client/search/keybindings/conditions.rs
index bc485713..d460d7d4 100644
--- a/crates/atuin/src/command/client/search/keybindings/conditions.rs
+++ b/crates/atuin/src/command/client/search/keybindings/conditions.rs
@@ -13,6 +13,7 @@ pub enum ConditionAtom {
ListAtStart,
NoResults,
HasResults,
+ HasContext,
}
/// Boolean expression tree over condition atoms.
@@ -49,6 +50,8 @@ pub struct EvalContext {
pub results_len: usize,
/// Whether the original input (query passed to the TUI) was empty.
pub original_input_empty: bool,
+ /// Whether we use a search context of a command from the history.
+ pub has_context: bool,
}
// ---------------------------------------------------------------------------
@@ -69,6 +72,7 @@ impl ConditionAtom {
ConditionAtom::ListAtStart => ctx.results_len == 0 || ctx.selected_index == 0,
ConditionAtom::NoResults => ctx.results_len == 0,
ConditionAtom::HasResults => ctx.results_len > 0,
+ ConditionAtom::HasContext => ctx.has_context,
}
}
@@ -83,6 +87,7 @@ impl ConditionAtom {
"list-at-start" => Ok(ConditionAtom::ListAtStart),
"no-results" => Ok(ConditionAtom::NoResults),
"has-results" => Ok(ConditionAtom::HasResults),
+ "has-context" => Ok(ConditionAtom::HasContext),
_ => Err(format!("unknown condition: {s}")),
}
}
@@ -98,6 +103,7 @@ impl ConditionAtom {
ConditionAtom::ListAtStart => "list-at-start",
ConditionAtom::NoResults => "no-results",
ConditionAtom::HasResults => "has-results",
+ ConditionAtom::HasContext => "has-context",
}
}
}
@@ -394,6 +400,7 @@ mod tests {
selected_index: selected,
results_len: len,
original_input_empty,
+ has_context: false,
}
}
@@ -457,6 +464,14 @@ mod tests {
}
#[test]
+ fn atom_has_context() {
+ let mut context = ctx(0, 0, 0, 0, 0);
+ assert!(!ConditionAtom::HasContext.evaluate(&context));
+ context.has_context = true;
+ assert!(ConditionAtom::HasContext.evaluate(&context));
+ }
+
+ #[test]
fn atom_parse_round_trip() {
let conditions = [
"cursor-at-start",
diff --git a/crates/atuin/src/command/client/search/keybindings/defaults.rs b/crates/atuin/src/command/client/search/keybindings/defaults.rs
index 64dca691..f19bf377 100644
--- a/crates/atuin/src/command/client/search/keybindings/defaults.rs
+++ b/crates/atuin/src/command/client/search/keybindings/defaults.rs
@@ -405,6 +405,13 @@ pub fn default_prefix_keymap() -> Keymap {
km.bind(key("d"), Action::Delete);
km.bind(key("a"), Action::CursorStart);
+ km.bind_conditional(
+ key("c"),
+ vec![
+ KeyRule::when(ConditionAtom::HasContext, Action::ClearContext),
+ KeyRule::always(Action::SwitchContext),
+ ],
+ );
km
}
@@ -530,6 +537,7 @@ mod tests {
selected_index: selected,
results_len: len,
original_input_empty: false,
+ has_context: false,
}
}
@@ -1250,6 +1258,7 @@ mod tests {
selected_index: 0,
results_len: 10,
original_input_empty: true,
+ has_context: false,
};
assert_eq!(
set.emacs.resolve(&key("esc"), &ctx_original_empty),
@@ -1265,6 +1274,7 @@ mod tests {
selected_index: 0,
results_len: 10,
original_input_empty: false,
+ has_context: false,
};
assert_eq!(
set.emacs.resolve(&key("esc"), &ctx_original_not_empty),
diff --git a/crates/atuin/src/command/client/search/keybindings/keymap.rs b/crates/atuin/src/command/client/search/keybindings/keymap.rs
index bbf034b2..8c7fcfa8 100644
--- a/crates/atuin/src/command/client/search/keybindings/keymap.rs
+++ b/crates/atuin/src/command/client/search/keybindings/keymap.rs
@@ -127,6 +127,7 @@ mod tests {
selected_index: selected,
results_len: len,
original_input_empty: false,
+ has_context: false,
}
}