diff --git a/config.example.toml b/config.example.toml index 67c3030..66396a0 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,6 +1,13 @@ [defaults] profile = "git-bash" +[todos] +idle_seconds = 90 +prompts = [ + "Review the latest changes and summarize anything risky.", + "Run the fastest relevant validation and report failures.", +] + [profiles.codex-fast] command = "codex" args = ["--model", "gpt-5.4-mini"] diff --git a/docs/devlogs/2026-07-07-idle-terminal-todo-prompts.md b/docs/devlogs/2026-07-07-idle-terminal-todo-prompts.md new file mode 100644 index 0000000..cdbf392 --- /dev/null +++ b/docs/devlogs/2026-07-07-idle-terminal-todo-prompts.md @@ -0,0 +1,28 @@ +# Idle terminal todo prompts + +Date: 2026-07-07 +Release target: unreleased + +## Summary + +- Added settings-managed todo prompts that can be offered to idle terminals. + +## What Changed + +- Added persisted `[todos]` config support for enabling idle prompts, setting the quiet delay, and storing prompt text. +- Added a TODO section to the redesigned settings pane with add, edit, delete, enable, and quiet-delay controls. +- Added per-pane quiet tracking and a small follow-up dialog that can send, cycle, remove, or dismiss queued prompts. +- Updated the example config with sample todo prompts. + +## Why It Matters + +- Users can keep spare terminals productive by preparing follow-up prompts ahead of time, then sending one when a pane has gone quiet. + +## Validation + +- `cargo check` +- `npm test` + +## Release Notes + +- New settings todo list can suggest queued follow-up prompts for quiet panes. diff --git a/src/app.rs b/src/app.rs index 62e19bd..e2a808f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,6 +2,7 @@ use std::{ collections::{BTreeMap, BTreeSet}, env, io::{self, Stdout, Write}, + path::PathBuf, sync::mpsc as std_mpsc, time::{Duration, Instant}, }; @@ -40,16 +41,22 @@ pub type Tui = Terminal>; const INPUT_POLL_INTERVAL: Duration = Duration::from_millis(16); const MAX_PANE_NAME_CHARS: usize = 32; const CONVERSATION_SUMMARY_MAX_CHARS: usize = 120; +const TODO_INPUT_LIMIT: usize = 240; +const MIN_TODO_IDLE_SECONDS: u64 = 15; +const MAX_TODO_IDLE_SECONDS: u64 = 600; +const TODO_IDLE_STEP_SECONDS: u64 = 15; const ACTIVITY_DECAY_INTERVAL: Duration = Duration::from_millis(250); const OUTPUT_QUIET_AFTER: Duration = Duration::from_secs(3); pub struct App { config: Config, + config_path: Option, worktrees: Option, launch_plan: Option, layout: GridLayout, grid_area: Rect, panes: Vec, + pane_idle: Vec, focus: usize, selected: BTreeSet, pane_names: Vec>, @@ -62,6 +69,7 @@ pub struct App { image_overlay: Option, settings: SettingsState, rename: RenamePaneState, + follow_up: Option, status: String, next_pane_id: usize, event_tx: mpsc::UnboundedSender, @@ -332,10 +340,86 @@ impl GridPalette { #[derive(Debug, Clone)] pub struct SettingsRow { pub selected: bool, - pub label: &'static str, + pub group: SettingsGroup, + pub value_kind: SettingsValueKind, + pub editing: bool, + pub label: String, pub value: String, pub value_color: Option, - pub hint: &'static str, + pub hint: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsGroup { + Display, + Workflow, + Todo, + Performance, + Theme, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsValueKind { + Switch, + Stepper, + Choice, + Text, + Action, +} + +#[derive(Debug, Clone)] +pub struct FollowUpDialog { + pub pane_number: usize, + pub prompt: String, + pub todo_position: usize, + pub todo_count: usize, + pub quiet_seconds: u64, +} + +#[derive(Debug, Clone, Copy)] +struct PaneIdleState { + last_output_at: Instant, + snoozed_until: Option, +} + +#[derive(Debug, Clone, Copy)] +struct FollowUpPromptState { + pane_index: usize, + todo_index: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SettingsTarget { + CompactTitles, + ActivityBadges, + ConfirmQuit, + IdleFollowups, + IdleSeconds, + Todo(usize), + AddTodo, + PaneDensity, + Scrollback, + RefreshMs, + Palette(PaletteRole), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TodoEditState { + target: TodoEditTarget, + buffer: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TodoEditTarget { + Existing(usize), + New, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SettingsChange { + None, + Render, + SaveTodos, } #[derive(Debug, Clone)] @@ -432,6 +516,10 @@ struct SettingsState { compact_titles: bool, activity_badges: bool, confirm_quit: bool, + idle_followups: bool, + idle_seconds: u64, + todo_prompts: Vec, + todo_edit: Option, pane_density: i32, scrollback: i32, refresh_ms: i32, @@ -446,6 +534,10 @@ impl Default for SettingsState { compact_titles: false, activity_badges: true, confirm_quit: false, + idle_followups: true, + idle_seconds: crate::config::TodoSettings::default_idle_seconds(), + todo_prompts: Vec::new(), + todo_edit: None, pane_density: 2, scrollback: 10_000, refresh_ms: 16, @@ -455,143 +547,446 @@ impl Default for SettingsState { } impl SettingsState { - const BASE_ROW_COUNT: usize = 6; - const ROW_COUNT: usize = Self::BASE_ROW_COUNT + PaletteRole::ALL.len(); - - fn move_cursor(&mut self, delta: isize) { - let current = self.cursor as isize; - self.cursor = (current + delta).clamp(0, Self::ROW_COUNT as isize - 1) as usize; + fn from_config(config: &Config) -> Self { + Self { + idle_followups: config.todos.enabled, + idle_seconds: config + .todos + .idle_seconds + .clamp(MIN_TODO_IDLE_SECONDS, MAX_TODO_IDLE_SECONDS), + todo_prompts: config.todos.normalized_prompts(), + ..Self::default() + } } - fn activate(&mut self) { - if self.palette_role().is_some() { - self.adjust(1); + fn move_cursor(&mut self, delta: isize) { + self.cancel_todo_edit(); + let row_count = self.row_targets().len(); + if row_count == 0 { + self.cursor = 0; return; } - match self.cursor { - 0 => self.compact_titles = !self.compact_titles, - 1 => self.activity_badges = !self.activity_badges, - 2 => self.confirm_quit = !self.confirm_quit, - _ => self.adjust(1), - } + let current = self.cursor as isize; + self.cursor = (current + delta).clamp(0, row_count as isize - 1) as usize; } - fn adjust(&mut self, delta: i32) { - if let Some(role) = self.palette_role() { - self.palette.adjust(role, delta as isize); - return; + fn activate(&mut self) -> SettingsChange { + match self.selected_target() { + Some(SettingsTarget::CompactTitles) => { + self.compact_titles = !self.compact_titles; + SettingsChange::Render + } + Some(SettingsTarget::ActivityBadges) => { + self.activity_badges = !self.activity_badges; + SettingsChange::Render + } + Some(SettingsTarget::ConfirmQuit) => { + self.confirm_quit = !self.confirm_quit; + SettingsChange::Render + } + Some(SettingsTarget::IdleFollowups) => { + self.idle_followups = !self.idle_followups; + SettingsChange::SaveTodos + } + Some(SettingsTarget::Todo(index)) => { + self.start_todo_edit(TodoEditTarget::Existing(index)); + SettingsChange::Render + } + Some(SettingsTarget::AddTodo) => { + self.start_todo_edit(TodoEditTarget::New); + SettingsChange::Render + } + Some(_) => self.adjust(1), + None => SettingsChange::None, } + } - match self.cursor { - 0 => { + fn adjust(&mut self, delta: i32) -> SettingsChange { + self.cancel_todo_edit(); + match self.selected_target() { + Some(SettingsTarget::CompactTitles) => { if delta != 0 { self.compact_titles = !self.compact_titles; } + SettingsChange::Render } - 1 => { + Some(SettingsTarget::ActivityBadges) => { if delta != 0 { self.activity_badges = !self.activity_badges; } + SettingsChange::Render } - 2 => { + Some(SettingsTarget::ConfirmQuit) => { if delta != 0 { self.confirm_quit = !self.confirm_quit; } + SettingsChange::Render } - 3 => self.pane_density = (self.pane_density + delta).clamp(1, 5), - 4 => self.scrollback = (self.scrollback + delta * 1000).clamp(1_000, 50_000), - 5 => self.refresh_ms = (self.refresh_ms + delta * 4).clamp(8, 100), - _ => {} + Some(SettingsTarget::IdleFollowups) => { + if delta != 0 { + self.idle_followups = !self.idle_followups; + } + SettingsChange::SaveTodos + } + Some(SettingsTarget::IdleSeconds) => { + let step = (delta as i64) * (TODO_IDLE_STEP_SECONDS as i64); + let next = (self.idle_seconds as i64 + step) + .clamp(MIN_TODO_IDLE_SECONDS as i64, MAX_TODO_IDLE_SECONDS as i64) + as u64; + self.idle_seconds = next; + SettingsChange::SaveTodos + } + Some(SettingsTarget::PaneDensity) => { + self.pane_density = (self.pane_density + delta).clamp(1, 5); + SettingsChange::Render + } + Some(SettingsTarget::Scrollback) => { + self.scrollback = (self.scrollback + delta * 1000).clamp(1_000, 50_000); + SettingsChange::Render + } + Some(SettingsTarget::RefreshMs) => { + self.refresh_ms = (self.refresh_ms + delta * 4).clamp(8, 100); + SettingsChange::Render + } + Some(SettingsTarget::Palette(role)) => { + self.palette.adjust(role, delta as isize); + SettingsChange::Render + } + Some(SettingsTarget::Todo(index)) => { + self.start_todo_edit(TodoEditTarget::Existing(index)); + SettingsChange::Render + } + Some(SettingsTarget::AddTodo) => { + self.start_todo_edit(TodoEditTarget::New); + SettingsChange::Render + } + None => SettingsChange::None, } } - fn rows(&self) -> Vec { - let mut rows = vec![ - self.row( - 0, - "Compact pane titles", - switch_value(self.compact_titles), - None, - "shorter labels in pane chrome", - ), - self.row( - 1, - "Activity badges", - switch_value(self.activity_badges), - None, - "quiet markers", - ), - self.row( - 2, - "Confirm before quit", - switch_value(self.confirm_quit), - None, - "extra guard for Alt+q", - ), - self.row( - 3, - "Pane density", - self.pane_density.to_string(), - None, - "spacing scale from 1 to 5", - ), - self.row( - 4, - "Scrollback rows", - self.scrollback.to_string(), - None, - "history budget per pane", - ), - self.row( - 5, - "Refresh delay", - format!("{} ms", self.refresh_ms), - None, - "render loop throttle", - ), + fn delete_selected_todo(&mut self) -> SettingsChange { + self.cancel_todo_edit(); + if let Some(SettingsTarget::Todo(index)) = self.selected_target() + && index < self.todo_prompts.len() + { + self.todo_prompts.remove(index); + self.clamp_cursor(); + return SettingsChange::SaveTodos; + } + + SettingsChange::None + } + + fn editing_todo(&self) -> bool { + self.todo_edit.is_some() + } + + fn cancel_todo_edit(&mut self) { + self.todo_edit = None; + } + + fn insert_todo_text(&mut self, text: &str) -> bool { + let Some(edit) = self.todo_edit.as_mut() else { + return false; + }; + + let available = TODO_INPUT_LIMIT.saturating_sub(edit.buffer.len()); + if available == 0 { + return false; + } + + edit.buffer.extend(text.chars().take(available)); + true + } + + fn backspace_todo_text(&mut self) -> bool { + self.todo_edit + .as_mut() + .and_then(|edit| edit.buffer.pop()) + .is_some() + } + + fn commit_todo_edit(&mut self) -> bool { + let Some(edit) = self.todo_edit.take() else { + return false; + }; + + let prompt = edit.buffer.trim().to_string(); + match edit.target { + TodoEditTarget::Existing(index) if prompt.is_empty() => { + if index < self.todo_prompts.len() { + self.todo_prompts.remove(index); + self.clamp_cursor(); + return true; + } + } + TodoEditTarget::Existing(index) => { + if let Some(existing) = self.todo_prompts.get_mut(index) { + *existing = prompt; + return true; + } + } + TodoEditTarget::New if !prompt.is_empty() => { + self.todo_prompts.push(prompt); + self.cursor = self.row_targets().len().saturating_sub(1); + return true; + } + TodoEditTarget::New => {} + } + + false + } + + fn todo_settings(&self) -> crate::config::TodoSettings { + crate::config::TodoSettings { + enabled: self.idle_followups, + idle_seconds: self.idle_seconds, + prompts: self.todo_prompts.clone(), + } + } + + fn idle_delay(&self) -> Duration { + Duration::from_secs( + self.idle_seconds + .clamp(MIN_TODO_IDLE_SECONDS, MAX_TODO_IDLE_SECONDS), + ) + } + + fn selected_target(&self) -> Option { + self.row_targets().get(self.cursor).copied() + } + + fn row_targets(&self) -> Vec { + let mut targets = vec![ + SettingsTarget::CompactTitles, + SettingsTarget::ActivityBadges, + SettingsTarget::ConfirmQuit, + SettingsTarget::IdleFollowups, + SettingsTarget::IdleSeconds, ]; + targets.extend( + self.todo_prompts + .iter() + .enumerate() + .map(|(index, _)| SettingsTarget::Todo(index)), + ); + targets.extend([ + SettingsTarget::AddTodo, + SettingsTarget::PaneDensity, + SettingsTarget::Scrollback, + SettingsTarget::RefreshMs, + ]); + targets.extend( + PaletteRole::ALL + .iter() + .copied() + .map(SettingsTarget::Palette), + ); + targets + } + + fn clamp_cursor(&mut self) { + let last = self.row_targets().len().saturating_sub(1); + self.cursor = self.cursor.min(last); + } + fn start_todo_edit(&mut self, target: TodoEditTarget) { + let buffer = match target { + TodoEditTarget::Existing(index) => { + self.todo_prompts.get(index).cloned().unwrap_or_default() + } + TodoEditTarget::New => String::new(), + }; + self.todo_edit = Some(TodoEditState { target, buffer }); + } + + fn todo_edit_value(&self, target: SettingsTarget) -> Option { + let edit = self.todo_edit.as_ref()?; + let matches = match (target, edit.target) { + (SettingsTarget::Todo(row), TodoEditTarget::Existing(editing)) => row == editing, + (SettingsTarget::AddTodo, TodoEditTarget::New) => true, + _ => false, + }; + + matches.then(|| format!("{}_", edit.buffer)) + } + + fn is_editing_target(&self, target: SettingsTarget) -> bool { + self.todo_edit_value(target).is_some() + } + + fn rows(&self) -> Vec { + let mut rows = Vec::new(); + + rows.push(self.row( + SettingsTarget::CompactTitles, + SettingsGroup::Display, + SettingsValueKind::Switch, + "Compact pane titles", + switch_value(self.compact_titles), + "shorter labels in pane chrome", + )); + rows.push(self.row( + SettingsTarget::ActivityBadges, + SettingsGroup::Display, + SettingsValueKind::Switch, + "Activity badges", + switch_value(self.activity_badges), + "show live and selected state", + )); + rows.push(self.row( + SettingsTarget::ConfirmQuit, + SettingsGroup::Workflow, + SettingsValueKind::Switch, + "Confirm before quit", + switch_value(self.confirm_quit), + "extra guard for Alt+q", + )); + rows.push(self.row( + SettingsTarget::IdleFollowups, + SettingsGroup::Todo, + SettingsValueKind::Switch, + "Idle todo prompts", + switch_value(self.idle_followups), + "ask before sending queued follow-ups", + )); + rows.push(self.row( + SettingsTarget::IdleSeconds, + SettingsGroup::Todo, + SettingsValueKind::Stepper, + "Quiet delay", + format!("{} s", self.idle_seconds), + "time since last terminal output", + )); + + for (index, prompt) in self.todo_prompts.iter().enumerate() { + let target = SettingsTarget::Todo(index); + let value = self + .todo_edit_value(target) + .unwrap_or_else(|| prompt.to_string()); + let hint = if self.is_editing_target(target) { + "Enter save | Esc cancel" + } else { + "Enter edit | Del remove" + }; + rows.push(self.row( + target, + SettingsGroup::Todo, + SettingsValueKind::Text, + format!("Todo {}", index + 1), + value, + hint, + )); + } + + let add_target = SettingsTarget::AddTodo; + let add_value = self + .todo_edit_value(add_target) + .unwrap_or_else(|| "new prompt".to_string()); + let add_hint = if self.is_editing_target(add_target) { + "Enter save | Esc cancel" + } else { + "Enter add" + }; + rows.push(self.row( + add_target, + SettingsGroup::Todo, + if self.is_editing_target(add_target) { + SettingsValueKind::Text + } else { + SettingsValueKind::Action + }, + "Add todo", + add_value, + add_hint, + )); + + rows.push(self.row( + SettingsTarget::PaneDensity, + SettingsGroup::Performance, + SettingsValueKind::Stepper, + "Pane density", + self.pane_density.to_string(), + "spacing scale from 1 to 5", + )); + rows.push(self.row( + SettingsTarget::Scrollback, + SettingsGroup::Performance, + SettingsValueKind::Stepper, + "Scrollback rows", + self.scrollback.to_string(), + "history budget per pane", + )); + rows.push(self.row( + SettingsTarget::RefreshMs, + SettingsGroup::Performance, + SettingsValueKind::Stepper, + "Refresh delay", + format!("{} ms", self.refresh_ms), + "render loop throttle", + )); rows.extend( PaletteRole::ALL .iter() - .enumerate() - .map(|(offset, role)| self.palette_row(Self::BASE_ROW_COUNT + offset, *role)), + .copied() + .map(|role| self.palette_row(role)), ); rows } - fn palette_role(&self) -> Option { - self.cursor - .checked_sub(Self::BASE_ROW_COUNT) - .and_then(|index| PaletteRole::ALL.get(index).copied()) - } - - fn palette_row(&self, index: usize, role: PaletteRole) -> SettingsRow { + fn palette_row(&self, role: PaletteRole) -> SettingsRow { let color = self.palette.color_for(role); - self.row( - index, - role.label(), - color.name().to_string(), - Some(color.color()), - "-/+ color", - ) + let target = SettingsTarget::Palette(role); + SettingsRow { + selected: self.selected_target() == Some(target), + group: SettingsGroup::Theme, + value_kind: SettingsValueKind::Choice, + editing: false, + label: role.label().into(), + value: color.name().to_string(), + value_color: Some(color.color()), + hint: "-/+ color".into(), + } } fn row( &self, - index: usize, - label: &'static str, + target: SettingsTarget, + group: SettingsGroup, + value_kind: SettingsValueKind, + label: impl Into, value: String, - value_color: Option, - hint: &'static str, + hint: impl Into, ) -> SettingsRow { SettingsRow { - selected: self.cursor == index, - label, + selected: self.selected_target() == Some(target), + group, + value_kind, + editing: self.is_editing_target(target), + label: label.into(), value, - value_color, - hint, + value_color: None, + hint: hint.into(), + } + } +} + +impl SettingsChange { + fn render(self) -> bool { + !matches!(self, Self::None) + } + + fn save_todos(self) -> bool { + matches!(self, Self::SaveTodos) + } +} + +impl PaneIdleState { + fn new(now: Instant) -> Self { + Self { + last_output_at: now, + snoozed_until: None, } } } @@ -607,6 +1002,7 @@ impl App { .then(|| ManagedWorktreeOptions::new(cli.worktree_prefix.clone())) .transpose()?; let launch_plan = resolve_direct_launch_plan(&cli, &config, worktrees.as_ref())?; + let config_path = cli.config.clone(); let mouse_enabled = !cli.no_mouse; let grid = launch_plan .as_ref() @@ -639,14 +1035,17 @@ impl App { .as_ref() .map(|control| format!("agent API {} | {base_status}", control.endpoint())) .unwrap_or_else(|| base_status.into()); + let settings = SettingsState::from_config(&config); Ok(Self { config, + config_path, worktrees, launch_plan, layout: GridLayout::new(grid), grid_area: Rect::default(), panes: Vec::new(), + pane_idle: Vec::new(), focus: 0, selected: BTreeSet::new(), pane_names: Vec::new(), @@ -657,8 +1056,9 @@ impl App { control_handle, control_rx, image_overlay: None, - settings: SettingsState::default(), + settings, rename: RenamePaneState::default(), + follow_up: None, status, next_pane_id: 0, event_tx, @@ -709,6 +1109,8 @@ impl App { self.text_selection = None; self.sleeping.clear(); self.next_pane_id = 0; + self.pane_idle.clear(); + self.follow_up = None; for spec in &plan.panes { self.spawn_pane_spec(spec)?; @@ -749,6 +1151,7 @@ impl App { self.event_tx.clone(), )?; self.panes.push(pane); + self.pane_idle.push(PaneIdleState::new(Instant::now())); Ok(()) } @@ -776,6 +1179,7 @@ impl App { needs_render |= self.drain_usage_events(); needs_render |= self.drain_control_events(); needs_render |= self.decay_activity(); + needs_render |= self.update_follow_up_prompt(); if needs_render { terminal.draw(|frame| { @@ -802,12 +1206,20 @@ impl App { self.rename.insert_text(&text); needs_render = true; } - Event::Paste(text) if !self.settings.open => { + Event::Paste(text) if self.settings.editing_todo() => { + if self.settings.insert_todo_text(&text) { + needs_render = true; + } + } + Event::Paste(text) + if !self.settings.open && !self.rename.open && self.follow_up.is_none() => + { self.route_input(text.as_bytes())?; } Event::Mouse(mouse) if (self.mouse_enabled || !self.sleeping.is_empty()) - && !self.settings.open => + && !self.settings.open + && self.follow_up.is_none() => { needs_render |= self.handle_mouse(mouse, terminal)?; } @@ -848,31 +1260,48 @@ impl App { generation, bytes, } => { - if let Some(target) = self + if let Some((index, target)) = self .panes .iter_mut() - .find(|p| p.id() == pane && p.generation() == generation) + .enumerate() + .find(|(_, p)| p.id() == pane && p.generation() == generation) { target.process_output(&bytes); + self.mark_pane_touched(index); changed = true; } } PtyEvent::Exited { pane, generation } => { - if let Some(target) = self + if let Some((index, target)) = self .panes .iter_mut() - .find(|p| p.id() == pane && p.generation() == generation) + .enumerate() + .find(|(_, p)| p.id() == pane && p.generation() == generation) && !target.exited { target.exited = true; + if self + .follow_up + .is_some_and(|prompt| prompt.pane_index == index) + { + self.follow_up = None; + } changed = true; } } } } - for pane in &mut self.panes { - changed |= pane.poll_exit(); + for (index, pane) in self.panes.iter_mut().enumerate() { + if pane.poll_exit() { + if self + .follow_up + .is_some_and(|prompt| prompt.pane_index == index) + { + self.follow_up = None; + } + changed = true; + } } changed @@ -1063,6 +1492,11 @@ impl App { let selection_cleared = self.clear_text_selection(); + if self.follow_up.is_some() { + let outcome = self.handle_follow_up_key(key)?; + return Ok(render_if_selection_cleared(outcome, selection_cleared)); + } + if self.settings.open { let outcome = self.handle_settings_key(key)?; return Ok(render_if_selection_cleared(outcome, selection_cleared)); @@ -1306,12 +1740,18 @@ impl App { if first < self.pane_names.len() && second < self.pane_names.len() { self.pane_names.swap(first, second); } + if first < self.pane_idle.len() && second < self.pane_idle.len() { + self.pane_idle.swap(first, second); + } if let Some(selection) = self.text_selection { self.text_selection = Some(MouseSelection { pane: swapped_index(selection.pane, first, second), ..selection }); } + if let Some(prompt) = self.follow_up.as_mut() { + prompt.pane_index = swapped_index(prompt.pane_index, first, second); + } if let Some(plan) = self.launch_plan.as_mut() && first < plan.panes.len() && second < plan.panes.len() @@ -1327,6 +1767,11 @@ impl App { if key.modifiers.contains(KeyModifiers::ALT) && matches!(key.code, KeyCode::Char('q')) { return Ok(KeyOutcome::Quit); } + + if self.settings.editing_todo() { + return self.handle_todo_edit_key(key); + } + if key.modifiers.contains(KeyModifiers::ALT) && matches!(key.code, KeyCode::Char('o') | KeyCode::Char('O')) { @@ -1335,36 +1780,29 @@ impl App { return Ok(KeyOutcome::Render); } - let changed = match key.code { + let change = match key.code { KeyCode::Esc | KeyCode::Char('q') => { self.settings.open = false; self.status = "settings closed".into(); - true + SettingsChange::Render } KeyCode::Up => { self.settings.move_cursor(-1); - true + SettingsChange::Render } KeyCode::Down => { self.settings.move_cursor(1); - true + SettingsChange::Render } - KeyCode::Left | KeyCode::Char('-') => { - self.settings.adjust(-1); - true - } - KeyCode::Right | KeyCode::Char('+') | KeyCode::Char('=') => { - self.settings.adjust(1); - true - } - KeyCode::Enter | KeyCode::Char(' ') => { - self.settings.activate(); - true - } - _ => false, + KeyCode::Left | KeyCode::Char('-') => self.settings.adjust(-1), + KeyCode::Right | KeyCode::Char('+') | KeyCode::Char('=') => self.settings.adjust(1), + KeyCode::Enter | KeyCode::Char(' ') => self.settings.activate(), + KeyCode::Delete => self.settings.delete_selected_todo(), + _ => SettingsChange::None, }; - Ok(if changed { + self.apply_settings_change(change); + Ok(if change.render() { KeyOutcome::Render } else { KeyOutcome::Continue @@ -1386,6 +1824,7 @@ impl App { && self.sleeping.remove(&index) { self.focus = index; + self.mark_pane_touched(index); self.status = format!("woke pane {}", index + 1); return Ok(true); } @@ -1658,6 +2097,13 @@ impl App { self.focus = target_count.saturating_sub(1); } self.pane_names.truncate(target_count); + self.pane_idle.truncate(target_count); + if self + .follow_up + .is_some_and(|prompt| prompt.pane_index >= target_count) + { + self.follow_up = None; + } self.sleeping = self .sleeping .iter() @@ -1685,6 +2131,12 @@ impl App { self.sleeping.insert(*index); self.selected.remove(index); } + if self + .follow_up + .is_some_and(|prompt| targets.contains(&prompt.pane_index)) + { + self.follow_up = None; + } if self.sleeping.contains(&self.focus) && let Some(index) = self.next_awake_pane(self.focus) @@ -1694,6 +2146,7 @@ impl App { } else { for index in &targets { self.sleeping.remove(index); + self.mark_pane_touched(*index); } self.focus = targets[0]; } @@ -1702,6 +2155,227 @@ impl App { self.status = format!("{} {} {}", action, targets.len(), pane_word(targets.len())); } + fn handle_todo_edit_key(&mut self, key: KeyEvent) -> Result { + if key.modifiers.contains(KeyModifiers::ALT) + && matches!(key.code, KeyCode::Char('o') | KeyCode::Char('O')) + { + self.settings.cancel_todo_edit(); + self.settings.open = false; + self.status = "settings closed".into(); + return Ok(KeyOutcome::Render); + } + + match key.code { + KeyCode::Esc => { + self.settings.cancel_todo_edit(); + self.status = "todo edit cancelled".into(); + Ok(KeyOutcome::Render) + } + KeyCode::Enter => { + let saved = self.settings.commit_todo_edit(); + if saved { + if self.save_todo_settings() { + self.status = "todo prompt saved".into(); + } + } else { + self.status = "empty todo ignored".into(); + } + Ok(KeyOutcome::Render) + } + KeyCode::Backspace => Ok(if self.settings.backspace_todo_text() { + KeyOutcome::Render + } else { + KeyOutcome::Continue + }), + KeyCode::Char(ch) + if !key.modifiers.contains(KeyModifiers::CONTROL) + && !key.modifiers.contains(KeyModifiers::ALT) => + { + let mut buffer = [0; 4]; + let changed = self.settings.insert_todo_text(ch.encode_utf8(&mut buffer)); + Ok(if changed { + KeyOutcome::Render + } else { + KeyOutcome::Continue + }) + } + _ => Ok(KeyOutcome::Continue), + } + } + + fn apply_settings_change(&mut self, change: SettingsChange) { + if change.save_todos() { + self.save_todo_settings(); + } + } + + fn save_todo_settings(&mut self) -> bool { + self.config.todos = self.settings.todo_settings(); + match self.config.save(self.config_path.as_deref()) { + Ok(_) => true, + Err(error) => { + self.status = format!("failed to save todos: {error:#}"); + false + } + } + } + + fn update_follow_up_prompt(&mut self) -> bool { + if self.follow_up.is_some() + || self.settings.open + || !self.settings.idle_followups + || self.settings.todo_prompts.is_empty() + { + return false; + } + + let now = Instant::now(); + let idle_delay = self.settings.idle_delay(); + for (index, pane) in self.panes.iter().enumerate() { + if pane.exited || self.sleeping.contains(&index) { + continue; + } + + let Some(idle) = self.pane_idle.get(index) else { + continue; + }; + if idle.snoozed_until.is_some_and(|until| now < until) { + continue; + } + if now.duration_since(idle.last_output_at) < idle_delay { + continue; + } + + self.follow_up = Some(FollowUpPromptState { + pane_index: index, + todo_index: 0, + }); + self.status = format!("pane {} is quiet; todo follow-up ready", index + 1); + return true; + } + + false + } + + fn handle_follow_up_key(&mut self, key: KeyEvent) -> Result { + if key.modifiers.contains(KeyModifiers::ALT) && matches!(key.code, KeyCode::Char('q')) { + return Ok(KeyOutcome::Quit); + } + + match key.code { + KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => { + self.send_follow_up_prompt()?; + Ok(KeyOutcome::Render) + } + KeyCode::Tab | KeyCode::Right | KeyCode::Char(']') => { + self.cycle_follow_up_prompt(1); + Ok(KeyOutcome::Render) + } + KeyCode::BackTab | KeyCode::Left | KeyCode::Char('[') => { + self.cycle_follow_up_prompt(-1); + Ok(KeyOutcome::Render) + } + KeyCode::Delete => { + self.delete_follow_up_prompt(); + Ok(KeyOutcome::Render) + } + KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => { + self.dismiss_follow_up_prompt(); + Ok(KeyOutcome::Render) + } + _ => Ok(KeyOutcome::Continue), + } + } + + fn send_follow_up_prompt(&mut self) -> Result<()> { + let Some(dialog) = self.follow_up.take() else { + return Ok(()); + }; + let Some(prompt) = self.settings.todo_prompts.get(dialog.todo_index).cloned() else { + self.status = "todo prompt no longer exists".into(); + return Ok(()); + }; + + let mut bytes = prompt.into_bytes(); + bytes.push(b'\r'); + self.panes + .get(dialog.pane_index) + .ok_or_else(|| anyhow!("invalid pane index {}", dialog.pane_index))? + .write(&bytes)?; + self.mark_pane_touched(dialog.pane_index); + + if dialog.todo_index < self.settings.todo_prompts.len() { + self.settings.todo_prompts.remove(dialog.todo_index); + self.settings.clamp_cursor(); + if self.save_todo_settings() { + self.status = format!("sent todo follow-up to pane {}", dialog.pane_index + 1); + } + } else { + self.status = format!("sent todo follow-up to pane {}", dialog.pane_index + 1); + } + + Ok(()) + } + + fn dismiss_follow_up_prompt(&mut self) { + let Some(dialog) = self.follow_up.take() else { + return; + }; + let delay = self.settings.idle_delay(); + if let Some(idle) = self.pane_idle.get_mut(dialog.pane_index) { + idle.snoozed_until = Some(Instant::now() + delay); + } + self.status = format!("todo follow-up snoozed for pane {}", dialog.pane_index + 1); + } + + fn delete_follow_up_prompt(&mut self) { + let Some(dialog) = self.follow_up.take() else { + return; + }; + if dialog.todo_index < self.settings.todo_prompts.len() { + self.settings.todo_prompts.remove(dialog.todo_index); + self.settings.clamp_cursor(); + if self.save_todo_settings() { + self.status = "todo prompt removed".into(); + } + } + if let Some(idle) = self.pane_idle.get_mut(dialog.pane_index) { + idle.snoozed_until = Some(Instant::now() + self.settings.idle_delay()); + } + } + + fn cycle_follow_up_prompt(&mut self, delta: isize) { + let count = self.settings.todo_prompts.len(); + let Some(dialog) = self.follow_up.as_mut() else { + return; + }; + if count <= 1 { + return; + } + + dialog.todo_index = + (dialog.todo_index as isize + delta).rem_euclid(count as isize) as usize; + self.status = format!( + "todo follow-up {}/{} for pane {}", + dialog.todo_index + 1, + count, + dialog.pane_index + 1 + ); + } + + fn mark_pane_touched(&mut self, index: usize) { + if let Some(idle) = self.pane_idle.get_mut(index) { + idle.last_output_at = Instant::now(); + idle.snoozed_until = None; + } + if self + .follow_up + .is_some_and(|prompt| prompt.pane_index == index) + { + self.follow_up = None; + } + } + fn route_input(&mut self, bytes: &[u8]) -> Result<()> { let targets = self.input_targets(); for index in targets { @@ -1709,6 +2383,7 @@ impl App { .get(index) .ok_or_else(|| anyhow!("invalid pane index {index}"))? .write(bytes)?; + self.mark_pane_touched(index); } Ok(()) } @@ -1843,6 +2518,24 @@ impl App { }) } + pub fn follow_up_dialog(&self) -> Option { + let prompt = self.follow_up.as_ref()?; + let todo = self.settings.todo_prompts.get(prompt.todo_index)?; + let quiet_seconds = self + .pane_idle + .get(prompt.pane_index) + .map(|idle| idle.last_output_at.elapsed().as_secs()) + .unwrap_or_default(); + + Some(FollowUpDialog { + pane_number: prompt.pane_index + 1, + prompt: todo.clone(), + todo_position: prompt.todo_index + 1, + todo_count: self.settings.todo_prompts.len(), + quiet_seconds, + }) + } + pub fn pane_folder(&self, index: usize) -> Option<&str> { self.launch_plan .as_ref() @@ -2379,16 +3072,17 @@ mod tests { fn settings_rows_include_live_grid_palette_roles() { let settings = SettingsState::default(); let rows = settings.rows(); + let palette_start = rows + .iter() + .position(|row| row.label == "Accent color") + .expect("accent palette row"); - assert_eq!(rows.len(), SettingsState::ROW_COUNT); + assert_eq!(rows.len(), settings.row_targets().len()); assert_eq!(rows[1].label, "Activity badges"); assert_eq!(rows[1].value, "on"); - assert_eq!(rows[SettingsState::BASE_ROW_COUNT].label, "Accent color"); - assert_eq!( - rows[SettingsState::BASE_ROW_COUNT + 3].label, - "Quiet border" - ); - assert_eq!(rows[SettingsState::BASE_ROW_COUNT + 3].value, "magenta"); + assert_eq!(rows[palette_start].label, "Accent color"); + assert_eq!(rows[palette_start + 3].label, "Quiet border"); + assert_eq!(rows[palette_start + 3].value, "magenta"); } } diff --git a/src/config.rs b/src/config.rs index b3d402b..ab5106d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,6 +14,8 @@ use crate::profiles::Profile; pub struct Config { #[serde(default, skip_serializing_if = "Defaults::is_empty")] pub defaults: Defaults, + #[serde(default, skip_serializing_if = "TodoSettings::is_empty")] + pub todos: TodoSettings, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub profiles: BTreeMap, } @@ -24,6 +26,32 @@ pub struct Defaults { pub profile: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TodoSettings { + #[serde( + default = "TodoSettings::default_enabled", + skip_serializing_if = "TodoSettings::is_enabled" + )] + pub enabled: bool, + #[serde( + default = "TodoSettings::default_idle_seconds", + skip_serializing_if = "TodoSettings::is_default_idle_seconds" + )] + pub idle_seconds: u64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub prompts: Vec, +} + +impl Default for TodoSettings { + fn default() -> Self { + Self { + enabled: Self::default_enabled(), + idle_seconds: Self::default_idle_seconds(), + prompts: Vec::new(), + } + } +} + impl Config { pub fn load(path: Option<&Path>) -> Result { let default_path = Self::default_path(); @@ -75,6 +103,39 @@ impl Defaults { } } +impl TodoSettings { + pub fn default_enabled() -> bool { + true + } + + pub fn default_idle_seconds() -> u64 { + 90 + } + + pub fn normalized_prompts(&self) -> Vec { + self.prompts + .iter() + .map(|prompt| prompt.trim()) + .filter(|prompt| !prompt.is_empty()) + .map(str::to_string) + .collect() + } + + fn is_empty(&self) -> bool { + self.enabled == Self::default_enabled() + && self.idle_seconds == Self::default_idle_seconds() + && self.prompts.is_empty() + } + + fn is_enabled(value: &bool) -> bool { + *value + } + + fn is_default_idle_seconds(value: &u64) -> bool { + *value == Self::default_idle_seconds() + } +} + #[cfg(test)] mod tests { use super::*; @@ -112,4 +173,24 @@ mod tests { assert_eq!(config.defaults.profile.as_deref(), Some("powershell")); assert!(config.profiles.is_empty()); } + + #[test] + fn parses_todo_settings() { + let config: Config = toml::from_str( + r#" + [todos] + enabled = false + idle_seconds = 45 + prompts = ["Review the diff", "Run tests"] + "#, + ) + .expect("parse config"); + + assert!(!config.todos.enabled); + assert_eq!(config.todos.idle_seconds, 45); + assert_eq!( + config.todos.normalized_prompts(), + vec!["Review the diff".to_string(), "Run tests".to_string()] + ); + } } diff --git a/src/ui.rs b/src/ui.rs index 705b644..a8e0fa6 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -9,7 +9,10 @@ use std::path::Path; use vt100::Cell; use crate::{ - app::{App, GridPalette, PaneSelection, RenamePaneView, SettingsRow}, + app::{ + App, FollowUpDialog, GridPalette, PaneSelection, RenamePaneView, SettingsGroup, + SettingsRow, SettingsValueKind, + }, image_preview::ImagePreview, }; @@ -41,8 +44,12 @@ pub fn draw(frame: &mut Frame<'_>, app: &App) -> DrawState { let rects = app.pane_rects(grid_area); let palette = app.palette(); let rename_view = app.rename_pane_view(); + let follow_up_dialog = app.follow_up_dialog(); let image_overlay = app.image_overlay_view(); - let modal_open = app.settings_open() || rename_view.is_some() || image_overlay.is_some(); + let modal_open = app.settings_open() + || rename_view.is_some() + || follow_up_dialog.is_some() + || image_overlay.is_some(); for (index, pane) in app.panes().iter().enumerate() { let Some(rect) = rects.get(index).copied() else { @@ -142,6 +149,8 @@ pub fn draw(frame: &mut Frame<'_>, app: &App) -> DrawState { if app.settings_open() { render_settings(frame, area, &app.settings_rows(), palette); + } else if let Some(dialog) = follow_up_dialog.as_ref() { + render_follow_up_dialog(frame, area, dialog); } if let Some(rename) = rename_view.as_ref() { render_rename_pane(frame, area, rename); @@ -345,6 +354,37 @@ fn render_rename_pane(frame: &mut Frame<'_>, area: Rect, rename: &RenamePaneView } } +fn render_follow_up_dialog(frame: &mut Frame<'_>, area: Rect, dialog: &FollowUpDialog) { + let modal = follow_up_modal_rect(area); + let shadow = settings_shadow_rect(area, modal); + + if shadow != modal { + frame.render_widget(Clear, shadow); + frame.render_widget( + Paragraph::new("").style(Style::default().bg(SETTINGS_SHADOW)), + shadow, + ); + } + + frame.render_widget(Clear, modal); + + let block = Block::default() + .borders(Borders::ALL) + .border_style( + Style::default() + .fg(SETTINGS_BORDER) + .add_modifier(Modifier::BOLD), + ) + .style(settings_panel_style()) + .title(" Todo Follow-up "); + let inner = block.inner(modal); + frame.render_widget(block, modal); + frame.render_widget( + Paragraph::new(follow_up_lines(dialog, inner.width)).style(settings_panel_style()), + inner, + ); +} + fn render_image_overlay(frame: &mut Frame<'_>, area: Rect, image: &ImagePreview) { let modal = image_modal_rect(area, image); frame.render_widget(Clear, modal); @@ -390,6 +430,77 @@ fn render_image_overlay(frame: &mut Frame<'_>, area: Rect, image: &ImagePreview) ); } +fn follow_up_lines(dialog: &FollowUpDialog, width: u16) -> Vec> { + let quiet = format!( + "Pane {} has been quiet for {}s.", + dialog.pane_number, dialog.quiet_seconds + ); + let count = format!("Todo {}/{}", dialog.todo_position, dialog.todo_count); + let prompt_width = width.saturating_sub(4) as usize; + let mut lines = vec![ + Line::from(vec![ + Span::raw(" "), + Span::styled( + quiet, + Style::default() + .fg(SETTINGS_BORDER) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(vec![ + Span::raw(" "), + Span::styled( + "Send this queued prompt?", + Style::default().fg(SETTINGS_TEXT), + ), + Span::raw(" "), + Span::styled( + count, + Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(""), + ]; + + for line in wrap_dialog_text(&dialog.prompt, prompt_width, 3) { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(line, Style::default().fg(Color::LightCyan)), + ])); + } + + lines.push(Line::from("")); + lines.push(follow_up_command_bar(width)); + lines +} + +fn follow_up_command_bar(width: u16) -> Line<'static> { + if width < 54 { + return Line::from(vec![ + Span::raw(" "), + command_key("Enter"), + Span::styled(" send ", Style::default().fg(Color::Gray)), + command_key("Esc"), + Span::styled(" no", Style::default().fg(Color::Gray)), + ]); + } + + Line::from(vec![ + Span::raw(" "), + command_key("Enter/Y"), + Span::styled(" send ", Style::default().fg(Color::Gray)), + command_key("Tab"), + Span::styled(" next ", Style::default().fg(Color::Gray)), + command_key("Del"), + Span::styled(" remove ", Style::default().fg(Color::Gray)), + command_key("Esc/N"), + Span::styled(" no", Style::default().fg(Color::Gray)), + ]) +} + fn image_meta_line(image: &ImagePreview, width: u16) -> Line<'static> { let text = format!( "{}x{} -> {}x{} cells | {}", @@ -469,42 +580,48 @@ fn settings_lines(rows: &[SettingsRow], width: u16) -> Vec> { Span::styled(settings_summary(width), Style::default().fg(Color::Gray)), ]), Line::from(""), - settings_section("DISPLAY", "title bar and state signals", width), ]; - for row in rows.iter().take(2) { - lines.push(settings_row(row, width)); - } - - lines.push(Line::from("")); - lines.push(settings_section( + push_settings_group( + &mut lines, + rows, + SettingsGroup::Display, + "DISPLAY", + "title bar and state signals", + width, + ); + push_settings_group( + &mut lines, + rows, + SettingsGroup::Workflow, "WORKFLOW", "guard rails for high-speed sessions", width, - )); - if let Some(row) = rows.get(2) { - lines.push(settings_row(row, width)); - } - - lines.push(Line::from("")); - lines.push(settings_section( + ); + push_settings_group( + &mut lines, + rows, + SettingsGroup::Todo, + "TODO", + "queued prompts for quiet panes", + width, + ); + push_settings_group( + &mut lines, + rows, + SettingsGroup::Performance, "PERFORMANCE", "spacing and terminal budget", width, - )); - for row in rows.iter().skip(3).take(3) { - lines.push(settings_row(row, width)); - } - - lines.push(Line::from("")); - lines.push(settings_section( + ); + push_settings_group( + &mut lines, + rows, + SettingsGroup::Theme, "THEME", "runtime palette for grid chrome", width, - )); - for row in rows.iter().skip(6) { - lines.push(settings_row(row, width)); - } + ); lines.push(Line::from("")); lines.push(settings_command_bar(width)); @@ -530,13 +647,38 @@ fn conversation_footer(summary: String, emphasized: bool) -> Line<'static> { fn settings_summary(width: u16) -> String { let text = if width < 70 { - "Refine pane chrome, safety prompts, and highlight color." + "Refine pane chrome, todo prompts, and highlight color." } else { - "Refine pane chrome, safety prompts, performance, and highlight color." + "Refine pane chrome, idle follow-up todos, performance, and highlight color." }; truncate_text(text, width.saturating_sub(2) as usize) } +fn push_settings_group( + lines: &mut Vec>, + rows: &[SettingsRow], + group: SettingsGroup, + title: &'static str, + helper: &'static str, + width: u16, +) { + let group_rows = rows + .iter() + .filter(|row| row.group == group) + .collect::>(); + if group_rows.is_empty() { + return; + } + + if lines.last().is_none_or(|line| line.width() != 0) { + lines.push(Line::from("")); + } + lines.push(settings_section(title, helper, width)); + for row in group_rows { + lines.push(settings_row(row, width)); + } +} + fn settings_section(title: &'static str, helper: &'static str, width: u16) -> Line<'static> { let used = 2 + title.len() + 2; let helper = width @@ -562,6 +704,15 @@ fn settings_section(title: &'static str, helper: &'static str, width: u16) -> Li } fn settings_row(row: &SettingsRow, width: u16) -> Line<'static> { + if row.group == SettingsGroup::Todo + && matches!( + row.value_kind, + SettingsValueKind::Text | SettingsValueKind::Action + ) + { + return settings_todo_row(row, width); + } + let width = width as usize; let narrow = width < 66; let label_width = if narrow { 20 } else { 24 }; @@ -569,10 +720,10 @@ fn settings_row(row: &SettingsRow, width: u16) -> Line<'static> { let reserved = 2 + label_width + 2 + value_width + 2; let hint_width = width.saturating_sub(reserved); let marker = if row.selected { "> " } else { " " }; - let label = fixed_width(row.label, label_width); + let label = fixed_width(&row.label, label_width); let value = fixed_width(&settings_value_label(row), value_width); let hint = if hint_width >= 10 { - truncate_text(row.hint, hint_width) + truncate_text(&row.hint, hint_width) } else { String::new() }; @@ -601,6 +752,42 @@ fn settings_row(row: &SettingsRow, width: u16) -> Line<'static> { Line::from(spans) } +fn settings_todo_row(row: &SettingsRow, width: u16) -> Line<'static> { + let width = width as usize; + let marker = if row.selected { "> " } else { " " }; + let label_width = if width < 66 { 10 } else { 12 }; + let hint_width = if row.selected && width >= 72 { 24 } else { 0 }; + let hint_gap = if hint_width > 0 { 2 } else { 0 }; + let reserved = marker.len() + label_width + 2 + hint_width + hint_gap; + let value_width = width.saturating_sub(reserved); + let row_bg = row.selected.then_some(SETTINGS_ROW_ACTIVE); + let label = fixed_width(&row.label, label_width); + let value = fixed_width(&settings_value_label(row), value_width); + let mut used = marker.len() + label.len() + 2 + value.len(); + let mut spans = vec![ + Span::styled(marker.to_string(), row_style(Color::Yellow, row_bg, false)), + Span::styled(label, row_style(SETTINGS_TEXT, row_bg, row.selected)), + Span::styled(" ", row_style(SETTINGS_TEXT, row_bg, false)), + Span::styled(value, settings_value_style(row)), + ]; + + if hint_width > 0 { + let hint = fixed_width(&row.hint, hint_width); + used += 2 + hint.len(); + spans.push(Span::styled(" ", row_style(SETTINGS_TEXT, row_bg, false))); + spans.push(Span::styled(hint, row_style(SETTINGS_MUTED, row_bg, false))); + } + + if used < width { + spans.push(Span::styled( + " ".repeat(width - used), + row_style(SETTINGS_TEXT, row_bg, false), + )); + } + + Line::from(spans) +} + fn settings_command_bar(width: u16) -> Line<'static> { if width < 50 { return Line::from(vec![ @@ -612,7 +799,7 @@ fn settings_command_bar(width: u16) -> Line<'static> { ]); } - if width < 58 { + if width < 62 { return Line::from(vec![ Span::raw(" "), command_key("Up/Down"), @@ -632,6 +819,8 @@ fn settings_command_bar(width: u16) -> Line<'static> { Span::styled(" toggle ", Style::default().fg(Color::Gray)), command_key("Left/Right"), Span::styled(" adjust ", Style::default().fg(Color::Gray)), + command_key("Del"), + Span::styled(" remove ", Style::default().fg(Color::Gray)), command_key("Esc"), Span::styled(" close", Style::default().fg(Color::Gray)), ]) @@ -648,10 +837,13 @@ fn command_key(label: &'static str) -> Span<'static> { } fn settings_value_label(row: &SettingsRow) -> String { - match row.value.as_str() { - "on" | "off" => format!("[ {} ]", row.value), - _ if row.value_color.is_some() => format!("< {} >", row.value), - _ => format!("- {} +", row.value), + match row.value_kind { + SettingsValueKind::Switch => format!("[ {} ]", row.value), + SettingsValueKind::Choice => format!("< {} >", row.value), + SettingsValueKind::Stepper => format!("- {} +", row.value), + SettingsValueKind::Action => format!("[ {} ]", row.value), + SettingsValueKind::Text if row.value.is_empty() => "(empty)".into(), + SettingsValueKind::Text => row.value.clone(), } } @@ -663,12 +855,36 @@ fn settings_value_style(row: &SettingsRow) -> Style { .add_modifier(Modifier::BOLD); } - let mut style = match row.value.as_str() { - "on" => Style::default() + let mut style = match row.value_kind { + SettingsValueKind::Switch if row.value == "on" => Style::default() .fg(Color::Black) .bg(SETTINGS_BORDER) .add_modifier(Modifier::BOLD), - "off" => Style::default().fg(SETTINGS_MUTED).bg(SETTINGS_SURFACE), + SettingsValueKind::Switch => Style::default().fg(SETTINGS_MUTED).bg(SETTINGS_SURFACE), + SettingsValueKind::Choice if row.value == "cyan" => Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD), + SettingsValueKind::Choice if row.value == "yellow" => Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD), + SettingsValueKind::Choice if row.value == "green" => Style::default() + .fg(Color::Black) + .bg(Color::Green) + .add_modifier(Modifier::BOLD), + SettingsValueKind::Choice if row.value == "magenta" => Style::default() + .fg(Color::Black) + .bg(Color::Magenta) + .add_modifier(Modifier::BOLD), + SettingsValueKind::Text if row.editing => Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD), + SettingsValueKind::Action if row.selected => Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD), _ if row.selected => Style::default() .fg(Color::Black) .bg(Color::Yellow) @@ -679,7 +895,7 @@ fn settings_value_style(row: &SettingsRow) -> Style { .add_modifier(Modifier::BOLD), }; - if row.selected && matches!(row.value.as_str(), "off") { + if row.selected && row.value_kind == SettingsValueKind::Switch && row.value == "off" { style = style.fg(Color::White); } @@ -741,6 +957,22 @@ fn settings_modal_rect(area: Rect, row_count: usize) -> Rect { } } +fn follow_up_modal_rect(area: Rect) -> Rect { + let width = area.width.saturating_sub(4).min(74).max(area.width.min(1)); + let height = area + .height + .saturating_sub(2) + .min(12) + .max(area.height.min(1)); + + Rect { + x: area.x + area.width.saturating_sub(width) / 2, + y: area.y + area.height.saturating_sub(height) / 2, + width, + height, + } +} + fn settings_shadow_rect(area: Rect, modal: Rect) -> Rect { let offset_x = if modal.x.saturating_add(modal.width).saturating_add(2) <= area.x.saturating_add(area.width) @@ -784,6 +1016,63 @@ fn truncate_text(text: &str, width: usize) -> String { format!("{}...", &text[..width - 3]) } +fn wrap_dialog_text(text: &str, width: usize, max_lines: usize) -> Vec { + if width == 0 || max_lines == 0 { + return Vec::new(); + } + + let mut lines = Vec::new(); + let mut current = String::new(); + + for word in text.split_whitespace() { + let next_len = if current.is_empty() { + word.len() + } else { + current.len() + 1 + word.len() + }; + + if next_len <= width { + if !current.is_empty() { + current.push(' '); + } + current.push_str(word); + continue; + } + + if !current.is_empty() { + lines.push(current); + current = String::new(); + } + + if word.len() > width { + lines.push(truncate_text(word, width)); + } else { + current.push_str(word); + } + + if lines.len() == max_lines { + break; + } + } + + if !current.is_empty() && lines.len() < max_lines { + lines.push(current); + } + + if lines.is_empty() { + lines.push("(empty prompt)".into()); + } + if lines.len() == max_lines + && text.len() > lines.join(" ").len() + && let Some(last) = lines.last_mut() + { + *last = truncate_text(last, width.saturating_sub(3)); + last.push_str("..."); + } + + lines +} + fn folder_label(cwd: &Path) -> String { let label = cwd .file_name()