diff options
| author | Ellie Huxtable <ellie@atuin.sh> | 2026-01-22 15:33:12 -0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-22 15:33:12 -0800 |
| commit | 8990f61820c5b1d688f2e01f9d06b3736556889e (patch) | |
| tree | 26d087b212b013cf7f8816de6860b68cd502f07e /crates | |
| parent | Update regex for AWS Access Key ID pattern (#3088) (diff) | |
| download | atuin-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 }]
```
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/atuin-client/config.toml | 41 | ||||
| -rw-r--r-- | crates/atuin-client/src/settings.rs | 184 | ||||
| -rw-r--r-- | crates/atuin/src/command/client/search/history_list.rs | 160 | ||||
| -rw-r--r-- | crates/atuin/src/command/client/search/interactive.rs | 7 |
4 files changed, 371 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 { |
