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
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ AgentP is a single-binary Rust application for downloading and tagging podcast e
- `download.rs` — `download_selected_episodes` (async download + ID3 tagging); `sanitize_filename` shared `pub(crate)` helper
- `ui/` — rendering, split per screen:
- `theme.rs` — Dracula color palette
- `banner.rs` — the podcast-list banner: mascot and wordmark art, and `draw_banner`, which left-aligns them in a centered box and animates a one-shot typewriter reveal plus a periodic blink, both pure functions of `App::banner_started.elapsed()`
- `banner.rs` — the podcast-list banner: mascot art, both wordmarks, and `draw_banner`, which left-aligns them in a centered box and animates a one-shot typewriter reveal plus a periodic blink, both pure functions of `App::banner_started.elapsed()`. `banner_style` picks `WORDMARK_JOINED` (box-drawing strokes that meet across cell edges) or `WORDMARK_ASCII`; both place the letters in the same columns.
- `widgets.rs` — shared helpers (`styled_block`, `key_hint`, `hint_bar`, `render_centered_dialog`, `format_date`)
- `podcast_list.rs` — render podcast list screen
- `episode_select.rs` — render episode selection with checkboxes
Expand All @@ -60,7 +60,7 @@ AgentP is a single-binary Rust application for downloading and tagging podcast e
1. **PodcastList** — podcast list with latest-episode preview; `j/k` or arrows move, `Enter` opens episodes, `r` refresh feeds, `c` config, `e` edit selected podcast, `D` delete prompt (confirm with `y`/`D`, cancel `Esc`; `Enter` is excluded here because it opens a podcast on this screen and deletion is irreversible); `Home`/`g`, `End`/`G`, `PageUp`/`PageDown` jump and page
2. **EpisodeSelect** — checkboxes per episode, `Space` toggle, `a` all, `s` sort, `o` open podcast folder, `Enter` download (if any selected), `Esc` back
3. **Downloading** — progress gauge and log; `Esc` returns when finished or on error
4. **Config** — menu: add podcast, download folder, new-podcast defaults, edit podcasts list, open `config.json`, open `podcasts.json`, open download folder; dialog and directory text-edit modes; `Esc` back to podcast list in navigate mode; `r` on download-folder row restores default path
4. **Config** — menu: add podcast, download folder, default mode, banner style, new-podcast defaults, edit podcasts list, open `config.json`, open `podcasts.json`, open download folder; dialog and directory text-edit modes; `Esc` back to podcast list in navigate mode; `r` on download-folder row restores default path
5. **EditPodcastSelect** — pick a podcast; `Enter` opens editor, `Esc` to config
6. **EditPodcast** — field list for one podcast or template; text / bool / usize editing; `s` save, `r` restore field default, `R` reset all on the template, `Esc` discard in navigate mode; `Ctrl+C` exit with discard confirmation when dirty; `Ctrl+V` paste while editing text
7. **AddPodcast** — multi-step wizard (`WIZARD_STEPS` in `add_podcast_wizard.rs`); first step is Feed URL; pressing Enter on it shows a **mode-select overlay** (`1`/`m` Manual, `2`/`p` Prepopulate from feed); Prepopulate fetches channel metadata and pre-fills Name, Album Name (sanitized), and Artist; `Esc` cancels/returns at each stage; `Ctrl+V` on text steps
Expand All @@ -84,7 +84,7 @@ AgentP is a single-binary Rust application for downloading and tagging podcast e

**Data flow:**

