feat(shell): add fallback channel to the config for smart autocomplete

This commit is contained in:
Alexandre Pasmantier 2025-03-20 01:48:01 +01:00
parent 97314d629a
commit f78795cbb8
6 changed files with 115 additions and 10 deletions

View File

@ -109,6 +109,10 @@ toggle_preview = "ctrl-o"
# E.g. typing `git checkout <CTRL-T>` will open television with a list of # E.g. typing `git checkout <CTRL-T>` will open television with a list of
# branches to choose from. # branches to choose from.
[shell_integration]
# This specifies the default fallback channel if no other channel is matched.
fallback_channel = "files"
[shell_integration.channel_triggers] [shell_integration.channel_triggers]
# Add your channel triggers here. Each key is a channel that will be triggered # Add your channel triggers here. Each key is a channel that will be triggered
# by the corresponding commands. # by the corresponding commands.

View File

@ -136,6 +136,15 @@ pub enum ParsedCliChannel {
Cable(CableChannelPrototype), Cable(CableChannelPrototype),
} }
impl ParsedCliChannel {
pub fn name(&self) -> String {
match self {
Self::Builtin(c) => c.to_string(),
Self::Cable(c) => c.name.clone(),
}
}
}
const CLI_KEYBINDINGS_DELIMITER: char = ';'; const CLI_KEYBINDINGS_DELIMITER: char = ';';
/// Parse the keybindings string into a hashmap of key -> action. /// Parse the keybindings string into a hashmap of key -> action.
@ -154,7 +163,7 @@ fn parse_keybindings(cli_keybindings: &str) -> Result<KeyBindings> {
toml::from_str(&toml_definition).map_err(|e| anyhow!(e)) toml::from_str(&toml_definition).map_err(|e| anyhow!(e))
} }
fn parse_channel(channel: &str) -> Result<ParsedCliChannel> { pub fn parse_channel(channel: &str) -> Result<ParsedCliChannel> {
let cable_channels = cable::load_cable_channels().unwrap_or_default(); let cable_channels = cable::load_cable_channels().unwrap_or_default();
// try to parse the channel as a cable channel // try to parse the channel as a cable channel
cable_channels cable_channels
@ -165,7 +174,7 @@ fn parse_channel(channel: &str) -> Result<ParsedCliChannel> {
// try to parse the channel as a builtin channel // try to parse the channel as a builtin channel
CliTvChannel::try_from(channel) CliTvChannel::try_from(channel)
.map(ParsedCliChannel::Builtin) .map(ParsedCliChannel::Builtin)
.map_err(|_| anyhow!("Unknown channel: {}", channel)) .map_err(|_| anyhow!("Unknown channel: '{}'", channel))
}, },
|(_, v)| Ok(ParsedCliChannel::Cable(v.clone())), |(_, v)| Ok(ParsedCliChannel::Cable(v.clone())),
) )
@ -223,15 +232,13 @@ pub fn list_channels() {
pub fn guess_channel_from_prompt( pub fn guess_channel_from_prompt(
prompt: &str, prompt: &str,
command_mapping: &FxHashMap<String, String>, command_mapping: &FxHashMap<String, String>,
fallback_channel: ParsedCliChannel,
) -> Result<ParsedCliChannel> { ) -> Result<ParsedCliChannel> {
debug!("Guessing channel from prompt: {}", prompt); debug!("Guessing channel from prompt: {}", prompt);
// git checkout -qf // git checkout -qf
// --- -------- --- <--------- // --- -------- --- <---------
if prompt.trim().is_empty() { if prompt.trim().is_empty() {
return match command_mapping.get("") { return Ok(fallback_channel);
Some(channel) => parse_channel(channel),
None => Err(anyhow!("No channel found for prompt: {}", prompt)),
};
} }
let rev_prompt_words = prompt.split_whitespace().rev(); let rev_prompt_words = prompt.split_whitespace().rev();
let mut stack = Vec::new(); let mut stack = Vec::new();
@ -259,7 +266,9 @@ pub fn guess_channel_from_prompt(
// reset the stack // reset the stack
stack.clear(); stack.clear();
} }
Err(anyhow!("No channel found for prompt: {}", prompt))
debug!("No match found, falling back to default channel");
Ok(fallback_channel)
} }
const VERSION_MESSAGE: &str = env!("CARGO_PKG_VERSION"); const VERSION_MESSAGE: &str = env!("CARGO_PKG_VERSION");
@ -458,4 +467,61 @@ mod tests {
assert_eq!(post_processed_cli.keybindings, Some(expected)); assert_eq!(post_processed_cli.keybindings, Some(expected));
} }
fn guess_channel_from_prompt_setup(
) -> (FxHashMap<String, String>, ParsedCliChannel) {
let mut command_mapping = FxHashMap::default();
command_mapping.insert("vim".to_string(), "files".to_string());
command_mapping.insert("export".to_string(), "env".to_string());
(
command_mapping,
ParsedCliChannel::Builtin(CliTvChannel::Env),
)
}
#[test]
fn test_guess_channel_from_prompt_present() {
let prompt = "vim -d file1";
let (command_mapping, fallback) = guess_channel_from_prompt_setup();
let channel =
guess_channel_from_prompt(prompt, &command_mapping, fallback)
.unwrap();
assert_eq!(channel.name(), "files");
}
#[test]
fn test_guess_channel_from_prompt_fallback() {
let prompt = "git checkout ";
let (command_mapping, fallback) = guess_channel_from_prompt_setup();
let channel = guess_channel_from_prompt(
prompt,
&command_mapping,
fallback.clone(),
)
.unwrap();
assert_eq!(channel, fallback);
}
#[test]
fn test_guess_channel_from_prompt_empty() {
let prompt = "";
let (command_mapping, fallback) = guess_channel_from_prompt_setup();
let channel = guess_channel_from_prompt(
prompt,
&command_mapping,
fallback.clone(),
)
.unwrap();
assert_eq!(channel, fallback);
}
} }

