From 44c84db161694c38821485a6be41a4a37c6a938d Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Fri, 6 Mar 2026 09:20:35 +0100 Subject: [PATCH 01/53] test(tui): add Ratatui app coverage --- toki-tui/README.md | 15 +++ toki-tui/src/app/mod.rs | 174 ++++++++++++++++++++++++++++++++ toki-tui/src/app/state.rs | 37 +++++++ toki-tui/src/main.rs | 2 + toki-tui/src/runtime/actions.rs | 103 +++++++++++++++++++ toki-tui/src/test_support.rs | 67 ++++++++++++ toki-tui/src/ui/mod.rs | 91 +++++++++++++++++ 7 files changed, 489 insertions(+) create mode 100644 toki-tui/src/test_support.rs diff --git a/toki-tui/README.md b/toki-tui/README.md index 963364ca..0266b32e 100644 --- a/toki-tui/README.md +++ b/toki-tui/README.md @@ -76,6 +76,21 @@ git_default_prefix = "Development" task_filter = "+work" ``` +## Testing + +Run the TUI test suite with: + +```bash +cargo test -p toki-tui +``` + +The current tests focus on the most stable and useful layers first: + +- app and state behavior +- parsing and text input helpers +- runtime action handling with the dev backend +- focused Ratatui render assertions for important UI states + ## Standard key bindings | Key | Action | diff --git a/toki-tui/src/app/mod.rs b/toki-tui/src/app/mod.rs index 4f940750..e5bc015f 100644 --- a/toki-tui/src/app/mod.rs +++ b/toki-tui/src/app/mod.rs @@ -1086,3 +1086,177 @@ fn longest_common_prefix(strings: &[String]) -> String { prefix.into_iter().collect() } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{activity, project, test_app, time_entry}; + + #[test] + fn start_timer_sets_running_state_and_shifts_focus() { + let mut app = test_app(); + app.focused_this_week_index = Some(2); + + app.start_timer(); + + assert_eq!(app.timer_state, TimerState::Running); + assert!(app.absolute_start.is_some()); + assert!(app.local_start.is_some()); + assert_eq!(app.focused_this_week_index, Some(3)); + } + + #[test] + fn stop_timer_clears_running_state() { + let mut app = test_app(); + app.focused_this_week_index = Some(2); + app.start_timer(); + + app.stop_timer(); + + assert_eq!(app.timer_state, TimerState::Stopped); + assert!(app.absolute_start.is_none()); + assert!(app.local_start.is_none()); + assert_eq!(app.focused_this_week_index, Some(3)); + } + + #[test] + fn clear_timer_resets_selected_fields_and_note() { + let mut app = test_app(); + app.timer_size = TimerSize::Large; + app.selected_project = Some(project("proj-1", "Project One")); + app.selected_activity = Some(activity("act-1", "proj-1", "Activity One")); + app.description_input = TextInput::from_str("Existing note"); + app.description_is_default = false; + + app.clear_timer(); + + assert_eq!(app.timer_state, TimerState::Stopped); + assert_eq!(app.timer_size, TimerSize::Large); + assert!(app.selected_project.is_none()); + assert!(app.selected_activity.is_none()); + assert_eq!(app.description_input, TextInput::new()); + assert!(app.description_is_default); + } + + #[test] + fn navigate_to_edit_description_clears_default_note() { + let mut app = test_app(); + app.description_input = TextInput::from_str("Prefill"); + app.description_is_default = true; + + app.navigate_to(View::EditDescription); + + assert_eq!(app.current_view, View::EditDescription); + assert!(app.editing_description); + assert_eq!(app.description_input, TextInput::new()); + assert!(!app.description_is_default); + } + + #[test] + fn select_save_action_by_number_ignores_unknown_values() { + let mut app = test_app(); + app.selected_save_action = SaveAction::ContinueSameProject; + + app.select_save_action_by_number(9); + + assert_eq!(app.selected_save_action, SaveAction::ContinueSameProject); + } + + #[test] + fn filter_projects_orders_best_match_first() { + let mut app = test_app(); + app.projects = vec![ + project("proj-1", "Backend Platform"), + project("proj-2", "Timer UI"), + project("proj-3", "Documentation"), + ]; + app.project_search_input = TextInput::from_str("tmr"); + + app.filter_projects(); + + assert_eq!( + app.filtered_projects.first().map(|p| p.name.as_str()), + Some("Timer UI") + ); + } + + #[test] + fn filter_activities_respects_selected_project() { + let mut app = test_app(); + app.selected_project = Some(project("proj-2", "Timer UI")); + app.activities = vec![ + activity("act-1", "proj-1", "Planning"), + activity("act-2", "proj-2", "Implementation"), + activity("act-3", "proj-2", "Testing"), + ]; + + app.filter_activities(); + + assert_eq!(app.filtered_activities.len(), 2); + assert!(app + .filtered_activities + .iter() + .all(|activity| activity.project_id == "proj-2")); + } + + #[test] + fn parse_task_export_rejects_invalid_utf8_or_json() { + let utf8_err = parse_task_export(&[0xff]).expect_err("invalid UTF-8 should fail"); + let json_err = parse_task_export(b"not-json").expect_err("invalid JSON should fail"); + + assert!(utf8_err.contains("UTF-8")); + assert!(json_err.contains("JSON")); + } + + #[test] + fn parse_task_export_sorts_by_urgency_desc() { + let output = br#"[ + {"id": 2, "description": "Lower", "urgency": 1.0}, + {"id": 1, "description": "Higher", "urgency": 9.5}, + {"id": 3, "description": "Medium", "urgency": 3.2} + ]"#; + + let tasks = parse_task_export(output).expect("valid export should parse"); + + let descriptions: Vec<&str> = tasks.iter().map(|task| task.description.as_str()).collect(); + assert_eq!(descriptions, vec!["Higher", "Medium", "Lower"]); + } + + #[test] + fn update_history_sorts_entries_newest_first() { + let mut app = test_app(); + let early = time_entry( + "reg-1", + "proj-1", + "Project One", + "act-1", + "Activity One", + "2026-03-01", + 1.0, + None, + None, + None, + ); + let late = time_entry( + "reg-2", + "proj-1", + "Project One", + "act-1", + "Activity One", + "2026-03-02", + 1.0, + None, + None, + None, + ); + + app.update_history(vec![early, late]); + + let ids: Vec<&str> = app + .time_entries + .iter() + .map(|entry| entry.registration_id.as_str()) + .collect(); + assert_eq!(ids, vec!["reg-2", "reg-1"]); + } +} diff --git a/toki-tui/src/app/state.rs b/toki-tui/src/app/state.rs index c5ab2eda..03e85dfc 100644 --- a/toki-tui/src/app/state.rs +++ b/toki-tui/src/app/state.rs @@ -285,3 +285,40 @@ pub enum MilltimeReauthField { } // Keep Instant re-exported so App struct can use it without needing to import state internals + +#[cfg(test)] +mod tests { + use super::TextInput; + + #[test] + fn text_input_inserts_and_backspaces_at_utf8_boundaries() { + let mut input = TextInput::from_str("a"); + input.insert('e'); + input.insert('\u{301}'); + + assert_eq!(input.value, "ae\u{301}"); + + input.backspace(); + assert_eq!(input.value, "ae"); + + input.backspace(); + assert_eq!(input.value, "a"); + } + + #[test] + fn text_input_moves_cursor_left_and_right_by_char() { + let mut input = TextInput::from_str("a😀b"); + + input.move_left(); + assert_eq!(input.cursor, "a😀".len()); + + input.move_left(); + assert_eq!(input.cursor, "a".len()); + + input.move_right(); + assert_eq!(input.cursor, "a😀".len()); + + input.move_right(); + assert_eq!(input.cursor, "a😀b".len()); + } +} diff --git a/toki-tui/src/main.rs b/toki-tui/src/main.rs index fca00b82..4c6c4320 100644 --- a/toki-tui/src/main.rs +++ b/toki-tui/src/main.rs @@ -8,6 +8,8 @@ mod login; mod runtime; mod session_store; mod terminal; +#[cfg(test)] +mod test_support; mod time_utils; mod types; mod ui; diff --git a/toki-tui/src/runtime/actions.rs b/toki-tui/src/runtime/actions.rs index f56021f0..16627476 100644 --- a/toki-tui/src/runtime/actions.rs +++ b/toki-tui/src/runtime/actions.rs @@ -852,3 +852,106 @@ pub(super) fn is_milltime_auth_error(e: &anyhow::Error) -> bool { let msg = e.to_string().to_lowercase(); msg.contains("unauthorized") || msg.contains("authenticate") || msg.contains("milltime") } + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::ApiClient; + use crate::app::{DeleteContext, DeleteOrigin, SaveAction, View}; + use crate::test_support::test_app; + use crate::types::ActiveTimerState; + use time::macros::datetime; + + #[test] + fn restore_active_timer_populates_local_app_state() { + let mut app = test_app(); + let timer = ActiveTimerState { + start_time: datetime!(2026-03-06 09:15 UTC), + project_id: Some("proj-1".to_string()), + project_name: Some("Project One".to_string()), + activity_id: Some("act-1".to_string()), + activity_name: Some("Activity One".to_string()), + note: "Investigate tests".to_string(), + hours: 1, + minutes: 2, + seconds: 3, + }; + + restore_active_timer(&mut app, timer); + + assert_eq!(app.timer_state, app::TimerState::Running); + assert_eq!(app.selected_project.as_ref().map(|p| p.id.as_str()), Some("proj-1")); + assert_eq!( + app.selected_activity.as_ref().map(|a| a.name.as_str()), + Some("Activity One") + ); + assert_eq!(app.description_input.value, "Investigate tests"); + assert!(!app.description_is_default); + assert_eq!(app.absolute_start, Some(datetime!(2026-03-06 09:15 UTC))); + assert!(app.local_start.is_some()); + } + + #[tokio::test] + async fn handle_start_timer_starts_timer_in_dev_mode() { + let mut app = test_app(); + let mut client = ApiClient::dev().expect("dev client"); + + handle_start_timer(&mut app, &mut client) + .await + .expect("start timer should succeed"); + + assert_eq!(app.timer_state, app::TimerState::Running); + assert!(app.absolute_start.is_some()); + assert!(app.local_start.is_some()); + assert!(app.status_message.is_none()); + } + + #[tokio::test] + async fn handle_save_timer_cancel_returns_to_timer_without_saving() { + let mut app = test_app(); + let mut client = ApiClient::dev().expect("dev client"); + app.current_view = View::SaveAction; + app.selected_save_action = SaveAction::Cancel; + app.timer_state = app::TimerState::Running; + + handle_save_timer_with_action(&mut app, &mut client) + .await + .expect("cancel should succeed"); + + assert_eq!(app.current_view, View::Timer); + assert_eq!(app.timer_state, app::TimerState::Running); + } + + #[tokio::test] + async fn handle_confirm_delete_removes_entry_and_returns_to_origin_view() { + let mut app = test_app(); + let mut client = ApiClient::dev().expect("dev client"); + let today = time::OffsetDateTime::now_utc().date(); + let entries = client + .get_time_entries(today, today) + .await + .expect("history should load"); + let entry = entries.first().expect("seeded history entry").clone(); + + app.update_history(entries); + app.rebuild_history_list(); + app.current_view = View::ConfirmDelete; + app.delete_context = Some(DeleteContext { + registration_id: entry.registration_id.clone(), + display_label: format!("{} / {}", entry.project_name, entry.activity_name), + display_date: entry.date.clone(), + display_hours: entry.hours, + origin: DeleteOrigin::History, + }); + + handle_confirm_delete(&mut app, &mut client).await; + + assert_eq!(app.current_view, View::History); + assert!(app.status_message.is_none()); + assert!(app + .time_entries + .iter() + .all(|item| item.registration_id != entry.registration_id)); + assert!(app.delete_context.is_none()); + } +} diff --git a/toki-tui/src/test_support.rs b/toki-tui/src/test_support.rs new file mode 100644 index 00000000..37a5700e --- /dev/null +++ b/toki-tui/src/test_support.rs @@ -0,0 +1,67 @@ +use crate::app::App; +use crate::config::TokiConfig; +use crate::types::{Activity, Project, TimeEntry}; +use time::OffsetDateTime; + +pub fn test_config() -> TokiConfig { + TokiConfig::default() +} + +pub fn test_app() -> App { + App::new(1, &test_config()) +} + +#[allow(dead_code)] +pub fn project(id: &str, name: &str) -> Project { + Project { + id: id.to_string(), + name: name.to_string(), + } +} + +#[allow(dead_code)] +pub fn activity(id: &str, project_id: &str, name: &str) -> Activity { + Activity { + id: id.to_string(), + project_id: project_id.to_string(), + name: name.to_string(), + } +} + +#[allow(clippy::too_many_arguments)] +#[allow(dead_code)] +pub fn time_entry( + registration_id: &str, + project_id: &str, + project_name: &str, + activity_id: &str, + activity_name: &str, + date: &str, + hours: f64, + note: Option<&str>, + start_time: Option, + end_time: Option, +) -> TimeEntry { + TimeEntry { + registration_id: registration_id.to_string(), + project_id: project_id.to_string(), + project_name: project_name.to_string(), + activity_id: activity_id.to_string(), + activity_name: activity_name.to_string(), + date: date.to_string(), + hours, + note: note.map(ToString::to_string), + start_time, + end_time, + week_number: 1, + } +} + +#[test] +fn app_defaults_to_timer_view() { + let app = test_app(); + + assert!(app.running); + assert_eq!(app.current_view, crate::app::View::Timer); + assert_eq!(app.timer_state, crate::app::TimerState::Stopped); +} diff --git a/toki-tui/src/ui/mod.rs b/toki-tui/src/ui/mod.rs index 169b4e42..5e39a600 100644 --- a/toki-tui/src/ui/mod.rs +++ b/toki-tui/src/ui/mod.rs @@ -158,3 +158,94 @@ fn render_milltime_reauth_overlay(frame: &mut Frame, app: &App) { frame.render_widget(paragraph, area); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{FocusedBox, MilltimeReauthState, TimerState}; + use crate::test_support::{activity, project, test_app}; + use ratatui::{backend::TestBackend, Terminal}; + use time::macros::datetime; + + fn render_lines(app: &mut App) -> Vec { + let backend = TestBackend::new(100, 30); + let mut terminal = Terminal::new(backend).expect("test terminal"); + terminal + .draw(|frame| render(frame, app)) + .expect("render should succeed"); + + let backend = terminal.backend(); + let buffer = backend.buffer(); + + (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect() + } + + fn rendered_text(app: &mut App) -> String { + render_lines(app).join("\n") + } + + #[test] + fn render_shows_running_timer_project_and_note() { + let mut app = test_app(); + app.timer_state = TimerState::Running; + app.absolute_start = Some(datetime!(2026-03-06 09:15 UTC)); + app.selected_project = Some(project("proj-1", "Project One")); + app.selected_activity = Some(activity("act-1", "proj-1", "Activity One")); + app.description_input.value = "Investigate tests".to_string(); + app.description_input.cursor = app.description_input.value.len(); + app.focused_box = FocusedBox::ProjectActivity; + + let text = rendered_text(&mut app); + + assert!(text.contains("Timer")); + assert!(text.contains("(running)")); + assert!(text.contains("Project One: Activity One")); + assert!(text.contains("Investigate tests")); + } + + #[test] + fn render_status_shows_error_copy() { + let mut app = test_app(); + app.status_message = Some("Error starting timer: boom".to_string()); + + let text = rendered_text(&mut app); + + assert!(text.contains("Status")); + assert!(text.contains("Error starting timer: boom")); + } + + #[test] + fn render_status_shows_success_copy() { + let mut app = test_app(); + app.status_message = Some("Saved 00:15:00 to Project / Activity".to_string()); + + let text = rendered_text(&mut app); + + assert!(text.contains("Saved 00:15:00 to Project / Activity")); + } + + #[test] + fn render_milltime_reauth_masks_password() { + let mut app = test_app(); + let mut reauth = MilltimeReauthState::default(); + reauth.username_input.value = "alice".to_string(); + reauth.username_input.cursor = reauth.username_input.value.len(); + reauth.password_input.value = "secret".to_string(); + reauth.password_input.cursor = reauth.password_input.value.len(); + app.milltime_reauth = Some(reauth); + + let text = rendered_text(&mut app); + + assert!(text.contains("Milltime session expired. Please re-authenticate.")); + assert!(text.contains("alice")); + assert!(text.contains("Password:")); + assert!(!text.contains("secret")); + assert!(text.contains("••••••")); + } +} From 0b7b3847f342a821bcbf5db7712931dc549904a7 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 09:53:10 +0100 Subject: [PATCH 02/53] test(tui): fix cherry-picked tests for current master API (start_timer bool arg, attest_level, timer_size assertions) --- toki-tui/src/app/mod.rs | 10 +++++----- toki-tui/src/test_support.rs | 3 ++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/toki-tui/src/app/mod.rs b/toki-tui/src/app/mod.rs index e5bc015f..d0d64846 100644 --- a/toki-tui/src/app/mod.rs +++ b/toki-tui/src/app/mod.rs @@ -1097,7 +1097,7 @@ mod tests { let mut app = test_app(); app.focused_this_week_index = Some(2); - app.start_timer(); + app.start_timer(false); assert_eq!(app.timer_state, TimerState::Running); assert!(app.absolute_start.is_some()); @@ -1109,14 +1109,14 @@ mod tests { fn stop_timer_clears_running_state() { let mut app = test_app(); app.focused_this_week_index = Some(2); - app.start_timer(); + app.start_timer(false); - app.stop_timer(); + app.stop_timer(false); assert_eq!(app.timer_state, TimerState::Stopped); assert!(app.absolute_start.is_none()); assert!(app.local_start.is_none()); - assert_eq!(app.focused_this_week_index, Some(3)); + assert_eq!(app.focused_this_week_index, Some(2)); } #[test] @@ -1131,7 +1131,7 @@ mod tests { app.clear_timer(); assert_eq!(app.timer_state, TimerState::Stopped); - assert_eq!(app.timer_size, TimerSize::Large); + assert_eq!(app.timer_size, TimerSize::Normal); assert!(app.selected_project.is_none()); assert!(app.selected_activity.is_none()); assert_eq!(app.description_input, TextInput::new()); diff --git a/toki-tui/src/test_support.rs b/toki-tui/src/test_support.rs index 37a5700e..409cccea 100644 --- a/toki-tui/src/test_support.rs +++ b/toki-tui/src/test_support.rs @@ -1,6 +1,6 @@ use crate::app::App; use crate::config::TokiConfig; -use crate::types::{Activity, Project, TimeEntry}; +use crate::types::{Activity, AttestLevel, Project, TimeEntry}; use time::OffsetDateTime; pub fn test_config() -> TokiConfig { @@ -54,6 +54,7 @@ pub fn time_entry( start_time, end_time, week_number: 1, + attest_level: AttestLevel::None, } } From 105ccbcc79ff8eb05a77c0af2537e627ebf8af92 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 09:54:57 +0100 Subject: [PATCH 03/53] refactor(tui): move parse_date_weekday to shared ui utils --- toki-tui/src/ui/history_panel.rs | 32 +------------------- toki-tui/src/ui/utils.rs | 52 ++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 31 deletions(-) diff --git a/toki-tui/src/ui/history_panel.rs b/toki-tui/src/ui/history_panel.rs index 33860e33..fb148335 100644 --- a/toki-tui/src/ui/history_panel.rs +++ b/toki-tui/src/ui/history_panel.rs @@ -92,7 +92,7 @@ pub fn render_this_week_history(frame: &mut Frame, area: ratatui::layout::Rect, "── Yesterday ──".to_string() } else { // Parse YYYY-MM-DD to get weekday - let weekday_label = parse_date_weekday(entry_date); + let weekday_label = super::utils::parse_date_weekday(entry_date); format!("── {} ({}) ──", weekday_label, entry_date) }; logical_rows.push(ThisWeekRow::Separator(label)); @@ -239,33 +239,3 @@ pub fn render_this_week_history(frame: &mut Frame, area: ratatui::layout::Rect, ); } } - -/// Parse a YYYY-MM-DD string and return the weekday name, or "Unknown" on failure. -fn parse_date_weekday(date_str: &str) -> &'static str { - let parts: Vec<&str> = date_str.splitn(3, '-').collect(); - if parts.len() != 3 { - return "Unknown"; - } - let (Ok(year), Ok(month_u8), Ok(day)) = ( - parts[0].parse::(), - parts[1].parse::(), - parts[2].parse::(), - ) else { - return "Unknown"; - }; - let Ok(month) = time::Month::try_from(month_u8) else { - return "Unknown"; - }; - let Ok(date) = time::Date::from_calendar_date(year, month, day) else { - return "Unknown"; - }; - match date.weekday() { - time::Weekday::Monday => "Monday", - time::Weekday::Tuesday => "Tuesday", - time::Weekday::Wednesday => "Wednesday", - time::Weekday::Thursday => "Thursday", - time::Weekday::Friday => "Friday", - time::Weekday::Saturday => "Saturday", - time::Weekday::Sunday => "Sunday", - } -} diff --git a/toki-tui/src/ui/utils.rs b/toki-tui/src/ui/utils.rs index f095cacd..1f4afe53 100644 --- a/toki-tui/src/ui/utils.rs +++ b/toki-tui/src/ui/utils.rs @@ -24,3 +24,55 @@ pub fn centered_rect(width: u16, height: u16, r: Rect) -> Rect { ]) .split(popup_layout[1])[1] } + +/// Parses a "YYYY-MM-DD" date string and returns the English weekday name. +/// Returns "Unknown" if parsing fails. +pub fn parse_date_weekday(date_str: &str) -> &'static str { + let parts: Vec<&str> = date_str.splitn(3, '-').collect(); + if parts.len() != 3 { + return "Unknown"; + } + let (Ok(year), Ok(month_u8), Ok(day)) = ( + parts[0].parse::(), + parts[1].parse::(), + parts[2].parse::(), + ) else { + return "Unknown"; + }; + let Ok(month) = time::Month::try_from(month_u8) else { + return "Unknown"; + }; + let Ok(date) = time::Date::from_calendar_date(year, month, day) else { + return "Unknown"; + }; + match date.weekday() { + time::Weekday::Monday => "Monday", + time::Weekday::Tuesday => "Tuesday", + time::Weekday::Wednesday => "Wednesday", + time::Weekday::Thursday => "Thursday", + time::Weekday::Friday => "Friday", + time::Weekday::Saturday => "Saturday", + time::Weekday::Sunday => "Sunday", + } +} + +/// Formats a duration in hours (f64) as "HHh:MMm", e.g. 6.5 → "06h:30m". +pub fn format_hours_hm(hours: f64) -> String { + let total_minutes = (hours * 60.0).round() as u64; + let h = total_minutes / 60; + let m = total_minutes % 60; + format!("{:02}h:{:02}m", h, m) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_hours_hm() { + assert_eq!(format_hours_hm(0.0), "00h:00m"); + assert_eq!(format_hours_hm(6.5), "06h:30m"); + assert_eq!(format_hours_hm(1.0 / 60.0), "00h:01m"); // 1 minute + assert_eq!(format_hours_hm(10.0), "10h:00m"); + } +} From 03b2310c13288879bf917830184235fa794a514d Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 09:55:10 +0100 Subject: [PATCH 04/53] feat(tui): add format_hours_hm utility From ecff1f1b230ccd16cdbf80d2cb7b005533c285a0 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 09:55:49 +0100 Subject: [PATCH 05/53] feat(tui): show daily total hours in this-week panel separators --- toki-tui/src/ui/history_panel.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/toki-tui/src/ui/history_panel.rs b/toki-tui/src/ui/history_panel.rs index fb148335..85e7086a 100644 --- a/toki-tui/src/ui/history_panel.rs +++ b/toki-tui/src/ui/history_panel.rs @@ -83,17 +83,25 @@ pub fn render_this_week_history(frame: &mut Frame, area: ratatui::layout::Rect, visible_entry_idx = 1; // DB entries start at visible_entry_idx = 1 } + // Compute total hours per date for separator labels + let mut date_totals: std::collections::HashMap<&str, f64> = std::collections::HashMap::new(); + for entry in &this_week_entries { + *date_totals.entry(entry.date.as_str()).or_insert(0.0) += entry.hours; + } + for entry in &this_week_entries { let entry_date = &entry.date; if last_date.as_deref() != Some(entry_date.as_str()) { + let total = date_totals.get(entry_date.as_str()).copied().unwrap_or(0.0); + let total_str = super::utils::format_hours_hm(total); let label = if entry_date == &today_str { - "── Today ──".to_string() + format!("── Today ({}) ──", total_str) } else if entry_date == &yesterday_str { - "── Yesterday ──".to_string() + format!("── Yesterday ({}) ──", total_str) } else { // Parse YYYY-MM-DD to get weekday let weekday_label = super::utils::parse_date_weekday(entry_date); - format!("── {} ({}) ──", weekday_label, entry_date) + format!("── {}, {} ({}) ──", weekday_label, entry_date, total_str) }; logical_rows.push(ThisWeekRow::Separator(label)); last_date = Some(entry_date.clone()); From f77e4c3e7d208903126baed7e29c20627531a340 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 09:56:25 +0100 Subject: [PATCH 06/53] feat(tui): show daily total hours in history view separators --- toki-tui/src/ui/history_view.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/toki-tui/src/ui/history_view.rs b/toki-tui/src/ui/history_view.rs index 6a260109..8de49161 100644 --- a/toki-tui/src/ui/history_view.rs +++ b/toki-tui/src/ui/history_view.rs @@ -78,14 +78,24 @@ pub fn render_history_view(frame: &mut Frame, app: &mut App, body: Rect) { let mut logical_rows: Vec> = Vec::new(); let mut last_date: Option = None; + // Compute total hours per date for separator labels + let mut date_totals: std::collections::HashMap<&str, f64> = + std::collections::HashMap::new(); + for (_, entry) in &entries { + *date_totals.entry(entry.date.as_str()).or_insert(0.0) += entry.hours; + } + for (history_idx, entry) in &entries { if last_date.as_deref() != Some(&entry.date) { + let total = date_totals.get(entry.date.as_str()).copied().unwrap_or(0.0); + let total_str = super::utils::format_hours_hm(total); let label = if entry.date == today_str { - "── Today ──".to_string() + format!("── Today ({}) ──", total_str) } else if entry.date == yesterday_str { - "── Yesterday ──".to_string() + format!("── Yesterday ({}) ──", total_str) } else { - format!("── {} ──", entry.date) + let weekday = super::utils::parse_date_weekday(&entry.date); + format!("── {}, {} ({}) ──", weekday, entry.date, total_str) }; logical_rows.push(HistoryRow::Separator(label)); last_date = Some(entry.date.clone()); From cd05d97b7a046d420101805633f44bbd74861ea2 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 10:02:15 +0100 Subject: [PATCH 07/53] feat(tui): add TemplateConfig to TokiConfig --- toki-tui/src/config.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/toki-tui/src/config.rs b/toki-tui/src/config.rs index fa2c995f..3896a650 100644 --- a/toki-tui/src/config.rs +++ b/toki-tui/src/config.rs @@ -2,6 +2,14 @@ use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TemplateConfig { + pub description: String, + pub project: String, + pub activity: String, + pub note: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TokiConfig { /// URL of the toki-api server. Defaults to the production instance. @@ -19,6 +27,9 @@ pub struct TokiConfig { /// and back to Normal when stopped. Default: true. #[serde(default = "default_auto_resize_timer")] pub auto_resize_timer: bool, + /// Named presets of (project, activity, note) applied via the template picker. + #[serde(default)] + pub templates: Vec, } fn default_api_url() -> String { @@ -40,6 +51,7 @@ impl Default for TokiConfig { task_filter: String::new(), git_default_prefix: default_git_prefix(), auto_resize_timer: default_auto_resize_timer(), + templates: Vec::new(), } } } From e31c2b585d9bbb33a9bd3d15392e0ef801263871 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 10:03:20 +0100 Subject: [PATCH 08/53] feat(tui): add template fields and filter_templates to App --- toki-tui/src/app/mod.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/toki-tui/src/app/mod.rs b/toki-tui/src/app/mod.rs index d0d64846..828b22fe 100644 --- a/toki-tui/src/app/mod.rs +++ b/toki-tui/src/app/mod.rs @@ -113,6 +113,12 @@ pub struct App { pub task_filter: String, pub git_default_prefix: String, pub auto_resize_timer: bool, + + // Templates + pub templates: Vec, + pub template_search_input: TextInput, + pub filtered_templates: Vec, + pub filtered_template_index: usize, } impl App { @@ -177,6 +183,10 @@ impl App { task_filter: cfg.task_filter.clone(), git_default_prefix: cfg.git_default_prefix.clone(), auto_resize_timer: cfg.auto_resize_timer, + templates: cfg.templates.clone(), + template_search_input: TextInput::new(), + filtered_templates: Vec::new(), + filtered_template_index: 0, } } @@ -689,6 +699,27 @@ impl App { self.filter_activities(); } + pub fn filter_templates(&mut self) { + let query = &self.template_search_input.value; + if query.is_empty() { + self.filtered_templates = self.templates.clone(); + } else { + let matcher = SkimMatcherV2::default(); + let mut scored: Vec<_> = self + .templates + .iter() + .filter_map(|t| { + matcher + .fuzzy_match(&t.description, query) + .map(|score| (score, t.clone())) + }) + .collect(); + scored.sort_by(|a, b| b.0.cmp(&a.0)); + self.filtered_templates = scored.into_iter().map(|(_, t)| t).collect(); + } + self.filtered_template_index = 0; + } + pub fn activity_search_input_backspace(&mut self) { self.activity_search_input.backspace(); self.filter_activities(); From cbcb16b2da8267465b1115897114faac285dcaa7 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 10:04:36 +0100 Subject: [PATCH 09/53] feat(tui): add SelectTemplate view variant and navigation --- toki-tui/src/app/mod.rs | 7 +++++++ toki-tui/src/app/state.rs | 1 + 2 files changed, 8 insertions(+) diff --git a/toki-tui/src/app/mod.rs b/toki-tui/src/app/mod.rs index 828b22fe..3a7f0c21 100644 --- a/toki-tui/src/app/mod.rs +++ b/toki-tui/src/app/mod.rs @@ -412,6 +412,13 @@ impl App { self.filter_activities(); self.selection_list_focused = false; } + View::SelectTemplate => { + self.template_search_input = TextInput::new(); + self.filtered_templates = self.templates.clone(); + self.filtered_template_index = 0; + self.selection_list_focused = false; + self.current_view = View::SelectTemplate; + } View::EditDescription => { if self.description_is_default && self.this_week_edit_state.is_none() diff --git a/toki-tui/src/app/state.rs b/toki-tui/src/app/state.rs index 03e85dfc..b17632f8 100644 --- a/toki-tui/src/app/state.rs +++ b/toki-tui/src/app/state.rs @@ -10,6 +10,7 @@ pub enum View { History, SelectProject, SelectActivity, + SelectTemplate, EditDescription, SaveAction, Statistics, From aeaf6898677440dc8652212a7828e94e73cfa547 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 10:06:03 +0100 Subject: [PATCH 10/53] feat(tui): add ApplyTemplate action and handler --- toki-tui/src/runtime/action_queue.rs | 3 ++ toki-tui/src/runtime/actions.rs | 65 ++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/toki-tui/src/runtime/action_queue.rs b/toki-tui/src/runtime/action_queue.rs index e55a88fe..530ed6bd 100644 --- a/toki-tui/src/runtime/action_queue.rs +++ b/toki-tui/src/runtime/action_queue.rs @@ -30,6 +30,9 @@ pub(super) enum Action { RefreshHistoryBackground, YankEntryToTimer(TimeEntry), ResumeEntry(TimeEntry), + ApplyTemplate { + template: crate::config::TemplateConfig, + }, } pub(super) type ActionTx = UnboundedSender; diff --git a/toki-tui/src/runtime/actions.rs b/toki-tui/src/runtime/actions.rs index 16627476..a2fcf66e 100644 --- a/toki-tui/src/runtime/actions.rs +++ b/toki-tui/src/runtime/actions.rs @@ -124,6 +124,9 @@ pub(super) async fn run_action( Action::ResumeEntry(entry) => { resume_entry(entry, app, client).await; } + Action::ApplyTemplate { template } => { + handle_apply_template(template, app, client).await?; + } } Ok(()) } @@ -285,6 +288,68 @@ async fn sync_running_timer_note(note: String, app: &mut App, client: &mut ApiCl } } +async fn handle_apply_template( + template: crate::config::TemplateConfig, + app: &mut App, + client: &mut ApiClient, +) -> Result<()> { + // Find project by name (case-insensitive) + let project = app + .projects + .iter() + .find(|p| p.name.eq_ignore_ascii_case(&template.project)) + .cloned(); + + let Some(project) = project else { + // Project not found — navigate back silently + app.navigate_to(app::View::Timer); + return Ok(()); + }; + + app.selected_project = Some(project.clone()); + + // Ensure activities are loaded for this project + ensure_activities_for_project(app, client, &project.id).await; + + // Find activity by name (case-insensitive) + let activity = app + .activity_cache + .get(&project.id) + .and_then(|acts| { + acts.iter() + .find(|a| a.name.eq_ignore_ascii_case(&template.activity)) + .cloned() + }); + + if let Some(activity) = activity { + app.selected_activity = Some(activity); + } + + // Set note + app.description_input = app::TextInput::from_str(&template.note); + app.description_is_default = template.note.is_empty(); + + // Navigate back to timer + app.navigate_to(app::View::Timer); + + // If timer is running, sync to server + if app.timer_state == app::TimerState::Running { + let note = app.description_input.value.clone(); + let project_id = app.selected_project.as_ref().map(|p| p.id.clone()); + let project_name = app.selected_project.as_ref().map(|p| p.name.clone()); + let activity_id = app.selected_activity.as_ref().map(|a| a.id.clone()); + let activity_name = app.selected_activity.as_ref().map(|a| a.name.clone()); + if let Err(e) = client + .update_active_timer(project_id, project_name, activity_id, activity_name, Some(note), None) + .await + { + app.set_status(format!("Warning: Could not sync template to server: {}", e)); + } + } + + Ok(()) +} + async fn load_history_and_open(app: &mut App, client: &mut ApiClient) { match fetch_recent_history(client).await { Ok(entries) => { From 18655e393e62daa77eadf98e8da22742432a1e16 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 10:10:43 +0100 Subject: [PATCH 11/53] feat(tui): add SelectTemplate key handler, App input methods, and UI rendering --- toki-tui/src/app/mod.rs | 46 +++++++ toki-tui/src/runtime/views.rs | 4 + toki-tui/src/runtime/views/selection.rs | 4 +- .../src/runtime/views/template_selection.rs | 113 ++++++++++++++++ toki-tui/src/ui/mod.rs | 4 + toki-tui/src/ui/template_selection_view.rs | 126 ++++++++++++++++++ 6 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 toki-tui/src/runtime/views/template_selection.rs create mode 100644 toki-tui/src/ui/template_selection_view.rs diff --git a/toki-tui/src/app/mod.rs b/toki-tui/src/app/mod.rs index 3a7f0c21..18e3820c 100644 --- a/toki-tui/src/app/mod.rs +++ b/toki-tui/src/app/mod.rs @@ -452,6 +452,12 @@ impl App { (self.filtered_activity_index + 1) % self.filtered_activities.len(); } } + View::SelectTemplate => { + if !self.filtered_templates.is_empty() { + self.filtered_template_index = + (self.filtered_template_index + 1) % self.filtered_templates.len(); + } + } View::History => { self.history_focus_down(); } @@ -480,6 +486,15 @@ impl App { }; } } + View::SelectTemplate => { + if !self.filtered_templates.is_empty() { + self.filtered_template_index = if self.filtered_template_index == 0 { + self.filtered_templates.len() - 1 + } else { + self.filtered_template_index - 1 + }; + } + } View::History => { self.history_focus_up(); } @@ -737,6 +752,37 @@ impl App { self.filter_activities(); } + pub fn template_search_input_char(&mut self, c: char) { + self.template_search_input.insert(c); + self.filter_templates(); + } + + pub fn template_search_input_backspace(&mut self) { + self.template_search_input.backspace(); + self.filter_templates(); + } + + pub fn template_search_input_clear(&mut self) { + self.template_search_input.clear(); + self.filter_templates(); + } + + pub fn template_search_move_cursor(&mut self, left: bool) { + if left { + self.template_search_input.move_left(); + } else { + self.template_search_input.move_right(); + } + } + + pub fn template_search_cursor_home_end(&mut self, home: bool) { + if home { + self.template_search_input.home(); + } else { + self.template_search_input.end(); + } + } + pub fn search_move_cursor(&mut self, left: bool) { if left { self.project_search_input.move_left(); diff --git a/toki-tui/src/runtime/views.rs b/toki-tui/src/runtime/views.rs index 72ece672..5c496b5f 100644 --- a/toki-tui/src/runtime/views.rs +++ b/toki-tui/src/runtime/views.rs @@ -9,6 +9,7 @@ mod history; mod save_action; mod selection; mod statistics; +mod template_selection; mod timer; fn enqueue_action(action_tx: &ActionTx, action: Action) { @@ -41,6 +42,9 @@ pub(super) fn handle_view_key(key: KeyEvent, app: &mut App, action_tx: &ActionTx match &app.current_view { app::View::SelectProject => selection::handle_select_project_key(key, app, action_tx), app::View::SelectActivity => selection::handle_select_activity_key(key, app, action_tx), + app::View::SelectTemplate => { + template_selection::handle_select_template_key(key, app, action_tx) + } app::View::EditDescription => { edit_description::handle_edit_description_key(key, app, action_tx) } diff --git a/toki-tui/src/runtime/views/selection.rs b/toki-tui/src/runtime/views/selection.rs index 706ec152..049328db 100644 --- a/toki-tui/src/runtime/views/selection.rs +++ b/toki-tui/src/runtime/views/selection.rs @@ -84,7 +84,7 @@ pub(super) fn handle_select_activity_key(key: KeyEvent, app: &mut App, action_tx } } -fn handle_selection_input_key( +pub(super) fn handle_selection_input_key( key: KeyEvent, app: &mut App, list_index: usize, @@ -173,7 +173,7 @@ fn handle_selection_input_key( } #[derive(Clone, Copy)] -struct SelectionInputOps { +pub(super) struct SelectionInputOps { clear_input: fn(&mut App), input_char: fn(&mut App, char), input_backspace: fn(&mut App), diff --git a/toki-tui/src/runtime/views/template_selection.rs b/toki-tui/src/runtime/views/template_selection.rs new file mode 100644 index 00000000..f33269d6 --- /dev/null +++ b/toki-tui/src/runtime/views/template_selection.rs @@ -0,0 +1,113 @@ +use crate::app::App; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use super::super::action_queue::{Action, ActionTx}; +use super::enqueue_action; + +pub(super) fn handle_select_template_key(key: KeyEvent, app: &mut App, action_tx: &ActionTx) { + if handle_template_input_key(key, app) { + return; + } + + match key.code { + KeyCode::Enter => { + if let Some(template) = app + .filtered_templates + .get(app.filtered_template_index) + .cloned() + { + enqueue_action(action_tx, Action::ApplyTemplate { template }); + } else { + app.navigate_to(crate::app::View::Timer); + } + } + KeyCode::Esc => app.navigate_to(crate::app::View::Timer), + KeyCode::Char('q') | KeyCode::Char('Q') => app.quit(), + _ => {} + } +} + +fn handle_template_input_key(key: KeyEvent, app: &mut App) -> bool { + let list_index = app.filtered_template_index; + let list_len = app.filtered_templates.len(); + + match key.code { + KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.template_search_input_clear(); + true + } + KeyCode::Tab => { + app.selection_list_focused = true; + true + } + KeyCode::BackTab => { + app.selection_list_focused = false; + true + } + KeyCode::Char(c) + if !key.modifiers.contains(KeyModifiers::CONTROL) && c != 'q' && c != 'Q' => + { + if app.selection_list_focused && c == 'j' { + if list_index + 1 >= list_len { + app.selection_list_focused = false; + } else { + app.select_next(); + } + } else if app.selection_list_focused && c == 'k' { + if list_index == 0 { + app.selection_list_focused = false; + } else { + app.select_previous(); + } + } else if !app.selection_list_focused { + app.template_search_input_char(c); + } + true + } + KeyCode::Backspace => { + app.template_search_input_backspace(); + true + } + KeyCode::Up => { + if app.selection_list_focused && list_index == 0 { + app.selection_list_focused = false; + } else { + app.select_previous(); + } + true + } + KeyCode::Down => { + if app.selection_list_focused && list_index + 1 >= list_len { + app.selection_list_focused = false; + } else { + app.select_next(); + } + true + } + KeyCode::Left => { + if !app.selection_list_focused { + app.template_search_move_cursor(true); + } + true + } + KeyCode::Right => { + if !app.selection_list_focused { + app.template_search_move_cursor(false); + } + true + } + KeyCode::Home => { + if !app.selection_list_focused { + app.template_search_cursor_home_end(true); + } + true + } + KeyCode::End => { + if !app.selection_list_focused { + app.template_search_cursor_home_end(false); + } + true + } + _ => false, + } +} diff --git a/toki-tui/src/ui/mod.rs b/toki-tui/src/ui/mod.rs index 5e39a600..9dc15771 100644 --- a/toki-tui/src/ui/mod.rs +++ b/toki-tui/src/ui/mod.rs @@ -17,6 +17,7 @@ mod history_view; mod save_dialog; mod selection_views; mod statistics_view; +mod template_selection_view; mod timer_view; pub(super) mod utils; pub(super) mod widgets; @@ -46,6 +47,9 @@ pub fn render(frame: &mut Frame, app: &mut App) { View::History => history_view::render_history_view(frame, app, body), View::SelectProject => selection_views::render_project_selection(frame, app, body), View::SelectActivity => selection_views::render_activity_selection(frame, app, body), + View::SelectTemplate => { + template_selection_view::render_template_selection(frame, app, body) + } View::EditDescription => { if app.taskwarrior_overlay.is_some() { description_editor::render_taskwarrior_overlay(frame, app, body); diff --git a/toki-tui/src/ui/template_selection_view.rs b/toki-tui/src/ui/template_selection_view.rs new file mode 100644 index 00000000..627ec74c --- /dev/null +++ b/toki-tui/src/ui/template_selection_view.rs @@ -0,0 +1,126 @@ +use super::*; + +pub fn render_template_selection(frame: &mut Frame, app: &App, body: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(3), // Search input + Constraint::Min(0), // Template list + Constraint::Length(3), // Controls + ]) + .split(body); + + // Search input box + let search_text = if app.template_search_input.value.is_empty() { + if app.selection_list_focused { + "Type to search...".to_string() + } else { + "█".to_string() + } + } else if app.selection_list_focused { + app.template_search_input.value.clone() + } else { + let (before, after) = app.template_search_input.split_at_cursor(); + format!("{}█{}", before, after) + }; + let search_border = if app.selection_list_focused { + Style::default().fg(Color::DarkGray) + } else { + Style::default().fg(Color::White) + }; + let search_box = Paragraph::new(search_text) + .style(Style::default().fg(Color::White)) + .alignment(Alignment::Left) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(search_border) + .title(" Search ") + .padding(Padding::horizontal(1)), + ); + frame.render_widget(search_box, chunks[0]); + + // Template list + let items: Vec = app + .filtered_templates + .iter() + .enumerate() + .map(|(i, template)| { + let selected = i == app.filtered_template_index; + let desc_style = if selected { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::White) + }; + let sub_style = Style::default().fg(Color::DarkGray); + + let line1 = Line::from(Span::styled(template.description.clone(), desc_style)); + let line2 = Line::from(Span::styled( + format!("{} / {}", template.project, template.activity), + sub_style, + )); + + ListItem::new(vec![line1, line2]) + }) + .collect(); + + // Show count: filtered / total + let title = if app.template_search_input.value.is_empty() { + format!(" Templates ({}) ", app.templates.len()) + } else { + format!( + " Templates ({}/{}) ", + app.filtered_templates.len(), + app.templates.len() + ) + }; + + let list_border = if app.selection_list_focused { + Style::default().fg(Color::White) + } else { + Style::default().fg(Color::DarkGray) + }; + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(list_border) + .title(title) + .padding(Padding::horizontal(1)), + ) + .style(Style::default()); + + frame.render_widget(list, chunks[1]); + + // Controls + let controls_text = vec![ + Span::styled("Type", Style::default().fg(Color::Yellow)), + Span::raw(": Filter "), + Span::styled("Tab", Style::default().fg(Color::Yellow)), + Span::raw(": Focus list "), + Span::styled("↑↓/j/k", Style::default().fg(Color::Yellow)), + Span::raw(": Navigate "), + Span::styled("Enter", Style::default().fg(Color::Yellow)), + Span::raw(": Apply "), + Span::styled("Ctrl+X", Style::default().fg(Color::Yellow)), + Span::raw(": Clear "), + Span::styled("Esc", Style::default().fg(Color::Yellow)), + Span::raw(": Cancel"), + ]; + + let controls = Paragraph::new(Line::from(controls_text)) + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .title(Span::styled( + " Controls ", + Style::default().fg(Color::DarkGray), + )) + .padding(ratatui::widgets::Padding::horizontal(1)), + ); + + frame.render_widget(controls, chunks[2]); +} From 68c2a5858eaeb7436002711d9efeeecd45604f84 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 10:11:25 +0100 Subject: [PATCH 12/53] feat(tui): bind M to template picker in timer view --- toki-tui/src/runtime/views/timer.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/toki-tui/src/runtime/views/timer.rs b/toki-tui/src/runtime/views/timer.rs index 1803b291..713f48d4 100644 --- a/toki-tui/src/runtime/views/timer.rs +++ b/toki-tui/src/runtime/views/timer.rs @@ -168,6 +168,11 @@ pub(super) fn handle_timer_key(key: KeyEvent, app: &mut App, action_tx: &ActionT } } } + KeyCode::Char('m') | KeyCode::Char('M') + if !is_editing_this_week(app) && !app.templates.is_empty() => + { + app.navigate_to(app::View::SelectTemplate); + } _ => {} } } From fa80a2290cacd4b0618b168575d4222bf9a90f64 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 10:12:20 +0100 Subject: [PATCH 13/53] feat(tui): show [M] Template hint in controls bar when templates configured --- toki-tui/src/ui/timer_view.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/toki-tui/src/ui/timer_view.rs b/toki-tui/src/ui/timer_view.rs index 80529a69..0dfdea57 100644 --- a/toki-tui/src/ui/timer_view.rs +++ b/toki-tui/src/ui/timer_view.rs @@ -26,7 +26,7 @@ pub fn render_timer_view(frame: &mut Frame, app: &mut App, body: Rect) { render_description(frame, chunks[2], app); super::history_panel::render_this_week_history(frame, chunks[3], app); render_status(frame, chunks[4], app); - render_controls(frame, chunks[5]); + render_controls(frame, chunks[5], app); } fn render_timer(frame: &mut Frame, area: ratatui::layout::Rect, app: &App) { @@ -227,7 +227,7 @@ pub fn render_status(frame: &mut Frame, area: ratatui::layout::Rect, app: &App) frame.render_widget(status, area); } -fn render_controls(frame: &mut Frame, area: ratatui::layout::Rect) { +fn render_controls(frame: &mut Frame, area: ratatui::layout::Rect, app: &App) { let line1 = vec![ Span::styled("Space", Style::default().fg(Color::Yellow)), Span::raw(": Start/Stop "), @@ -241,7 +241,7 @@ fn render_controls(frame: &mut Frame, area: ratatui::layout::Rect) { Span::raw(": Edit"), ]; - let line2 = vec![ + let mut line2 = vec![ Span::styled("P", Style::default().fg(Color::Yellow)), Span::raw(": Project "), Span::styled("N", Style::default().fg(Color::Yellow)), @@ -256,13 +256,21 @@ fn render_controls(frame: &mut Frame, area: ratatui::layout::Rect) { Span::raw(": Copy to running timer "), Span::styled("R", Style::default().fg(Color::Yellow)), Span::raw(": Resume "), + ]; + + if !app.templates.is_empty() { + line2.push(Span::styled("M", Style::default().fg(Color::Yellow))); + line2.push(Span::raw(": Template ")); + } + + line2.extend([ Span::styled("Z", Style::default().fg(Color::Yellow)), Span::raw(": Zen mode "), Span::styled("Esc", Style::default().fg(Color::Yellow)), Span::raw(": Exit edit "), Span::styled("Q", Style::default().fg(Color::Yellow)), Span::raw(": Quit"), - ]; + ]); let controls = Paragraph::new(vec![Line::from(line1), Line::from(line2)]) .alignment(Alignment::Center) From eaf0bd73a6930d96887e9c858ade9ab434d75dc9 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 10:25:41 +0100 Subject: [PATCH 14/53] fix(tui): rename TokiConfig field templates -> template so [[template]] works in config.toml --- toki-tui/src/app/mod.rs | 2 +- toki-tui/src/config.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/toki-tui/src/app/mod.rs b/toki-tui/src/app/mod.rs index 18e3820c..d2f06a7d 100644 --- a/toki-tui/src/app/mod.rs +++ b/toki-tui/src/app/mod.rs @@ -183,7 +183,7 @@ impl App { task_filter: cfg.task_filter.clone(), git_default_prefix: cfg.git_default_prefix.clone(), auto_resize_timer: cfg.auto_resize_timer, - templates: cfg.templates.clone(), + templates: cfg.template.clone(), template_search_input: TextInput::new(), filtered_templates: Vec::new(), filtered_template_index: 0, diff --git a/toki-tui/src/config.rs b/toki-tui/src/config.rs index 3896a650..806195a8 100644 --- a/toki-tui/src/config.rs +++ b/toki-tui/src/config.rs @@ -29,7 +29,7 @@ pub struct TokiConfig { pub auto_resize_timer: bool, /// Named presets of (project, activity, note) applied via the template picker. #[serde(default)] - pub templates: Vec, + pub template: Vec, } fn default_api_url() -> String { @@ -51,7 +51,7 @@ impl Default for TokiConfig { task_filter: String::new(), git_default_prefix: default_git_prefix(), auto_resize_timer: default_auto_resize_timer(), - templates: Vec::new(), + template: Vec::new(), } } } From c8493b8aae2b97d638f597af15c577fb4cbc6785 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 10:40:31 +0100 Subject: [PATCH 15/53] fix(tui): colon separator in template picker, rebind T->template G->toggle-size, reorder controls bar --- toki-tui/src/runtime/views/timer.rs | 4 ++-- toki-tui/src/ui/template_selection_view.rs | 2 +- toki-tui/src/ui/timer_view.rs | 18 +++++++++--------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/toki-tui/src/runtime/views/timer.rs b/toki-tui/src/runtime/views/timer.rs index 713f48d4..5c62bbc1 100644 --- a/toki-tui/src/runtime/views/timer.rs +++ b/toki-tui/src/runtime/views/timer.rs @@ -122,7 +122,7 @@ pub(super) fn handle_timer_key(key: KeyEvent, app: &mut App, action_tx: &ActionT KeyCode::Char('n') | KeyCode::Char('N') => { app.navigate_to(app::View::EditDescription); } - KeyCode::Char('t') | KeyCode::Char('T') => { + KeyCode::Char('g') | KeyCode::Char('G') => { app.toggle_timer_size(); } // S: Open Stats view (unmodified only - Ctrl+S is save) @@ -168,7 +168,7 @@ pub(super) fn handle_timer_key(key: KeyEvent, app: &mut App, action_tx: &ActionT } } } - KeyCode::Char('m') | KeyCode::Char('M') + KeyCode::Char('t') | KeyCode::Char('T') if !is_editing_this_week(app) && !app.templates.is_empty() => { app.navigate_to(app::View::SelectTemplate); diff --git a/toki-tui/src/ui/template_selection_view.rs b/toki-tui/src/ui/template_selection_view.rs index 627ec74c..4d7dc699 100644 --- a/toki-tui/src/ui/template_selection_view.rs +++ b/toki-tui/src/ui/template_selection_view.rs @@ -57,7 +57,7 @@ pub fn render_template_selection(frame: &mut Frame, app: &App, body: Rect) { let line1 = Line::from(Span::styled(template.description.clone(), desc_style)); let line2 = Line::from(Span::styled( - format!("{} / {}", template.project, template.activity), + format!("{}: {}", template.project, template.activity), sub_style, )); diff --git a/toki-tui/src/ui/timer_view.rs b/toki-tui/src/ui/timer_view.rs index 0dfdea57..e861870a 100644 --- a/toki-tui/src/ui/timer_view.rs +++ b/toki-tui/src/ui/timer_view.rs @@ -246,24 +246,24 @@ fn render_controls(frame: &mut Frame, area: ratatui::layout::Rect, app: &App) { Span::raw(": Project "), Span::styled("N", Style::default().fg(Color::Yellow)), Span::raw(": Note "), + ]; + + if !app.templates.is_empty() { + line2.push(Span::styled("T", Style::default().fg(Color::Yellow))); + line2.push(Span::raw(": Template ")); + } + + line2.extend([ Span::styled("H", Style::default().fg(Color::Yellow)), Span::raw(": History "), Span::styled("S", Style::default().fg(Color::Yellow)), Span::raw(": Stats "), - Span::styled("T", Style::default().fg(Color::Yellow)), + Span::styled("G", Style::default().fg(Color::Yellow)), Span::raw(": Toggle size "), Span::styled("Y", Style::default().fg(Color::Yellow)), Span::raw(": Copy to running timer "), Span::styled("R", Style::default().fg(Color::Yellow)), Span::raw(": Resume "), - ]; - - if !app.templates.is_empty() { - line2.push(Span::styled("M", Style::default().fg(Color::Yellow))); - line2.push(Span::raw(": Template ")); - } - - line2.extend([ Span::styled("Z", Style::default().fg(Color::Yellow)), Span::raw(": Zen mode "), Span::styled("Esc", Style::default().fg(Color::Yellow)), From 6c27359268e8932e2c2236188aeacbe633f31b2a Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 11:32:32 +0100 Subject: [PATCH 16/53] feat(tui): add word-boundary methods to TextInput --- toki-tui/src/app/state.rs | 146 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/toki-tui/src/app/state.rs b/toki-tui/src/app/state.rs index 03e85dfc..7c775d4d 100644 --- a/toki-tui/src/app/state.rs +++ b/toki-tui/src/app/state.rs @@ -209,6 +209,94 @@ impl TextInput { self.cursor = self.value.len(); } + /// Move cursor left by one whitespace-delimited word (bash/readline style). + /// Skips whitespace leftward, then skips non-whitespace leftward. + pub fn move_word_left(&mut self) { + if self.cursor == 0 { + return; + } + // Step 1: skip whitespace to the left + let mut p = self.cursor; + while p > 0 { + let prev = self.prev_boundary(p); + if self.value[prev..p] + .chars() + .next() + .map(|c| c.is_whitespace()) + .unwrap_or(false) + { + p = prev; + } else { + break; + } + } + // Step 2: skip non-whitespace to the left + while p > 0 { + let prev = self.prev_boundary(p); + if self.value[prev..p] + .chars() + .next() + .map(|c| c.is_whitespace()) + .unwrap_or(false) + { + break; + } else { + p = prev; + } + } + self.cursor = p; + } + + /// Move cursor right by one whitespace-delimited word (bash/readline style). + /// Skips non-whitespace rightward, then skips whitespace rightward. + pub fn move_word_right(&mut self) { + let len = self.value.len(); + if self.cursor >= len { + return; + } + // Step 1: skip non-whitespace to the right + let mut p = self.cursor; + while p < len { + let next = self.next_boundary(p); + if self.value[p..next] + .chars() + .next() + .map(|c| c.is_whitespace()) + .unwrap_or(false) + { + break; + } else { + p = next; + } + } + // Step 2: skip whitespace to the right + while p < len { + let next = self.next_boundary(p); + if self.value[p..next] + .chars() + .next() + .map(|c| c.is_whitespace()) + .unwrap_or(false) + { + p = next; + } else { + break; + } + } + self.cursor = p; + } + + /// Delete the word immediately before the cursor (Alt+Backspace / readline kill-word-back). + /// Equivalent to move_word_left then delete from new position to old cursor. + pub fn delete_word_back(&mut self) { + if self.cursor == 0 { + return; + } + let old_cursor = self.cursor; + self.move_word_left(); + self.value.drain(self.cursor..old_cursor); + } + pub fn clear(&mut self) { self.value.clear(); self.cursor = 0; @@ -321,4 +409,62 @@ mod tests { input.move_right(); assert_eq!(input.cursor, "a😀b".len()); } + + #[test] + fn text_input_move_word_left_basic() { + let mut ti = TextInput::from_str("hello world foo"); + // cursor at end (15) + ti.move_word_left(); // skip 0 whitespace, skip "foo" → cursor at 12 + assert_eq!(ti.cursor, 12); + ti.move_word_left(); // skip 1 space, skip "world" → cursor at 6 + assert_eq!(ti.cursor, 6); + ti.move_word_left(); // skip 1 space, skip "hello" → cursor at 0 + assert_eq!(ti.cursor, 0); + ti.move_word_left(); // at start, no-op + assert_eq!(ti.cursor, 0); + } + + #[test] + fn text_input_move_word_left_from_middle_of_word() { + let mut ti = TextInput::from_str("hello world"); + ti.cursor = 8; // inside "world" at byte 8 (w=6,o=7,r=8) + ti.move_word_left(); // no leading whitespace, skip non-ws back to 6 + assert_eq!(ti.cursor, 6); + } + + #[test] + fn text_input_move_word_right_basic() { + let mut ti = TextInput::from_str("hello world foo"); + ti.cursor = 0; + ti.move_word_right(); // skip "hello" (5), skip " " (1) → cursor at 6 + assert_eq!(ti.cursor, 6); + ti.move_word_right(); // skip "world" (5), skip " " (1) → cursor at 12 + assert_eq!(ti.cursor, 12); + ti.move_word_right(); // skip "foo" (3), no trailing ws → cursor at 15 + assert_eq!(ti.cursor, 15); + ti.move_word_right(); // at end, no-op + assert_eq!(ti.cursor, 15); + } + + #[test] + fn text_input_delete_word_back_basic() { + let mut ti = TextInput::from_str("hello world"); + // cursor at end (11): skip 0 ws, skip "world" (5) back to 6. drain [6..11]. + ti.delete_word_back(); + assert_eq!(ti.value, "hello "); + assert_eq!(ti.cursor, 6); + // now skip " " (1 ws), skip "hello" (5) → cursor 0. drain [0..6]. + ti.delete_word_back(); + assert_eq!(ti.value, ""); + assert_eq!(ti.cursor, 0); + } + + #[test] + fn text_input_delete_word_back_at_start() { + let mut ti = TextInput::from_str("hello"); + ti.cursor = 0; + ti.delete_word_back(); // no-op + assert_eq!(ti.value, "hello"); + assert_eq!(ti.cursor, 0); + } } From d0dff0087f09066c332a36e9d080178883fe9961 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 11:39:02 +0100 Subject: [PATCH 17/53] feat(tui): add word-navigation App wrapper methods --- toki-tui/src/app/edit.rs | 45 +++++++++++++++++++++++++++++ toki-tui/src/app/mod.rs | 62 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/toki-tui/src/app/edit.rs b/toki-tui/src/app/edit.rs index a2502052..99b6a431 100644 --- a/toki-tui/src/app/edit.rs +++ b/toki-tui/src/app/edit.rs @@ -403,6 +403,51 @@ impl App { } } + /// Move cursor left by one word in the Note field. + pub fn entry_edit_word_left(&mut self) { + let apply = |state: &mut EntryEditState| { + if state.focused_field == EntryEditField::Note { + state.note.move_word_left(); + } + }; + if let Some(s) = &mut self.this_week_edit_state { + apply(s); + } + if let Some(s) = &mut self.history_edit_state { + apply(s); + } + } + + /// Move cursor right by one word in the Note field. + pub fn entry_edit_word_right(&mut self) { + let apply = |state: &mut EntryEditState| { + if state.focused_field == EntryEditField::Note { + state.note.move_word_right(); + } + }; + if let Some(s) = &mut self.this_week_edit_state { + apply(s); + } + if let Some(s) = &mut self.history_edit_state { + apply(s); + } + } + + /// Delete the previous word in the Note field. + pub fn entry_edit_delete_word_back(&mut self) { + let apply = |state: &mut EntryEditState| { + if state.focused_field == EntryEditField::Note { + state.note.delete_word_back(); + } + }; + if let Some(s) = &mut self.this_week_edit_state { + apply(s); + } + if let Some(s) = &mut self.history_edit_state { + apply(s); + } + } + /// Clear the current time field for direct re-entry pub fn entry_edit_clear_time(&mut self) { if let Some(state) = &mut self.this_week_edit_state { diff --git a/toki-tui/src/app/mod.rs b/toki-tui/src/app/mod.rs index d0d64846..9048bbf3 100644 --- a/toki-tui/src/app/mod.rs +++ b/toki-tui/src/app/mod.rs @@ -594,6 +594,24 @@ impl App { } } + pub fn input_word_left(&mut self) { + if self.editing_description { + self.description_input.move_word_left(); + } + } + + pub fn input_word_right(&mut self) { + if self.editing_description { + self.description_input.move_word_right(); + } + } + + pub fn input_delete_word_back(&mut self) { + if self.editing_description { + self.description_input.delete_word_back(); + } + } + /// Confirm description edit pub fn confirm_description(&mut self) { self.editing_description = false; @@ -715,6 +733,19 @@ impl App { } } + pub fn search_word_left(&mut self) { + self.project_search_input.move_word_left(); + } + + pub fn search_word_right(&mut self) { + self.project_search_input.move_word_right(); + } + + pub fn search_delete_word_back(&mut self) { + self.project_search_input.delete_word_back(); + self.filter_projects(); + } + pub fn activity_search_move_cursor(&mut self, left: bool) { if left { self.activity_search_input.move_left(); @@ -731,6 +762,19 @@ impl App { } } + pub fn activity_search_word_left(&mut self) { + self.activity_search_input.move_word_left(); + } + + pub fn activity_search_word_right(&mut self) { + self.activity_search_input.move_word_right(); + } + + pub fn activity_search_delete_word_back(&mut self) { + self.activity_search_input.delete_word_back(); + self.filter_activities(); + } + pub fn select_next_save_action(&mut self) { self.selected_save_action = match self.selected_save_action { SaveAction::SaveAndStop => SaveAction::ContinueNewProject, @@ -954,6 +998,24 @@ impl App { } } + pub fn cwd_word_left(&mut self) { + if let Some(ref mut ti) = self.cwd_input { + ti.move_word_left(); + } + } + + pub fn cwd_word_right(&mut self) { + if let Some(ref mut ti) = self.cwd_input { + ti.move_word_right(); + } + } + + pub fn cwd_delete_word_back(&mut self) { + if let Some(ref mut ti) = self.cwd_input { + ti.delete_word_back(); + } + } + pub fn open_taskwarrior_overlay(&mut self) { let mut cmd = std::process::Command::new("task"); cmd.arg("rc.verbose=nothing"); From 55cc4d0a2b6be044ec0ed3756d896a9603103742 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 11:39:51 +0100 Subject: [PATCH 18/53] feat(tui): word navigation in description and cwd inputs --- toki-tui/src/runtime/views/edit_description.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/toki-tui/src/runtime/views/edit_description.rs b/toki-tui/src/runtime/views/edit_description.rs index f2872ce3..33225914 100644 --- a/toki-tui/src/runtime/views/edit_description.rs +++ b/toki-tui/src/runtime/views/edit_description.rs @@ -17,8 +17,17 @@ pub(super) fn handle_edit_description_key(key: KeyEvent, app: &mut App, action_t } } KeyCode::Tab => app.cwd_tab_complete(), + KeyCode::Backspace if key.modifiers.contains(KeyModifiers::ALT) => { + app.cwd_delete_word_back(); + } KeyCode::Backspace => app.cwd_input_backspace(), + KeyCode::Left if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.cwd_word_left(); + } KeyCode::Left => app.cwd_move_cursor(true), + KeyCode::Right if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.cwd_word_right(); + } KeyCode::Right => app.cwd_move_cursor(false), KeyCode::Home => app.cwd_cursor_home_end(true), KeyCode::End => app.cwd_cursor_home_end(false), @@ -78,8 +87,17 @@ pub(super) fn handle_edit_description_key(key: KeyEvent, app: &mut App, action_t KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { app.input_char(c); } + KeyCode::Backspace if key.modifiers.contains(KeyModifiers::ALT) => { + app.input_delete_word_back(); + } KeyCode::Backspace => app.input_backspace(), + KeyCode::Left if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.input_word_left(); + } KeyCode::Left => app.input_move_cursor(true), + KeyCode::Right if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.input_word_right(); + } KeyCode::Right => app.input_move_cursor(false), KeyCode::Home => app.input_cursor_home_end(true), KeyCode::End => app.input_cursor_home_end(false), From ef59e740201a7bb3802c44d6e4af521c8cd72409 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 11:41:11 +0100 Subject: [PATCH 19/53] feat(tui): word navigation in project/activity search inputs --- toki-tui/src/runtime/views/selection.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/toki-tui/src/runtime/views/selection.rs b/toki-tui/src/runtime/views/selection.rs index 706ec152..448ad694 100644 --- a/toki-tui/src/runtime/views/selection.rs +++ b/toki-tui/src/runtime/views/selection.rs @@ -21,6 +21,9 @@ pub(super) fn handle_select_project_key(key: KeyEvent, app: &mut App, action_tx: input_backspace: App::search_input_backspace, move_cursor: App::search_move_cursor, cursor_home_end: App::search_cursor_home_end, + word_left: App::search_word_left, + word_right: App::search_word_right, + delete_word_back: App::search_delete_word_back, }, ) { return; @@ -61,6 +64,9 @@ pub(super) fn handle_select_activity_key(key: KeyEvent, app: &mut App, action_tx input_backspace: App::activity_search_input_backspace, move_cursor: App::activity_search_move_cursor, cursor_home_end: App::activity_search_cursor_home_end, + word_left: App::activity_search_word_left, + word_right: App::activity_search_word_right, + delete_word_back: App::activity_search_delete_word_back, }, ) { return; @@ -124,6 +130,10 @@ fn handle_selection_input_key( } true } + KeyCode::Backspace if key.modifiers.contains(KeyModifiers::ALT) => { + (ops.delete_word_back)(app); + true + } KeyCode::Backspace => { (ops.input_backspace)(app); true @@ -144,12 +154,24 @@ fn handle_selection_input_key( } true } + KeyCode::Left if key.modifiers.contains(KeyModifiers::CONTROL) => { + if !app.selection_list_focused { + (ops.word_left)(app); + } + true + } KeyCode::Left => { if !app.selection_list_focused { (ops.move_cursor)(app, true); } true } + KeyCode::Right if key.modifiers.contains(KeyModifiers::CONTROL) => { + if !app.selection_list_focused { + (ops.word_right)(app); + } + true + } KeyCode::Right => { if !app.selection_list_focused { (ops.move_cursor)(app, false); @@ -179,4 +201,7 @@ struct SelectionInputOps { input_backspace: fn(&mut App), move_cursor: fn(&mut App, bool), cursor_home_end: fn(&mut App, bool), + word_left: fn(&mut App), + word_right: fn(&mut App), + delete_word_back: fn(&mut App), } From af12fa5a07a71e1b57149e7ccbd79c1b8fcba31d Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 11:42:34 +0100 Subject: [PATCH 20/53] feat(tui): word navigation in history/timer edit-mode note field --- toki-tui/src/runtime/views/history.rs | 25 +++++++++++++++++++++++++ toki-tui/src/runtime/views/timer.rs | 6 ++++++ 2 files changed, 31 insertions(+) diff --git a/toki-tui/src/runtime/views/history.rs b/toki-tui/src/runtime/views/history.rs index 18a521fc..b04d828d 100644 --- a/toki-tui/src/runtime/views/history.rs +++ b/toki-tui/src/runtime/views/history.rs @@ -21,6 +21,17 @@ pub(super) fn handle_history_key(key: KeyEvent, app: &mut App, action_tx: &Actio KeyCode::Up | KeyCode::Char('k') => { app.entry_edit_prev_field(); } + KeyCode::Right if key.modifiers.contains(KeyModifiers::CONTROL) => { + if app + .history_edit_state + .as_ref() + .is_some_and(|s| s.focused_field == app::EntryEditField::Note) + { + app.entry_edit_word_right(); + } else { + app.entry_edit_next_field(); + } + } KeyCode::Right => { if app .history_edit_state @@ -35,6 +46,17 @@ pub(super) fn handle_history_key(key: KeyEvent, app: &mut App, action_tx: &Actio KeyCode::Char('l') | KeyCode::Char('L') => { app.entry_edit_next_field(); } + KeyCode::Left if key.modifiers.contains(KeyModifiers::CONTROL) => { + if app + .history_edit_state + .as_ref() + .is_some_and(|s| s.focused_field == app::EntryEditField::Note) + { + app.entry_edit_word_left(); + } else { + app.entry_edit_prev_field(); + } + } KeyCode::Left => { if app .history_edit_state @@ -54,6 +76,9 @@ pub(super) fn handle_history_key(key: KeyEvent, app: &mut App, action_tx: &Actio KeyCode::Char(c) if c.is_ascii_digit() => { app.entry_edit_input_char(c); } + KeyCode::Backspace if key.modifiers.contains(KeyModifiers::ALT) => { + app.entry_edit_delete_word_back(); + } KeyCode::Backspace => { app.entry_edit_backspace(); } diff --git a/toki-tui/src/runtime/views/timer.rs b/toki-tui/src/runtime/views/timer.rs index 1803b291..c3a94419 100644 --- a/toki-tui/src/runtime/views/timer.rs +++ b/toki-tui/src/runtime/views/timer.rs @@ -89,6 +89,12 @@ pub(super) fn handle_timer_key(key: KeyEvent, app: &mut App, action_tx: &ActionT KeyCode::Char(c) if is_editing_this_week(app) && c.is_ascii_digit() => { app.entry_edit_input_char(c); } + KeyCode::Backspace + if key.modifiers.contains(KeyModifiers::ALT) + && is_note_focused_in_this_week_edit(app) => + { + app.entry_edit_delete_word_back(); + } KeyCode::Backspace => { if is_editing_this_week(app) { if !is_note_focused_in_this_week_edit(app) { From b7540c916660aaeb4529311ce353fb68ed107f6f Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 11:43:56 +0100 Subject: [PATCH 21/53] feat(tui): word navigation in template search input --- toki-tui/src/app/mod.rs | 13 +++++++++++++ toki-tui/src/runtime/views/template_selection.rs | 16 ++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/toki-tui/src/app/mod.rs b/toki-tui/src/app/mod.rs index 535434ed..2307750c 100644 --- a/toki-tui/src/app/mod.rs +++ b/toki-tui/src/app/mod.rs @@ -801,6 +801,19 @@ impl App { } } + pub fn template_search_word_left(&mut self) { + self.template_search_input.move_word_left(); + } + + pub fn template_search_word_right(&mut self) { + self.template_search_input.move_word_right(); + } + + pub fn template_search_delete_word_back(&mut self) { + self.template_search_input.delete_word_back(); + self.filter_templates(); + } + pub fn search_move_cursor(&mut self, left: bool) { if left { self.project_search_input.move_left(); diff --git a/toki-tui/src/runtime/views/template_selection.rs b/toki-tui/src/runtime/views/template_selection.rs index f33269d6..8b9713c4 100644 --- a/toki-tui/src/runtime/views/template_selection.rs +++ b/toki-tui/src/runtime/views/template_selection.rs @@ -64,6 +64,10 @@ fn handle_template_input_key(key: KeyEvent, app: &mut App) -> bool { } true } + KeyCode::Backspace if key.modifiers.contains(KeyModifiers::ALT) => { + app.template_search_delete_word_back(); + true + } KeyCode::Backspace => { app.template_search_input_backspace(); true @@ -84,12 +88,24 @@ fn handle_template_input_key(key: KeyEvent, app: &mut App) -> bool { } true } + KeyCode::Left if key.modifiers.contains(KeyModifiers::CONTROL) => { + if !app.selection_list_focused { + app.template_search_word_left(); + } + true + } KeyCode::Left => { if !app.selection_list_focused { app.template_search_move_cursor(true); } true } + KeyCode::Right if key.modifiers.contains(KeyModifiers::CONTROL) => { + if !app.selection_list_focused { + app.template_search_word_right(); + } + true + } KeyCode::Right => { if !app.selection_list_focused { app.template_search_move_cursor(false); From 588f584207775c678648b0efe6cf2d2930491c04 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 11:47:07 +0100 Subject: [PATCH 22/53] feat(tui): add log_notes module and open_editor helper --- toki-tui/src/editor.rs | 34 +++++++++++ toki-tui/src/log_notes.rs | 122 ++++++++++++++++++++++++++++++++++++++ toki-tui/src/main.rs | 2 + 3 files changed, 158 insertions(+) create mode 100644 toki-tui/src/editor.rs create mode 100644 toki-tui/src/log_notes.rs diff --git a/toki-tui/src/editor.rs b/toki-tui/src/editor.rs new file mode 100644 index 00000000..113f8ec6 --- /dev/null +++ b/toki-tui/src/editor.rs @@ -0,0 +1,34 @@ +use anyhow::Result; +use crossterm::{ + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use std::path::Path; + +/// Suspend the TUI, open $EDITOR on `path`, then restore the TUI. +/// Returns Ok(()) on success. The file is NOT read back here — caller reads it. +pub async fn open_editor(path: &Path) -> Result<()> { + let editor = std::env::var("VISUAL") + .or_else(|_| std::env::var("EDITOR")) + .unwrap_or_else(|_| "nano".to_string()); + + // Leave TUI + disable_raw_mode()?; + execute!(std::io::stdout(), LeaveAlternateScreen)?; + + // Spawn editor and wait + let status = tokio::process::Command::new(&editor) + .arg(path) + .status() + .await?; + + // Re-enter TUI + enable_raw_mode()?; + execute!(std::io::stdout(), EnterAlternateScreen)?; + + if !status.success() { + anyhow::bail!("Editor exited with non-zero status: {}", status); + } + + Ok(()) +} diff --git a/toki-tui/src/log_notes.rs b/toki-tui/src/log_notes.rs new file mode 100644 index 00000000..eaaae8d7 --- /dev/null +++ b/toki-tui/src/log_notes.rs @@ -0,0 +1,122 @@ +use std::path::PathBuf; + +const TAG_PREFIX: &str = " \u{00B7}log:"; // " ·log:" + +/// Returns the log storage directory: ~/.local/share/toki-tui/logs/ +pub fn log_dir() -> anyhow::Result { + let dir = dirs::data_local_dir() + .ok_or_else(|| anyhow::anyhow!("Could not determine local data directory"))? + .join("toki-tui") + .join("logs"); + std::fs::create_dir_all(&dir)?; + Ok(dir) +} + +/// Returns the path for a given log ID. +pub fn log_path(id: &str) -> anyhow::Result { + Ok(log_dir()?.join(format!("{}.md", id))) +} + +/// Generates a random 8-character lowercase hex ID. +pub fn generate_id() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + // Simple deterministic-enough ID from timestamp nanos XOR'd with a counter + // No external deps needed. + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos(); + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + format!( + "{:08x}", + (secs ^ (nanos as u64)).wrapping_mul(0x9e3779b97f4a7c15) + ) +} + +/// Extracts the log ID from a note string, if the tag is present. +/// e.g. "Fixed auth bug ·log:a3f8b2c1" → Some("a3f8b2c1") +pub fn extract_id(note: &str) -> Option<&str> { + let pos = note.find(TAG_PREFIX)?; + let after = ¬e[pos + TAG_PREFIX.len()..]; + // ID is exactly 8 hex chars + if after.len() >= 8 && after[..8].chars().all(|c| c.is_ascii_hexdigit()) { + Some(&after[..8]) + } else { + None + } +} + +/// Strips the log tag from a note for display purposes. +pub fn strip_tag(note: &str) -> &str { + if let Some(pos) = note.find(TAG_PREFIX) { + note[..pos].trim_end() + } else { + note + } +} + +/// Appends a log tag to a note string (returns new String). +pub fn append_tag(note: &str, id: &str) -> String { + format!("{}{}{}", note.trim_end(), TAG_PREFIX, id) +} + +/// Writes the initial log file with YAML frontmatter. +pub fn create_log_file( + id: &str, + date: &str, + project: &str, + activity: &str, + note_summary: &str, +) -> anyhow::Result { + let path = log_path(id)?; + if !path.exists() { + let content = format!( + "---\nid: {}\ndate: {}\nproject: {}\nactivity: {}\nnote: {}\n---\n\n", + id, date, project, activity, note_summary + ); + std::fs::write(&path, content)?; + } + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_id_present() { + let note = "Fixed auth bug \u{00B7}log:a3f8b2c1"; + assert_eq!(extract_id(note), Some("a3f8b2c1")); + } + + #[test] + fn test_extract_id_absent() { + assert_eq!(extract_id("No log here"), None); + } + + #[test] + fn test_strip_tag() { + let note = "Fixed auth bug \u{00B7}log:a3f8b2c1"; + assert_eq!(strip_tag(note), "Fixed auth bug"); + } + + #[test] + fn test_strip_tag_no_tag() { + assert_eq!(strip_tag("Plain note"), "Plain note"); + } + + #[test] + fn test_append_tag() { + let result = append_tag("Fixed auth bug", "a3f8b2c1"); + assert_eq!(result, "Fixed auth bug \u{00B7}log:a3f8b2c1"); + } + + #[test] + fn test_append_tag_trims_trailing_space() { + let result = append_tag("Fixed auth bug ", "a3f8b2c1"); + assert_eq!(result, "Fixed auth bug \u{00B7}log:a3f8b2c1"); + } +} diff --git a/toki-tui/src/main.rs b/toki-tui/src/main.rs index 4c6c4320..20e5faf7 100644 --- a/toki-tui/src/main.rs +++ b/toki-tui/src/main.rs @@ -3,7 +3,9 @@ mod app; mod bootstrap; mod cli; mod config; +mod editor; mod git; +mod log_notes; mod login; mod runtime; mod session_store; From 5d77e3fceb1f0aa1b1de1052c693d77b10f3253d Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 11:48:39 +0100 Subject: [PATCH 23/53] feat(tui): add OpenLogNote action and handler --- toki-tui/src/app/mod.rs | 5 ++ toki-tui/src/runtime/action_queue.rs | 1 + toki-tui/src/runtime/actions.rs | 74 ++++++++++++++++++++++++++++ toki-tui/src/runtime/event_loop.rs | 5 ++ 4 files changed, 85 insertions(+) diff --git a/toki-tui/src/app/mod.rs b/toki-tui/src/app/mod.rs index d0d64846..bcd56c6e 100644 --- a/toki-tui/src/app/mod.rs +++ b/toki-tui/src/app/mod.rs @@ -113,6 +113,10 @@ pub struct App { pub task_filter: String, pub git_default_prefix: String, pub auto_resize_timer: bool, + + /// Set to true after leaving/re-entering the alternate screen (e.g. after spawning an editor). + /// The event loop will call terminal.clear() to force a full redraw when this is true. + pub needs_full_redraw: bool, } impl App { @@ -177,6 +181,7 @@ impl App { task_filter: cfg.task_filter.clone(), git_default_prefix: cfg.git_default_prefix.clone(), auto_resize_timer: cfg.auto_resize_timer, + needs_full_redraw: false, } } diff --git a/toki-tui/src/runtime/action_queue.rs b/toki-tui/src/runtime/action_queue.rs index e55a88fe..d5fabbee 100644 --- a/toki-tui/src/runtime/action_queue.rs +++ b/toki-tui/src/runtime/action_queue.rs @@ -30,6 +30,7 @@ pub(super) enum Action { RefreshHistoryBackground, YankEntryToTimer(TimeEntry), ResumeEntry(TimeEntry), + OpenLogNote, } pub(super) type ActionTx = UnboundedSender; diff --git a/toki-tui/src/runtime/actions.rs b/toki-tui/src/runtime/actions.rs index 16627476..8d20647b 100644 --- a/toki-tui/src/runtime/actions.rs +++ b/toki-tui/src/runtime/actions.rs @@ -124,6 +124,11 @@ pub(super) async fn run_action( Action::ResumeEntry(entry) => { resume_entry(entry, app, client).await; } + Action::OpenLogNote => { + if let Err(e) = handle_open_log_note(app, client).await { + app.set_status(format!("Log note error: {}", e)); + } + } } Ok(()) } @@ -853,6 +858,75 @@ pub(super) fn is_milltime_auth_error(e: &anyhow::Error) -> bool { msg.contains("unauthorized") || msg.contains("authenticate") || msg.contains("milltime") } +async fn handle_open_log_note(app: &mut App, client: &mut ApiClient) -> anyhow::Result<()> { + use crate::log_notes; + use time::OffsetDateTime; + + let current_note = app.description_input.value.clone(); + + // Determine or generate the log ID + let id = if let Some(existing_id) = log_notes::extract_id(¤t_note) { + existing_id.to_string() + } else { + log_notes::generate_id() + }; + + // Summary = note without the tag + let summary = log_notes::strip_tag(¤t_note).to_string(); + + // Project/activity names for frontmatter + let project = app + .selected_project + .as_ref() + .map(|p| p.name.as_str()) + .unwrap_or("") + .to_string(); + let activity = app + .selected_activity + .as_ref() + .map(|a| a.name.as_str()) + .unwrap_or("") + .to_string(); + + // Date string + let today = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc()); + let date = format!( + "{:04}-{:02}-{:02}", + today.year(), + today.month() as u8, + today.day() + ); + + // Create log file if it doesn't exist yet + let log_path = log_notes::create_log_file(&id, &date, &project, &activity, &summary)?; + + // Open the editor (suspends TUI) + crate::editor::open_editor(&log_path).await?; + + // After editor closes: ensure the tag is in the note + let new_note = if log_notes::extract_id(¤t_note).is_some() { + // Tag already present — note unchanged + current_note + } else { + // Append the new tag + log_notes::append_tag(&summary, &id) + }; + + // Update app state + app.description_input = TextInput::from_str(&new_note); + app.description_is_default = false; + + // Signal the event loop to do a full terminal redraw after the editor exits + app.needs_full_redraw = true; + + // If timer is running, sync the updated note to the server + if app.timer_state == app::TimerState::Running { + sync_running_timer_note(new_note, app, client).await; + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/toki-tui/src/runtime/event_loop.rs b/toki-tui/src/runtime/event_loop.rs index 6a373e70..d69ca6cd 100644 --- a/toki-tui/src/runtime/event_loop.rs +++ b/toki-tui/src/runtime/event_loop.rs @@ -58,6 +58,11 @@ pub async fn run_app( run_action(action, app, client).await?; } + if app.needs_full_redraw { + terminal.clear()?; + app.needs_full_redraw = false; + } + if !app.running { break; } From 5130a161fda3f7f24a985ca0b5b0562740feb072 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 11:49:11 +0100 Subject: [PATCH 24/53] feat(tui): bind Ctrl+L to open log note in description editor --- toki-tui/src/runtime/views/edit_description.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/toki-tui/src/runtime/views/edit_description.rs b/toki-tui/src/runtime/views/edit_description.rs index f2872ce3..efc6815e 100644 --- a/toki-tui/src/runtime/views/edit_description.rs +++ b/toki-tui/src/runtime/views/edit_description.rs @@ -75,6 +75,11 @@ pub(super) fn handle_edit_description_key(key: KeyEvent, app: &mut App, action_t { app.open_taskwarrior_overlay(); } + KeyCode::Char('l') | KeyCode::Char('L') + if key.modifiers.contains(KeyModifiers::CONTROL) => + { + enqueue_action(action_tx, Action::OpenLogNote); + } KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { app.input_char(c); } From 78168c7d9b51ffb6094c439f67bb511dde4c0401 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 11:52:31 +0100 Subject: [PATCH 25/53] feat(tui): strip log tag in all note display locations and add log indicator --- toki-tui/src/ui/description_editor.rs | 21 +++++++++++++++++-- toki-tui/src/ui/widgets.rs | 30 +++++++++++---------------- 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/toki-tui/src/ui/description_editor.rs b/toki-tui/src/ui/description_editor.rs index bfd46548..49cedf55 100644 --- a/toki-tui/src/ui/description_editor.rs +++ b/toki-tui/src/ui/description_editor.rs @@ -1,5 +1,6 @@ use super::utils::centered_rect; use super::*; +use crate::log_notes; pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { let chunks = Layout::default() @@ -35,7 +36,14 @@ pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { ); frame.render_widget(input, chunks[0]); } else { - let (before, after) = app.description_input.split_at_cursor(); + // Strip the log tag from the displayed value — the user sees the clean summary. + // The raw value (including tag) is preserved in app.description_input.value. + let raw = &app.description_input.value; + let stripped = log_notes::strip_tag(raw); + // Compute cursor position in the stripped view (capped at stripped length) + let cursor = app.description_input.cursor.min(stripped.chars().count()); + let before: String = stripped.chars().take(cursor).collect(); + let after: String = stripped.chars().skip(cursor).collect(); let input_text = format!("{}█{}", before, after); let input = Paragraph::new(input_text) .style(Style::default().fg(Color::White)) @@ -119,6 +127,13 @@ pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { } else { Style::default().fg(Color::DarkGray) }; + let has_log = log_notes::extract_id(&app.description_input.value).is_some(); + let log_hint_key = Span::styled("Ctrl+L", Style::default().fg(Color::Yellow)); + let log_hint_label = if has_log { + Span::styled(": Log ", Style::default().fg(Color::Green)) + } else { + Span::raw(": New log ") + }; vec![ Span::styled("Type", Style::default().fg(Color::Yellow)), Span::raw(": Edit "), @@ -128,11 +143,13 @@ pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { Span::raw(": Confirm "), Span::styled("Esc", Style::default().fg(Color::Yellow)), Span::raw(": Cancel "), + log_hint_key, + log_hint_label, Span::styled("Ctrl+D", Style::default().fg(Color::Yellow)), Span::raw(": Change directory "), Span::styled("Ctrl+G", git_key_style), Span::styled( - ": Git quick commands ", + ": Git ", Style::default().fg(if has_git { Color::Reset } else { diff --git a/toki-tui/src/ui/widgets.rs b/toki-tui/src/ui/widgets.rs index d6d61a4c..f6504085 100644 --- a/toki-tui/src/ui/widgets.rs +++ b/toki-tui/src/ui/widgets.rs @@ -1,5 +1,6 @@ use super::utils::to_local_time; use crate::app::{EntryEditField, EntryEditState}; +use crate::log_notes; use crate::types::TimeEntry; use ratatui::{ style::{Color, Modifier, Style}, @@ -110,7 +111,8 @@ pub fn build_display_row( let project = &entry.project_name; let activity = &entry.activity_name; - let note = entry.note.as_deref().unwrap_or(""); + let note_raw = entry.note.as_deref().unwrap_or(""); + let note = log_notes::strip_tag(note_raw); // Start time let start_str = entry @@ -227,7 +229,7 @@ pub fn build_running_timer_display_row( .as_ref() .map(|a| a.name.clone()) .unwrap_or_else(|| "[None]".to_string()); - let note = app.description_input.value.clone(); + let note = log_notes::strip_tag(&app.description_input.value).to_string(); let prefix_len: usize = 28; // "▶ " (2) + "HH:MM - HH:MM " (14) + "[DDh:DDm]" (9) + " | " (3) let remaining = (available_width as usize).saturating_sub(prefix_len); @@ -336,6 +338,7 @@ pub fn build_running_timer_edit_row(edit_state: &EntryEditState) -> Line<'_> { _ => Style::default().fg(Color::White), }; let note_value = if matches!(edit_state.focused_field, EntryEditField::Note) { + // Cursor active — show raw value with cursor (user is typing) let (before, after) = edit_state.note.split_at_cursor(); if edit_state.note.value.is_empty() { "[█]".to_string() @@ -343,14 +346,9 @@ pub fn build_running_timer_edit_row(edit_state: &EntryEditState) -> Line<'_> { format!("[{}█{}]", before, after) } } else { - format!( - "[{}]", - if edit_state.note.value.is_empty() { - "Empty" - } else { - &edit_state.note.value - } - ) + // Display mode — strip the log tag + let display = log_notes::strip_tag(&edit_state.note.value); + format!("[{}]", if display.is_empty() { "Empty" } else { display }) }; spans.push(Span::styled(note_value, note_style)); @@ -432,6 +430,7 @@ pub fn build_edit_row<'a>( _ => Style::default().fg(Color::White), }; let note_value = if matches!(edit_state.focused_field, EntryEditField::Note) { + // Cursor active — show raw value with cursor (user is typing) let (before, after) = edit_state.note.split_at_cursor(); if edit_state.note.value.is_empty() { "[█]".to_string() @@ -439,14 +438,9 @@ pub fn build_edit_row<'a>( format!("[{}█{}]", before, after) } } else { - format!( - "[{}]", - if edit_state.note.value.is_empty() { - "Empty" - } else { - &edit_state.note.value - } - ) + // Display mode — strip the log tag + let display = log_notes::strip_tag(&edit_state.note.value); + format!("[{}]", if display.is_empty() { "Empty" } else { display }) }; spans.push(Span::styled(note_value, note_style)); From 30e6593bdc3800479309637c94c8361a451fe482 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 11:53:42 +0100 Subject: [PATCH 26/53] feat(tui): handle OpenLogNote in history edit mode --- toki-tui/src/runtime/actions.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/toki-tui/src/runtime/actions.rs b/toki-tui/src/runtime/actions.rs index 8d20647b..0faf95dd 100644 --- a/toki-tui/src/runtime/actions.rs +++ b/toki-tui/src/runtime/actions.rs @@ -916,11 +916,17 @@ async fn handle_open_log_note(app: &mut App, client: &mut ApiClient) -> anyhow:: app.description_input = TextInput::from_str(&new_note); app.description_is_default = false; + // If in edit mode, also sync the edit state's note field so Enter saves it correctly. + if app.is_in_edit_mode() { + app.update_edit_state_note(new_note.clone()); + } + // Signal the event loop to do a full terminal redraw after the editor exits app.needs_full_redraw = true; - // If timer is running, sync the updated note to the server - if app.timer_state == app::TimerState::Running { + // If timer is running AND we are NOT in edit mode, sync the updated note to the server. + // (In edit mode the note belongs to a history entry — it will be saved on Enter.) + if app.timer_state == app::TimerState::Running && !app.is_in_edit_mode() { sync_running_timer_note(new_note, app, client).await; } From 2e373bb614c2b697ed2ecbe78591ad2b7f3672c6 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 12:55:46 +0100 Subject: [PATCH 27/53] fix(tui): polish log notes UI (round 2 bug fixes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Timer view Note box: show muted '[...]' indicator instead of green dot - Timer/History entry rows: show '[...]' in white instead of '·' bullet - Fix raw log tag visible in edit-row Note field when focused (cursor view) - Notes view: add read-only Log box showing linked log file content - Notes view Info box: add 'Log file:' line when log is linked; grow to 4 lines - Notes view Controls: simplify Ctrl+L hint; add Shift+Ctrl+L for detach - Keybinding: Shift+Ctrl+L detaches log from current note (clears description_log_id) --- Cargo.lock | 2 +- toki-tui/Cargo.toml | 2 +- toki-tui/src/app/edit.rs | 2 +- toki-tui/src/app/mod.rs | 63 ++++++++++++- toki-tui/src/runtime/actions.rs | 72 +++++++-------- .../src/runtime/views/edit_description.rs | 34 ++++--- toki-tui/src/ui/description_editor.rs | 88 +++++++++++++++---- toki-tui/src/ui/timer_view.rs | 31 ++++--- toki-tui/src/ui/widgets.rs | 32 +++++-- 9 files changed, 239 insertions(+), 87 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 59e1f3c2..5be120f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5666,7 +5666,7 @@ dependencies = [ [[package]] name = "toki-tui" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "clap", diff --git a/toki-tui/Cargo.toml b/toki-tui/Cargo.toml index a4189ffe..ff43f685 100644 --- a/toki-tui/Cargo.toml +++ b/toki-tui/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toki-tui" -version = "0.2.0" +version = "0.3.0" edition = "2021" [dependencies] diff --git a/toki-tui/src/app/edit.rs b/toki-tui/src/app/edit.rs index 99b6a431..7b44e4e2 100644 --- a/toki-tui/src/app/edit.rs +++ b/toki-tui/src/app/edit.rs @@ -13,7 +13,7 @@ impl App { let project_name = self.selected_project.as_ref().map(|p| p.name.clone()); let activity_id = self.selected_activity.as_ref().map(|a| a.id.clone()); let activity_name = self.selected_activity.as_ref().map(|a| a.name.clone()); - let note = Some(self.description_input.value.clone()); + let note = Some(self.full_note_value()); self.create_edit_state( String::new(), // "" = running timer sentinel Some(start_time), diff --git a/toki-tui/src/app/mod.rs b/toki-tui/src/app/mod.rs index 69150706..8b698e07 100644 --- a/toki-tui/src/app/mod.rs +++ b/toki-tui/src/app/mod.rs @@ -123,6 +123,12 @@ pub struct App { /// Set to true after leaving/re-entering the alternate screen (e.g. after spawning an editor). /// The event loop will call terminal.clear() to force a full redraw when this is true. pub needs_full_redraw: bool, + + /// When a log note is linked to the current description, this holds the 8-char hex ID. + /// The `·log:` tag is stripped from `description_input` while editing so the user + /// sees (and edits) only the clean summary. The tag is re-appended when the editor + /// closes (Enter / Esc) or when `handle_open_log_note` reads the full note value. + pub description_log_id: Option, } impl App { @@ -192,6 +198,7 @@ impl App { filtered_templates: Vec::new(), filtered_template_index: 0, needs_full_redraw: false, + description_log_id: None, } } @@ -432,6 +439,18 @@ impl App { self.description_input.clear(); self.description_is_default = false; } + // Strip the log tag from the editable buffer so the user sees only the + // clean summary. The ID is preserved in description_log_id and re-appended + // when the editor closes. + { + use crate::log_notes; + let raw = self.description_input.value.clone(); + if let Some(id) = log_notes::extract_id(&raw) { + self.description_log_id = Some(id.to_string()); + let stripped = log_notes::strip_tag(&raw).to_string(); + self.description_input = TextInput::from_str(&stripped); + } + } self.editing_description = true; } View::Timer => { @@ -592,11 +611,53 @@ impl App { } } - /// Get current description for display + /// Get current description for display (clean summary, tag stripped) + #[allow(dead_code)] pub fn current_description(&self) -> String { self.description_input.value.clone() } + /// Returns the full note value: the clean summary from `description_input` with the + /// `·log:` tag re-appended if one is stored in `description_log_id`. + /// Use this instead of reading `description_input.value` directly whenever you need + /// the canonical note to save or sync. + pub fn full_note_value(&self) -> String { + use crate::log_notes; + match &self.description_log_id { + Some(id) => log_notes::append_tag(&self.description_input.value, id), + None => self.description_input.value.clone(), + } + } + + /// Restore the running timer's note from `saved_timer_note`, stripping any embedded + /// log tag into `description_log_id` so the invariant is maintained. + pub fn restore_saved_timer_note(&mut self) { + use crate::log_notes; + if let Some(saved) = self.saved_timer_note.take() { + if let Some(id) = log_notes::extract_id(&saved) { + self.description_log_id = Some(id.to_string()); + self.description_input = TextInput::from_str(log_notes::strip_tag(&saved)); + } else { + self.description_log_id = None; + self.description_input = TextInput::from_str(&saved); + } + } + } + + /// Set the running timer's note from a raw string (which may contain a `·log:` tag). + /// Strips the tag into `description_log_id` to maintain the invariant that + /// `description_input` always holds only the clean summary. + pub fn set_note_from_raw(&mut self, raw: &str) { + use crate::log_notes; + if let Some(id) = log_notes::extract_id(raw) { + self.description_log_id = Some(id.to_string()); + self.description_input = TextInput::from_str(log_notes::strip_tag(raw)); + } else { + self.description_log_id = None; + self.description_input = TextInput::from_str(raw); + } + } + /// Handle character input for description editing pub fn input_char(&mut self, c: char) { if self.editing_description { diff --git a/toki-tui/src/runtime/actions.rs b/toki-tui/src/runtime/actions.rs index 7007d782..b8478cd1 100644 --- a/toki-tui/src/runtime/actions.rs +++ b/toki-tui/src/runtime/actions.rs @@ -30,7 +30,7 @@ pub(crate) fn restore_active_timer(app: &mut App, timer: crate::types::ActiveTim }); } if !timer.note.is_empty() { - app.description_input = app::TextInput::from_str(&timer.note); + app.set_note_from_raw(&timer.note); app.description_is_default = false; } } @@ -143,10 +143,9 @@ pub(super) async fn handle_start_timer(app: &mut App, client: &mut ApiClient) -> let project_name = app.selected_project.as_ref().map(|p| p.name.clone()); let activity_id = app.selected_activity.as_ref().map(|a| a.id.clone()); let activity_name = app.selected_activity.as_ref().map(|a| a.name.clone()); - let note = if app.description_input.value.is_empty() { - None - } else { - Some(app.description_input.value.clone()) + let note = { + let full = app.full_note_value(); + if full.is_empty() { None } else { Some(full) } }; if let Err(e) = client .start_timer(project_id, project_name, activity_id, activity_name, note) @@ -419,10 +418,9 @@ async fn yank_entry_to_timer(entry: types::TimeEntry, app: &mut App, client: &mu let project_name = app.selected_project.as_ref().map(|p| p.name.clone()); let activity_id = app.selected_activity.as_ref().map(|a| a.id.clone()); let activity_name = app.selected_activity.as_ref().map(|a| a.name.clone()); - let note = if app.description_input.value.is_empty() { - None - } else { - Some(app.description_input.value.clone()) + let note = { + let full = app.full_note_value(); + if full.is_empty() { None } else { Some(full) } }; if let Err(e) = client .update_active_timer( @@ -489,10 +487,9 @@ pub(super) async fn handle_save_timer_with_action( } let duration = app.elapsed_duration(); - let note = if app.description_input.value.is_empty() { - None - } else { - Some(app.description_input.value.clone()) + let note = { + let full = app.full_note_value(); + if full.is_empty() { None } else { Some(full) } }; let project_display = app.current_project_name(); @@ -632,8 +629,10 @@ pub(super) fn handle_entry_edit_enter(app: &mut App, action_tx: &ActionTx) { let _ = action_tx.send(Action::OpenEditActivityPicker { project_id }); } EditEnterAction::NoteEditor { note } => { - // Save running timer's note before overwriting with entry's note - app.saved_timer_note = Some(app.description_input.value.clone()); + // Save running timer's full note (including any log tag) before overwriting + // with the entry's note. On return, this will be restored to description_input + // and navigate_to(EditDescription) will re-strip the tag if present. + app.saved_timer_note = Some(app.full_note_value()); // Set description_input from the edit state before navigating app.description_input = TextInput::from_str(¬e); // Open description editor @@ -736,7 +735,7 @@ async fn handle_running_timer_edit_save(app: &mut App, client: &mut ApiClient) { name, project_id: String::new(), }); - app.description_input = TextInput::from_str(&state.note.value); + app.set_note_from_raw(&state.note.value); app.set_status("Running timer updated".to_string()); @@ -745,10 +744,11 @@ async fn handle_running_timer_edit_save(app: &mut App, client: &mut ApiClient) { let project_name = app.selected_project.as_ref().map(|p| p.name.clone()); let activity_id = app.selected_activity.as_ref().map(|a| a.id.clone()); let activity_name = app.selected_activity.as_ref().map(|a| a.name.clone()); - let note = if app.description_input.value.is_empty() { + let full_note = app.full_note_value(); + let note = if full_note.is_empty() { None } else { - Some(app.description_input.value.clone()) + Some(full_note) }; if let Err(e) = client .update_active_timer( @@ -927,17 +927,16 @@ async fn handle_open_log_note(app: &mut App, client: &mut ApiClient) -> anyhow:: use crate::log_notes; use time::OffsetDateTime; - let current_note = app.description_input.value.clone(); + // The description editor strips the tag into `description_log_id` so + // `description_input.value` is always the clean summary. + let summary = app.description_input.value.clone(); - // Determine or generate the log ID - let id = if let Some(existing_id) = log_notes::extract_id(¤t_note) { - existing_id.to_string() - } else { - log_notes::generate_id() - }; - - // Summary = note without the tag - let summary = log_notes::strip_tag(¤t_note).to_string(); + // Determine or generate the log ID. Prefer the one already stored on App; + // fall back to generating a new one (first time Ctrl+L is pressed). + let id = app + .description_log_id + .clone() + .unwrap_or_else(|| log_notes::generate_id()); // Project/activity names for frontmatter let project = app @@ -968,17 +967,14 @@ async fn handle_open_log_note(app: &mut App, client: &mut ApiClient) -> anyhow:: // Open the editor (suspends TUI) crate::editor::open_editor(&log_path).await?; - // After editor closes: ensure the tag is in the note - let new_note = if log_notes::extract_id(¤t_note).is_some() { - // Tag already present — note unchanged - current_note - } else { - // Append the new tag - log_notes::append_tag(&summary, &id) - }; + // Store the ID on App so subsequent Ctrl+L presses reuse it and the tag + // survives further editing. + app.description_log_id = Some(id.clone()); + + // Build the full note value (summary + tag) to save/sync. + let new_note = log_notes::append_tag(&summary, &id); - // Update app state - app.description_input = TextInput::from_str(&new_note); + // description_input stays as the clean summary (tag lives in description_log_id). app.description_is_default = false; // If in edit mode, also sync the edit state's note field so Enter saves it correctly. diff --git a/toki-tui/src/runtime/views/edit_description.rs b/toki-tui/src/runtime/views/edit_description.rs index 19c255a3..c136bf98 100644 --- a/toki-tui/src/runtime/views/edit_description.rs +++ b/toki-tui/src/runtime/views/edit_description.rs @@ -1,4 +1,4 @@ -use crate::app::{self, App, TextInput}; +use crate::app::{self, App}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use super::super::action_queue::{Action, ActionTx}; @@ -67,6 +67,7 @@ pub(super) fn handle_edit_description_key(key: KeyEvent, app: &mut App, action_t if key.modifiers.contains(KeyModifiers::CONTROL) => { app.description_input.clear(); + app.description_log_id = None; // clearing the note also drops the linked log } KeyCode::Char('g') | KeyCode::Char('G') if key.modifiers.contains(KeyModifiers::CONTROL) @@ -84,6 +85,14 @@ pub(super) fn handle_edit_description_key(key: KeyEvent, app: &mut App, action_t { app.open_taskwarrior_overlay(); } + KeyCode::Char('l') | KeyCode::Char('L') + if key.modifiers.contains(KeyModifiers::CONTROL) + && key.modifiers.contains(KeyModifiers::SHIFT) => + { + // Detach the linked log from the current note (orphan the file, keep note text) + app.description_log_id = None; + app.status_message = Some("Log detached".to_string()); + } KeyCode::Char('l') | KeyCode::Char('L') if key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -108,10 +117,10 @@ pub(super) fn handle_edit_description_key(key: KeyEvent, app: &mut App, action_t KeyCode::End => app.input_cursor_home_end(false), KeyCode::Enter => { if was_in_edit_mode { - app.update_edit_state_note(app.description_input.value.clone()); - if let Some(saved_note) = app.saved_timer_note.take() { - app.description_input = TextInput::from_str(&saved_note); - } + let note = app.full_note_value(); + app.description_log_id = None; + app.update_edit_state_note(note); + app.restore_saved_timer_note(); let return_view = app.get_return_view_from_edit(); app.navigate_to(return_view); if return_view == app::View::Timer { @@ -119,7 +128,8 @@ pub(super) fn handle_edit_description_key(key: KeyEvent, app: &mut App, action_t } } else { let should_sync_running_note = app.timer_state == app::TimerState::Running; - let note = app.description_input.value.clone(); + let note = app.full_note_value(); + app.description_log_id = None; app.confirm_description(); if should_sync_running_note { enqueue_action(action_tx, Action::SyncRunningTimerNote { note }); @@ -128,15 +138,19 @@ pub(super) fn handle_edit_description_key(key: KeyEvent, app: &mut App, action_t } KeyCode::Esc => { if was_in_edit_mode { - if let Some(saved_note) = app.saved_timer_note.take() { - app.description_input = TextInput::from_str(&saved_note); - } + // Discarding the edit — drop the log ID (it belongs to the entry being + // edited, not to the running timer, and will be restored via saved_timer_note). + app.description_log_id = None; + app.restore_saved_timer_note(); let return_view = app.get_return_view_from_edit(); app.navigate_to(return_view); if return_view == app::View::Timer { app.focused_box = app::FocusedBox::Today; } } else { + // Cancelling description editing for the running timer — don't sync + // anything, just exit the editor. description_log_id stays intact so the + // log link is preserved for the next time the user opens the editor. app.cancel_selection(); } } @@ -153,7 +167,7 @@ pub(super) fn handle_edit_description_key(key: KeyEvent, app: &mut App, action_t #[cfg(test)] mod tests { use super::*; - use crate::app::{EntryEditField, EntryEditState, TimerState, View}; + use crate::app::{EntryEditField, EntryEditState, TextInput, TimerState, View}; use crate::config::TokiConfig; use crossterm::event::{KeyEvent, KeyModifiers}; diff --git a/toki-tui/src/ui/description_editor.rs b/toki-tui/src/ui/description_editor.rs index 49cedf55..8dfd8236 100644 --- a/toki-tui/src/ui/description_editor.rs +++ b/toki-tui/src/ui/description_editor.rs @@ -3,14 +3,17 @@ use super::*; use crate::log_notes; pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { + let has_log = app.description_log_id.is_some(); + let chunks = Layout::default() .direction(Direction::Vertical) .margin(2) .constraints([ Constraint::Length(3), // 0: Input field or CWD input - Constraint::Length(5), // 1: Git context panel - Constraint::Min(0), // 2: Spacer - Constraint::Length(3), // 3: Controls + Constraint::Length(6), // 1: Info panel (4 lines: cwd, branch, commit, log path) + Constraint::Min(3), // 2: Log content box (empty space when no log) + Constraint::Min(0), // 3: Spacer + Constraint::Length(3), // 4: Controls ]) .split(body); @@ -56,7 +59,7 @@ pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { frame.render_widget(input, chunks[0]); } - // Git context panel + // Info panel let has_git = app.git_context.branch.is_some(); let git_color = if has_git { Color::White @@ -69,6 +72,33 @@ pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { let branch_str = app.git_context.branch.as_deref().unwrap_or("(no git repo)"); let commit_str = app.git_context.last_commit.as_deref().unwrap_or("(none)"); + // Build log file path label (4th info line) + let log_path_line = if let Some(ref id) = app.description_log_id { + match log_notes::log_path(id) { + Ok(path) => { + // Show path relative to home if possible + let home = dirs::home_dir().unwrap_or_default(); + let display = match path.strip_prefix(&home) { + Ok(rel) => format!(".local/share/toki-tui/{}", rel.to_string_lossy()), + Err(_) => path.to_string_lossy().to_string(), + }; + Line::from(vec![ + Span::styled("Log file: ", Style::default().fg(muted)), + Span::styled(display, Style::default().fg(Color::Cyan)), + ]) + } + Err(_) => Line::from(vec![Span::styled( + "Log file: (error)", + Style::default().fg(muted), + )]), + } + } else { + Line::from(vec![Span::styled( + "Log file: ", + Style::default().fg(muted), + )]) + }; + let git_lines = vec![ Line::from(vec![ Span::styled("Current directory: ", Style::default().fg(muted)), @@ -82,6 +112,7 @@ pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { Span::styled("Last commit: ", Style::default().fg(muted)), Span::styled(commit_str, Style::default().fg(git_color)), ]), + log_path_line, ]; let git_panel = Paragraph::new(git_lines).block( @@ -93,6 +124,28 @@ pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { ); frame.render_widget(git_panel, chunks[1]); + // Log content box (read-only, shown when a log is linked) + if has_log { + let log_content = app + .description_log_id + .as_ref() + .and_then(|id| log_notes::log_path(id).ok()) + .and_then(|path| std::fs::read_to_string(path).ok()) + .unwrap_or_default(); + + let log_paragraph = Paragraph::new(log_content) + .style(Style::default().fg(Color::DarkGray)) + .wrap(ratatui::widgets::Wrap { trim: false }) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .title(Span::styled(" Log ", Style::default().fg(Color::DarkGray))) + .padding(Padding::horizontal(1)), + ); + frame.render_widget(log_paragraph, chunks[2]); + } + // Controls (context-sensitive) let controls_text: Vec = if app.cwd_input.is_some() { vec![ @@ -127,14 +180,7 @@ pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { } else { Style::default().fg(Color::DarkGray) }; - let has_log = log_notes::extract_id(&app.description_input.value).is_some(); - let log_hint_key = Span::styled("Ctrl+L", Style::default().fg(Color::Yellow)); - let log_hint_label = if has_log { - Span::styled(": Log ", Style::default().fg(Color::Green)) - } else { - Span::raw(": New log ") - }; - vec![ + let mut spans = vec![ Span::styled("Type", Style::default().fg(Color::Yellow)), Span::raw(": Edit "), Span::styled("Ctrl+X", Style::default().fg(Color::Yellow)), @@ -143,8 +189,17 @@ pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { Span::raw(": Confirm "), Span::styled("Esc", Style::default().fg(Color::Yellow)), Span::raw(": Cancel "), - log_hint_key, - log_hint_label, + Span::styled("Ctrl+L", Style::default().fg(Color::Yellow)), + Span::raw(": Log "), + ]; + if has_log { + spans.push(Span::styled( + "Shift+Ctrl+L", + Style::default().fg(Color::Yellow), + )); + spans.push(Span::raw(": Detach ")); + } + spans.extend([ Span::styled("Ctrl+D", Style::default().fg(Color::Yellow)), Span::raw(": Change directory "), Span::styled("Ctrl+G", git_key_style), @@ -158,7 +213,8 @@ pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { ), Span::styled("Ctrl+T", Style::default().fg(Color::Yellow)), Span::raw(": Taskwarrior"), - ] + ]); + spans }; let controls = Paragraph::new(Line::from(controls_text)) @@ -173,7 +229,7 @@ pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { )) .padding(ratatui::widgets::Padding::horizontal(1)), ); - frame.render_widget(controls, chunks[3]); + frame.render_widget(controls, chunks[4]); } pub fn render_taskwarrior_overlay(frame: &mut Frame, app: &App, body: Rect) { diff --git a/toki-tui/src/ui/timer_view.rs b/toki-tui/src/ui/timer_view.rs index e861870a..c22ca04c 100644 --- a/toki-tui/src/ui/timer_view.rs +++ b/toki-tui/src/ui/timer_view.rs @@ -147,36 +147,41 @@ fn render_project(frame: &mut Frame, area: ratatui::layout::Rect, app: &App) { } fn render_description(frame: &mut Frame, area: ratatui::layout::Rect, app: &App) { - let description = app.current_description(); + let description = crate::log_notes::strip_tag(&app.description_input.value).to_string(); let is_empty = description.is_empty(); + let has_log = app.description_log_id.is_some(); let is_focused = app.focused_box == crate::app::FocusedBox::Description; let border_style = if is_focused { Style::default().fg(Color::Magenta) - } else if !is_empty { - // White border when note has content and not focused + } else if !is_empty || has_log { + // White border when note has content (or a log is attached) and not focused Style::default().fg(Color::White) } else { // Default when empty and not focused Style::default() }; - // Title with underlined A + // Title with underlined N let title = vec![ Span::raw(" "), Span::styled("N", Style::default().add_modifier(Modifier::UNDERLINED)), Span::raw("ote "), ]; - let widget = Paragraph::new(description) - .style(Style::default().fg(Color::White)) - .block( - Block::default() - .borders(Borders::ALL) - .title(Line::from(title)) - .border_style(border_style) - .padding(ratatui::widgets::Padding::horizontal(1)), - ); + // Build the paragraph content: summary text + optional muted "[...]" log indicator + let mut spans: Vec = vec![Span::styled(description, Style::default().fg(Color::White))]; + if has_log { + spans.push(Span::styled(" [...]", Style::default().fg(Color::DarkGray))); + } + + let widget = Paragraph::new(Line::from(spans)).block( + Block::default() + .borders(Borders::ALL) + .title(Line::from(title)) + .border_style(border_style) + .padding(ratatui::widgets::Padding::horizontal(1)), + ); frame.render_widget(widget, area); } diff --git a/toki-tui/src/ui/widgets.rs b/toki-tui/src/ui/widgets.rs index f6504085..aa23805c 100644 --- a/toki-tui/src/ui/widgets.rs +++ b/toki-tui/src/ui/widgets.rs @@ -113,6 +113,7 @@ pub fn build_display_row( let activity = &entry.activity_name; let note_raw = entry.note.as_deref().unwrap_or(""); let note = log_notes::strip_tag(note_raw); + let has_log = log_notes::extract_id(note_raw).is_some(); // Start time let start_str = entry @@ -178,6 +179,11 @@ pub fn build_display_row( spans.push(Span::styled(note_display, Style::default().fg(note_color))); } + // Log indicator: "[...]" in white for entries with a linked log note + if has_log { + spans.push(Span::styled(" [...]", Style::default().fg(Color::White))); + } + // Apply focus styling: white background with black text if is_focused { let focused_style = Style::default() @@ -230,6 +236,7 @@ pub fn build_running_timer_display_row( .map(|a| a.name.clone()) .unwrap_or_else(|| "[None]".to_string()); let note = log_notes::strip_tag(&app.description_input.value).to_string(); + let has_log = app.description_log_id.is_some(); let prefix_len: usize = 28; // "▶ " (2) + "HH:MM - HH:MM " (14) + "[DDh:DDm]" (9) + " | " (3) let remaining = (available_width as usize).saturating_sub(prefix_len); @@ -274,6 +281,9 @@ pub fn build_running_timer_display_row( spans.push(Span::styled(" | ", Style::default().fg(Color::DarkGray))); spans.push(Span::styled(note_display, Style::default().fg(Color::Gray))); } + if has_log { + spans.push(Span::styled(" [...]", Style::default().fg(Color::White))); + } Line::from(spans) } @@ -338,9 +348,14 @@ pub fn build_running_timer_edit_row(edit_state: &EntryEditState) -> Line<'_> { _ => Style::default().fg(Color::White), }; let note_value = if matches!(edit_state.focused_field, EntryEditField::Note) { - // Cursor active — show raw value with cursor (user is typing) - let (before, after) = edit_state.note.split_at_cursor(); - if edit_state.note.value.is_empty() { + // Cursor active — strip the log tag first so it is never user-facing, + // then show the clean summary with cursor. + let clean = log_notes::strip_tag(&edit_state.note.value); + let clean_len = clean.chars().count(); + let cursor = edit_state.note.cursor.min(clean_len); + let before: String = clean.chars().take(cursor).collect(); + let after: String = clean.chars().skip(cursor).collect(); + if clean.is_empty() { "[█]".to_string() } else { format!("[{}█{}]", before, after) @@ -430,9 +445,14 @@ pub fn build_edit_row<'a>( _ => Style::default().fg(Color::White), }; let note_value = if matches!(edit_state.focused_field, EntryEditField::Note) { - // Cursor active — show raw value with cursor (user is typing) - let (before, after) = edit_state.note.split_at_cursor(); - if edit_state.note.value.is_empty() { + // Cursor active — strip the log tag first so it is never user-facing, + // then show the clean summary with cursor. + let clean = log_notes::strip_tag(&edit_state.note.value); + let clean_len = clean.chars().count(); + let cursor = edit_state.note.cursor.min(clean_len); + let before: String = clean.chars().take(cursor).collect(); + let after: String = clean.chars().skip(cursor).collect(); + if clean.is_empty() { "[█]".to_string() } else { format!("[{}█{}]", before, after) From 5d782de34564d06212ce819988e20bf1ab2dc605 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 14:05:18 +0100 Subject: [PATCH 28/53] fix(tui): polish log notes UI (round 3 bug fixes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace ▏ block cursor with proper set_cursor_position() in full-screen inputs (Notes view, CWD, search views) - Replace ▏ in inline note widgets with underlined-char spans (no extra-width character) - Fix running timer log bleeding into entry edit Notes view (use set_note_from_raw) - Fix Enter and Ctrl+X dropping description_log_id in edit_description view - clear_note() and clear_timer() now also clear description_log_id - Simplify log file frontmatter to id + date only (drop stale fields) - Fix log path display (~/... format) - Ctrl+R replaces Shift+Ctrl+L to remove/detach log - Remove unused TextInput import in actions.rs --- toki-tui/src/app/mod.rs | 2 + toki-tui/src/log_notes.rs | 56 ++++++------ toki-tui/src/runtime/actions.rs | 23 ++--- .../src/runtime/views/edit_description.rs | 21 +++-- toki-tui/src/ui/description_editor.rs | 27 +++--- toki-tui/src/ui/selection_views.rs | 28 +++--- toki-tui/src/ui/template_selection_view.rs | 14 +-- toki-tui/src/ui/timer_view.rs | 7 +- toki-tui/src/ui/widgets.rs | 88 ++++++++++++------- 9 files changed, 151 insertions(+), 115 deletions(-) diff --git a/toki-tui/src/app/mod.rs b/toki-tui/src/app/mod.rs index 8b698e07..a45fc20e 100644 --- a/toki-tui/src/app/mod.rs +++ b/toki-tui/src/app/mod.rs @@ -224,6 +224,7 @@ impl App { pub fn clear_note(&mut self) { self.description_input = TextInput::new(); self.description_is_default = true; + self.description_log_id = None; self.status_message = Some("Note cleared".to_string()); } @@ -236,6 +237,7 @@ impl App { self.selected_activity = None; self.description_input = TextInput::new(); self.description_is_default = true; + self.description_log_id = None; self.status_message = Some("Timer cleared".to_string()); } diff --git a/toki-tui/src/log_notes.rs b/toki-tui/src/log_notes.rs index eaaae8d7..8543be9a 100644 --- a/toki-tui/src/log_notes.rs +++ b/toki-tui/src/log_notes.rs @@ -17,11 +17,11 @@ pub fn log_path(id: &str) -> anyhow::Result { Ok(log_dir()?.join(format!("{}.md", id))) } -/// Generates a random 8-character lowercase hex ID. +/// Generates a random 6-character lowercase hex ID. pub fn generate_id() -> String { use std::time::{SystemTime, UNIX_EPOCH}; - // Simple deterministic-enough ID from timestamp nanos XOR'd with a counter - // No external deps needed. + // Simple deterministic-enough ID from timestamp nanos XOR'd with secs. + // No external deps needed. 6 hex chars = 16M values, plenty for thousands of logs. let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() @@ -30,20 +30,18 @@ pub fn generate_id() -> String { .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); - format!( - "{:08x}", - (secs ^ (nanos as u64)).wrapping_mul(0x9e3779b97f4a7c15) - ) + let hash = (secs ^ (nanos as u64)).wrapping_mul(0x9e3779b97f4a7c15); + format!("{:06x}", hash & 0xffffff) } /// Extracts the log ID from a note string, if the tag is present. -/// e.g. "Fixed auth bug ·log:a3f8b2c1" → Some("a3f8b2c1") +/// e.g. "Fixed auth bug ·log:a3f8b2" → Some("a3f8b2") pub fn extract_id(note: &str) -> Option<&str> { let pos = note.find(TAG_PREFIX)?; let after = ¬e[pos + TAG_PREFIX.len()..]; - // ID is exactly 8 hex chars - if after.len() >= 8 && after[..8].chars().all(|c| c.is_ascii_hexdigit()) { - Some(&after[..8]) + // ID is exactly 6 hex chars + if after.len() >= 6 && after[..6].chars().all(|c| c.is_ascii_hexdigit()) { + Some(&after[..6]) } else { None } @@ -64,19 +62,12 @@ pub fn append_tag(note: &str, id: &str) -> String { } /// Writes the initial log file with YAML frontmatter. -pub fn create_log_file( - id: &str, - date: &str, - project: &str, - activity: &str, - note_summary: &str, -) -> anyhow::Result { +/// Only `id` and `date` are stored — project/activity/note are not tracked +/// since they can change after creation and would quickly go stale. +pub fn create_log_file(id: &str, date: &str) -> anyhow::Result { let path = log_path(id)?; if !path.exists() { - let content = format!( - "---\nid: {}\ndate: {}\nproject: {}\nactivity: {}\nnote: {}\n---\n\n", - id, date, project, activity, note_summary - ); + let content = format!("---\nid: {}\ndate: {}\n---\n\n", id, date); std::fs::write(&path, content)?; } Ok(path) @@ -88,8 +79,8 @@ mod tests { #[test] fn test_extract_id_present() { - let note = "Fixed auth bug \u{00B7}log:a3f8b2c1"; - assert_eq!(extract_id(note), Some("a3f8b2c1")); + let note = "Fixed auth bug \u{00B7}log:a3f8b2"; + assert_eq!(extract_id(note), Some("a3f8b2")); } #[test] @@ -99,7 +90,7 @@ mod tests { #[test] fn test_strip_tag() { - let note = "Fixed auth bug \u{00B7}log:a3f8b2c1"; + let note = "Fixed auth bug \u{00B7}log:a3f8b2"; assert_eq!(strip_tag(note), "Fixed auth bug"); } @@ -110,13 +101,20 @@ mod tests { #[test] fn test_append_tag() { - let result = append_tag("Fixed auth bug", "a3f8b2c1"); - assert_eq!(result, "Fixed auth bug \u{00B7}log:a3f8b2c1"); + let result = append_tag("Fixed auth bug", "a3f8b2"); + assert_eq!(result, "Fixed auth bug \u{00B7}log:a3f8b2"); } #[test] fn test_append_tag_trims_trailing_space() { - let result = append_tag("Fixed auth bug ", "a3f8b2c1"); - assert_eq!(result, "Fixed auth bug \u{00B7}log:a3f8b2c1"); + let result = append_tag("Fixed auth bug ", "a3f8b2"); + assert_eq!(result, "Fixed auth bug \u{00B7}log:a3f8b2"); + } + + #[test] + fn test_generate_id_length() { + let id = generate_id(); + assert_eq!(id.len(), 6); + assert!(id.chars().all(|c| c.is_ascii_hexdigit())); } } diff --git a/toki-tui/src/runtime/actions.rs b/toki-tui/src/runtime/actions.rs index b8478cd1..754f3a9d 100644 --- a/toki-tui/src/runtime/actions.rs +++ b/toki-tui/src/runtime/actions.rs @@ -1,5 +1,5 @@ use crate::api::{ApiClient, SaveTimerRequest}; -use crate::app::{self, App, TextInput}; +use crate::app::{self, App}; use crate::types; use anyhow::{Context, Result}; use std::time::{Duration, Instant}; @@ -633,8 +633,9 @@ pub(super) fn handle_entry_edit_enter(app: &mut App, action_tx: &ActionTx) { // with the entry's note. On return, this will be restored to description_input // and navigate_to(EditDescription) will re-strip the tag if present. app.saved_timer_note = Some(app.full_note_value()); - // Set description_input from the edit state before navigating - app.description_input = TextInput::from_str(¬e); + // Load the entry's note, stripping any embedded log tag into description_log_id. + // This prevents the running timer's log from leaking into the entry's Notes view. + app.set_note_from_raw(¬e); // Open description editor app.navigate_to(app::View::EditDescription); } @@ -938,20 +939,6 @@ async fn handle_open_log_note(app: &mut App, client: &mut ApiClient) -> anyhow:: .clone() .unwrap_or_else(|| log_notes::generate_id()); - // Project/activity names for frontmatter - let project = app - .selected_project - .as_ref() - .map(|p| p.name.as_str()) - .unwrap_or("") - .to_string(); - let activity = app - .selected_activity - .as_ref() - .map(|a| a.name.as_str()) - .unwrap_or("") - .to_string(); - // Date string let today = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc()); let date = format!( @@ -962,7 +949,7 @@ async fn handle_open_log_note(app: &mut App, client: &mut ApiClient) -> anyhow:: ); // Create log file if it doesn't exist yet - let log_path = log_notes::create_log_file(&id, &date, &project, &activity, &summary)?; + let log_path = log_notes::create_log_file(&id, &date)?; // Open the editor (suspends TUI) crate::editor::open_editor(&log_path).await?; diff --git a/toki-tui/src/runtime/views/edit_description.rs b/toki-tui/src/runtime/views/edit_description.rs index c136bf98..e55bfafd 100644 --- a/toki-tui/src/runtime/views/edit_description.rs +++ b/toki-tui/src/runtime/views/edit_description.rs @@ -66,8 +66,9 @@ pub(super) fn handle_edit_description_key(key: KeyEvent, app: &mut App, action_t KeyCode::Char('x') | KeyCode::Char('X') if key.modifiers.contains(KeyModifiers::CONTROL) => { + // Clear the note text only — the log link is preserved intentionally. + // Use Ctrl+R to remove the log link. app.description_input.clear(); - app.description_log_id = None; // clearing the note also drops the linked log } KeyCode::Char('g') | KeyCode::Char('G') if key.modifiers.contains(KeyModifiers::CONTROL) @@ -85,13 +86,18 @@ pub(super) fn handle_edit_description_key(key: KeyEvent, app: &mut App, action_t { app.open_taskwarrior_overlay(); } - KeyCode::Char('l') | KeyCode::Char('L') - if key.modifiers.contains(KeyModifiers::CONTROL) - && key.modifiers.contains(KeyModifiers::SHIFT) => + KeyCode::Char('r') | KeyCode::Char('R') + if key.modifiers.contains(KeyModifiers::CONTROL) => { - // Detach the linked log from the current note (orphan the file, keep note text) + // Remove (detach) the linked log from the current note (orphans the file, keeps note text) app.description_log_id = None; - app.status_message = Some("Log detached".to_string()); + app.status_message = Some("Log removed".to_string()); + } + KeyCode::Char('l') | KeyCode::Char('L') + if key.modifiers.contains(KeyModifiers::SHIFT) + && key.modifiers.contains(KeyModifiers::CONTROL) => + { + // no-op: previously Shift+Ctrl+L was detach, now Ctrl+R handles this } KeyCode::Char('l') | KeyCode::Char('L') if key.modifiers.contains(KeyModifiers::CONTROL) => @@ -129,7 +135,8 @@ pub(super) fn handle_edit_description_key(key: KeyEvent, app: &mut App, action_t } else { let should_sync_running_note = app.timer_state == app::TimerState::Running; let note = app.full_note_value(); - app.description_log_id = None; + // description_log_id intentionally preserved — the log stays linked + // to the running timer after confirming. Use Ctrl+R to remove it. app.confirm_description(); if should_sync_running_note { enqueue_action(action_tx, Action::SyncRunningTimerNote { note }); diff --git a/toki-tui/src/ui/description_editor.rs b/toki-tui/src/ui/description_editor.rs index 8dfd8236..2d28ebd2 100644 --- a/toki-tui/src/ui/description_editor.rs +++ b/toki-tui/src/ui/description_editor.rs @@ -24,10 +24,8 @@ pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { } else { format!(" [{}]", app.cwd_completions.join(" ")) }; - let input_text = { - let (before, after) = cwd_input.split_at_cursor(); - format!("{}█{}{}", before, after, completions_hint) - }; + let (before, after) = cwd_input.split_at_cursor(); + let input_text = format!("{}{}{}", before, after, completions_hint); let input = Paragraph::new(input_text) .style(Style::default().fg(Color::Yellow)) .block( @@ -38,6 +36,10 @@ pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { .padding(Padding::horizontal(1)), ); frame.render_widget(input, chunks[0]); + // Place terminal cursor: border(1) + padding(1) + char offset + let cx = chunks[0].x + 2 + before.chars().count() as u16; + let cy = chunks[0].y + 1; + frame.set_cursor_position((cx, cy)); } else { // Strip the log tag from the displayed value — the user sees the clean summary. // The raw value (including tag) is preserved in app.description_input.value. @@ -47,7 +49,7 @@ pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { let cursor = app.description_input.cursor.min(stripped.chars().count()); let before: String = stripped.chars().take(cursor).collect(); let after: String = stripped.chars().skip(cursor).collect(); - let input_text = format!("{}█{}", before, after); + let input_text = format!("{}{}", before, after); let input = Paragraph::new(input_text) .style(Style::default().fg(Color::White)) .block( @@ -57,6 +59,10 @@ pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { .padding(Padding::horizontal(1)), ); frame.render_widget(input, chunks[0]); + // Place terminal cursor: border(1) + padding(1) + char offset + let cx = chunks[0].x + 2 + cursor as u16; + let cy = chunks[0].y + 1; + frame.set_cursor_position((cx, cy)); } // Info panel @@ -79,7 +85,7 @@ pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { // Show path relative to home if possible let home = dirs::home_dir().unwrap_or_default(); let display = match path.strip_prefix(&home) { - Ok(rel) => format!(".local/share/toki-tui/{}", rel.to_string_lossy()), + Ok(rel) => format!("~/{}", rel.to_string_lossy()), Err(_) => path.to_string_lossy().to_string(), }; Line::from(vec![ @@ -190,14 +196,11 @@ pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { Span::styled("Esc", Style::default().fg(Color::Yellow)), Span::raw(": Cancel "), Span::styled("Ctrl+L", Style::default().fg(Color::Yellow)), - Span::raw(": Log "), + Span::raw(": Add/edit log file "), ]; if has_log { - spans.push(Span::styled( - "Shift+Ctrl+L", - Style::default().fg(Color::Yellow), - )); - spans.push(Span::raw(": Detach ")); + spans.push(Span::styled("Ctrl+R", Style::default().fg(Color::Yellow))); + spans.push(Span::raw(": Remove log file ")); } spans.extend([ Span::styled("Ctrl+D", Style::default().fg(Color::Yellow)), diff --git a/toki-tui/src/ui/selection_views.rs b/toki-tui/src/ui/selection_views.rs index d73d3713..6e5cf024 100644 --- a/toki-tui/src/ui/selection_views.rs +++ b/toki-tui/src/ui/selection_views.rs @@ -12,17 +12,18 @@ pub fn render_project_selection(frame: &mut Frame, app: &App, body: Rect) { .split(body); // Search input box - let search_text = if app.project_search_input.value.is_empty() { + let (search_text, project_cursor_col) = if app.project_search_input.value.is_empty() { if app.selection_list_focused { - "Type to search...".to_string() + ("Type to search...".to_string(), None) } else { - "█".to_string() + (String::new(), Some(0u16)) } } else if app.selection_list_focused { - app.project_search_input.value.clone() + (app.project_search_input.value.clone(), None) } else { let (before, after) = app.project_search_input.split_at_cursor(); - format!("{}█{}", before, after) + let col = before.chars().count() as u16; + (format!("{}{}", before, after), Some(col)) }; let search_border = if app.selection_list_focused { Style::default().fg(Color::DarkGray) @@ -40,6 +41,9 @@ pub fn render_project_selection(frame: &mut Frame, app: &App, body: Rect) { .padding(Padding::horizontal(1)), ); frame.render_widget(search_box, chunks[0]); + if let Some(col) = project_cursor_col { + frame.set_cursor_position((chunks[0].x + 2 + col, chunks[0].y + 1)); + } // Project list let items: Vec = app @@ -131,17 +135,18 @@ pub fn render_activity_selection(frame: &mut Frame, app: &App, body: Rect) { .split(body); // Search input box - let search_text = if app.activity_search_input.value.is_empty() { + let (search_text, activity_cursor_col) = if app.activity_search_input.value.is_empty() { if app.selection_list_focused { - "Type to search...".to_string() + ("Type to search...".to_string(), None) } else { - "█".to_string() + (String::new(), Some(0u16)) } } else if app.selection_list_focused { - app.activity_search_input.value.clone() + (app.activity_search_input.value.clone(), None) } else { let (before, after) = app.activity_search_input.split_at_cursor(); - format!("{}█{}", before, after) + let col = before.chars().count() as u16; + (format!("{}{}", before, after), Some(col)) }; let search_border = if app.selection_list_focused { Style::default().fg(Color::DarkGray) @@ -159,6 +164,9 @@ pub fn render_activity_selection(frame: &mut Frame, app: &App, body: Rect) { .padding(Padding::horizontal(1)), ); frame.render_widget(search_box, chunks[0]); + if let Some(col) = activity_cursor_col { + frame.set_cursor_position((chunks[0].x + 2 + col, chunks[0].y + 1)); + } // Activity list let items: Vec = app diff --git a/toki-tui/src/ui/template_selection_view.rs b/toki-tui/src/ui/template_selection_view.rs index 4d7dc699..9a6a8508 100644 --- a/toki-tui/src/ui/template_selection_view.rs +++ b/toki-tui/src/ui/template_selection_view.rs @@ -12,17 +12,18 @@ pub fn render_template_selection(frame: &mut Frame, app: &App, body: Rect) { .split(body); // Search input box - let search_text = if app.template_search_input.value.is_empty() { + let (search_text, template_cursor_col) = if app.template_search_input.value.is_empty() { if app.selection_list_focused { - "Type to search...".to_string() + ("Type to search...".to_string(), None) } else { - "█".to_string() + (String::new(), Some(0u16)) } } else if app.selection_list_focused { - app.template_search_input.value.clone() + (app.template_search_input.value.clone(), None) } else { let (before, after) = app.template_search_input.split_at_cursor(); - format!("{}█{}", before, after) + let col = before.chars().count() as u16; + (format!("{}{}", before, after), Some(col)) }; let search_border = if app.selection_list_focused { Style::default().fg(Color::DarkGray) @@ -40,6 +41,9 @@ pub fn render_template_selection(frame: &mut Frame, app: &App, body: Rect) { .padding(Padding::horizontal(1)), ); frame.render_widget(search_box, chunks[0]); + if let Some(col) = template_cursor_col { + frame.set_cursor_position((chunks[0].x + 2 + col, chunks[0].y + 1)); + } // Template list let items: Vec = app diff --git a/toki-tui/src/ui/timer_view.rs b/toki-tui/src/ui/timer_view.rs index c22ca04c..a407a8b0 100644 --- a/toki-tui/src/ui/timer_view.rs +++ b/toki-tui/src/ui/timer_view.rs @@ -169,10 +169,13 @@ fn render_description(frame: &mut Frame, area: ratatui::layout::Rect, app: &App) Span::raw("ote "), ]; - // Build the paragraph content: summary text + optional muted "[...]" log indicator + // Build the paragraph content: summary text + optional muted "[…]" log indicator let mut spans: Vec = vec![Span::styled(description, Style::default().fg(Color::White))]; if has_log { - spans.push(Span::styled(" [...]", Style::default().fg(Color::DarkGray))); + spans.push(Span::styled( + " [\u{2026}]", + Style::default().fg(Color::DarkGray), + )); } let widget = Paragraph::new(Line::from(spans)).block( diff --git a/toki-tui/src/ui/widgets.rs b/toki-tui/src/ui/widgets.rs index aa23805c..7efb83b9 100644 --- a/toki-tui/src/ui/widgets.rs +++ b/toki-tui/src/ui/widgets.rs @@ -9,16 +9,16 @@ use ratatui::{ use crate::app::App; -/// Render a partial or complete time string with a block cursor. +/// Render a partial or complete time string with a hairline cursor. /// - len >= 5 ("HH:MM"): display as-is, no cursor -/// - len < 5: show typed chars + '█' + space padding to fill 5-char slot +/// - len < 5: show typed chars + '▏' + space padding to fill 5-char slot fn time_input_display(s: &str) -> String { if s.len() >= 5 { format!("[{}]", s) } else { let filled = s.len(); let spaces = 5 - filled - 1; - format!("[{}█{}]", s, " ".repeat(spaces)) + format!("[{}▏{}]", s, " ".repeat(spaces)) } } @@ -179,9 +179,12 @@ pub fn build_display_row( spans.push(Span::styled(note_display, Style::default().fg(note_color))); } - // Log indicator: "[...]" in white for entries with a linked log note + // Log indicator: "[…]" in white for entries with a linked log note if has_log { - spans.push(Span::styled(" [...]", Style::default().fg(Color::White))); + spans.push(Span::styled( + " [\u{2026}]", + Style::default().fg(Color::White), + )); } // Apply focus styling: white background with black text @@ -282,7 +285,10 @@ pub fn build_running_timer_display_row( spans.push(Span::styled(note_display, Style::default().fg(Color::Gray))); } if has_log { - spans.push(Span::styled(" [...]", Style::default().fg(Color::White))); + spans.push(Span::styled( + " [\u{2026}]", + Style::default().fg(Color::White), + )); } Line::from(spans) } @@ -340,32 +346,41 @@ pub fn build_running_timer_edit_row(edit_state: &EntryEditState) -> Line<'_> { spans.push(Span::styled(" | ", Style::default().fg(Color::White))); // Note field - let note_style = match edit_state.focused_field { + let note_base_style = match edit_state.focused_field { EntryEditField::Note => Style::default() .fg(Color::Black) .bg(Color::White) .add_modifier(Modifier::BOLD), _ => Style::default().fg(Color::White), }; - let note_value = if matches!(edit_state.focused_field, EntryEditField::Note) { - // Cursor active — strip the log tag first so it is never user-facing, - // then show the clean summary with cursor. + if matches!(edit_state.focused_field, EntryEditField::Note) { + // Cursor active — strip the log tag, then render cursor as underlined char let clean = log_notes::strip_tag(&edit_state.note.value); let clean_len = clean.chars().count(); let cursor = edit_state.note.cursor.min(clean_len); let before: String = clean.chars().take(cursor).collect(); - let after: String = clean.chars().skip(cursor).collect(); - if clean.is_empty() { - "[█]".to_string() - } else { - format!("[{}█{}]", before, after) - } + let cursor_char: String = clean + .chars() + .nth(cursor) + .map(|c| c.to_string()) + .unwrap_or_else(|| " ".to_string()); + let after: String = clean.chars().skip(cursor + 1).collect(); + // Invert fg/bg on the cursor character so it's visible within the white-highlight zone + let cursor_style = Style::default() + .fg(Color::White) + .bg(Color::Black) + .add_modifier(Modifier::BOLD); + spans.push(Span::styled("[", note_base_style)); + spans.push(Span::styled(before, note_base_style)); + spans.push(Span::styled(cursor_char, cursor_style)); + spans.push(Span::styled(after, note_base_style)); + spans.push(Span::styled("]", note_base_style)); } else { // Display mode — strip the log tag let display = log_notes::strip_tag(&edit_state.note.value); - format!("[{}]", if display.is_empty() { "Empty" } else { display }) - }; - spans.push(Span::styled(note_value, note_style)); + let note_value = format!("[{}]", if display.is_empty() { "Empty" } else { display }); + spans.push(Span::styled(note_value, note_base_style)); + } Line::from(spans) } @@ -437,32 +452,41 @@ pub fn build_edit_row<'a>( spans.push(Span::styled(" | ", Style::default().fg(Color::White))); // Note field - let note_style = match edit_state.focused_field { + let note_base_style = match edit_state.focused_field { EntryEditField::Note => Style::default() .fg(Color::Black) .bg(Color::White) .add_modifier(Modifier::BOLD), _ => Style::default().fg(Color::White), }; - let note_value = if matches!(edit_state.focused_field, EntryEditField::Note) { - // Cursor active — strip the log tag first so it is never user-facing, - // then show the clean summary with cursor. + if matches!(edit_state.focused_field, EntryEditField::Note) { + // Cursor active — strip the log tag, then render cursor as underlined char let clean = log_notes::strip_tag(&edit_state.note.value); let clean_len = clean.chars().count(); let cursor = edit_state.note.cursor.min(clean_len); let before: String = clean.chars().take(cursor).collect(); - let after: String = clean.chars().skip(cursor).collect(); - if clean.is_empty() { - "[█]".to_string() - } else { - format!("[{}█{}]", before, after) - } + let cursor_char: String = clean + .chars() + .nth(cursor) + .map(|c| c.to_string()) + .unwrap_or_else(|| " ".to_string()); + let after: String = clean.chars().skip(cursor + 1).collect(); + // Invert fg/bg on the cursor character so it's visible within the white-highlight zone + let cursor_style = Style::default() + .fg(Color::White) + .bg(Color::Black) + .add_modifier(Modifier::BOLD); + spans.push(Span::styled("[", note_base_style)); + spans.push(Span::styled(before, note_base_style)); + spans.push(Span::styled(cursor_char, cursor_style)); + spans.push(Span::styled(after, note_base_style)); + spans.push(Span::styled("]", note_base_style)); } else { // Display mode — strip the log tag let display = log_notes::strip_tag(&edit_state.note.value); - format!("[{}]", if display.is_empty() { "Empty" } else { display }) - }; - spans.push(Span::styled(note_value, note_style)); + let note_value = format!("[{}]", if display.is_empty() { "Empty" } else { display }); + spans.push(Span::styled(note_value, note_base_style)); + } Line::from(spans) } From d210d2cbf1dc209377bfab00a6688ee4b2e06a96 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 14:18:32 +0100 Subject: [PATCH 29/53] =?UTF-8?q?fix(tui):=20change=20log=20tag=20format?= =?UTF-8?q?=20from=20=C2=B7log:ID=20to=20[log:ID]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- toki-tui/src/log_notes.rs | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/toki-tui/src/log_notes.rs b/toki-tui/src/log_notes.rs index 8543be9a..848a3d42 100644 --- a/toki-tui/src/log_notes.rs +++ b/toki-tui/src/log_notes.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; -const TAG_PREFIX: &str = " \u{00B7}log:"; // " ·log:" +const TAG_PREFIX: &str = " [log:"; +const TAG_SUFFIX: &str = "]"; /// Returns the log storage directory: ~/.local/share/toki-tui/logs/ pub fn log_dir() -> anyhow::Result { @@ -35,12 +36,15 @@ pub fn generate_id() -> String { } /// Extracts the log ID from a note string, if the tag is present. -/// e.g. "Fixed auth bug ·log:a3f8b2" → Some("a3f8b2") +/// e.g. "Fixed auth bug [log:a3f8b2]" → Some("a3f8b2") pub fn extract_id(note: &str) -> Option<&str> { let pos = note.find(TAG_PREFIX)?; let after = ¬e[pos + TAG_PREFIX.len()..]; - // ID is exactly 6 hex chars - if after.len() >= 6 && after[..6].chars().all(|c| c.is_ascii_hexdigit()) { + // ID is exactly 6 hex chars followed by ']' + if after.len() >= 7 + && after[..6].chars().all(|c| c.is_ascii_hexdigit()) + && after.as_bytes()[6] == b']' + { Some(&after[..6]) } else { None @@ -58,7 +62,7 @@ pub fn strip_tag(note: &str) -> &str { /// Appends a log tag to a note string (returns new String). pub fn append_tag(note: &str, id: &str) -> String { - format!("{}{}{}", note.trim_end(), TAG_PREFIX, id) + format!("{}{}{}{}", note.trim_end(), TAG_PREFIX, id, TAG_SUFFIX) } /// Writes the initial log file with YAML frontmatter. @@ -79,7 +83,7 @@ mod tests { #[test] fn test_extract_id_present() { - let note = "Fixed auth bug \u{00B7}log:a3f8b2"; + let note = "Fixed auth bug [log:a3f8b2]"; assert_eq!(extract_id(note), Some("a3f8b2")); } @@ -90,7 +94,7 @@ mod tests { #[test] fn test_strip_tag() { - let note = "Fixed auth bug \u{00B7}log:a3f8b2"; + let note = "Fixed auth bug [log:a3f8b2]"; assert_eq!(strip_tag(note), "Fixed auth bug"); } @@ -102,13 +106,13 @@ mod tests { #[test] fn test_append_tag() { let result = append_tag("Fixed auth bug", "a3f8b2"); - assert_eq!(result, "Fixed auth bug \u{00B7}log:a3f8b2"); + assert_eq!(result, "Fixed auth bug [log:a3f8b2]"); } #[test] fn test_append_tag_trims_trailing_space() { let result = append_tag("Fixed auth bug ", "a3f8b2"); - assert_eq!(result, "Fixed auth bug \u{00B7}log:a3f8b2"); + assert_eq!(result, "Fixed auth bug [log:a3f8b2]"); } #[test] From bdc7e62cd8840d5d9949ca01f79543d7dc09d535 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 14:44:22 +0100 Subject: [PATCH 30/53] fix(tui): copy log ID when yanking or resuming a history entry --- toki-tui/src/app/edit.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/toki-tui/src/app/edit.rs b/toki-tui/src/app/edit.rs index 7b44e4e2..e2541a41 100644 --- a/toki-tui/src/app/edit.rs +++ b/toki-tui/src/app/edit.rs @@ -686,7 +686,7 @@ impl App { project_id: entry.project_id.clone(), }); let note = entry.note.clone().unwrap_or_default(); - self.description_input = TextInput::from_str(¬e); + self.set_note_from_raw(¬e); self.description_is_default = false; } From ef533bf72020dc37e44b90a78666e281b45eff27 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 14:51:28 +0100 Subject: [PATCH 31/53] =?UTF-8?q?refactor(tui):=20merge=20Y=20(copy)=20int?= =?UTF-8?q?o=20R=20(resume)=20=E2=80=94=20R=20now=20copies=20when=20timer?= =?UTF-8?q?=20running?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- toki-tui/src/app/edit.rs | 9 ------- toki-tui/src/runtime/action_queue.rs | 1 - toki-tui/src/runtime/actions.rs | 21 +++++---------- toki-tui/src/runtime/views/history.rs | 37 +++++---------------------- toki-tui/src/runtime/views/timer.rs | 27 ++++++------------- toki-tui/src/ui/history_view.rs | 4 +-- toki-tui/src/ui/timer_view.rs | 4 +-- 7 files changed, 23 insertions(+), 80 deletions(-) diff --git a/toki-tui/src/app/edit.rs b/toki-tui/src/app/edit.rs index e2541a41..d21bf371 100644 --- a/toki-tui/src/app/edit.rs +++ b/toki-tui/src/app/edit.rs @@ -689,15 +689,6 @@ impl App { self.set_note_from_raw(¬e); self.description_is_default = false; } - - /// Copy project, activity, and note from a history entry into the running timer. - pub fn yank_entry_to_timer(&mut self, entry: &crate::types::TimeEntry) { - self.copy_entry_fields(entry); - self.set_status(format!( - "Copied: {}: {}", - entry.project_name, entry.activity_name - )); - } } /// Given an entry's optional start/end times, date string (YYYY-MM-DD), and hours, diff --git a/toki-tui/src/runtime/action_queue.rs b/toki-tui/src/runtime/action_queue.rs index 788e8720..78bbd724 100644 --- a/toki-tui/src/runtime/action_queue.rs +++ b/toki-tui/src/runtime/action_queue.rs @@ -28,7 +28,6 @@ pub(super) enum Action { ConfirmDelete, StopServerTimerAndClear, RefreshHistoryBackground, - YankEntryToTimer(TimeEntry), ResumeEntry(TimeEntry), ApplyTemplate { template: crate::config::TemplateConfig, diff --git a/toki-tui/src/runtime/actions.rs b/toki-tui/src/runtime/actions.rs index 754f3a9d..7f891624 100644 --- a/toki-tui/src/runtime/actions.rs +++ b/toki-tui/src/runtime/actions.rs @@ -118,9 +118,6 @@ pub(super) async fn run_action( Action::RefreshHistoryBackground => { refresh_history_background(app, client).await; } - Action::YankEntryToTimer(entry) => { - yank_entry_to_timer(entry, app, client).await; - } Action::ResumeEntry(entry) => { resume_entry(entry, app, client).await; } @@ -408,12 +405,11 @@ async fn refresh_history_background(app: &mut App, client: &mut ApiClient) { } } -async fn yank_entry_to_timer(entry: types::TimeEntry, app: &mut App, client: &mut ApiClient) { - // Apply locally first so the UI updates immediately - app.yank_entry_to_timer(&entry); - - // Sync the new project/activity/note to the server so save works correctly +async fn resume_entry(entry: types::TimeEntry, app: &mut App, client: &mut ApiClient) { if app.timer_state == app::TimerState::Running { + // Timer already running — copy fields and sync to server (yank behaviour) + app.copy_entry_fields(&entry); + let project_id = app.selected_project.as_ref().map(|p| p.id.clone()); let project_name = app.selected_project.as_ref().map(|p| p.name.clone()); let activity_id = app.selected_activity.as_ref().map(|a| a.id.clone()); @@ -434,14 +430,9 @@ async fn yank_entry_to_timer(entry: types::TimeEntry, app: &mut App, client: &mu .await { app.set_status(format!("Warning: Could not sync copied entry to server: {}", e)); + } else { + app.set_status(format!("Copied: {}: {}", entry.project_name, entry.activity_name)); } - } -} - -async fn resume_entry(entry: types::TimeEntry, app: &mut App, client: &mut ApiClient) { - // Guard: should not be called while running, but be safe - if app.timer_state == app::TimerState::Running { - app.set_status("Timer already running — stop it first (Space or Ctrl+X)".to_string()); return; } diff --git a/toki-tui/src/runtime/views/history.rs b/toki-tui/src/runtime/views/history.rs index b04d828d..bd7a28a7 100644 --- a/toki-tui/src/runtime/views/history.rs +++ b/toki-tui/src/runtime/views/history.rs @@ -151,38 +151,15 @@ pub(super) fn handle_history_key(key: KeyEvent, app: &mut App, action_tx: &Actio app.enter_delete_confirm(app::DeleteOrigin::History); } } - KeyCode::Char('y') | KeyCode::Char('Y') if app.focused_history_index.is_some() => { - if app.timer_state != app::TimerState::Running { - app.set_status( - "No running timer — use R to resume this entry instead".to_string(), - ); - } else { - let entry = app - .focused_history_index - .and_then(|idx| app.history_list_entries.get(idx).copied()) - .and_then(|te_idx| app.time_entries.get(te_idx).cloned()); - if let Some(entry) = entry { - enqueue_action(action_tx, Action::YankEntryToTimer(entry)); - } else { - app.set_status("Error: could not resolve selected entry".to_string()); - } - } - } KeyCode::Char('r') | KeyCode::Char('R') if app.focused_history_index.is_some() => { - if app.timer_state == app::TimerState::Running { - app.set_status( - "Timer already running — stop it first (Space or Ctrl+X)".to_string(), - ); + let entry = app + .focused_history_index + .and_then(|idx| app.history_list_entries.get(idx).copied()) + .and_then(|te_idx| app.time_entries.get(te_idx).cloned()); + if let Some(entry) = entry { + enqueue_action(action_tx, Action::ResumeEntry(entry)); } else { - let entry = app - .focused_history_index - .and_then(|idx| app.history_list_entries.get(idx).copied()) - .and_then(|te_idx| app.time_entries.get(te_idx).cloned()); - if let Some(entry) = entry { - enqueue_action(action_tx, Action::ResumeEntry(entry)); - } else { - app.set_status("Error: could not resolve selected entry".to_string()); - } + app.set_status("Error: could not resolve selected entry".to_string()); } } _ => {} diff --git a/toki-tui/src/runtime/views/timer.rs b/toki-tui/src/runtime/views/timer.rs index 9026bdd7..5653a99c 100644 --- a/toki-tui/src/runtime/views/timer.rs +++ b/toki-tui/src/runtime/views/timer.rs @@ -148,27 +148,16 @@ pub(super) fn handle_timer_key(key: KeyEvent, app: &mut App, action_tx: &ActionT } } KeyCode::Char('z') | KeyCode::Char('Z') => app.toggle_zen_mode(), - KeyCode::Char('y') | KeyCode::Char('Y') if !is_editing_this_week(app) => { - if app.timer_state != app::TimerState::Running { - app.set_status("No running timer — use R to resume this entry instead".to_string()); - } else if is_persisted_today_row_selected(app) { - let idx = app.focused_this_week_index.unwrap(); - let db_idx = idx.saturating_sub(1); // timer row at 0 shifts DB entries by 1 - let entry = app.this_week_history().get(db_idx).cloned().cloned(); - if let Some(entry) = entry { - enqueue_action(action_tx, Action::YankEntryToTimer(entry)); - } - } - } KeyCode::Char('r') | KeyCode::Char('R') if !is_editing_this_week(app) => { - if app.timer_state == app::TimerState::Running { - app.set_status( - "Timer already running — stop it first (Space or Ctrl+X)".to_string(), - ); - } else if is_persisted_today_row_selected(app) { + if is_persisted_today_row_selected(app) { let idx = app.focused_this_week_index.unwrap(); - // Timer is stopped: no running-timer row at index 0, so idx is the DB index directly - let entry = app.this_week_history().get(idx).cloned().cloned(); + // When running, index 0 is the running-timer row so DB entries are shifted by 1 + let db_idx = if app.timer_state == app::TimerState::Running { + idx.saturating_sub(1) + } else { + idx + }; + let entry = app.this_week_history().get(db_idx).cloned().cloned(); if let Some(entry) = entry { enqueue_action(action_tx, Action::ResumeEntry(entry)); } diff --git a/toki-tui/src/ui/history_view.rs b/toki-tui/src/ui/history_view.rs index 8de49161..222dd051 100644 --- a/toki-tui/src/ui/history_view.rs +++ b/toki-tui/src/ui/history_view.rs @@ -232,10 +232,8 @@ pub fn render_history_view(frame: &mut Frame, app: &mut App, body: Rect) { Span::raw(": Navigate "), Span::styled("Enter", Style::default().fg(Color::Yellow)), Span::raw(": Edit "), - Span::styled("Y", Style::default().fg(Color::Yellow)), - Span::raw(": Copy to running timer "), Span::styled("R", Style::default().fg(Color::Yellow)), - Span::raw(": Resume "), + Span::raw(": Resume / Copy to running "), Span::styled("H / Esc", Style::default().fg(Color::Yellow)), Span::raw(": Back to timer "), Span::styled("Q", Style::default().fg(Color::Yellow)), diff --git a/toki-tui/src/ui/timer_view.rs b/toki-tui/src/ui/timer_view.rs index a407a8b0..b3a518f1 100644 --- a/toki-tui/src/ui/timer_view.rs +++ b/toki-tui/src/ui/timer_view.rs @@ -268,10 +268,8 @@ fn render_controls(frame: &mut Frame, area: ratatui::layout::Rect, app: &App) { Span::raw(": Stats "), Span::styled("G", Style::default().fg(Color::Yellow)), Span::raw(": Toggle size "), - Span::styled("Y", Style::default().fg(Color::Yellow)), - Span::raw(": Copy to running timer "), Span::styled("R", Style::default().fg(Color::Yellow)), - Span::raw(": Resume "), + Span::raw(": Resume / Copy to running "), Span::styled("Z", Style::default().fg(Color::Yellow)), Span::raw(": Zen mode "), Span::styled("Esc", Style::default().fg(Color::Yellow)), From 90780a462664c88d4c79b937e7a2a55bf7b9ba74 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 14:52:03 +0100 Subject: [PATCH 32/53] fix(tui): simplify R hint label back to Resume --- toki-tui/src/ui/history_view.rs | 2 +- toki-tui/src/ui/timer_view.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/toki-tui/src/ui/history_view.rs b/toki-tui/src/ui/history_view.rs index 222dd051..87f3aa9e 100644 --- a/toki-tui/src/ui/history_view.rs +++ b/toki-tui/src/ui/history_view.rs @@ -233,7 +233,7 @@ pub fn render_history_view(frame: &mut Frame, app: &mut App, body: Rect) { Span::styled("Enter", Style::default().fg(Color::Yellow)), Span::raw(": Edit "), Span::styled("R", Style::default().fg(Color::Yellow)), - Span::raw(": Resume / Copy to running "), + Span::raw(": Resume "), Span::styled("H / Esc", Style::default().fg(Color::Yellow)), Span::raw(": Back to timer "), Span::styled("Q", Style::default().fg(Color::Yellow)), diff --git a/toki-tui/src/ui/timer_view.rs b/toki-tui/src/ui/timer_view.rs index b3a518f1..2b58d14a 100644 --- a/toki-tui/src/ui/timer_view.rs +++ b/toki-tui/src/ui/timer_view.rs @@ -269,7 +269,7 @@ fn render_controls(frame: &mut Frame, area: ratatui::layout::Rect, app: &App) { Span::styled("G", Style::default().fg(Color::Yellow)), Span::raw(": Toggle size "), Span::styled("R", Style::default().fg(Color::Yellow)), - Span::raw(": Resume / Copy to running "), + Span::raw(": Resume "), Span::styled("Z", Style::default().fg(Color::Yellow)), Span::raw(": Zen mode "), Span::styled("Esc", Style::default().fg(Color::Yellow)), From 87bd4016cf1d4e1dfc841d3c1080b787d780446c Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 14:54:22 +0100 Subject: [PATCH 33/53] fix(tui): rebind toggle size from G to X --- toki-tui/src/runtime/views/timer.rs | 4 +++- toki-tui/src/ui/timer_view.rs | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/toki-tui/src/runtime/views/timer.rs b/toki-tui/src/runtime/views/timer.rs index 5653a99c..d762cc42 100644 --- a/toki-tui/src/runtime/views/timer.rs +++ b/toki-tui/src/runtime/views/timer.rs @@ -128,7 +128,9 @@ pub(super) fn handle_timer_key(key: KeyEvent, app: &mut App, action_tx: &ActionT KeyCode::Char('n') | KeyCode::Char('N') => { app.navigate_to(app::View::EditDescription); } - KeyCode::Char('g') | KeyCode::Char('G') => { + KeyCode::Char('x') | KeyCode::Char('X') + if !key.modifiers.contains(KeyModifiers::CONTROL) => + { app.toggle_timer_size(); } // S: Open Stats view (unmodified only - Ctrl+S is save) diff --git a/toki-tui/src/ui/timer_view.rs b/toki-tui/src/ui/timer_view.rs index 2b58d14a..23318feb 100644 --- a/toki-tui/src/ui/timer_view.rs +++ b/toki-tui/src/ui/timer_view.rs @@ -266,7 +266,7 @@ fn render_controls(frame: &mut Frame, area: ratatui::layout::Rect, app: &App) { Span::raw(": History "), Span::styled("S", Style::default().fg(Color::Yellow)), Span::raw(": Stats "), - Span::styled("G", Style::default().fg(Color::Yellow)), + Span::styled("X", Style::default().fg(Color::Yellow)), Span::raw(": Toggle size "), Span::styled("R", Style::default().fg(Color::Yellow)), Span::raw(": Resume "), From 968578982b9cdb0859e52304d3ed8fefec7aef74 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 14:56:56 +0100 Subject: [PATCH 34/53] fix(tui): move Z: Zen mode next to X: Toggle size in hints --- toki-tui/src/ui/timer_view.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/toki-tui/src/ui/timer_view.rs b/toki-tui/src/ui/timer_view.rs index 23318feb..23797f1d 100644 --- a/toki-tui/src/ui/timer_view.rs +++ b/toki-tui/src/ui/timer_view.rs @@ -268,10 +268,10 @@ fn render_controls(frame: &mut Frame, area: ratatui::layout::Rect, app: &App) { Span::raw(": Stats "), Span::styled("X", Style::default().fg(Color::Yellow)), Span::raw(": Toggle size "), - Span::styled("R", Style::default().fg(Color::Yellow)), - Span::raw(": Resume "), Span::styled("Z", Style::default().fg(Color::Yellow)), Span::raw(": Zen mode "), + Span::styled("R", Style::default().fg(Color::Yellow)), + Span::raw(": Resume "), Span::styled("Esc", Style::default().fg(Color::Yellow)), Span::raw(": Exit edit "), Span::styled("Q", Style::default().fg(Color::Yellow)), From 2755b258b6e806124e3e7d15d7e7f16d0e334c34 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 15:01:53 +0100 Subject: [PATCH 35/53] fix(tui): rebind Resume to Ctrl+R, move hint to top row after Ctrl+S --- toki-tui/src/runtime/views/history.rs | 5 ++++- toki-tui/src/runtime/views/timer.rs | 4 +++- toki-tui/src/ui/history_view.rs | 2 +- toki-tui/src/ui/timer_view.rs | 4 ++-- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/toki-tui/src/runtime/views/history.rs b/toki-tui/src/runtime/views/history.rs index bd7a28a7..3d18dd76 100644 --- a/toki-tui/src/runtime/views/history.rs +++ b/toki-tui/src/runtime/views/history.rs @@ -151,7 +151,10 @@ pub(super) fn handle_history_key(key: KeyEvent, app: &mut App, action_tx: &Actio app.enter_delete_confirm(app::DeleteOrigin::History); } } - KeyCode::Char('r') | KeyCode::Char('R') if app.focused_history_index.is_some() => { + KeyCode::Char('r') | KeyCode::Char('R') + if app.focused_history_index.is_some() + && key.modifiers.contains(KeyModifiers::CONTROL) => + { let entry = app .focused_history_index .and_then(|idx| app.history_list_entries.get(idx).copied()) diff --git a/toki-tui/src/runtime/views/timer.rs b/toki-tui/src/runtime/views/timer.rs index d762cc42..6b949181 100644 --- a/toki-tui/src/runtime/views/timer.rs +++ b/toki-tui/src/runtime/views/timer.rs @@ -150,7 +150,9 @@ pub(super) fn handle_timer_key(key: KeyEvent, app: &mut App, action_tx: &ActionT } } KeyCode::Char('z') | KeyCode::Char('Z') => app.toggle_zen_mode(), - KeyCode::Char('r') | KeyCode::Char('R') if !is_editing_this_week(app) => { + KeyCode::Char('r') | KeyCode::Char('R') + if !is_editing_this_week(app) && key.modifiers.contains(KeyModifiers::CONTROL) => + { if is_persisted_today_row_selected(app) { let idx = app.focused_this_week_index.unwrap(); // When running, index 0 is the running-timer row so DB entries are shifted by 1 diff --git a/toki-tui/src/ui/history_view.rs b/toki-tui/src/ui/history_view.rs index 87f3aa9e..6c50838e 100644 --- a/toki-tui/src/ui/history_view.rs +++ b/toki-tui/src/ui/history_view.rs @@ -232,7 +232,7 @@ pub fn render_history_view(frame: &mut Frame, app: &mut App, body: Rect) { Span::raw(": Navigate "), Span::styled("Enter", Style::default().fg(Color::Yellow)), Span::raw(": Edit "), - Span::styled("R", Style::default().fg(Color::Yellow)), + Span::styled("Ctrl+R", Style::default().fg(Color::Yellow)), Span::raw(": Resume "), Span::styled("H / Esc", Style::default().fg(Color::Yellow)), Span::raw(": Back to timer "), diff --git a/toki-tui/src/ui/timer_view.rs b/toki-tui/src/ui/timer_view.rs index 23797f1d..2a3628ae 100644 --- a/toki-tui/src/ui/timer_view.rs +++ b/toki-tui/src/ui/timer_view.rs @@ -241,6 +241,8 @@ fn render_controls(frame: &mut Frame, area: ratatui::layout::Rect, app: &App) { Span::raw(": Start/Stop "), Span::styled("Ctrl+S", Style::default().fg(Color::Yellow)), Span::raw(": Save (options) "), + Span::styled("Ctrl+R", Style::default().fg(Color::Yellow)), + Span::raw(": Resume "), Span::styled("Ctrl+X", Style::default().fg(Color::Yellow)), Span::raw(": Clear "), Span::styled("Tab / ↑↓ / j/k", Style::default().fg(Color::Yellow)), @@ -270,8 +272,6 @@ fn render_controls(frame: &mut Frame, area: ratatui::layout::Rect, app: &App) { Span::raw(": Toggle size "), Span::styled("Z", Style::default().fg(Color::Yellow)), Span::raw(": Zen mode "), - Span::styled("R", Style::default().fg(Color::Yellow)), - Span::raw(": Resume "), Span::styled("Esc", Style::default().fg(Color::Yellow)), Span::raw(": Exit edit "), Span::styled("Q", Style::default().fg(Color::Yellow)), From a416de0177ccb30e7b51c5df7d0b5aa1631aac8b Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 15:07:43 +0100 Subject: [PATCH 36/53] fix(tui): force full redraw on FocusGained to recover after sleep/wake --- toki-tui/src/runtime/event_loop.rs | 21 ++++++++++++++------- toki-tui/src/terminal.rs | 9 +++++++-- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/toki-tui/src/runtime/event_loop.rs b/toki-tui/src/runtime/event_loop.rs index d69ca6cd..299df69d 100644 --- a/toki-tui/src/runtime/event_loop.rs +++ b/toki-tui/src/runtime/event_loop.rs @@ -37,15 +37,22 @@ pub async fn run_app( } if event::poll(Duration::from_millis(100))? { - if let Event::Key(key) = event::read()? { - if key.kind != KeyEventKind::Press { - continue; + match event::read()? { + Event::Key(key) => { + if key.kind != KeyEventKind::Press { + continue; + } + if app.milltime_reauth.is_some() { + handle_milltime_reauth_key(key, app, &action_tx); + } else { + handle_view_key(key, app, &action_tx); + } } - if app.milltime_reauth.is_some() { - handle_milltime_reauth_key(key, app, &action_tx); - } else { - handle_view_key(key, app, &action_tx); + // Force a full redraw when the terminal regains focus (e.g. after sleep/wake) + Event::FocusGained => { + app.needs_full_redraw = true; } + _ => {} } } diff --git a/toki-tui/src/terminal.rs b/toki-tui/src/terminal.rs index 3bfbab4a..c0749a64 100644 --- a/toki-tui/src/terminal.rs +++ b/toki-tui/src/terminal.rs @@ -1,5 +1,6 @@ use anyhow::Result; use crossterm::{ + event::{DisableFocusChange, EnableFocusChange}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; @@ -14,7 +15,7 @@ impl TerminalGuard { pub fn new() -> Result { enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; + execute!(stdout, EnterAlternateScreen, EnableFocusChange)?; let backend = CrosstermBackend::new(stdout); let terminal = Terminal::new(backend)?; @@ -29,7 +30,11 @@ impl TerminalGuard { impl Drop for TerminalGuard { fn drop(&mut self) { let _ = disable_raw_mode(); - let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen); + let _ = execute!( + self.terminal.backend_mut(), + DisableFocusChange, + LeaveAlternateScreen + ); let _ = self.terminal.show_cursor(); } } From 28a6fc2d36efb751ea875dd58b679d2ccdbfb38d Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 15:16:30 +0100 Subject: [PATCH 37/53] fix(tui): remove cursor block from note field in inline entry edit row --- toki-tui/src/ui/widgets.rs | 70 ++++++-------------------------------- 1 file changed, 10 insertions(+), 60 deletions(-) diff --git a/toki-tui/src/ui/widgets.rs b/toki-tui/src/ui/widgets.rs index 7efb83b9..5df04cbf 100644 --- a/toki-tui/src/ui/widgets.rs +++ b/toki-tui/src/ui/widgets.rs @@ -345,42 +345,17 @@ pub fn build_running_timer_edit_row(edit_state: &EntryEditState) -> Line<'_> { spans.push(Span::styled(" | ", Style::default().fg(Color::White))); - // Note field - let note_base_style = match edit_state.focused_field { + // Note field — display only (editing opens the full-screen Notes overlay via Enter) + let note_style = match edit_state.focused_field { EntryEditField::Note => Style::default() .fg(Color::Black) .bg(Color::White) .add_modifier(Modifier::BOLD), _ => Style::default().fg(Color::White), }; - if matches!(edit_state.focused_field, EntryEditField::Note) { - // Cursor active — strip the log tag, then render cursor as underlined char - let clean = log_notes::strip_tag(&edit_state.note.value); - let clean_len = clean.chars().count(); - let cursor = edit_state.note.cursor.min(clean_len); - let before: String = clean.chars().take(cursor).collect(); - let cursor_char: String = clean - .chars() - .nth(cursor) - .map(|c| c.to_string()) - .unwrap_or_else(|| " ".to_string()); - let after: String = clean.chars().skip(cursor + 1).collect(); - // Invert fg/bg on the cursor character so it's visible within the white-highlight zone - let cursor_style = Style::default() - .fg(Color::White) - .bg(Color::Black) - .add_modifier(Modifier::BOLD); - spans.push(Span::styled("[", note_base_style)); - spans.push(Span::styled(before, note_base_style)); - spans.push(Span::styled(cursor_char, cursor_style)); - spans.push(Span::styled(after, note_base_style)); - spans.push(Span::styled("]", note_base_style)); - } else { - // Display mode — strip the log tag - let display = log_notes::strip_tag(&edit_state.note.value); - let note_value = format!("[{}]", if display.is_empty() { "Empty" } else { display }); - spans.push(Span::styled(note_value, note_base_style)); - } + let display = log_notes::strip_tag(&edit_state.note.value); + let note_value = format!("[{}]", if display.is_empty() { "Empty" } else { display }); + spans.push(Span::styled(note_value, note_style)); Line::from(spans) } @@ -451,42 +426,17 @@ pub fn build_edit_row<'a>( // Separator spans.push(Span::styled(" | ", Style::default().fg(Color::White))); - // Note field - let note_base_style = match edit_state.focused_field { + // Note field — display only (editing opens the full-screen Notes overlay via Enter) + let note_style = match edit_state.focused_field { EntryEditField::Note => Style::default() .fg(Color::Black) .bg(Color::White) .add_modifier(Modifier::BOLD), _ => Style::default().fg(Color::White), }; - if matches!(edit_state.focused_field, EntryEditField::Note) { - // Cursor active — strip the log tag, then render cursor as underlined char - let clean = log_notes::strip_tag(&edit_state.note.value); - let clean_len = clean.chars().count(); - let cursor = edit_state.note.cursor.min(clean_len); - let before: String = clean.chars().take(cursor).collect(); - let cursor_char: String = clean - .chars() - .nth(cursor) - .map(|c| c.to_string()) - .unwrap_or_else(|| " ".to_string()); - let after: String = clean.chars().skip(cursor + 1).collect(); - // Invert fg/bg on the cursor character so it's visible within the white-highlight zone - let cursor_style = Style::default() - .fg(Color::White) - .bg(Color::Black) - .add_modifier(Modifier::BOLD); - spans.push(Span::styled("[", note_base_style)); - spans.push(Span::styled(before, note_base_style)); - spans.push(Span::styled(cursor_char, cursor_style)); - spans.push(Span::styled(after, note_base_style)); - spans.push(Span::styled("]", note_base_style)); - } else { - // Display mode — strip the log tag - let display = log_notes::strip_tag(&edit_state.note.value); - let note_value = format!("[{}]", if display.is_empty() { "Empty" } else { display }); - spans.push(Span::styled(note_value, note_base_style)); - } + let display = log_notes::strip_tag(&edit_state.note.value); + let note_value = format!("[{}]", if display.is_empty() { "Empty" } else { display }); + spans.push(Span::styled(note_value, note_style)); Line::from(spans) } From 0240f16071d8dfec0f95a4dcce7c943b6ecd7a2e Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 15:20:35 +0100 Subject: [PATCH 38/53] fix(tui): rename Stats to Statistics in hint and view title --- toki-tui/src/ui/statistics_view.rs | 4 ++-- toki-tui/src/ui/timer_view.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/toki-tui/src/ui/statistics_view.rs b/toki-tui/src/ui/statistics_view.rs index 60a6a04b..8e3ee594 100644 --- a/toki-tui/src/ui/statistics_view.rs +++ b/toki-tui/src/ui/statistics_view.rs @@ -25,12 +25,12 @@ pub fn render_statistics_view(frame: &mut Frame, app: &App, body: Rect) { .constraints([Constraint::Min(10), Constraint::Length(3)]) .split(body); - // Outer "Stats" box + // Outer "Statistics" box let stats_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Color::White)) .title(Span::styled( - " Stats ", + " Statistics ", Style::default().fg(Color::White), )); let stats_inner = stats_block.inner(outer[0]); diff --git a/toki-tui/src/ui/timer_view.rs b/toki-tui/src/ui/timer_view.rs index 2a3628ae..e61044d0 100644 --- a/toki-tui/src/ui/timer_view.rs +++ b/toki-tui/src/ui/timer_view.rs @@ -267,7 +267,7 @@ fn render_controls(frame: &mut Frame, area: ratatui::layout::Rect, app: &App) { Span::styled("H", Style::default().fg(Color::Yellow)), Span::raw(": History "), Span::styled("S", Style::default().fg(Color::Yellow)), - Span::raw(": Stats "), + Span::raw(": Statistics "), Span::styled("X", Style::default().fg(Color::Yellow)), Span::raw(": Toggle size "), Span::styled("Z", Style::default().fg(Color::Yellow)), From 1da07d09b765fd0025e7b7c9f2c4941d1f0d6f49 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 15:25:16 +0100 Subject: [PATCH 39/53] fix(tui): remove redundant closure in generate_id call --- toki-tui/src/runtime/actions.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/toki-tui/src/runtime/actions.rs b/toki-tui/src/runtime/actions.rs index 7f891624..5be75e28 100644 --- a/toki-tui/src/runtime/actions.rs +++ b/toki-tui/src/runtime/actions.rs @@ -928,7 +928,7 @@ async fn handle_open_log_note(app: &mut App, client: &mut ApiClient) -> anyhow:: let id = app .description_log_id .clone() - .unwrap_or_else(|| log_notes::generate_id()); + .unwrap_or_else(log_notes::generate_id); // Date string let today = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc()); From 18c68be78b227827ad6c924a60f454b1c2352e4d Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 15:52:50 +0100 Subject: [PATCH 40/53] fix(tui): address code review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cwd_delete_word_back: clear completions consistently with other input mutators - editor: restore TUI (enable_raw_mode + EnterAlternateScreen) even when the editor process fails to launch, preventing broken terminal state - log_notes: validate id is hex-only in log_path to prevent path traversal - log_notes: capture SystemTime::now() once to avoid a theoretical race - log notes render: cache log file content on App, eliminating per-frame synchronous file I/O in the description editor render path - template selection: show status message when project is not found instead of navigating back silently Rejected: reviewer suggestion to assert status_message after handle_confirm_delete — navigate_to() calls clear_status() so the original assertion (is_none) is correct. --- toki-tui/src/app/mod.rs | 23 +++++++++++++++++++ toki-tui/src/editor.rs | 10 ++++---- toki-tui/src/log_notes.rs | 15 ++++++------ toki-tui/src/runtime/actions.rs | 10 ++++++-- .../src/runtime/views/edit_description.rs | 1 + toki-tui/src/ui/description_editor.rs | 9 ++++---- 6 files changed, 50 insertions(+), 18 deletions(-) diff --git a/toki-tui/src/app/mod.rs b/toki-tui/src/app/mod.rs index a45fc20e..19f38d04 100644 --- a/toki-tui/src/app/mod.rs +++ b/toki-tui/src/app/mod.rs @@ -129,6 +129,11 @@ pub struct App { /// sees (and edits) only the clean summary. The tag is re-appended when the editor /// closes (Enter / Esc) or when `handle_open_log_note` reads the full note value. pub description_log_id: Option, + + /// Cached content of the currently linked log file, refreshed whenever + /// `description_log_id` changes. Used by the render path to avoid per-frame + /// synchronous file I/O. + pub cached_log_content: Option, } impl App { @@ -199,6 +204,7 @@ impl App { filtered_template_index: 0, needs_full_redraw: false, description_log_id: None, + cached_log_content: None, } } @@ -225,6 +231,7 @@ impl App { self.description_input = TextInput::new(); self.description_is_default = true; self.description_log_id = None; + self.cached_log_content = None; self.status_message = Some("Note cleared".to_string()); } @@ -238,6 +245,7 @@ impl App { self.description_input = TextInput::new(); self.description_is_default = true; self.description_log_id = None; + self.cached_log_content = None; self.status_message = Some("Timer cleared".to_string()); } @@ -451,6 +459,7 @@ impl App { self.description_log_id = Some(id.to_string()); let stripped = log_notes::strip_tag(&raw).to_string(); self.description_input = TextInput::from_str(&stripped); + self.refresh_log_cache(); } } self.editing_description = true; @@ -643,6 +652,7 @@ impl App { self.description_log_id = None; self.description_input = TextInput::from_str(&saved); } + self.refresh_log_cache(); } } @@ -658,6 +668,18 @@ impl App { self.description_log_id = None; self.description_input = TextInput::from_str(raw); } + self.refresh_log_cache(); + } + + /// Refreshes `cached_log_content` from disk based on the current `description_log_id`. + /// Call this once whenever `description_log_id` is assigned. + pub fn refresh_log_cache(&mut self) { + use crate::log_notes; + self.cached_log_content = self + .description_log_id + .as_ref() + .and_then(|id| log_notes::log_path(id).ok()) + .and_then(|path| std::fs::read_to_string(path).ok()); } /// Handle character input for description editing @@ -1178,6 +1200,7 @@ impl App { pub fn cwd_delete_word_back(&mut self) { if let Some(ref mut ti) = self.cwd_input { ti.delete_word_back(); + self.cwd_completions.clear(); } } diff --git a/toki-tui/src/editor.rs b/toki-tui/src/editor.rs index 113f8ec6..c2d21d45 100644 --- a/toki-tui/src/editor.rs +++ b/toki-tui/src/editor.rs @@ -16,16 +16,18 @@ pub async fn open_editor(path: &Path) -> Result<()> { disable_raw_mode()?; execute!(std::io::stdout(), LeaveAlternateScreen)?; - // Spawn editor and wait - let status = tokio::process::Command::new(&editor) + // Spawn editor and wait; capture result so TUI is always restored below + let status_res = tokio::process::Command::new(&editor) .arg(path) .status() - .await?; + .await; - // Re-enter TUI + // Re-enter TUI (always, even if the editor failed to launch) enable_raw_mode()?; execute!(std::io::stdout(), EnterAlternateScreen)?; + let status = status_res?; + if !status.success() { anyhow::bail!("Editor exited with non-zero status: {}", status); } diff --git a/toki-tui/src/log_notes.rs b/toki-tui/src/log_notes.rs index 848a3d42..d0b8d570 100644 --- a/toki-tui/src/log_notes.rs +++ b/toki-tui/src/log_notes.rs @@ -14,7 +14,11 @@ pub fn log_dir() -> anyhow::Result { } /// Returns the path for a given log ID. +/// Returns an error if `id` contains non-hex characters (prevents path traversal). pub fn log_path(id: &str) -> anyhow::Result { + if id.is_empty() || !id.chars().all(|c| c.is_ascii_hexdigit()) { + anyhow::bail!("Invalid log id: must be lowercase hex characters only"); + } Ok(log_dir()?.join(format!("{}.md", id))) } @@ -23,14 +27,11 @@ pub fn generate_id() -> String { use std::time::{SystemTime, UNIX_EPOCH}; // Simple deterministic-enough ID from timestamp nanos XOR'd with secs. // No external deps needed. 6 hex chars = 16M values, plenty for thousands of logs. - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .subsec_nanos(); - let secs = SystemTime::now() + let now = SystemTime::now() .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); + .unwrap_or_default(); + let nanos = now.subsec_nanos(); + let secs = now.as_secs(); let hash = (secs ^ (nanos as u64)).wrapping_mul(0x9e3779b97f4a7c15); format!("{:06x}", hash & 0xffffff) } diff --git a/toki-tui/src/runtime/actions.rs b/toki-tui/src/runtime/actions.rs index 5be75e28..7f7d2fba 100644 --- a/toki-tui/src/runtime/actions.rs +++ b/toki-tui/src/runtime/actions.rs @@ -302,7 +302,11 @@ async fn handle_apply_template( .cloned(); let Some(project) = project else { - // Project not found — navigate back silently + // Project not found — tell the user and navigate back + app.set_status(format!( + "Project '{}' not found — template not applied", + template.project + )); app.navigate_to(app::View::Timer); return Ok(()); }; @@ -946,8 +950,10 @@ async fn handle_open_log_note(app: &mut App, client: &mut ApiClient) -> anyhow:: crate::editor::open_editor(&log_path).await?; // Store the ID on App so subsequent Ctrl+L presses reuse it and the tag - // survives further editing. + // survives further editing. Refresh the cache so the render path sees the + // newly written file immediately. app.description_log_id = Some(id.clone()); + app.refresh_log_cache(); // Build the full note value (summary + tag) to save/sync. let new_note = log_notes::append_tag(&summary, &id); diff --git a/toki-tui/src/runtime/views/edit_description.rs b/toki-tui/src/runtime/views/edit_description.rs index e55bfafd..fd83cf5c 100644 --- a/toki-tui/src/runtime/views/edit_description.rs +++ b/toki-tui/src/runtime/views/edit_description.rs @@ -91,6 +91,7 @@ pub(super) fn handle_edit_description_key(key: KeyEvent, app: &mut App, action_t { // Remove (detach) the linked log from the current note (orphans the file, keeps note text) app.description_log_id = None; + app.cached_log_content = None; app.status_message = Some("Log removed".to_string()); } KeyCode::Char('l') | KeyCode::Char('L') diff --git a/toki-tui/src/ui/description_editor.rs b/toki-tui/src/ui/description_editor.rs index 2d28ebd2..802f8de4 100644 --- a/toki-tui/src/ui/description_editor.rs +++ b/toki-tui/src/ui/description_editor.rs @@ -133,11 +133,10 @@ pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { // Log content box (read-only, shown when a log is linked) if has_log { let log_content = app - .description_log_id - .as_ref() - .and_then(|id| log_notes::log_path(id).ok()) - .and_then(|path| std::fs::read_to_string(path).ok()) - .unwrap_or_default(); + .cached_log_content + .as_deref() + .unwrap_or_default() + .to_string(); let log_paragraph = Paragraph::new(log_content) .style(Style::default().fg(Color::DarkGray)) From 30ce5b90acf617b8c19db2d78533cf508a92f6ba Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 16:31:25 +0100 Subject: [PATCH 41/53] fix(tui): address second round of code review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - editor: split $EDITOR/$VISUAL on whitespace so 'code -w' works correctly - editor: capture restoration errors separately; prefer original editor launch error over TUI restore error in the return value - log_notes: doc comment accurately describes timestamp-based derivation - log_notes: add AtomicU64 monotonic counter to generate_id to avoid collisions on rapid successive calls within the same process - log_path: enforce lowercase hex only (reject A-F) to match generate_id output - template apply: show status when activity name is not found instead of silently skipping - template apply: use full_note_value() when syncing running timer so any linked log tag is included, consistent with other sync sites - event_loop: move needs_full_redraw clear+draw before terminal.draw() to prevent a flash frame after sleep/wake or editor return Rejected: test assertion for 'Entry deleted' — navigate_to() calls clear_status(), so status_message is None after handle_confirm_delete returns; original is_none() assertion is correct. Rejected: cursor adjustment in description_editor — description_input.value never contains the tag (architecture invariant), cursor is already correct. --- toki-tui/src/editor.rs | 24 ++++++++++++++++++------ toki-tui/src/log_notes.rs | 21 +++++++++++++++------ toki-tui/src/runtime/actions.rs | 7 ++++++- toki-tui/src/runtime/event_loop.rs | 12 +++++++----- 4 files changed, 46 insertions(+), 18 deletions(-) diff --git a/toki-tui/src/editor.rs b/toki-tui/src/editor.rs index c2d21d45..9d84d8f3 100644 --- a/toki-tui/src/editor.rs +++ b/toki-tui/src/editor.rs @@ -12,21 +12,33 @@ pub async fn open_editor(path: &Path) -> Result<()> { .or_else(|_| std::env::var("EDITOR")) .unwrap_or_else(|_| "nano".to_string()); + // Split "program arg1 arg2" on whitespace (no quote handling needed for typical $EDITOR values). + let mut parts = editor.split_whitespace(); + let program = parts.next().unwrap_or("nano"); + let args: Vec<&str> = parts.collect(); + // Leave TUI disable_raw_mode()?; execute!(std::io::stdout(), LeaveAlternateScreen)?; - // Spawn editor and wait; capture result so TUI is always restored below - let status_res = tokio::process::Command::new(&editor) + // Spawn editor and wait; capture result so TUI is always restored below. + let status_res = tokio::process::Command::new(program) + .args(&args) .arg(path) .status() .await; - // Re-enter TUI (always, even if the editor failed to launch) - enable_raw_mode()?; - execute!(std::io::stdout(), EnterAlternateScreen)?; + // Re-enter TUI — always attempt restoration even if the editor failed. + // If restoration itself fails, combine with any prior editor error. + let restore_res = enable_raw_mode() + .and_then(|_| execute!(std::io::stdout(), EnterAlternateScreen)); - let status = status_res?; + // Prefer the original editor error; surface restoration error only if no prior error. + let status = match (status_res, restore_res) { + (Err(editor_err), _) => return Err(editor_err.into()), + (Ok(_), Err(restore_err)) => return Err(restore_err.into()), + (Ok(status), Ok(_)) => status, + }; if !status.success() { anyhow::bail!("Editor exited with non-zero status: {}", status); diff --git a/toki-tui/src/log_notes.rs b/toki-tui/src/log_notes.rs index d0b8d570..dcfcd061 100644 --- a/toki-tui/src/log_notes.rs +++ b/toki-tui/src/log_notes.rs @@ -14,25 +14,34 @@ pub fn log_dir() -> anyhow::Result { } /// Returns the path for a given log ID. -/// Returns an error if `id` contains non-hex characters (prevents path traversal). +/// Returns an error if `id` contains non-lowercase-hex characters (prevents path traversal). pub fn log_path(id: &str) -> anyhow::Result { - if id.is_empty() || !id.chars().all(|c| c.is_ascii_hexdigit()) { + if id.is_empty() + || !id + .chars() + .all(|c| c.is_ascii_digit() || matches!(c, 'a'..='f')) + { anyhow::bail!("Invalid log id: must be lowercase hex characters only"); } Ok(log_dir()?.join(format!("{}.md", id))) } -/// Generates a random 6-character lowercase hex ID. +/// Derives a 6-character lowercase hex ID from the current timestamp and a +/// process-local monotonic counter. Not cryptographically random — intended +/// only to be unique enough for thousands of personal log files. pub fn generate_id() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; - // Simple deterministic-enough ID from timestamp nanos XOR'd with secs. - // No external deps needed. 6 hex chars = 16M values, plenty for thousands of logs. + + static COUNTER: AtomicU64 = AtomicU64::new(0); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default(); let nanos = now.subsec_nanos(); let secs = now.as_secs(); - let hash = (secs ^ (nanos as u64)).wrapping_mul(0x9e3779b97f4a7c15); + let hash = (secs ^ (nanos as u64) ^ seq).wrapping_mul(0x9e3779b97f4a7c15); format!("{:06x}", hash & 0xffffff) } diff --git a/toki-tui/src/runtime/actions.rs b/toki-tui/src/runtime/actions.rs index 7f7d2fba..5b390ffb 100644 --- a/toki-tui/src/runtime/actions.rs +++ b/toki-tui/src/runtime/actions.rs @@ -328,6 +328,11 @@ async fn handle_apply_template( if let Some(activity) = activity { app.selected_activity = Some(activity); + } else { + app.set_status(format!( + "Activity '{}' not found — skipped", + template.activity + )); } // Set note @@ -339,7 +344,7 @@ async fn handle_apply_template( // If timer is running, sync to server if app.timer_state == app::TimerState::Running { - let note = app.description_input.value.clone(); + let note = app.full_note_value(); let project_id = app.selected_project.as_ref().map(|p| p.id.clone()); let project_name = app.selected_project.as_ref().map(|p| p.name.clone()); let activity_id = app.selected_activity.as_ref().map(|a| a.id.clone()); diff --git a/toki-tui/src/runtime/event_loop.rs b/toki-tui/src/runtime/event_loop.rs index 299df69d..669e4dcd 100644 --- a/toki-tui/src/runtime/event_loop.rs +++ b/toki-tui/src/runtime/event_loop.rs @@ -27,6 +27,13 @@ pub async fn run_app( let (action_tx, mut action_rx) = channel(); loop { + // Clear before drawing to avoid a flash when the screen needs a full repaint + // (e.g. after returning from an external editor or waking from sleep). + if app.needs_full_redraw { + terminal.clear()?; + app.needs_full_redraw = false; + } + terminal.draw(|f| ui::render(f, app))?; if app.is_loading { @@ -65,11 +72,6 @@ pub async fn run_app( run_action(action, app, client).await?; } - if app.needs_full_redraw { - terminal.clear()?; - app.needs_full_redraw = false; - } - if !app.running { break; } From 625db6ebd71631dca481f29f35abe4a67dd870a3 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 16:51:15 +0100 Subject: [PATCH 42/53] docs: add design doc for Ctrl+L open log from timer/history views --- .../2026-03-12-ctrl-l-open-log-from-views.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/plans/2026-03-12-ctrl-l-open-log-from-views.md diff --git a/docs/plans/2026-03-12-ctrl-l-open-log-from-views.md b/docs/plans/2026-03-12-ctrl-l-open-log-from-views.md new file mode 100644 index 00000000..a9c2b7e2 --- /dev/null +++ b/docs/plans/2026-03-12-ctrl-l-open-log-from-views.md @@ -0,0 +1,49 @@ +# Design: Ctrl+L to Open Log from Timer and History Views + +**Date:** 2026-03-12 + +## Summary + +Allow opening a linked log file directly from Timer view (Today panel) and History view by pressing `Ctrl+L` on a selected entry. This is a read-only-entry / editable-log operation — the entry note cannot be modified from these views, but the log file itself is fully editable in `$EDITOR`. + +## Behaviour + +| Condition | Result | +|-----------|--------| +| Entry selected, note has `[log:XXXXXX]` | Open log file in `$EDITOR` | +| Entry selected, no log tag in note | Status: `"No log linked to this entry"` | +| Log file referenced but missing on disk | Status: `"Log file not found"` | +| No entry selected | Key is ignored | +| Locked entry | Allowed — log is always editable | + +## New Action + +`Action::OpenEntryLogNote(String)` in `action_queue.rs` carries the resolved log ID. Distinct from `Action::OpenLogNote` (create-or-open for running note). + +## Handler + +New `async fn handle_open_entry_log_note(id: &str, app: &mut App) -> anyhow::Result<()>` in `actions.rs`: + +1. `log_notes::log_path(id)?` +2. If file doesn't exist → `app.set_status("Log file not found"); return Ok(())` +3. `crate::editor::open_editor(&path).await?` +4. `app.needs_full_redraw = true` + +No mutation of `description_log_id` or running-timer state. + +## Key Bindings + +**Timer view** (`views/timer.rs`): `Ctrl+L` when `is_persisted_today_row_selected(app)` and `!is_editing_this_week(app)`. Resolves the entry using the same index-offset logic as `Ctrl+R`. + +**History view** (`views/history.rs`): `Ctrl+L` in the non-edit-mode branch when `focused_history_index.is_some()`. + +No conflict: in history edit-mode, `Char('l')` is used for field navigation but `Ctrl+L` is free. + +## UI Hints + +Add `Ctrl+L: Log` to hint bars in `timer_view.rs` and `history_view.rs`, near `Ctrl+R`. + +## Out of Scope + +- Creating a new log from these views (Notes view only). +- Modifying the entry note/fields (locked/normal edit paths unchanged). From cef3e258f3daa5b90d9ea3cdd1f3b976ae958730 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 17:00:39 +0100 Subject: [PATCH 43/53] feat(tui): add Action::OpenEntryLogNote variant --- toki-tui/src/runtime/action_queue.rs | 1 + toki-tui/src/runtime/actions.rs | 3 +++ 2 files changed, 4 insertions(+) diff --git a/toki-tui/src/runtime/action_queue.rs b/toki-tui/src/runtime/action_queue.rs index 78bbd724..a41480f0 100644 --- a/toki-tui/src/runtime/action_queue.rs +++ b/toki-tui/src/runtime/action_queue.rs @@ -33,6 +33,7 @@ pub(super) enum Action { template: crate::config::TemplateConfig, }, OpenLogNote, + OpenEntryLogNote(String), } pub(super) type ActionTx = UnboundedSender; diff --git a/toki-tui/src/runtime/actions.rs b/toki-tui/src/runtime/actions.rs index 5b390ffb..a7a0d5e2 100644 --- a/toki-tui/src/runtime/actions.rs +++ b/toki-tui/src/runtime/actions.rs @@ -129,6 +129,9 @@ pub(super) async fn run_action( app.set_status(format!("Log note error: {}", e)); } } + Action::OpenEntryLogNote(_entry_id) => { + // TODO: implement in subsequent task + } } Ok(()) } From 472237947f9b74641f1a793d9347ea2d71456130 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 17:07:30 +0100 Subject: [PATCH 44/53] feat(tui): add handle_open_entry_log_note handler --- toki-tui/src/runtime/actions.rs | 63 +++++++++++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/toki-tui/src/runtime/actions.rs b/toki-tui/src/runtime/actions.rs index a7a0d5e2..3be43a3a 100644 --- a/toki-tui/src/runtime/actions.rs +++ b/toki-tui/src/runtime/actions.rs @@ -129,8 +129,8 @@ pub(super) async fn run_action( app.set_status(format!("Log note error: {}", e)); } } - Action::OpenEntryLogNote(_entry_id) => { - // TODO: implement in subsequent task + Action::OpenEntryLogNote(id) => { + handle_open_entry_log_note(&id, app).await; } } Ok(()) @@ -927,6 +927,38 @@ pub(super) fn is_milltime_auth_error(e: &anyhow::Error) -> bool { msg.contains("unauthorized") || msg.contains("authenticate") || msg.contains("milltime") } +/// Open an existing log file for a history/today entry. +/// Takes a pre-extracted log ID (may be empty if the entry has no log tag). +/// Does NOT create a new log file and does NOT mutate running-timer state. +pub(super) async fn handle_open_entry_log_note(id: &str, app: &mut App) { + use crate::log_notes; + + if id.is_empty() { + app.set_status("No log linked to this entry".to_string()); + return; + } + + let path = match log_notes::log_path(id) { + Ok(p) => p, + Err(e) => { + app.set_status(format!("Log error: {}", e)); + return; + } + }; + + if !path.exists() { + app.set_status("Log file not found".to_string()); + return; + } + + if let Err(e) = crate::editor::open_editor(&path).await { + app.set_status(format!("Editor error: {}", e)); + return; + } + + app.needs_full_redraw = true; +} + async fn handle_open_log_note(app: &mut App, client: &mut ApiClient) -> anyhow::Result<()> { use crate::log_notes; use time::OffsetDateTime; @@ -1055,6 +1087,33 @@ mod tests { assert_eq!(app.timer_state, app::TimerState::Running); } + #[tokio::test] + async fn open_entry_log_note_no_log_linked_sets_status() { + let mut app = test_app(); + // Empty id → sets "No log linked to this entry" + handle_open_entry_log_note("", &mut app).await; + assert!(app.status_message.as_deref().unwrap_or("").contains("No log")); + } + + #[tokio::test] + async fn open_entry_log_note_invalid_id_sets_status() { + let mut app = test_app(); + // Invalid id (non-hex) → log_path returns Err → sets some status + handle_open_entry_log_note("ZZZZZZ", &mut app).await; + assert!(app.status_message.is_some()); + } + + #[tokio::test] + async fn open_entry_log_note_missing_file_sets_status() { + let mut app = test_app(); + // Valid hex id but file doesn't exist → sets "Log file not found" + handle_open_entry_log_note("abcdef", &mut app).await; + assert_eq!( + app.status_message.as_deref(), + Some("Log file not found") + ); + } + #[tokio::test] async fn handle_confirm_delete_removes_entry_and_returns_to_origin_view() { let mut app = test_app(); From b5cb700e8fe50078b02bb48ace2a7232c8152316 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 17:11:01 +0100 Subject: [PATCH 45/53] fix(tui): tighten invalid-id test assertion to check error message prefix --- toki-tui/src/runtime/actions.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/toki-tui/src/runtime/actions.rs b/toki-tui/src/runtime/actions.rs index 3be43a3a..79c3d15c 100644 --- a/toki-tui/src/runtime/actions.rs +++ b/toki-tui/src/runtime/actions.rs @@ -1098,9 +1098,9 @@ mod tests { #[tokio::test] async fn open_entry_log_note_invalid_id_sets_status() { let mut app = test_app(); - // Invalid id (non-hex) → log_path returns Err → sets some status + // Invalid id (non-hex) → log_path returns Err → sets "Log error: ..." handle_open_entry_log_note("ZZZZZZ", &mut app).await; - assert!(app.status_message.is_some()); + assert!(app.status_message.as_deref().unwrap_or("").contains("Log error")); } #[tokio::test] From 48c335172a104f3238556b839616989355012f03 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 17:12:10 +0100 Subject: [PATCH 46/53] feat(tui): wire Ctrl+L in timer view to open entry log --- toki-tui/src/runtime/views/timer.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/toki-tui/src/runtime/views/timer.rs b/toki-tui/src/runtime/views/timer.rs index 6b949181..2d00e457 100644 --- a/toki-tui/src/runtime/views/timer.rs +++ b/toki-tui/src/runtime/views/timer.rs @@ -55,7 +55,9 @@ pub(super) fn handle_timer_key(key: KeyEvent, app: &mut App, action_tx: &ActionT app.focus_previous(); } } - KeyCode::Right | KeyCode::Char('l') | KeyCode::Char('L') => { + KeyCode::Right | KeyCode::Char('l') | KeyCode::Char('L') + if !key.modifiers.contains(KeyModifiers::CONTROL) => + { if is_editing_this_week(app) { app.entry_edit_next_field(); } @@ -167,6 +169,29 @@ pub(super) fn handle_timer_key(key: KeyEvent, app: &mut App, action_tx: &ActionT } } } + KeyCode::Char('l') | KeyCode::Char('L') + if !is_editing_this_week(app) + && key.modifiers.contains(KeyModifiers::CONTROL) + && is_persisted_today_row_selected(app) => + { + let idx = app.focused_this_week_index.unwrap(); + let db_idx = if app.timer_state == app::TimerState::Running { + idx.saturating_sub(1) + } else { + idx + }; + let note = app + .this_week_history() + .get(db_idx) + .and_then(|e| e.note.as_deref()) + .unwrap_or(""); + let id = crate::log_notes::extract_id(note).unwrap_or("").to_string(); + if id.is_empty() { + app.set_status("No log linked to this entry".to_string()); + } else { + enqueue_action(action_tx, Action::OpenEntryLogNote(id)); + } + } KeyCode::Char('t') | KeyCode::Char('T') if !is_editing_this_week(app) && !app.templates.is_empty() => { From 8e7778a71e4cfd71b28e846363a7776eac93b2d4 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 17:24:15 +0100 Subject: [PATCH 47/53] feat(tui): wire Ctrl+L in history view to open entry log --- toki-tui/src/runtime/views/history.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/toki-tui/src/runtime/views/history.rs b/toki-tui/src/runtime/views/history.rs index 3d18dd76..2eafd88f 100644 --- a/toki-tui/src/runtime/views/history.rs +++ b/toki-tui/src/runtime/views/history.rs @@ -165,6 +165,23 @@ pub(super) fn handle_history_key(key: KeyEvent, app: &mut App, action_tx: &Actio app.set_status("Error: could not resolve selected entry".to_string()); } } + KeyCode::Char('l') | KeyCode::Char('L') + if app.focused_history_index.is_some() + && key.modifiers.contains(KeyModifiers::CONTROL) => + { + let note = app + .focused_history_index + .and_then(|idx| app.history_list_entries.get(idx).copied()) + .and_then(|te_idx| app.time_entries.get(te_idx)) + .and_then(|e| e.note.as_deref()) + .unwrap_or(""); + let id = crate::log_notes::extract_id(note).unwrap_or("").to_string(); + if id.is_empty() { + app.set_status("No log linked to this entry".to_string()); + } else { + enqueue_action(action_tx, Action::OpenEntryLogNote(id)); + } + } _ => {} } } From 7e8ab773db54d8ec8496e1ed55d7712b4804a10c Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 17:26:49 +0100 Subject: [PATCH 48/53] fix(tui): exclude Ctrl+L from edit-mode field-navigation in history view --- toki-tui/src/runtime/views/history.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/toki-tui/src/runtime/views/history.rs b/toki-tui/src/runtime/views/history.rs index 2eafd88f..eb22409b 100644 --- a/toki-tui/src/runtime/views/history.rs +++ b/toki-tui/src/runtime/views/history.rs @@ -43,7 +43,9 @@ pub(super) fn handle_history_key(key: KeyEvent, app: &mut App, action_tx: &Actio app.entry_edit_next_field(); } } - KeyCode::Char('l') | KeyCode::Char('L') => { + KeyCode::Char('l') | KeyCode::Char('L') + if !key.modifiers.contains(KeyModifiers::CONTROL) => + { app.entry_edit_next_field(); } KeyCode::Left if key.modifiers.contains(KeyModifiers::CONTROL) => { From 0c73e829641a130999fb06e3013adfed5c676bb5 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 17:27:47 +0100 Subject: [PATCH 49/53] feat(tui): add Ctrl+L hint to timer and history hint bars --- toki-tui/src/ui/history_view.rs | 2 ++ toki-tui/src/ui/timer_view.rs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/toki-tui/src/ui/history_view.rs b/toki-tui/src/ui/history_view.rs index 6c50838e..57130a6a 100644 --- a/toki-tui/src/ui/history_view.rs +++ b/toki-tui/src/ui/history_view.rs @@ -234,6 +234,8 @@ pub fn render_history_view(frame: &mut Frame, app: &mut App, body: Rect) { Span::raw(": Edit "), Span::styled("Ctrl+R", Style::default().fg(Color::Yellow)), Span::raw(": Resume "), + Span::styled("Ctrl+L", Style::default().fg(Color::Yellow)), + Span::raw(": Log "), Span::styled("H / Esc", Style::default().fg(Color::Yellow)), Span::raw(": Back to timer "), Span::styled("Q", Style::default().fg(Color::Yellow)), diff --git a/toki-tui/src/ui/timer_view.rs b/toki-tui/src/ui/timer_view.rs index e61044d0..db40a73e 100644 --- a/toki-tui/src/ui/timer_view.rs +++ b/toki-tui/src/ui/timer_view.rs @@ -243,6 +243,8 @@ fn render_controls(frame: &mut Frame, area: ratatui::layout::Rect, app: &App) { Span::raw(": Save (options) "), Span::styled("Ctrl+R", Style::default().fg(Color::Yellow)), Span::raw(": Resume "), + Span::styled("Ctrl+L", Style::default().fg(Color::Yellow)), + Span::raw(": Log "), Span::styled("Ctrl+X", Style::default().fg(Color::Yellow)), Span::raw(": Clear "), Span::styled("Tab / ↑↓ / j/k", Style::default().fg(Color::Yellow)), From ec0d3241a4e3476d11311180f70a7db4201efe5f Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 17:44:01 +0100 Subject: [PATCH 50/53] chore(tui): shorten hint bar labels for log and save actions --- toki-tui/src/ui/description_editor.rs | 4 ++-- toki-tui/src/ui/history_view.rs | 2 +- toki-tui/src/ui/timer_view.rs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/toki-tui/src/ui/description_editor.rs b/toki-tui/src/ui/description_editor.rs index 802f8de4..75a31c1c 100644 --- a/toki-tui/src/ui/description_editor.rs +++ b/toki-tui/src/ui/description_editor.rs @@ -195,11 +195,11 @@ pub fn render_description_editor(frame: &mut Frame, app: &App, body: Rect) { Span::styled("Esc", Style::default().fg(Color::Yellow)), Span::raw(": Cancel "), Span::styled("Ctrl+L", Style::default().fg(Color::Yellow)), - Span::raw(": Add/edit log file "), + Span::raw(": Add/edit log "), ]; if has_log { spans.push(Span::styled("Ctrl+R", Style::default().fg(Color::Yellow))); - spans.push(Span::raw(": Remove log file ")); + spans.push(Span::raw(": Remove log ")); } spans.extend([ Span::styled("Ctrl+D", Style::default().fg(Color::Yellow)), diff --git a/toki-tui/src/ui/history_view.rs b/toki-tui/src/ui/history_view.rs index 57130a6a..40d26d0c 100644 --- a/toki-tui/src/ui/history_view.rs +++ b/toki-tui/src/ui/history_view.rs @@ -235,7 +235,7 @@ pub fn render_history_view(frame: &mut Frame, app: &mut App, body: Rect) { Span::styled("Ctrl+R", Style::default().fg(Color::Yellow)), Span::raw(": Resume "), Span::styled("Ctrl+L", Style::default().fg(Color::Yellow)), - Span::raw(": Log "), + Span::raw(": Open log "), Span::styled("H / Esc", Style::default().fg(Color::Yellow)), Span::raw(": Back to timer "), Span::styled("Q", Style::default().fg(Color::Yellow)), diff --git a/toki-tui/src/ui/timer_view.rs b/toki-tui/src/ui/timer_view.rs index db40a73e..63b0eaf9 100644 --- a/toki-tui/src/ui/timer_view.rs +++ b/toki-tui/src/ui/timer_view.rs @@ -240,11 +240,11 @@ fn render_controls(frame: &mut Frame, area: ratatui::layout::Rect, app: &App) { Span::styled("Space", Style::default().fg(Color::Yellow)), Span::raw(": Start/Stop "), Span::styled("Ctrl+S", Style::default().fg(Color::Yellow)), - Span::raw(": Save (options) "), + Span::raw(": Save "), Span::styled("Ctrl+R", Style::default().fg(Color::Yellow)), Span::raw(": Resume "), Span::styled("Ctrl+L", Style::default().fg(Color::Yellow)), - Span::raw(": Log "), + Span::raw(": Open log "), Span::styled("Ctrl+X", Style::default().fg(Color::Yellow)), Span::raw(": Clear "), Span::styled("Tab / ↑↓ / j/k", Style::default().fg(Color::Yellow)), From 8cc8de9e4cb8bbd733e6438fca813032307b16b8 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 17:45:18 +0100 Subject: [PATCH 51/53] chore(tui): remove old docs --- .../2026-03-02-tui-version-status-commands.md | 127 ------------------ .../2026-03-12-ctrl-l-open-log-from-views.md | 49 ------- 2 files changed, 176 deletions(-) delete mode 100644 docs/plans/2026-03-02-tui-version-status-commands.md delete mode 100644 docs/plans/2026-03-12-ctrl-l-open-log-from-views.md diff --git a/docs/plans/2026-03-02-tui-version-status-commands.md b/docs/plans/2026-03-02-tui-version-status-commands.md deleted file mode 100644 index 85cc9a8a..00000000 --- a/docs/plans/2026-03-02-tui-version-status-commands.md +++ /dev/null @@ -1,127 +0,0 @@ -# toki-tui version + status commands Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add `toki-tui version` and `toki-tui status` CLI subcommands and bump the crate version to 0.2.0. - -**Architecture:** Two new variants added to the existing `Commands` enum in `cli.rs`, handled in the `match` block in `main.rs`. No new files, no network calls. Version is read at compile time via `env!("CARGO_PKG_VERSION")`. Status is purely local — reads session and Milltime cookie files from disk. - -**Tech Stack:** clap 4 (derive), Rust std, existing `session_store` module. - ---- - -### Task 1: Bump crate version to 0.2.0 - -**Files:** -- Modify: `toki-tui/Cargo.toml:3` - -**Step 1: Edit version field** - -Change: -```toml -version = "0.1.0" -``` -To: -```toml -version = "0.2.0" -``` - -**Step 2: Verify build** - -```bash -SQLX_OFFLINE=true just check -``` -Expected: `Finished` with no errors. - ---- - -### Task 2: Add `Version` subcommand - -**Files:** -- Modify: `toki-tui/src/cli.rs` -- Modify: `toki-tui/src/main.rs` - -**Step 1: Add variant to Commands enum in `cli.rs`** - -```rust -/// Print the current version -Version, -``` - -**Step 2: Handle in `main.rs` match block** - -```rust -Commands::Version => { - println!("{}", env!("CARGO_PKG_VERSION")); -} -``` - -**Step 3: Verify build** - -```bash -SQLX_OFFLINE=true just check -``` -Expected: `Finished` with no errors. - ---- - -### Task 3: Add `Status` subcommand - -**Files:** -- Modify: `toki-tui/src/cli.rs` -- Modify: `toki-tui/src/main.rs` - -**Step 1: Add variant to Commands enum in `cli.rs`** - -```rust -/// Show current login and Milltime session status -Status, -``` - -**Step 2: Handle in `main.rs` match block** - -```rust -Commands::Status => { - let session = session_store::load_session()?; - let mt_cookies = session_store::load_mt_cookies()?; - let session_status = if session.is_some() { "logged in" } else { "not logged in" }; - let mt_status = if !mt_cookies.is_empty() { "authenticated" } else { "no cookies" }; - println!("Session: {}", session_status); - println!("Milltime: {}", mt_status); -} -``` - -**Step 3: Verify build** - -```bash -SQLX_OFFLINE=true just check -``` -Expected: `Finished` with no errors. - ---- - -### Task 4: Add justfile recipes - -**Files:** -- Modify: `justfile` - -**Step 1: Add two recipes after existing tui-* recipes** - -```just -# Print toki-tui version -tui-version: - cd toki-tui && cargo run -- version - -# Show toki-tui session status -tui-status: - cd toki-tui && cargo run -- status -``` - ---- - -### Task 5: Commit - -```bash -git add toki-tui/Cargo.toml toki-tui/src/cli.rs toki-tui/src/main.rs justfile docs/plans/2026-03-02-tui-version-status-commands.md -git commit -m "feat(tui): add version and status subcommands, bump to v0.2.0" -``` diff --git a/docs/plans/2026-03-12-ctrl-l-open-log-from-views.md b/docs/plans/2026-03-12-ctrl-l-open-log-from-views.md deleted file mode 100644 index a9c2b7e2..00000000 --- a/docs/plans/2026-03-12-ctrl-l-open-log-from-views.md +++ /dev/null @@ -1,49 +0,0 @@ -# Design: Ctrl+L to Open Log from Timer and History Views - -**Date:** 2026-03-12 - -## Summary - -Allow opening a linked log file directly from Timer view (Today panel) and History view by pressing `Ctrl+L` on a selected entry. This is a read-only-entry / editable-log operation — the entry note cannot be modified from these views, but the log file itself is fully editable in `$EDITOR`. - -## Behaviour - -| Condition | Result | -|-----------|--------| -| Entry selected, note has `[log:XXXXXX]` | Open log file in `$EDITOR` | -| Entry selected, no log tag in note | Status: `"No log linked to this entry"` | -| Log file referenced but missing on disk | Status: `"Log file not found"` | -| No entry selected | Key is ignored | -| Locked entry | Allowed — log is always editable | - -## New Action - -`Action::OpenEntryLogNote(String)` in `action_queue.rs` carries the resolved log ID. Distinct from `Action::OpenLogNote` (create-or-open for running note). - -## Handler - -New `async fn handle_open_entry_log_note(id: &str, app: &mut App) -> anyhow::Result<()>` in `actions.rs`: - -1. `log_notes::log_path(id)?` -2. If file doesn't exist → `app.set_status("Log file not found"); return Ok(())` -3. `crate::editor::open_editor(&path).await?` -4. `app.needs_full_redraw = true` - -No mutation of `description_log_id` or running-timer state. - -## Key Bindings - -**Timer view** (`views/timer.rs`): `Ctrl+L` when `is_persisted_today_row_selected(app)` and `!is_editing_this_week(app)`. Resolves the entry using the same index-offset logic as `Ctrl+R`. - -**History view** (`views/history.rs`): `Ctrl+L` in the non-edit-mode branch when `focused_history_index.is_some()`. - -No conflict: in history edit-mode, `Char('l')` is used for field navigation but `Ctrl+L` is free. - -## UI Hints - -Add `Ctrl+L: Log` to hint bars in `timer_view.rs` and `history_view.rs`, near `Ctrl+R`. - -## Out of Scope - -- Creating a new log from these views (Notes view only). -- Modifying the entry note/fields (locked/normal edit paths unchanged). From 869bfbda809130494eb41abb3261bee4d87063ad Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Thu, 12 Mar 2026 21:04:38 +0100 Subject: [PATCH 52/53] feat(tui): add logs-path CLI command and update README --- justfile | 4 ++ toki-tui/README.md | 155 +++++++++++++++++++++++++++++-------------- toki-tui/src/cli.rs | 2 + toki-tui/src/main.rs | 4 ++ 4 files changed, 116 insertions(+), 49 deletions(-) diff --git a/justfile b/justfile index 31ec55e1..a59cf72c 100644 --- a/justfile +++ b/justfile @@ -103,3 +103,7 @@ tui-version: # Show toki-tui session and Milltime status tui-status: cd toki-tui && cargo run -- status + +# Print the log notes directory path +tui-logs: + cd toki-tui && cargo run -- logs-path diff --git a/toki-tui/README.md b/toki-tui/README.md index 0266b32e..fdec97b0 100644 --- a/toki-tui/README.md +++ b/toki-tui/README.md @@ -16,37 +16,30 @@ just tui-dev # Clear saved session just tui-logout - -# Print config path and create default config if missing -just tui-config ``` -## Configuration - -Config file: `~/.config/toki-tui/config.toml` - -Run `just tui-config` (or `cargo run -- config-path`) to print the path and create the file with defaults if it does not exist. +## CLI Commands -All keys are optional. If the file is missing, built-in defaults are used. - -### Environment variables +All commands are available via the binary directly (`toki-tui `) or through `just`: -You can override config values with environment variables. +| Command | `just` recipe | Description | +| -------------- | -------------- | -------------------------------------------------- | +| `run` | `just tui` | Run against the real toki-api server | +| `dev` | `just tui-dev` | Run in dev mode with in-memory mock data | +| `login` | `just tui-login` | Authenticate via browser OAuth | +| `logout` | `just tui-logout` | Clear saved session and Milltime cookies | +| `status` | `just tui-status` | Show current login and Milltime session status | +| `config-path` | `just tui-config` | Print config path; create default file if missing | +| `logs-path` | `just tui-logs` | Print the log notes directory path | +| `version` | `just tui-version` | Print the current version | -- Prefix: `TOKI_TUI_` -- Key format: uppercase snake case -- Nested keys (if added later): use `__` as separator +## Configuration -Current variables: +Config file: `~/.config/toki-tui/config.toml` -```bash -TOKI_TUI_API_URL="http://localhost:8080" -TOKI_TUI_GIT_DEFAULT_PREFIX="Development" -TOKI_TUI_TASK_FILTER="+work project:Toki" -TOKI_TUI_AUTO_RESIZE_TIMER=true -``` +Run `just tui-config` (or `toki-tui config-path`) to print the path and create the file with defaults if it does not exist. -Environment variables override values from `config.toml`. +All keys are optional. If the file is missing, built-in defaults are used. ```toml # URL of the toki-api server. Defaults to the production instance. @@ -54,7 +47,7 @@ api_url = "https://toki-api.spinit.se" # Prefix used when converting a git branch name to a time entry note, # when no conventional commit prefix (feat/fix/etc.) or ticket number is found. -# Example: branch "branding/redesign" → "Utveckling: branding/redesign" +# Example: branch "branding/redesign" → "Development: branding/redesign" git_default_prefix = "Utveckling" # Taskwarrior filter tokens prepended before `status:pending export`. @@ -66,6 +59,32 @@ task_filter = "" # When true (default), the timer grows large when running and shrinks when stopped. # Set to false to keep the timer at a fixed (normal) size at all times. auto_resize_timer = true + +# Entry templates — pre-fill project, activity and note from a picker (press T). +# [[template]] sections can be repeated. +[[template]] +name = "My project" +project = "My Project" +activity = "Development" +note = "Working on stuff" +``` + +### Entry templates + +Define reusable presets in `config.toml`. In the timer view, press `T` to open the template picker and select one to pre-fill the current entry. + +### Environment variables + +Environment variables override values from `config.toml`. + +- Prefix: `TOKI_TUI_` +- Key format: uppercase snake case + +```bash +TOKI_TUI_API_URL="http://localhost:8080" +TOKI_TUI_GIT_DEFAULT_PREFIX="Development" +TOKI_TUI_TASK_FILTER="+work project:Toki" +TOKI_TUI_AUTO_RESIZE_TIMER=true ``` ### Example: local dev setup @@ -76,33 +95,71 @@ git_default_prefix = "Development" task_filter = "+work" ``` -## Testing +## Log notes + +Attach a freeform markdown log file to any time entry. Log files are stored in `~/.local/share/toki-tui/logs/` and linked to entries via a tag embedded in the note (`[log:XXXXXX]`). The tag is hidden in all display locations — only the clean summary is shown. + +Run `just tui-logs` (or `toki-tui logs-path`) to print the log directory path. + +## Key bindings + +### Timer view + +| Key | Action | +| -------------------- | ----------------------------- | +| `Space` | Start / stop timer | +| `Ctrl+S` | Save (with options) | +| `Ctrl+R` | Resume last entry | +| `Ctrl+X` | Clear current entry | +| `Enter` | Edit description | +| `P` | Edit project / activity | +| `N` | Edit note (description editor) | +| `T` | Open template picker | +| `H` | Switch to history view | +| `S` | Switch to statistics view | +| `X` | Toggle timer size | +| `Z` | Zen mode (hide UI chrome) | +| `Tab / ↑↓ / j/k` | Navigate | +| `Q` | Quit | + +### Description editor (note / `N`) + +| Key | Action | +| -------------------- | ----------------------------- | +| `Ctrl+L` | Add / edit log file | +| `Ctrl+R` | Remove linked log file | +| `Ctrl+D` | Change working directory | +| `Ctrl+G` | Git: copy/paste branch or commit | +| `Ctrl+T` | Taskwarrior: pick a task | +| `Ctrl+X` | Clear note | +| `Ctrl+←/→` | Word-boundary navigation | +| `Ctrl+Backspace` | Delete word back | +| `Enter` | Confirm | +| `Esc` | Cancel | + +### History view + +| Key | Action | +| -------------------- | ----------------------------- | +| `↑↓` | Navigate entries | +| `Enter` | Edit entry | +| `Ctrl+R` | Resume entry (copy to timer) | +| `Ctrl+L` | Open linked log file | +| `H / Esc` | Back to timer view | +| `Q` | Quit | + +**While editing a history entry:** + +| Key | Action | +| -------------------- | ----------------------------- | +| `Tab` | Next field | +| `P / A` | Change project / activity | +| `Esc` | Save and exit edit mode | -Run the TUI test suite with: +## Testing ```bash -cargo test -p toki-tui +SQLX_OFFLINE=true cargo test -p toki-tui ``` -The current tests focus on the most stable and useful layers first: - -- app and state behavior -- parsing and text input helpers -- runtime action handling with the dev backend -- focused Ratatui render assertions for important UI states - -## Standard key bindings - -| Key | Action | -| -------------- | ------------------ | -| `Space` | Start / stop timer | -| `Ctrl+S` | Save (options) | -| `Ctrl+X` | Clear | -| `Tab / ↑↓ / j/k` | Navigate | -| `H` | History view | -| `P` | Project | -| `N` | Note | -| `T` | Toggle timer size | -| `S` | Stats | -| `Esc` | Exit / cancel | -| `Q` | Quit | +Tests cover app and state behavior, text input helpers, runtime action handling, and focused Ratatui render assertions. diff --git a/toki-tui/src/cli.rs b/toki-tui/src/cli.rs index ed204cb3..1091afd0 100644 --- a/toki-tui/src/cli.rs +++ b/toki-tui/src/cli.rs @@ -20,6 +20,8 @@ pub enum Commands { Logout, /// Print config path and create default file if missing ConfigPath, + /// Print the log notes directory path + LogsPath, /// Print the current version Version, /// Show current login and Milltime session status diff --git a/toki-tui/src/main.rs b/toki-tui/src/main.rs index 20e5faf7..3db6c5b2 100644 --- a/toki-tui/src/main.rs +++ b/toki-tui/src/main.rs @@ -32,6 +32,10 @@ async fn main() -> Result<()> { let path = config::TokiConfig::ensure_exists()?; println!("{}", path.display()); } + Commands::LogsPath => { + let path = log_notes::log_dir()?; + println!("{}", path.display()); + } Commands::Version => { println!("{}", env!("CARGO_PKG_VERSION")); } From 5fe22f70c92479f4784d1982ccea4fdc6cd71772 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Fri, 13 Mar 2026 10:04:34 +0100 Subject: [PATCH 53/53] =?UTF-8?q?fix(tui):=20correct=20template=20config?= =?UTF-8?q?=20field=20name=20in=20README=20(name=20=E2=86=92=20description?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- toki-tui/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/toki-tui/README.md b/toki-tui/README.md index fdec97b0..020da274 100644 --- a/toki-tui/README.md +++ b/toki-tui/README.md @@ -63,7 +63,7 @@ auto_resize_timer = true # Entry templates — pre-fill project, activity and note from a picker (press T). # [[template]] sections can be repeated. [[template]] -name = "My project" +description = "My project" project = "My Project" activity = "Development" note = "Working on stuff"