Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ exec $SHELL # reload your shell, or open a new terminal

## Quick start

- Press **`Ctrl-R`** to open the interactive search.
- Press **`Ctrl-R`** to open the interactive search (rebindable via `[shell] keybinding`).
- **Type** to fuzzy-filter across the command, project, directory, and tags.
- Press **`Esc`** for normal mode, then **`:`** to open the filter cmdline
(e.g. `project:thoth exit:fail`), or **`?`** for help.
Expand Down
30 changes: 30 additions & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ Press **`Ctrl-R`** (or run `tth`) to open the finder. As you type, the list filt
with fuzzy matching across the command, project, directory, and tags. A preview pane on the
right shows the full details of the highlighted entry.

The trigger key is configurable via `[shell] keybinding` (e.g. `"ctrl+t"`); set it to
`"none"` to bind the widget yourself. Re-run `tth init` after changing it.

#### Modes (vim-style)

The finder is modal. **Insert** is the default (type to filter); `Esc` drops you into
Expand Down Expand Up @@ -161,8 +164,35 @@ default_limit = 50

[theme]
name = "default"

[shell]
keybinding = "ctrl+r" # key that opens the finder; combo, or "none"
```

Read or change any setting from the CLI, e.g. `tth config set shell.keybinding "ctrl+t"`
(run `tth init` again afterwards so the shell hook picks up the new key).

#### Keybinding notation

`shell.keybinding` takes a kitty-style combo: modifiers joined to a key with `+`.

| Combination | Value |
| --------------------- | ----------------- |
| Ctrl-R (default) | `ctrl+r` |
| Ctrl-T | `ctrl+t` |
| Alt-X | `alt+x` |
| Ctrl-Alt-R | `ctrl+alt+r` |
| Ctrl-Shift-Left | `ctrl+shift+left` |
| (disabled) | `none` |

- **Modifiers**: `ctrl`, `alt` (aka `meta`/`option`), `shift`.
- **Keys**: a single letter, or a named navigation key: `left`, `right`, `up`, `down`,
`home`, `end`, `pageup`, `pagedown`, `insert`, `delete`.

Modified navigation keys are encoded as xterm CSI sequences (`ctrl+shift+left` becomes
`\e[1;6D`), which modern terminals emit. For anything not covered you can still pass a
raw escape sequence in caret notation (e.g. `^[[1;6D`); find a key's bytes with `cat -v`.

### History filter

`[history] filter` is a list of regular expressions; any command matching one is **never
Expand Down
2 changes: 1 addition & 1 deletion shells/thoth.bash
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ _tth_widget() {
fi
}

bind -x '"\C-r": _tth_widget'
bind -x '"__THOTH_KEYBIND__": _tth_widget'

tth-tag() {
eval "$(command tth tag "$1")"
Expand Down
2 changes: 1 addition & 1 deletion shells/thoth.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,4 @@ _tth_widget() {
}

zle -N _tth_widget
bindkey '^R' _tth_widget
bindkey '__THOTH_KEYBIND__' _tth_widget
6 changes: 5 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,11 @@ pub fn run() -> Result<(), crate::error::ThothError> {
Some(Cmd::Init(args)) => {
let shell_env = std::env::var("SHELL").ok();
let shell = crate::hooks::detect_shell(args.shell.as_deref(), shell_env.as_deref())?;
print!("{}", crate::hooks::render_init(&shell));
let cfg = crate::config::load();
print!(
"{}",
crate::hooks::render_init(&shell, &cfg.shell.keybinding)
);
}
Some(Cmd::Tag(args)) => {
if args.name.is_empty() {
Expand Down
80 changes: 76 additions & 4 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const VALID_KEYS: &[(&str, &str)] = &[
("tui.orientation", r#""bottom" or "top""#),
("search.default_limit", "positive integer"),
("theme.name", "theme name string"),
("shell.keybinding", "key combo (e.g. \"ctrl+r\") or \"none\""),
];

pub const DEFAULT_CONFIG_TOML: &str = r#"# Thoth configuration. All settings are optional; values shown are the defaults.
Expand Down Expand Up @@ -40,9 +41,20 @@ default_limit = 50
# filter = ["^\\s*tth\\b", "--password", "export .*TOKEN"]

# [theme]
# Built-in themes: default, ember, frost, latte, frappe, macchiato, mocha
# Built-in themes: default, ember, frost, latte, frappe, macchiato, mocha,
# dracula, tokyonight, rosepine, solarized, kanagawa
# You can also drop a <name>.toml file in ~/.config/thoth/themes/ for a custom theme.
# name = "default"

# [shell]
# Key that opens the interactive finder. Combine modifiers with "+", e.g.
# "ctrl+r" "ctrl+t" "alt+x" "ctrl+alt+r" "ctrl+shift+left"
# Modifiers: ctrl, alt, shift. Keys: a letter or a named nav key
# (left/right/up/down, home, end, pageup, pagedown, insert, delete).
# Raw caret notation (e.g. "^[[1;6D") also works for anything not covered.
# Set to "none" to skip binding a key and bind the widget yourself.
# Takes effect after re-running `tth init` (regenerate your shell hook).
# keybinding = "ctrl+r"
"#;

#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Default)]
Expand Down Expand Up @@ -153,6 +165,10 @@ fn default_theme_name() -> String {
"default".into()
}

fn default_keybinding() -> String {
"ctrl+r".into()
}

#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
#[serde(default)]
pub struct ThemeSection {
Expand All @@ -168,6 +184,21 @@ impl Default for ThemeSection {
}
}

#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
#[serde(default)]
pub struct ShellSection {
#[serde(default = "default_keybinding")]
pub keybinding: String,
}

impl Default for ShellSection {
fn default() -> Self {
Self {
keybinding: default_keybinding(),
}
}
}

#[derive(Deserialize, Serialize, Debug, Clone, Default, PartialEq)]
#[serde(default)]
pub struct Config {
Expand All @@ -176,6 +207,7 @@ pub struct Config {
pub search: Search,
pub history: History,
pub theme: ThemeSection,
pub shell: ShellSection,
}

fn config_path_from(thoth_config: Option<&str>, xdg_config: Option<&str>, home: &Path) -> PathBuf {
Expand Down Expand Up @@ -352,6 +384,8 @@ pub fn get_value(cfg: &Config, key: &str) -> Result<String, ThothError> {
Ok(v.to_string())
}
"search.default_limit" => Ok(cfg.search.default_limit.to_string()),
"theme.name" => Ok(cfg.theme.name.clone()),
"shell.keybinding" => Ok(cfg.shell.keybinding.clone()),
_ => Err(ThothError::Config(format!(
"unknown key '{}'; valid keys: {}",
key,
Expand Down Expand Up @@ -406,7 +440,7 @@ pub fn apply_set(existing_toml: &str, key: &str, value: &str) -> Result<String,
.into_value()
.map_err(|e| ThothError::Config(format!("toml_edit error: {}", e)))?
}
"theme.name" => toml_edit::value(value)
"theme.name" | "shell.keybinding" => toml_edit::value(value)
.into_value()
.map_err(|e| ThothError::Config(format!("toml_edit error: {}", e)))?,
_ => {
Expand Down Expand Up @@ -512,7 +546,7 @@ pub fn render_config(cfg: &Config, path: &Path, exists: bool, color: bool) -> St
"false".red().to_string()
};
format!(
"Config path: {}\nExists: {}\n{} gap_minutes = {}\n{} orientation = {}\n{} default_limit = {}\n",
"Config path: {}\nExists: {}\n{} gap_minutes = {}\n{} orientation = {}\n{} default_limit = {}\n{} name = {}\n{} keybinding = {}\n",
path.display().to_string().yellow(),
exists_str,
"[session]".cyan(),
Expand All @@ -521,16 +555,22 @@ pub fn render_config(cfg: &Config, path: &Path, exists: bool, color: bool) -> St
orientation.green(),
"[search]".cyan(),
cfg.search.default_limit.to_string().green(),
"[theme]".cyan(),
cfg.theme.name.clone().green(),
"[shell]".cyan(),
cfg.shell.keybinding.clone().green(),
)
} else {
let exists_str = if exists { "true" } else { "false" };
format!(
"Config path: {}\nExists: {}\n[session] gap_minutes = {}\n[tui] orientation = {}\n[search] default_limit = {}\n",
"Config path: {}\nExists: {}\n[session] gap_minutes = {}\n[tui] orientation = {}\n[search] default_limit = {}\n[theme] name = {}\n[shell] keybinding = {}\n",
path.display(),
exists_str,
cfg.session.gap_minutes,
orientation,
cfg.search.default_limit,
cfg.theme.name,
cfg.shell.keybinding,
)
}
}
Expand Down Expand Up @@ -611,6 +651,8 @@ mod tests {
assert!(out.contains("gap_minutes = 30"));
assert!(out.contains("orientation = bottom"));
assert!(out.contains("default_limit = 50"));
assert!(out.contains("[theme] name = default"));
assert!(out.contains("[shell] keybinding = ctrl+r"));
assert!(out.contains("false"));
}

Expand Down Expand Up @@ -703,6 +745,19 @@ mod tests {
assert!(get_value(&cfg, "foo.bar").is_err());
}

#[test]
fn get_value_shell_keybinding() {
let cfg = Config::default();
assert_eq!(get_value(&cfg, "shell.keybinding").unwrap(), "ctrl+r");
}

#[test]
fn apply_set_shell_keybinding() {
let result = apply_set("", "shell.keybinding", "^T").unwrap();
assert!(result.contains("[shell]"));
assert!(result.contains("keybinding = \"^T\""));
}

#[test]
fn apply_set_gap_minutes_creates_section() {
let result = apply_set("", "session.gap_minutes", "15").unwrap();
Expand Down Expand Up @@ -777,6 +832,23 @@ mod tests {
assert_eq!(ThemeSection::default().name, "default");
}

#[test]
fn shell_section_default_keybinding_is_ctrl_r() {
assert_eq!(ShellSection::default().keybinding, "ctrl+r");
}

#[test]
fn parse_empty_string_keybinding_is_default() {
let cfg = parse("").unwrap();
assert_eq!(cfg.shell.keybinding, "ctrl+r");
}

#[test]
fn parse_custom_keybinding() {
let cfg = parse("[shell]\nkeybinding = \"^T\"\n").unwrap();
assert_eq!(cfg.shell.keybinding, "^T");
}

#[test]
fn parse_empty_string_theme_name_is_default() {
let cfg = parse("").unwrap();
Expand Down
Loading