aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorEllie Huxtable <ellie@atuin.sh>2026-01-22 15:33:12 -0800
committerGitHub <noreply@github.com>2026-01-22 15:33:12 -0800
commit8990f61820c5b1d688f2e01f9d06b3736556889e (patch)
tree26d087b212b013cf7f8816de6860b68cd502f07e
parentUpdate regex for AWS Access Key ID pattern (#3088) (diff)
downloadatuin-8990f61820c5b1d688f2e01f9d06b3736556889e.zip
feat: add custom column support (#3089)
Configure the interactive search UI appearance. Resolves #998 ```toml [ui] columns = ["duration", "time", "command"] ``` ### `columns` Default: `["duration", "time", "command"]` Columns to display in the interactive search, from left to right. The selection indicator (`" > "`) is always shown first implicitly. Each column can be specified as: - A simple string (uses default width): `"duration"` - An object with type and optional width/expand: `{ type = "directory", width = 30 }` #### Available column types | Column | Default Width | Description | | --------- | ------------- | ----------------------------------------------- | | duration | 5 | Command execution duration (e.g., "123ms") | | time | 8 | Relative time since execution (e.g., "59m ago") | | datetime | 16 | Absolute timestamp (e.g., "2025-01-22 14:35") | | directory | 20 | Working directory (truncated if too long) | | host | 15 | Hostname | | user | 10 | Username | | exit | 3 | Exit code (colored by success/failure) | | command | * | The command itself (expands by default) | #### Column options - **type**: The column type (required when using object format) - **width**: Custom width in characters (optional, uses default if not specified) - **expand**: If `true`, the column fills remaining space. Default is `true` for `command`, `false` for others. Only one column should have `expand = true`. #### Examples ```toml # Minimal - more space for commands columns = ["duration", "command"] # With custom directory width columns = ["duration", { type = "directory", width = 30 }, "command"] # Show host for multi-machine sync users columns = ["duration", "time", "host", "command"] # Show exit codes prominently columns = ["exit", "duration", "command"] # Make directory expand instead of command columns = ["duration", "time", { type = "directory", expand = true }, { type = "command", expand = false }] ```
-rw-r--r--crates/atuin-client/config.toml41
-rw-r--r--crates/atuin-client/src/settings.rs184
-rw-r--r--crates/atuin/src/command/client/search/history_list.rs160
-rw-r--r--crates/atuin/src/command/client/search/interactive.rs7
-rw-r--r--docs/docs/configuration/config.md60
5 files changed, 431 insertions, 21 deletions
diff --git a/crates/atuin-client/config.toml b/crates/atuin-client/config.toml
index 2eb5d790..117ea066 100644
--- a/crates/atuin-client/config.toml
+++ b/crates/atuin-client/config.toml
@@ -289,3 +289,44 @@ records = true
## The "workspace" mode is skipped when not in a workspace or workspaces = false.
## Default filter mode can be overridden with the filter_mode setting.
# filters = [ "global", "host", "session", "session-preload", "workspace", "directory" ]
+
+[ui]
+## Columns to display in the interactive search, from left to right.
+## The selection indicator (" > ") is always shown first implicitly.
+##
+## Each column can be specified as a simple string (uses default width)
+## or as an object with type, width, and expand:
+## { type = "directory", width = 30, expand = true }
+##
+## Available column types (with default widths):
+## duration (5) - Command execution duration (e.g., "123ms")
+## time (8) - Relative time since execution (e.g., "59m ago")
+## datetime (16) - Absolute timestamp (e.g., "2025-01-22 14:35")
+## directory (20) - Working directory (truncated if too long)
+## host (15) - Hostname where command was run
+## user (10) - Username
+## exit (3) - Exit code (colored by success/failure)
+## command (*) - The command itself (expands by default)
+##
+## The "expand" option (default: true for command, false for others) makes a
+## column fill remaining space. Only one column should have expand = true.
+##
+## Default:
+# columns = ["duration", "time", "command"]
+##
+## Examples:
+##
+## Minimal - more space for commands:
+# columns = ["duration", "command"]
+##
+## With wider directory column:
+# columns = ["duration", { type = "directory", width = 30 }, "command"]
+##
+## Show host for multi-machine sync users:
+# columns = ["duration", "time", "host", "command"]
+##
+## Show exit codes prominently:
+# columns = ["exit", "duration", "command"]
+##
+## Make directory expand instead of command:
+# columns = ["duration", "time", { type = "directory", expand = true }, { type = "command", expand = false }]
diff --git a/crates/atuin-client/src/settings.rs b/crates/atuin-client/src/settings.rs
index 87ed9383..bfe9278d 100644
--- a/crates/atuin-client/src/settings.rs
+++ b/crates/atuin-client/src/settings.rs
@@ -451,6 +451,184 @@ pub enum PreviewStrategy {
Fixed,
}
+/// Column types available for the interactive search UI.
+#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum UiColumnType {
+ /// Command execution duration (e.g., "123ms")
+ Duration,
+ /// Relative time since execution (e.g., "59s ago")
+ Time,
+ /// Absolute timestamp (e.g., "2025-01-22 14:35")
+ Datetime,
+ /// Working directory
+ Directory,
+ /// Hostname
+ Host,
+ /// Username
+ User,
+ /// Exit code
+ Exit,
+ /// The command itself (should be last, expands to fill)
+ Command,
+}
+
+impl UiColumnType {
+ /// Returns the default width for this column type (in characters).
+ /// The Command column returns 0 as it expands to fill remaining space.
+ pub fn default_width(&self) -> u16 {
+ match self {
+ UiColumnType::Duration => 5,
+ UiColumnType::Time => 8, // "59m ago" with padding
+ UiColumnType::Datetime => 16, // "2025-01-22 14:35"
+ UiColumnType::Directory => 20,
+ UiColumnType::Host => 15,
+ UiColumnType::User => 10,
+ UiColumnType::Exit => 3,
+ UiColumnType::Command => 0, // Expands to fill
+ }
+ }
+}
+
+/// A column configuration with type and optional custom width.
+/// Can be specified as just a string (uses default width) or as an object with type and width.
+#[derive(Clone, Debug, Serialize)]
+pub struct UiColumn {
+ pub column_type: UiColumnType,
+ pub width: u16,
+ /// If true, this column expands to fill remaining space. Only one column should expand.
+ pub expand: bool,
+}
+
+impl UiColumn {
+ pub fn new(column_type: UiColumnType) -> Self {
+ Self {
+ width: column_type.default_width(),
+ expand: column_type == UiColumnType::Command,
+ column_type,
+ }
+ }
+
+ pub fn with_width(column_type: UiColumnType, width: u16) -> Self {
+ Self {
+ column_type,
+ width,
+ expand: column_type == UiColumnType::Command,
+ }
+ }
+}
+
+// Custom deserialize to handle both string and object formats:
+// "duration" or { type = "duration", width = 8, expand = true }
+impl<'de> serde::Deserialize<'de> for UiColumn {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ use serde::de::{self, MapAccess, Visitor};
+
+ struct UiColumnVisitor;
+
+ impl<'de> Visitor<'de> for UiColumnVisitor {
+ type Value = UiColumn;
+
+ fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
+ formatter.write_str(
+ "a column type string or an object with 'type' and optional 'width'/'expand'",
+ )
+ }
+
+ fn visit_str<E>(self, value: &str) -> Result<UiColumn, E>
+ where
+ E: de::Error,
+ {
+ let column_type: UiColumnType =
+ serde::Deserialize::deserialize(serde::de::value::StrDeserializer::new(value))?;
+ Ok(UiColumn::new(column_type))
+ }
+
+ fn visit_map<M>(self, mut map: M) -> Result<UiColumn, M::Error>
+ where
+ M: MapAccess<'de>,
+ {
+ let mut column_type: Option<UiColumnType> = None;
+ let mut width: Option<u16> = None;
+ let mut expand: Option<bool> = None;
+
+ while let Some(key) = map.next_key::<String>()? {
+ match key.as_str() {
+ "type" => {
+ column_type = Some(map.next_value()?);
+ }
+ "width" => {
+ width = Some(map.next_value()?);
+ }
+ "expand" => {
+ expand = Some(map.next_value()?);
+ }
+ _ => {
+ let _: serde::de::IgnoredAny = map.next_value()?;
+ }
+ }
+ }
+
+ let column_type = column_type.ok_or_else(|| de::Error::missing_field("type"))?;
+ let width = width.unwrap_or_else(|| column_type.default_width());
+ let expand = expand.unwrap_or(column_type == UiColumnType::Command);
+ Ok(UiColumn {
+ column_type,
+ width,
+ expand,
+ })
+ }
+ }
+
+ deserializer.deserialize_any(UiColumnVisitor)
+ }
+}
+
+/// UI-specific settings for the interactive search.
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct Ui {
+ /// Columns to display in interactive search, from left to right.
+ /// The indicator column (" > ") is always shown first implicitly.
+ /// The "command" column should be last as it expands to fill remaining space.
+ /// Can be simple strings or objects with type and width.
+ #[serde(default = "Ui::default_columns")]
+ pub columns: Vec<UiColumn>,
+}
+
+impl Ui {
+ fn default_columns() -> Vec<UiColumn> {
+ vec![
+ UiColumn::new(UiColumnType::Duration),
+ UiColumn::new(UiColumnType::Time),
+ UiColumn::new(UiColumnType::Command),
+ ]
+ }
+
+ /// Validate the UI configuration.
+ /// Returns an error if more than one column has expand = true.
+ pub fn validate(&self) -> Result<()> {
+ let expand_count = self.columns.iter().filter(|c| c.expand).count();
+ if expand_count > 1 {
+ bail!(
+ "Only one column can have expand = true, but {} columns are set to expand",
+ expand_count
+ );
+ }
+ Ok(())
+ }
+}
+
+impl Default for Ui {
+ fn default() -> Self {
+ Self {
+ columns: Self::default_columns(),
+ }
+ }
+}
+
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Settings {
pub dialect: Dialect,
@@ -531,6 +709,9 @@ pub struct Settings {
pub theme: Theme,
#[serde(default)]
+ pub ui: Ui,
+
+ #[serde(default)]
pub scripts: scripts::Settings,
#[serde(default)]
@@ -905,6 +1086,9 @@ impl Settings {
settings.session_path = Self::expand_path(settings.session_path)?;
settings.daemon.socket_path = Self::expand_path(settings.daemon.socket_path)?;
+ // Validate UI settings
+ settings.ui.validate()?;
+
Ok(settings)
}
diff --git a/crates/atuin/src/command/client/search/history_list.rs b/crates/atuin/src/command/client/search/history_list.rs
index 47a7d9dc..7974fd0f 100644
--- a/crates/atuin/src/command/client/search/history_list.rs
+++ b/crates/atuin/src/command/client/search/history_list.rs
@@ -4,6 +4,7 @@ use super::duration::format_duration;
use super::engines::SearchEngine;
use atuin_client::{
history::History,
+ settings::{UiColumn, UiColumnType},
theme::{Meaning, Theme},
};
use atuin_common::utils::Escapable as _;
@@ -40,6 +41,8 @@ pub struct HistoryList<'a> {
theme: &'a Theme,
history_highlighter: HistoryHighlighter<'a>,
show_numeric_shortcuts: bool,
+ /// Columns to display (in order, after the indicator)
+ columns: &'a [UiColumn],
}
#[derive(Default)]
@@ -95,13 +98,11 @@ impl StatefulWidget for HistoryList<'_> {
theme: self.theme,
history_highlighter: self.history_highlighter,
show_numeric_shortcuts: self.show_numeric_shortcuts,
+ columns: self.columns,
};
for item in self.history.iter().skip(state.offset).take(end - start) {
- s.index();
- s.duration(item);
- s.time(item);
- s.command(item);
+ s.render_row(item);
// reset line
s.y += 1;
@@ -121,6 +122,7 @@ impl<'a> HistoryList<'a> {
theme: &'a Theme,
history_highlighter: HistoryHighlighter<'a>,
show_numeric_shortcuts: bool,
+ columns: &'a [UiColumn],
) -> Self {
Self {
history,
@@ -132,6 +134,7 @@ impl<'a> HistoryList<'a> {
theme,
history_highlighter,
show_numeric_shortcuts,
+ columns,
}
}
@@ -168,19 +171,57 @@ struct DrawState<'a> {
theme: &'a Theme,
history_highlighter: HistoryHighlighter<'a>,
show_numeric_shortcuts: bool,
+ columns: &'a [UiColumn],
}
-// longest line prefix I could come up with
+// Default prefix length for backwards compatibility (used by interactive.rs)
#[allow(clippy::cast_possible_truncation)] // we know that this is <65536 length
pub const PREFIX_LENGTH: u16 = " > 123ms 59s ago".len() as u16;
-static SPACES: &str = " ";
-static _ASSERT: () = assert!(SPACES.len() == PREFIX_LENGTH as usize);
// these encode the slices of `" > "`, `" {n} "`, or `" "` in a compact form.
// Yes, this is a hack, but it makes me feel happy
static SLICES: &str = " > 1 2 3 4 5 6 7 8 9 ";
impl DrawState<'_> {
+ /// Render a complete row for a history item based on configured columns.
+ fn render_row(&mut self, h: &History) {
+ // Always render the indicator first (width 3)
+ self.index();
+
+ // Calculate the width for the expanding column
+ // Fixed columns use their configured width + 1 (trailing space)
+ let indicator_width: u16 = 3;
+ let fixed_width: u16 = self
+ .columns
+ .iter()
+ .filter(|c| !c.expand)
+ .map(|c| c.width + 1)
+ .sum();
+ let expand_width = self
+ .list_area
+ .width
+ .saturating_sub(indicator_width + fixed_width);
+
+ // Render each configured column
+ for column in self.columns {
+ let width = if column.expand {
+ expand_width
+ } else {
+ column.width
+ };
+ match column.column_type {
+ UiColumnType::Duration => self.duration(h, width),
+ UiColumnType::Time => self.time(h, width),
+ UiColumnType::Datetime => self.datetime(h, width),
+ UiColumnType::Directory => self.directory(h, width),
+ UiColumnType::Host => self.host(h, width),
+ UiColumnType::User => self.user(h, width),
+ UiColumnType::Exit => self.exit_code(h, width),
+ UiColumnType::Command => self.command(h),
+ }
+ }
+ }
+
fn index(&mut self) {
if !self.show_numeric_shortcuts {
let i = self.y as usize + self.state.offset;
@@ -204,18 +245,21 @@ impl DrawState<'_> {
self.draw(prompt, Style::default());
}
- fn duration(&mut self, h: &History) {
- let status = self.theme.as_style(if h.success() {
+ fn duration(&mut self, h: &History, width: u16) {
+ let style = self.theme.as_style(if h.success() {
Meaning::AlertInfo
} else {
Meaning::AlertError
});
let duration = Duration::from_nanos(u64::try_from(h.duration).unwrap_or(0));
- self.draw(&format_duration(duration), status.into());
+ let formatted = format_duration(duration);
+ let w = width as usize;
+ // Right-align duration within its column width, plus trailing space
+ let display = format!("{formatted:>w$} ");
+ self.draw(&display, style.into());
}
- #[allow(clippy::cast_possible_truncation)] // we know that time.len() will be <6
- fn time(&mut self, h: &History) {
+ fn time(&mut self, h: &History, width: u16) {
let style = self.theme.as_style(Meaning::Guidance);
// Account for the chance that h.timestamp is "in the future"
@@ -226,14 +270,11 @@ impl DrawState<'_> {
let since = (self.now)() - h.timestamp;
let time = format_duration(since.try_into().unwrap_or_default());
- // pad the time a little bit before we write. this aligns things nicely
- // skip padding if for some reason it is already too long to align nicely
- let padding =
- usize::from(PREFIX_LENGTH).saturating_sub(usize::from(self.x) + 4 + time.len());
- self.draw(&SPACES[..padding], Style::default());
-
- self.draw(&time, style.into());
- self.draw(" ago", style.into());
+ // Format as "Xs ago" right-aligned within column width, plus trailing space
+ let w = width as usize;
+ let time_str = format!("{time} ago");
+ let display = format!("{time_str:>w$} ");
+ self.draw(&display, style.into());
}
fn command(&mut self, h: &History) {
@@ -281,6 +322,85 @@ impl DrawState<'_> {
}
}
+ /// Render the absolute datetime column (e.g., "2025-01-22 14:35")
+ fn datetime(&mut self, h: &History, width: u16) {
+ let style = self.theme.as_style(Meaning::Annotation);
+ // Format: YYYY-MM-DD HH:MM
+ let formatted = h
+ .timestamp
+ .format(
+ &time::format_description::parse("[year]-[month]-[day] [hour]:[minute]")
+ .expect("valid format"),
+ )
+ .unwrap_or_else(|_| "????-??-?? ??:??".to_string());
+ let w = width as usize;
+ let display = format!("{formatted:w$} ");
+ self.draw(&display, style.into());
+ }
+
+ /// Render the directory column (working directory, truncated)
+ fn directory(&mut self, h: &History, width: u16) {
+ let style = self.theme.as_style(Meaning::Annotation);
+ let w = width as usize;
+ let cwd = &h.cwd;
+ let char_count = cwd.chars().count();
+ // Truncate from the left with "..." if too long, plus trailing space
+ // Use character count for comparison and skip for UTF-8 safety
+ let display = if char_count > w && w >= 4 {
+ let truncated: String = cwd.chars().skip(char_count - (w - 3)).collect();
+ format!("...{truncated} ")
+ } else {
+ format!("{cwd:w$} ")
+ };
+ self.draw(&display, style.into());
+ }
+
+ /// Render the host column (just the hostname)
+ fn host(&mut self, h: &History, width: u16) {
+ let style = self.theme.as_style(Meaning::Annotation);
+ let w = width as usize;
+ // Database stores hostname as "hostname:username"
+ let host = h.hostname.split(':').next().unwrap_or(&h.hostname);
+ let char_count = host.chars().count();
+ // Use character count for comparison and take for UTF-8 safety
+ let display = if char_count > w && w >= 4 {
+ let truncated: String = host.chars().take(w.saturating_sub(4)).collect();
+ format!("{truncated}... ")
+ } else {
+ format!("{host:w$} ")
+ };
+ self.draw(&display, style.into());
+ }
+
+ /// Render the user column
+ fn user(&mut self, h: &History, width: u16) {
+ let style = self.theme.as_style(Meaning::Annotation);
+ let w = width as usize;
+ // Database stores hostname as "hostname:username"
+ let user = h.hostname.split(':').nth(1).unwrap_or("");
+ let char_count = user.chars().count();
+ // Use character count for comparison and take for UTF-8 safety
+ let display = if char_count > w && w >= 4 {
+ let truncated: String = user.chars().take(w.saturating_sub(4)).collect();
+ format!("{truncated}... ")
+ } else {
+ format!("{user:w$} ")
+ };
+ self.draw(&display, style.into());
+ }
+
+ /// Render the exit code column
+ fn exit_code(&mut self, h: &History, width: u16) {
+ let style = if h.success() {
+ self.theme.as_style(Meaning::AlertInfo)
+ } else {
+ self.theme.as_style(Meaning::AlertError)
+ };
+ let w = width as usize;
+ let display = format!("{:>w$} ", h.exit);
+ self.draw(&display, style.into());
+ }
+
fn draw(&mut self, s: &str, mut style: Style) {
let cx = self.list_area.left() + self.x;
diff --git a/crates/atuin/src/command/client/search/interactive.rs b/crates/atuin/src/command/client/search/interactive.rs
index fc6164d6..3a69b973 100644
--- a/crates/atuin/src/command/client/search/interactive.rs
+++ b/crates/atuin/src/command/client/search/interactive.rs
@@ -18,7 +18,9 @@ use super::{
use atuin_client::{
database::{Database, current_context},
history::{History, HistoryId, HistoryStats, store::HistoryStore},
- settings::{CursorStyle, ExitMode, KeymapMode, PreviewStrategy, SearchMode, Settings},
+ settings::{
+ CursorStyle, ExitMode, KeymapMode, PreviewStrategy, SearchMode, Settings, UiColumn,
+ },
};
use crate::command::client::search::history_list::HistoryHighlighter;
@@ -823,6 +825,7 @@ impl State {
theme,
history_highlighter,
settings.show_numeric_shortcuts,
+ &settings.ui.columns,
);
f.render_stateful_widget(results_list, results_list_chunk, &mut self.results_state);
}
@@ -989,6 +992,7 @@ impl State {
theme: &'a Theme,
history_highlighter: HistoryHighlighter<'a>,
show_numeric_shortcuts: bool,
+ columns: &'a [UiColumn],
) -> HistoryList<'a> {
let results_list = HistoryList::new(
results,
@@ -999,6 +1003,7 @@ impl State {
theme,
history_highlighter,
show_numeric_shortcuts,
+ columns,
);
match style.compactness {
diff --git a/docs/docs/configuration/config.md b/docs/docs/configuration/config.md
index 18fc86ee..f62c6160 100644
--- a/docs/docs/configuration/config.md
+++ b/docs/docs/configuration/config.md
@@ -758,3 +758,63 @@ need to be added in or changed in normal usage.
```toml
max_depth = 10
```
+
+## ui
+
+Atuin version: >= 18.5
+
+Configure the interactive search UI appearance.
+
+```toml
+[ui]
+columns = ["duration", "time", "command"]
+```
+
+### `columns`
+
+Default: `["duration", "time", "command"]`
+
+Columns to display in the interactive search, from left to right. The selection
+indicator (`" > "`) is always shown first implicitly.
+
+Each column can be specified as:
+- A simple string (uses default width): `"duration"`
+- An object with type and optional width/expand: `{ type = "directory", width = 30 }`
+
+#### Available column types
+
+| Column | Default Width | Description |
+| --------- | ------------- | ----------------------------------------------- |
+| duration | 5 | Command execution duration (e.g., "123ms") |
+| time | 8 | Relative time since execution (e.g., "59m ago") |
+| datetime | 16 | Absolute timestamp (e.g., "2025-01-22 14:35") |
+| directory | 20 | Working directory (truncated if too long) |
+| host | 15 | Hostname where command was run |
+| user | 10 | Username |
+| exit | 3 | Exit code (colored by success/failure) |
+| command | * | The command itself (expands by default) |
+
+#### Column options
+
+- **type**: The column type (required when using object format)
+- **width**: Custom width in characters (optional, uses default if not specified)
+- **expand**: If `true`, the column fills remaining space. Default is `true` for `command`, `false` for others. Only one column should have `expand = true`.
+
+#### Examples
+
+```toml
+# Minimal - more space for commands
+columns = ["duration", "command"]
+
+# With custom directory width
+columns = ["duration", { type = "directory", width = 30 }, "command"]
+
+# Show host for multi-machine sync users
+columns = ["duration", "time", "host", "command"]
+
+# Show exit codes prominently
+columns = ["exit", "duration", "command"]
+
+# Make directory expand instead of command
+columns = ["duration", "time", { type = "directory", expand = true }, { type = "command", expand = false }]
+```