1. `load_config()` reads from `<home>/.config/AgentP/` (via `dirs`); on Windows the same relative path is typically `%USERPROFILE%\.config\AgentP\`. `config.json` holds `download_dir_location`, `default_podcast`, and `default_mode`; `podcasts.json` holds the podcast list. Missing files are created (demo podcasts from `example.podcasts.json` when appropriate). `example.podcasts.json` in the repo is the template for the podcast list shape.
1. `load_config()` reads from `<home>/.config/AgentP/` (via `dirs`); on Windows the same relative path is typically `%USERPROFILE%\.config\AgentP\`. `config.json` holds `download_dir_location`, `default_podcast`, `default_mode`, and `banner_style`; `podcasts.json` holds the podcast list. Missing files are created (demo podcasts from `example.podcasts.json` when appropriate). `example.podcasts.json` in the repo is the template for the podcast list shape.
2. On TUI startup, `spawn_latest_fetches` runs `get_last_podcast_name` per podcast on `tokio::spawn`; results arrive on an unbounded `mpsc` channel consumed with `try_recv` in the main loop.
3. Choosing a podcast spawns `fetch_episode_list`; results arrive on a separate unbounded `mpsc` channel as `Ok(episodes)` / `Err`.
4. `download_selected_episodes()` writes under `<download_dir>/<effective_album_name>/` with optional ID3 tags; `effective_album_name()` falls back to podcast name when album_name is empty. Progress uses `DownloadEvent` on `mpsc`.
Expand Down
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ AgentP is a single-binary Rust application for downloading and tagging podcast e
- `download.rs` — `download_selected_episodes` (async download + ID3 tagging); `sanitize_filename` shared `pub(crate)` helper
- `ui/` — rendering, split per screen:
- `theme.rs` — Dracula color palette
- `banner.rs` — the podcast-list banner: mascot and wordmark art, and `draw_banner`, which left-aligns them in a centered box and animates a one-shot typewriter reveal plus a periodic blink, both pure functions of `App::banner_started.elapsed()`
- `banner.rs` — the podcast-list banner: mascot art, both wordmarks, and `draw_banner`, which left-aligns them in a centered box and animates a one-shot typewriter reveal plus a periodic blink, both pure functions of `App::banner_started.elapsed()`. `banner_style` picks `WORDMARK_JOINED` (box-drawing strokes that meet across cell edges) or `WORDMARK_ASCII`; both place the letters in the same columns.
- `widgets.rs` — shared helpers (`styled_block`, `key_hint`, `hint_bar`, `render_centered_dialog`, `format_date`)
- `podcast_list.rs` — render podcast list screen
- `episode_select.rs` — render episode selection with checkboxes
Expand All @@ -60,7 +60,7 @@ AgentP is a single-binary Rust application for downloading and tagging podcast e
1. **PodcastList** — podcast list with latest-episode preview; `j/k` or arrows move, `Enter` opens episodes, `r` refresh feeds, `c` config, `e` edit selected podcast, `D` delete prompt (confirm with `y`/`D`, cancel `Esc`; `Enter` is excluded here because it opens a podcast on this screen and deletion is irreversible); `Home`/`g`, `End`/`G`, `PageUp`/`PageDown` jump and page
2. **EpisodeSelect** — checkboxes per episode, `Space` toggle, `a` all, `s` sort, `o` open podcast folder, `Enter` download (if any selected), `Esc` back
3. **Downloading** — progress gauge and log; `Esc` returns when finished or on error
4. **Config** — menu: add podcast, download folder, new-podcast defaults, edit podcasts list, open `config.json`, open `podcasts.json`, open download folder; dialog and directory text-edit modes; `Esc` back to podcast list in navigate mode; `r` on download-folder row restores default path
4. **Config** — menu: add podcast, download folder, default mode, banner style, new-podcast defaults, edit podcasts list, open `config.json`, open `podcasts.json`, open download folder; dialog and directory text-edit modes; `Esc` back to podcast list in navigate mode; `r` on download-folder row restores default path
5. **EditPodcastSelect** — pick a podcast; `Enter` opens editor, `Esc` to config
6. **EditPodcast** — field list for one podcast or template; text / bool / usize editing; `s` save, `r` restore field default, `R` reset all on the template, `Esc` discard in navigate mode; `Ctrl+C` exit with discard confirmation when dirty; `Ctrl+V` paste while editing text
7. **AddPodcast** — multi-step wizard (`WIZARD_STEPS` in `add_podcast_wizard.rs`); first step is Feed URL; pressing Enter on it shows a **mode-select overlay** (`1`/`m` Manual, `2`/`p` Prepopulate from feed); Prepopulate fetches channel metadata and pre-fills Name, Album Name (sanitized), and Artist; `Esc` cancels/returns at each stage; `Ctrl+V` on text steps
Expand All @@ -84,7 +84,7 @@ AgentP is a single-binary Rust application for downloading and tagging podcast e

**Data flow:**

1. `load_config()` reads from `<home>/.config/AgentP/` (via `dirs`); on Windows the same relative path is typically `%USERPROFILE%\.config\AgentP\`. `config.json` holds `download_dir_location`, `default_podcast`, and `default_mode`; `podcasts.json` holds the podcast list. Missing files are created (demo podcasts from `example.podcasts.json` when appropriate). `example.podcasts.json` in the repo is the template for the podcast list shape.
1. `load_config()` reads from `<home>/.config/AgentP/` (via `dirs`); on Windows the same relative path is typically `%USERPROFILE%\.config\AgentP\`. `config.json` holds `download_dir_location`, `default_podcast`, `default_mode`, and `banner_style`; `podcasts.json` holds the podcast list. Missing files are created (demo podcasts from `example.podcasts.json` when appropriate). `example.podcasts.json` in the repo is the template for the podcast list shape.
2. On TUI startup, `spawn_latest_fetches` runs `get_last_podcast_name` per podcast on `tokio::spawn`; results arrive on an unbounded `mpsc` channel consumed with `try_recv` in the main loop.
3. Choosing a podcast spawns `fetch_episode_list`; results arrive on a separate unbounded `mpsc` channel as `Ok(episodes)` / `Err`.
4. `download_selected_episodes()` writes under `<download_dir>/<effective_album_name>/` with optional ID3 tags; `effective_album_name()` falls back to podcast name when album_name is empty. Progress uses `DownloadEvent` on `mpsc`.
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ Deleting is the one confirmation that does not take `Enter`. `Enter` opens a pod

### Config menu

The menu has eight rows: **Add New Podcast**, **Download Folder**, **Default Mode**, **New Podcast Defaults**, **Edit Existing Podcasts**, **Open Config File**, **Open Podcasts Config File**, and **Open Download Folder**.
The menu has nine rows: **Add New Podcast**, **Download Folder**, **Default Mode**, **Banner Style**, **New Podcast Defaults**, **Edit Existing Podcasts**, **Open Config File**, **Open Podcasts Config File**, and **Open Download Folder**.

| Key | Action |
|---|---|
Expand Down Expand Up @@ -248,6 +248,7 @@ agentp download --podcast 1 --all
agentp config show
agentp config set --download-dir ~/Podcasts
agentp config set --default-mode cli
agentp config set --banner-style ascii
agentp config path

agentp open downloads
Expand All @@ -261,9 +262,11 @@ agentp open podcast --podcast 1 --create

Config lives under `$HOME/.config/AgentP/` (or `%USERPROFILE%\.config\AgentP\` on Windows). `agentp config path` prints the directory.

- `config.json` — download folder, the `default_podcast` template, and `default_mode` (`tui` or `cli`)
- `config.json` — download folder, the `default_podcast` template, `default_mode` (`tui` or `cli`), and `banner_style` (`joined` or `ascii`)
- `podcasts.json` — your podcast list, as `{ "podcasts": [ ... ] }`

`banner_style` picks the TUI banner wordmark: `joined` (the default) draws the letters with box-drawing characters and needs a terminal that draws those itself, such as Windows Terminal or kitty; `ascii` is the plain fallback for terminals that leave gaps between the strokes, Warp among them.

### Per-podcast options (`podcasts.json`)

Each entry in `podcasts.json` has the same shape as `default_podcast` plus its identity fields. The **CLI flag** column applies to `podcast add` and `podcast edit`.
Expand Down
32 changes: 23 additions & 9 deletions src/app/config_screen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
use anyhow::Result;
use ratatui::widgets::ListState;

use crate::podcast::{DefaultMode, save_config};
use crate::podcast::{BannerStyle, DefaultMode, save_config};

use super::state::*;

Expand Down Expand Up @@ -45,15 +45,18 @@ impl App {
2 => {
let _ = self.config_toggle_default_mode();
}
3 => self.enter_template_editor(),
4 => self.enter_edit_podcast_select(),
5 => {
let _ = self.config_open_file();
3 => {
let _ = self.config_toggle_banner_style();
Comment thread
yoelrosenthal marked this conversation as resolved.
}
4 => self.enter_template_editor(),
5 => self.enter_edit_podcast_select(),
6 => {
let _ = self.config_open_file();
}
7 => {
let _ = self.config_open_podcasts_file();
}
7 => self.open_download_folder(),
8 => self.open_download_folder(),
_ => {}
}
}
Expand Down Expand Up @@ -87,13 +90,24 @@ impl App {
Ok(())
}

/// Both toggles put the old value back when the save fails, so the screen
/// never shows a setting that is not on disk.
pub fn config_toggle_default_mode(&mut self) -> Result<()> {
self.config.default_mode = match self.config.default_mode {
let previous = self.config.default_mode;
self.config.default_mode = match previous {
DefaultMode::Tui => DefaultMode::Cli,
DefaultMode::Cli => DefaultMode::Tui,
};
save_config(&self.config)?;
Ok(())
save_config(&self.config).inspect_err(|_| self.config.default_mode = previous)
Comment thread
yoelrosenthal marked this conversation as resolved.
}

pub fn config_toggle_banner_style(&mut self) -> Result<()> {
let previous = self.config.banner_style;
self.config.banner_style = match previous {
BannerStyle::Joined => BannerStyle::Ascii,
BannerStyle::Ascii => BannerStyle::Joined,
};
save_config(&self.config).inspect_err(|_| self.config.banner_style = previous)
}

pub fn config_confirm_dir_change(&mut self) -> Result<()> {
Expand Down
4 changes: 3 additions & 1 deletion src/app/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ pub fn apply_list_jump(state: &mut ListState, len: usize, floor: usize, code: Ke
}

/// Number of items in the config menu.
pub const CONFIG_MENU_ITEM_COUNT: usize = 8;
pub const CONFIG_MENU_ITEM_COUNT: usize = 9;
/// Total number of editable fields in the podcast editor.
pub const PODCAST_FIELD_COUNT: usize = 14;
/// Index of the first boolean field in the podcast editor.
Expand Down Expand Up @@ -465,6 +465,7 @@ pub(crate) fn test_app_one_podcast() -> App {
}],
default_podcast: Podcast::default(),
default_mode: crate::podcast::DefaultMode::default(),
banner_style: crate::podcast::BannerStyle::default(),
};
App::new(config, None)
}
Expand All @@ -491,6 +492,7 @@ mod tests {
],
default_podcast: Podcast::default(),
default_mode: crate::podcast::DefaultMode::default(),
banner_style: crate::podcast::BannerStyle::default(),
};
App::new(config, None)
}
Expand Down
8 changes: 7 additions & 1 deletion src/cli/args.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use clap::{ArgGroup, Args, Parser, Subcommand};

use crate::podcast::DefaultMode;
use crate::podcast::{BannerStyle, DefaultMode};

#[derive(Parser)]
#[command(
Expand Down Expand Up @@ -194,6 +194,12 @@ pub struct ConfigSetArgs {
pub download_dir: Option<String>,
#[arg(long, value_enum, help = "Set the default launch mode (tui or cli)")]
pub default_mode: Option<DefaultMode>,
#[arg(
long,
value_enum,
help = "Set the TUI banner wordmark (joined or ascii)"
)]
pub banner_style: Option<BannerStyle>,
}

#[derive(Subcommand)]
Expand Down
4 changes: 4 additions & 0 deletions src/cli/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@ fn run_config(command: ConfigCommand) -> Result<()> {
let (config, _) = load_config()?;
println!("download_dir: {}", config.download_dir_location);
println!("default_mode: {}", config.default_mode);
println!("banner_style: {}", config.banner_style);
println!("podcasts: {}", config.podcasts.len());
println!();
println!("Default podcast template:");
Expand Down Expand Up @@ -416,6 +417,9 @@ fn run_config(command: ConfigCommand) -> Result<()> {
if let Some(mode) = args.default_mode {
config.default_mode = mode;
}
if let Some(style) = args.banner_style {
config.banner_style = style;
}
save_config(&config)?;
println!("Config updated.");
Ok(())
Expand Down
25 changes: 24 additions & 1 deletion src/podcast/config_files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use std::io::BufReader;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

use super::data::{Config, DefaultMode, Podcast};
use super::data::{BannerStyle, Config, DefaultMode, Podcast};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub(super) struct ConfigFile {
Expand All @@ -16,6 +16,8 @@ pub(super) struct ConfigFile {
default_podcast: Podcast,
#[serde(default)]
default_mode: DefaultMode,
#[serde(default)]
banner_style: BannerStyle,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -71,6 +73,7 @@ pub fn save_config(config: &Config) -> Result<()> {
download_dir_location: config.download_dir_location.clone(),
default_podcast: config.default_podcast.clone(),
default_mode: config.default_mode,
banner_style: config.banner_style,
};
fs::write(
dir.join("config.json"),
Expand Down Expand Up @@ -103,6 +106,7 @@ pub fn load_config() -> Result<(Config, bool)> {
download_dir_location: default_download_dir_location(),
default_podcast: Podcast::default(),
default_mode: DefaultMode::default(),
banner_style: BannerStyle::default(),
};
fs::write(&cfg_path, serde_json::to_string_pretty(&config_file)?)?;
freshly_created = true;
Expand Down Expand Up @@ -132,6 +136,7 @@ pub fn load_config() -> Result<(Config, bool)> {
podcasts: pods.podcasts,
default_podcast: cfg.default_podcast,
default_mode: cfg.default_mode,
banner_style: cfg.banner_style,
};
config
.podcasts
Expand Down Expand Up @@ -166,6 +171,24 @@ mod tests {
);
}

#[test]
fn config_without_a_banner_style_keeps_loading_and_defaults_to_joined() {
let cfg: ConfigFile =
serde_json::from_str(r#"{"download_dir_location": "/tmp/pods"}"#).unwrap();
assert_eq!(cfg.banner_style, BannerStyle::Joined);
}

#[test]
fn banner_style_round_trips_through_json() {
let cfg: ConfigFile = serde_json::from_str(
r#"{"download_dir_location": "/tmp/pods", "banner_style": "ascii"}"#,
)
.unwrap();
assert_eq!(cfg.banner_style, BannerStyle::Ascii);
let text = serde_json::to_string(&cfg).unwrap();
assert!(text.contains("\"banner_style\":\"ascii\""), "{text}");
}

#[test]
fn default_download_dir_is_not_empty() {
let dir = default_download_dir_location();
Expand Down
24 changes: 24 additions & 0 deletions src/podcast/data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,28 @@ impl fmt::Display for DefaultMode {
}
}

/// Which wordmark the TUI banner draws.
///
/// `Joined` uses box-drawing characters whose strokes meet across cell edges,
/// which needs a terminal that draws those characters itself; `Ascii` is the
/// plain `/ \ | _` art that renders anywhere.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, clap::ValueEnum)]
#[serde(rename_all = "snake_case")]
pub enum BannerStyle {
#[default]
Joined,
Ascii,
}

impl fmt::Display for BannerStyle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
BannerStyle::Joined => write!(f, "joined"),
BannerStyle::Ascii => write!(f, "ascii"),
}
}
}

/// A single podcast with its feed URL, naming, and tag-override settings.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Podcast {
Expand Down Expand Up @@ -112,6 +134,8 @@ pub struct Config {
pub default_podcast: Podcast,
#[serde(default)]
pub default_mode: DefaultMode,
#[serde(default)]
pub banner_style: BannerStyle,
}

/// Metadata for a single episode parsed from an RSS feed.
Expand Down
Loading