View File

@ -157,6 +157,12 @@ impl Config {
mut default: Config, mut default: Config,
mut user: Config, mut user: Config,
) -> Config { ) -> Config {
// merge shell integration fallback channel with default fallback channel
if user.shell_integration.fallback_channel.is_empty() {
user.shell_integration
.fallback_channel
.clone_from(&default.shell_integration.fallback_channel);
}
// merge shell integration triggers with commands // merge shell integration triggers with commands
default.shell_integration.merge_triggers(); default.shell_integration.merge_triggers();
user.shell_integration.merge_triggers(); user.shell_integration.merge_triggers();

View File

@ -15,6 +15,7 @@ pub struct ShellIntegrationConfig {
pub commands: FxHashMap<String, String>, pub commands: FxHashMap<String, String>,
/// {channel: [commands]} /// {channel: [commands]}
pub channel_triggers: FxHashMap<String, Vec<String>>, pub channel_triggers: FxHashMap<String, Vec<String>>,
pub fallback_channel: String,
pub keybindings: FxHashMap<String, String>, pub keybindings: FxHashMap<String, String>,
} }

View File

@ -149,13 +149,28 @@ async fn poll_event(timeout: Duration) -> bool {
PollFuture { timeout }.await PollFuture { timeout }.await
} }
fn flush_existing_events() {
let mut counter = 0;
while let Ok(true) = crossterm::event::poll(Duration::from_millis(0)) {
if let Ok(crossterm::event::Event::Key(_)) = crossterm::event::read() {
counter += 1;
}
}
if counter > 0 {
debug!("Flushed {} existing events", counter);
}
}
impl EventLoop { impl EventLoop {
// FIXME: this init parameter doesn't seem to be used anymore
pub fn new(tick_rate: f64, init: bool) -> Self { pub fn new(tick_rate: f64, init: bool) -> Self {
let (tx, rx) = mpsc::unbounded_channel(); let (tx, rx) = mpsc::unbounded_channel();
let tick_interval = Duration::from_secs_f64(1.0 / tick_rate); let tick_interval = Duration::from_secs_f64(1.0 / tick_rate);
let (abort, mut abort_recv) = mpsc::unbounded_channel(); let (abort, mut abort_recv) = mpsc::unbounded_channel();
flush_existing_events();
if init { if init {
//let mut reader = crossterm::event::EventStream::new(); //let mut reader = crossterm::event::EventStream::new();
tokio::spawn(async move { tokio::spawn(async move {

View File

@ -6,6 +6,7 @@ use std::process::exit;
use anyhow::Result; use anyhow::Result;
use clap::Parser; use clap::Parser;
use television::channels::cable::PreviewKind; use television::channels::cable::PreviewKind;
use television::cli::parse_channel;
use television::utils::clipboard::CLIPBOARD; use television::utils::clipboard::CLIPBOARD;
use tracing::{debug, error, info}; use tracing::{debug, error, info};
@ -31,28 +32,37 @@ async fn main() -> Result<()> {
television::errors::init()?; television::errors::init()?;
television::logging::init()?; television::logging::init()?;
// post-process the CLI arguments debug!("\n\n==== NEW SESSION =====\n");
let args: PostProcessedCli = Cli::parse().into();
debug!("{:?}", args); // process the CLI arguments
let cli = Cli::parse();
debug!("CLI: {:?}", cli);
let args: PostProcessedCli = cli.into();
debug!("PostProcessedCli: {:?}", args);
// load the configuration file // load the configuration file
debug!("Loading configuration...");
let mut config = Config::new(&ConfigEnv::init()?)?; let mut config = Config::new(&ConfigEnv::init()?)?;
// optionally handle subcommands // optionally handle subcommands
debug!("Handling subcommands...");
args.command.as_ref().map(handle_subcommands); args.command.as_ref().map(handle_subcommands);
// optionally change the working directory // optionally change the working directory
args.working_directory.as_ref().map(set_current_dir); args.working_directory.as_ref().map(set_current_dir);
// optionally override configuration values with CLI arguments // optionally override configuration values with CLI arguments
debug!("Applying CLI overrides...");
apply_cli_overrides(&args, &mut config); apply_cli_overrides(&args, &mut config);
// determine the channel to use based on the CLI arguments and configuration // determine the channel to use based on the CLI arguments and configuration
debug!("Determining channel...");
let channel = let channel =
determine_channel(args.clone(), &config, is_readable_stdin())?; determine_channel(args.clone(), &config, is_readable_stdin())?;
CLIPBOARD.with(<_>::default); CLIPBOARD.with(<_>::default);
debug!("Creating application...");
let mut app = let mut app =
App::new(channel, config, &args.passthrough_keybindings, args.input); App::new(channel, config, &args.passthrough_keybindings, args.input);
stdout().flush()?; stdout().flush()?;
@ -141,9 +151,11 @@ pub fn determine_channel(
}, },
))) )))
} else if let Some(prompt) = args.autocomplete_prompt { } else if let Some(prompt) = args.autocomplete_prompt {
debug!("Using autocomplete prompt: {:?}", prompt);
let channel = guess_channel_from_prompt( let channel = guess_channel_from_prompt(
&prompt, &prompt,
&config.shell_integration.commands, &config.shell_integration.commands,
parse_channel(&config.shell_integration.fallback_channel)?,
)?; )?;
debug!("Using guessed channel: {:?}", channel); debug!("Using guessed channel: {:?}", channel);
match channel { match channel {
@ -217,6 +229,7 @@ mod tests {
let mut config = Config { let mut config = Config {
shell_integration: shell_integration:
television::config::shell_integration::ShellIntegrationConfig { television::config::shell_integration::ShellIntegrationConfig {
fallback_channel: "files".to_string(),
commands: FxHashMap::default(), commands: FxHashMap::default(),
channel_triggers: { channel_triggers: {
let mut m = FxHashMap::default(); let mut m = FxHashMap::default();