From 6936cd5fdae48a081a092160212e93a40624e74c Mon Sep 17 00:00:00 2001 From: Yoel Rosenthal Date: Thu, 17 Sep 2026 12:25:22 -0400 Subject: [PATCH 1/5] Fix Draw the banner wordmark in bold again Giving the wordmark its own purple style dropped the bold modifier the banner always had, so the letters rendered thin and faint. Restore it. Co-Authored-By: Claude Opus 5 (1M context) --- src/ui/banner.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/banner.rs b/src/ui/banner.rs index 4a625be..7d8ca34 100644 --- a/src/ui/banner.rs +++ b/src/ui/banner.rs @@ -64,7 +64,7 @@ fn prefix(row: &'static str, columns: usize) -> &'static str { fn banner_lines(elapsed: Duration) -> Vec> { let mic_style = Style::default().fg(ACCENT).add_modifier(Modifier::BOLD); - let text_style = Style::default().fg(TITLE_FG); + let text_style = Style::default().fg(TITLE_FG).add_modifier(Modifier::BOLD); let visible = revealed_columns(elapsed); let closed = eyes_closed(elapsed); From 97ffbfc7bd0597148462b056700389785eac52f3 Mon Sep 17 00:00:00 2001 From: Yoel Rosenthal Date: Thu, 17 Sep 2026 12:31:51 -0400 Subject: [PATCH 2/5] Fix Restore the banner's rightward lean The old banner centered rows of different widths, which left its bottom two rows one column left of the rest. That gave the mascot's stand and the wordmark a slight lean to the right, and the refactor that fixed the centering removed it. Keep the look on purpose: the top four rows get one leading space. Co-Authored-By: Claude Opus 5 (1M context) --- src/ui/banner.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/ui/banner.rs b/src/ui/banner.rs index 7d8ca34..bc9b60a 100644 --- a/src/ui/banner.rs +++ b/src/ui/banner.rs @@ -32,6 +32,10 @@ const WORDMARK: [&str; 6] = [ " |___/", ]; +/// Leading space per row: the top rows sit one column right of the bottom two, +/// so the mascot and the wordmark lean slightly to the right. +const LEAN: [&str; 6] = [" ", " ", " ", " ", "", ""]; + const EYES_ROW: usize = 1; const VERTICAL_PADDING: u16 = 1; const REVEAL_COLUMNS_PER_SECOND: u128 = 50; @@ -70,14 +74,19 @@ fn banner_lines(elapsed: Duration) -> Vec> { MIC.iter() .zip(WORDMARK) + .zip(LEAN) .enumerate() - .map(|(row, (&mic, text))| { + .map(|(row, ((&mic, text), lean))| { let mic = if closed && row == EYES_ROW { Span::styled(mic.replace('▀', "─"), mic_style) } else { Span::styled(mic, mic_style) }; - Line::from(vec![mic, Span::styled(prefix(text, visible), text_style)]) + Line::from(vec![ + Span::raw(lean), + mic, + Span::styled(prefix(text, visible), text_style), + ]) }) .collect() } @@ -87,7 +96,7 @@ fn banner_lines(elapsed: Duration) -> Vec> { /// The lines are left-aligned inside a box as wide as the finished art, so rows /// of different lengths and the growing reveal never shift horizontally. pub(super) fn draw_banner(frame: &mut Frame, area: Rect, elapsed: Duration) { - let width = (widest(&MIC) + widest(&WORDMARK)) as u16; + let width = (widest(&LEAN) + widest(&MIC) + widest(&WORDMARK)) as u16; let [area] = Layout::horizontal([Constraint::Length(width)]) .flex(Flex::Center) .areas(area); From ac10c0430aca6006c4fd69193391b1416ea2a15d Mon Sep 17 00:00:00 2001 From: Yoel Rosenthal Date: Thu, 17 Sep 2026 13:24:17 -0400 Subject: [PATCH 3/5] Update Draw the banner wordmark with joined line characters Keep today's letter shapes and spacing, but draw them with box-drawing diagonals, a centered vertical, and a lower one-eighth block, so the strokes of each letter meet instead of floating in separate cells. Shifting whole rows would pull the joined strokes apart, so the lean now lives in the mascot art alone, with the same offsets as before. Co-Authored-By: Claude Opus 5 (1M context) --- src/ui/banner.rs | 45 ++++++++++++++++++++------------------------- 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/src/ui/banner.rs b/src/ui/banner.rs index bc9b60a..1677d23 100644 --- a/src/ui/banner.rs +++ b/src/ui/banner.rs @@ -14,28 +14,28 @@ use ratatui::{ use super::theme::*; +/// The mascot. Its top rows sit one column right of the bottom two, so it +/// leans slightly to the right. const MIC: [&str; 6] = [ - " ▄██████▄ ", - " █ ▀ ▀ █ ", - " █ ▀▄▄▀ █ ", - " ▀██▄▄██▀ ", - " ██ ", - " ▄████▄ ", + " ▄██████▄ ", + " █ ▀ ▀ █ ", + " █ ▀▄▄▀ █ ", + " ▀██▄▄██▀ ", + " ██ ", + " ▄████▄ ", ]; +/// Today's letter shapes drawn with joined line characters, so the strokes of +/// each letter connect instead of floating inside separate cells. const WORDMARK: [&str; 6] = [ - " _ _ ____", - " / \\ __ _ ___ _ __ | |_| _ \\", - " / _ \\ / _` |/ _ \\ '_ \\| __| |_) |", - "/ ___ \\ (_| | __/ | | | |_| __/", - "/_/ \\_\\__, |\\___|_| |_|\\__|_|", - " |___/", + " ▁ ▁ ▁▁▁▁", + " ╱ ╲ ▁▁ ▁ ▁▁▁ ▁ ▁▁ │ │▁│ ▁ ╲", + " ╱ ▁ ╲ ╱ ▁` │╱ ▁ ╲ '▁ ╲│ ▁▁│ │▁) │", + " ╱ ▁▁▁ ╲ (▁│ │ ▁▁╱ │ │ │ │▁│ ▁▁╱", + "╱▁╱ ╲▁╲▁▁, │╲▁▁▁│▁│ │▁│╲▁▁│▁│", + " │▁▁▁╱", ]; -/// Leading space per row: the top rows sit one column right of the bottom two, -/// so the mascot and the wordmark lean slightly to the right. -const LEAN: [&str; 6] = [" ", " ", " ", " ", "", ""]; - const EYES_ROW: usize = 1; const VERTICAL_PADDING: u16 = 1; const REVEAL_COLUMNS_PER_SECOND: u128 = 50; @@ -74,19 +74,14 @@ fn banner_lines(elapsed: Duration) -> Vec> { MIC.iter() .zip(WORDMARK) - .zip(LEAN) .enumerate() - .map(|(row, ((&mic, text), lean))| { + .map(|(row, (&mic, text))| { let mic = if closed && row == EYES_ROW { Span::styled(mic.replace('▀', "─"), mic_style) } else { Span::styled(mic, mic_style) }; - Line::from(vec![ - Span::raw(lean), - mic, - Span::styled(prefix(text, visible), text_style), - ]) + Line::from(vec![mic, Span::styled(prefix(text, visible), text_style)]) }) .collect() } @@ -96,7 +91,7 @@ fn banner_lines(elapsed: Duration) -> Vec> { /// The lines are left-aligned inside a box as wide as the finished art, so rows /// of different lengths and the growing reveal never shift horizontally. pub(super) fn draw_banner(frame: &mut Frame, area: Rect, elapsed: Duration) { - let width = (widest(&LEAN) + widest(&MIC) + widest(&WORDMARK)) as u16; + let width = (widest(&MIC) + widest(&WORDMARK)) as u16; let [area] = Layout::horizontal([Constraint::Length(width)]) .flex(Flex::Center) .areas(area); @@ -120,7 +115,7 @@ mod tests { assert_eq!(prefix(WORDMARK[1], revealed_columns(Duration::ZERO)), ""); assert_eq!( prefix(WORDMARK[1], revealed_columns(Duration::from_millis(100))), - " / \\" + " ╱ " ); assert_eq!( prefix(WORDMARK[1], revealed_columns(Duration::from_secs(1))), From b7779423dc409522807fce39089419d8a3ca3933 Mon Sep 17 00:00:00 2001 From: Yoel Rosenthal Date: Thu, 17 Sep 2026 14:01:16 -0400 Subject: [PATCH 4/5] Add A banner_style setting to pick the wordmark The joined wordmark needs a terminal that draws box-drawing characters itself. Terminals that take them from the font, Warp and the classic Windows console among them, leave gaps between the strokes. Add `banner_style` to config.json, defaulting to `joined`, with `ascii` selecting the plain fallback. Both wordmarks place the letters in the same columns, so switching never moves the banner. The setting is reachable from `agentp config set --banner-style` and a new Banner Style row on the TUI config screen, and configs written before it keep loading. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 6 ++-- CLAUDE.md | 6 ++-- README.md | 11 +++++-- src/app/config_screen.rs | 24 ++++++++++---- src/app/state.rs | 4 ++- src/cli/args.rs | 8 ++++- src/cli/handlers.rs | 4 +++ src/podcast/config_files.rs | 25 +++++++++++++- src/podcast/data.rs | 24 ++++++++++++++ src/ui/banner.rs | 65 ++++++++++++++++++++++++++++--------- src/ui/config.rs | 15 +++++---- src/ui/episode_select.rs | 3 +- src/ui/podcast_list.rs | 10 ++++-- 13 files changed, 163 insertions(+), 42 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 64c4ad0..da54fd8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 plus 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()`. `WORDMARK_JOINED` draws the letters with box-drawing characters whose strokes meet across cell edges; `WORDMARK_ASCII` is the plain fallback, chosen by `banner_style` in `config.json`. Both arrays 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 @@ -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 @@ -84,7 +84,7 @@ AgentP is a single-binary Rust application for downloading and tagging podcast e **Data flow:** -1. `load_config()` reads from `/.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 `/.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 `//` with optional ID3 tags; `effective_album_name()` falls back to podcast name when album_name is empty. Progress uses `DownloadEvent` on `mpsc`. diff --git a/CLAUDE.md b/CLAUDE.md index 6e28e30..b118406 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 plus 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()`. `WORDMARK_JOINED` draws the letters with box-drawing characters whose strokes meet across cell edges; `WORDMARK_ASCII` is the plain fallback, chosen by `banner_style` in `config.json`. Both arrays 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 @@ -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 @@ -84,7 +84,7 @@ AgentP is a single-binary Rust application for downloading and tagging podcast e **Data flow:** -1. `load_config()` reads from `/.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 `/.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 `//` with optional ID3 tags; `effective_album_name()` falls back to podcast name when album_name is empty. Progress uses `DownloadEvent` on `mpsc`. diff --git a/README.md b/README.md index e0ca1d4..13c4365 100644 --- a/README.md +++ b/README.md @@ -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 | |---|---| @@ -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 @@ -261,9 +262,15 @@ 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` controls the wordmark in the TUI banner. The default, `joined`, draws the letters with box-drawing characters whose strokes meet, which needs a terminal that draws those characters itself, such as Windows Terminal, kitty, WezTerm, Alacritty, Ghostty, foot or the VS Code terminal. Terminals that take those characters from the font, Warp and the classic Windows console among them, leave gaps between the strokes. If the banner looks broken, switch it: + +```bash +agentp config set --banner-style ascii +``` + ### 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`. diff --git a/src/app/config_screen.rs b/src/app/config_screen.rs index 3f58ba3..b496958 100644 --- a/src/app/config_screen.rs +++ b/src/app/config_screen.rs @@ -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::*; @@ -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(); } + 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(), _ => {} } } @@ -96,6 +99,15 @@ impl App { Ok(()) } + pub fn config_toggle_banner_style(&mut self) -> Result<()> { + self.config.banner_style = match self.config.banner_style { + BannerStyle::Joined => BannerStyle::Ascii, + BannerStyle::Ascii => BannerStyle::Joined, + }; + save_config(&self.config)?; + Ok(()) + } + pub fn config_confirm_dir_change(&mut self) -> Result<()> { if let Some(new_dir) = self.config_editor.pending_dir_change.take() { self.config.download_dir_location = new_dir; diff --git a/src/app/state.rs b/src/app/state.rs index d944d8a..edb3ea5 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -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. @@ -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) } @@ -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) } diff --git a/src/cli/args.rs b/src/cli/args.rs index 4a11669..e604be8 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -1,6 +1,6 @@ use clap::{ArgGroup, Args, Parser, Subcommand}; -use crate::podcast::DefaultMode; +use crate::podcast::{BannerStyle, DefaultMode}; #[derive(Parser)] #[command( @@ -194,6 +194,12 @@ pub struct ConfigSetArgs { pub download_dir: Option, #[arg(long, value_enum, help = "Set the default launch mode (tui or cli)")] pub default_mode: Option, + #[arg( + long, + value_enum, + help = "Set the TUI banner wordmark (joined or ascii)" + )] + pub banner_style: Option, } #[derive(Subcommand)] diff --git a/src/cli/handlers.rs b/src/cli/handlers.rs index 839278f..7ce66ab 100644 --- a/src/cli/handlers.rs +++ b/src/cli/handlers.rs @@ -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:"); @@ -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(()) diff --git a/src/podcast/config_files.rs b/src/podcast/config_files.rs index 852d64e..baf4636 100644 --- a/src/podcast/config_files.rs +++ b/src/podcast/config_files.rs @@ -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 { @@ -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)] @@ -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"), @@ -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; @@ -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 @@ -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(); diff --git a/src/podcast/data.rs b/src/podcast/data.rs index 7d4ab79..f062334 100644 --- a/src/podcast/data.rs +++ b/src/podcast/data.rs @@ -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 { @@ -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. diff --git a/src/ui/banner.rs b/src/ui/banner.rs index 1677d23..473a88a 100644 --- a/src/ui/banner.rs +++ b/src/ui/banner.rs @@ -12,6 +12,8 @@ use ratatui::{ widgets::{Block, Padding, Paragraph}, }; +use crate::podcast::BannerStyle; + use super::theme::*; /// The mascot. Its top rows sit one column right of the bottom two, so it @@ -26,8 +28,9 @@ const MIC: [&str; 6] = [ ]; /// Today's letter shapes drawn with joined line characters, so the strokes of -/// each letter connect instead of floating inside separate cells. -const WORDMARK: [&str; 6] = [ +/// each letter connect instead of floating inside separate cells. Needs a +/// terminal that draws box-drawing characters itself. +const WORDMARK_JOINED: [&str; 6] = [ " ▁ ▁ ▁▁▁▁", " ╱ ╲ ▁▁ ▁ ▁▁▁ ▁ ▁▁ │ │▁│ ▁ ╲", " ╱ ▁ ╲ ╱ ▁` │╱ ▁ ╲ '▁ ╲│ ▁▁│ │▁) │", @@ -36,6 +39,24 @@ const WORDMARK: [&str; 6] = [ " │▁▁▁╱", ]; +/// The same letter shapes in plain ASCII, for terminals that draw box-drawing +/// characters from the font and leave gaps between the strokes. +const WORDMARK_ASCII: [&str; 6] = [ + " _ _ ____", + " / \\ __ _ ___ _ __ | |_| _ \\", + " / _ \\ / _` |/ _ \\ '_ \\| __| |_) |", + " / ___ \\ (_| | __/ | | | |_| __/", + "/_/ \\_\\__, |\\___|_| |_|\\__|_|", + " |___/", +]; + +fn wordmark(style: BannerStyle) -> &'static [&'static str; 6] { + match style { + BannerStyle::Joined => &WORDMARK_JOINED, + BannerStyle::Ascii => &WORDMARK_ASCII, + } +} + const EYES_ROW: usize = 1; const VERTICAL_PADDING: u16 = 1; const REVEAL_COLUMNS_PER_SECOND: u128 = 50; @@ -66,16 +87,16 @@ fn prefix(row: &'static str, columns: usize) -> &'static str { .map_or(row, |(byte, _)| &row[..byte]) } -fn banner_lines(elapsed: Duration) -> Vec> { +fn banner_lines(elapsed: Duration, style: BannerStyle) -> Vec> { let mic_style = Style::default().fg(ACCENT).add_modifier(Modifier::BOLD); let text_style = Style::default().fg(TITLE_FG).add_modifier(Modifier::BOLD); let visible = revealed_columns(elapsed); let closed = eyes_closed(elapsed); MIC.iter() - .zip(WORDMARK) + .zip(wordmark(style)) .enumerate() - .map(|(row, (&mic, text))| { + .map(|(row, (&mic, &text))| { let mic = if closed && row == EYES_ROW { Span::styled(mic.replace('▀', "─"), mic_style) } else { @@ -90,12 +111,12 @@ fn banner_lines(elapsed: Duration) -> Vec> { /// /// The lines are left-aligned inside a box as wide as the finished art, so rows /// of different lengths and the growing reveal never shift horizontally. -pub(super) fn draw_banner(frame: &mut Frame, area: Rect, elapsed: Duration) { - let width = (widest(&MIC) + widest(&WORDMARK)) as u16; +pub(super) fn draw_banner(frame: &mut Frame, area: Rect, elapsed: Duration, style: BannerStyle) { + let width = (widest(&MIC) + widest(wordmark(style))) as u16; let [area] = Layout::horizontal([Constraint::Length(width)]) .flex(Flex::Center) .areas(area); - let banner = Paragraph::new(banner_lines(elapsed)) + let banner = Paragraph::new(banner_lines(elapsed, style)) .block(Block::default().padding(Padding::vertical(VERTICAL_PADDING))); frame.render_widget(banner, area); } @@ -112,15 +133,27 @@ mod tests { #[test] fn reveal_types_the_wordmark_in_one_column_at_a_time() { - assert_eq!(prefix(WORDMARK[1], revealed_columns(Duration::ZERO)), ""); + let row = WORDMARK_JOINED[1]; + assert_eq!(prefix(row, revealed_columns(Duration::ZERO)), ""); assert_eq!( - prefix(WORDMARK[1], revealed_columns(Duration::from_millis(100))), + prefix(row, revealed_columns(Duration::from_millis(100))), " ╱ " ); - assert_eq!( - prefix(WORDMARK[1], revealed_columns(Duration::from_secs(1))), - WORDMARK[1] - ); + assert_eq!(prefix(row, revealed_columns(Duration::from_secs(1))), row); + } + + #[test] + fn both_wordmarks_place_the_letters_in_the_same_columns() { + for (joined, ascii) in WORDMARK_JOINED.iter().zip(WORDMARK_ASCII) { + assert_eq!(joined.chars().count(), ascii.chars().count()); + } + } + + #[test] + fn the_plain_wordmark_avoids_characters_that_need_a_drawing_terminal() { + for row in WORDMARK_ASCII { + assert!(row.is_ascii(), "{row} should be plain ASCII"); + } } #[test] @@ -134,8 +167,8 @@ mod tests { #[test] fn blinking_changes_only_the_eyes_row() { - let open = banner_lines(Duration::from_secs(1)); - let closed = banner_lines(Duration::from_millis(3900)); + let open = banner_lines(Duration::from_secs(1), BannerStyle::Joined); + let closed = banner_lines(Duration::from_millis(3900), BannerStyle::Joined); for row in 0..MIC.len() { assert_eq!(open[row] == closed[row], row != EYES_ROW); } diff --git a/src/ui/config.rs b/src/ui/config.rs index d824268..43d0568 100644 --- a/src/ui/config.rs +++ b/src/ui/config.rs @@ -51,10 +51,11 @@ pub(super) fn draw_config(frame: &mut Frame, app: &mut App) { String::new() }; - let menu_labels: [&str; 8] = [ + let menu_labels: [&str; 9] = [ " Add New Podcast ", " Download Folder ", " Default Mode: ", + " Banner Style: ", " New Podcast Defaults ", " Edit Existing Podcasts ", " Open Config File ", @@ -83,13 +84,15 @@ pub(super) fn draw_config(frame: &mut Frame, app: &mut App) { )); } ListItem::new(Line::from(spans)) - } else if fi == 2 { + } else if fi == 2 || fi == 3 { + let value = if fi == 2 { + app.config.default_mode.to_string() + } else { + app.config.banner_style.to_string() + }; ListItem::new(Line::from(vec![ Span::styled(label.to_string(), Style::default().fg(label_color)), - Span::styled( - app.config.default_mode.to_string(), - Style::default().fg(ORANGE), - ), + Span::styled(value, Style::default().fg(ORANGE)), Span::styled(" →".to_string(), Style::default().fg(arrow_color)), ])) } else { diff --git a/src/ui/episode_select.rs b/src/ui/episode_select.rs index 57fc926..16793c8 100644 --- a/src/ui/episode_select.rs +++ b/src/ui/episode_select.rs @@ -201,7 +201,7 @@ mod tests { use ratatui::{Terminal, backend::TestBackend}; use crate::app::Screen; - use crate::podcast::{Config, DefaultMode, EpisodeInfo, Podcast}; + use crate::podcast::{BannerStyle, Config, DefaultMode, EpisodeInfo, Podcast}; use super::*; @@ -218,6 +218,7 @@ mod tests { }], default_podcast: Podcast::default(), default_mode: DefaultMode::default(), + banner_style: BannerStyle::default(), }; let mut app = App::new(config, None); app.screen = Screen::EpisodeSelect; diff --git a/src/ui/podcast_list.rs b/src/ui/podcast_list.rs index efb9757..0499fd6 100644 --- a/src/ui/podcast_list.rs +++ b/src/ui/podcast_list.rs @@ -82,7 +82,12 @@ pub(super) fn draw_podcast_list(frame: &mut Frame, app: &mut App) { ]) .split(frame.area()); - draw_banner(frame, chunks[0], app.banner_started.elapsed()); + draw_banner( + frame, + chunks[0], + app.banner_started.elapsed(), + app.config.banner_style, + ); if let Some(notice) = &app.config_notice { let notice_widget = Paragraph::new(Line::from(vec![ @@ -207,7 +212,7 @@ pub(super) fn draw_podcast_list(frame: &mut Frame, app: &mut App) { mod tests { use ratatui::{Terminal, backend::TestBackend}; - use crate::podcast::{Config, DefaultMode, Podcast}; + use crate::podcast::{BannerStyle, Config, DefaultMode, Podcast}; use super::*; @@ -294,6 +299,7 @@ mod tests { .collect(), default_podcast: Podcast::default(), default_mode: DefaultMode::default(), + banner_style: BannerStyle::default(), }; let mut app = App::new(config, None); app.latest_episodes = podcasts From 767c8d92a6f8ec574c77d1f49b2b2ceeec4051df Mon Sep 17 00:00:00 2001 From: Yoel Rosenthal Date: Thu, 17 Sep 2026 14:09:32 -0400 Subject: [PATCH 5/5] Fix Put a toggled setting back when saving it fails The config screen flipped default_mode or banner_style in memory before saving, so a failed write left the screen showing a value that was not on disk and the change vanished on restart. Both toggles now restore the previous value when the save fails. Also trims the README note about banner_style to a sentence. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 2 +- CLAUDE.md | 2 +- README.md | 6 +----- src/app/config_screen.rs | 14 ++++++++------ 4 files changed, 11 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index da54fd8..e1fd6d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 art plus 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()`. `WORDMARK_JOINED` draws the letters with box-drawing characters whose strokes meet across cell edges; `WORDMARK_ASCII` is the plain fallback, chosen by `banner_style` in `config.json`. Both arrays place the letters in the same columns. + - `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 diff --git a/CLAUDE.md b/CLAUDE.md index b118406..c3f8402 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 art plus 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()`. `WORDMARK_JOINED` draws the letters with box-drawing characters whose strokes meet across cell edges; `WORDMARK_ASCII` is the plain fallback, chosen by `banner_style` in `config.json`. Both arrays place the letters in the same columns. + - `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 diff --git a/README.md b/README.md index 13c4365..b69eac3 100644 --- a/README.md +++ b/README.md @@ -265,11 +265,7 @@ Config lives under `$HOME/.config/AgentP/` (or `%USERPROFILE%\.config\AgentP\` o - `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` controls the wordmark in the TUI banner. The default, `joined`, draws the letters with box-drawing characters whose strokes meet, which needs a terminal that draws those characters itself, such as Windows Terminal, kitty, WezTerm, Alacritty, Ghostty, foot or the VS Code terminal. Terminals that take those characters from the font, Warp and the classic Windows console among them, leave gaps between the strokes. If the banner looks broken, switch it: - -```bash -agentp config set --banner-style ascii -``` +`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`) diff --git a/src/app/config_screen.rs b/src/app/config_screen.rs index b496958..b13c3a0 100644 --- a/src/app/config_screen.rs +++ b/src/app/config_screen.rs @@ -90,22 +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) } pub fn config_toggle_banner_style(&mut self) -> Result<()> { - self.config.banner_style = match self.config.banner_style { + let previous = self.config.banner_style; + self.config.banner_style = match previous { BannerStyle::Joined => BannerStyle::Ascii, BannerStyle::Ascii => BannerStyle::Joined, }; - save_config(&self.config)?; - Ok(()) + save_config(&self.config).inspect_err(|_| self.config.banner_style = previous) } pub fn config_confirm_dir_change(&mut self) -> Result<()> {