From c76b5fd4b56ead45358dfcf847794548d45b7bb0 Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Mon, 2 Mar 2026 16:52:47 +0100 Subject: [PATCH 1/2] feat(tui): add Y and R keybindings to copy/resume history entries Y: copies a selected history entry's project, activity, and note into the currently running timer (syncs to server via update_active_timer). R: resumes a previous entry by starting a new timer with the same project, activity, and note. Reads arguments directly from the entry before touching app state; local state is only mutated after confirmed server success. Also auto-switches timer size to Large on start and Normal on stop/clear, shortens controls labels, and colors the 'resumed' status message green. --- toki-tui/src/app/edit.rs | 26 +++++++++ toki-tui/src/app/mod.rs | 3 ++ toki-tui/src/runtime/action_queue.rs | 4 +- toki-tui/src/runtime/actions.rs | 76 +++++++++++++++++++++++++++ toki-tui/src/runtime/views/history.rs | 34 ++++++++++++ toki-tui/src/runtime/views/timer.rs | 26 +++++++++ toki-tui/src/ui/history_view.rs | 4 ++ toki-tui/src/ui/timer_view.rs | 21 +++++--- 8 files changed, 186 insertions(+), 8 deletions(-) diff --git a/toki-tui/src/app/edit.rs b/toki-tui/src/app/edit.rs index 93b5f9fd..f818d9b7 100644 --- a/toki-tui/src/app/edit.rs +++ b/toki-tui/src/app/edit.rs @@ -579,6 +579,32 @@ impl App { View::Timer } } + + /// Copy project, activity, and note from a history entry into the timer fields. + /// Does not set a status message — callers are responsible for that. + pub fn copy_entry_fields(&mut self, entry: &crate::types::TimeEntry) { + self.selected_project = Some(crate::types::Project { + id: entry.project_id.clone(), + name: entry.project_name.clone(), + }); + self.selected_activity = Some(crate::types::Activity { + id: entry.activity_id.clone(), + name: entry.activity_name.clone(), + project_id: entry.project_id.clone(), + }); + let note = entry.note.clone().unwrap_or_default(); + self.description_input = TextInput::from_str(¬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/app/mod.rs b/toki-tui/src/app/mod.rs index 708e5702..95341bb8 100644 --- a/toki-tui/src/app/mod.rs +++ b/toki-tui/src/app/mod.rs @@ -205,6 +205,7 @@ impl App { pub fn clear_timer(&mut self) { self.timer_state = TimerState::Stopped; + self.timer_size = TimerSize::Normal; self.absolute_start = None; self.local_start = None; self.selected_project = None; @@ -276,6 +277,7 @@ impl App { self.timer_state = TimerState::Running; self.absolute_start = Some(OffsetDateTime::now_utc()); self.local_start = Some(Instant::now()); + self.timer_size = TimerSize::Large; // Shift focus: running timer row is inserted at index 0, pushing DB entries up by 1 if let Some(idx) = self.focused_this_week_index { self.focused_this_week_index = Some(idx + 1); @@ -286,6 +288,7 @@ impl App { #[allow(dead_code)] pub fn stop_timer(&mut self) { self.timer_state = TimerState::Stopped; + self.timer_size = TimerSize::Normal; self.absolute_start = None; self.local_start = None; } diff --git a/toki-tui/src/runtime/action_queue.rs b/toki-tui/src/runtime/action_queue.rs index 329e16a7..e55a88fe 100644 --- a/toki-tui/src/runtime/action_queue.rs +++ b/toki-tui/src/runtime/action_queue.rs @@ -1,4 +1,4 @@ -use crate::types::{Activity, Project}; +use crate::types::{Activity, Project, TimeEntry}; use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; #[derive(Debug, Clone)] @@ -28,6 +28,8 @@ pub(super) enum Action { ConfirmDelete, StopServerTimerAndClear, RefreshHistoryBackground, + YankEntryToTimer(TimeEntry), + ResumeEntry(TimeEntry), } pub(super) type ActionTx = UnboundedSender; diff --git a/toki-tui/src/runtime/actions.rs b/toki-tui/src/runtime/actions.rs index 2994f8d5..88bc7190 100644 --- a/toki-tui/src/runtime/actions.rs +++ b/toki-tui/src/runtime/actions.rs @@ -12,6 +12,7 @@ pub(crate) fn restore_active_timer(app: &mut App, timer: crate::types::ActiveTim app.absolute_start = Some(timer.start_time); app.local_start = Some(Instant::now() - Duration::from_secs(elapsed_secs)); app.timer_state = app::TimerState::Running; + app.timer_size = app::TimerSize::Large; if let (Some(id), Some(name)) = (timer.project_id, timer.project_name) { app.selected_project = Some(crate::types::Project { id, name }); } @@ -115,6 +116,12 @@ 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; + } } Ok(()) } @@ -329,6 +336,74 @@ 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 + if app.timer_state == app::TimerState::Running { + 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()); + let note = if app.description_input.value.is_empty() { + None + } else { + Some(app.description_input.value.clone()) + }; + if let Err(e) = client + .update_active_timer( + project_id, + project_name, + activity_id, + activity_name, + note, + None, + ) + .await + { + app.set_status(format!("Warning: Could not sync copied entry to server: {}", e)); + } + } +} + +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; + } + + // Build server arguments from the entry directly (not from app state) + let project_id = Some(entry.project_id.clone()); + let project_name = Some(entry.project_name.clone()); + let activity_id = Some(entry.activity_id.clone()); + let activity_name = Some(entry.activity_name.clone()); + let note = entry.note.clone().filter(|n| !n.is_empty()); + + match client + .start_timer(project_id, project_name, activity_id, activity_name, note) + .await + { + Ok(()) => { + // Only mutate local state after confirmed server success + app.copy_entry_fields(&entry); + app.start_timer(); // sets TimerState::Running + TimerSize::Large + local_start + app.set_status(format!( + "Resumed: {}: {}", + entry.project_name, entry.activity_name + )); + } + Err(e) => { + if is_milltime_auth_error(&e) { + app.open_milltime_reauth(); + } else { + app.set_status(format!("Error resuming entry: {}", e)); + } + } + } +} + pub(super) async fn handle_save_timer_with_action( app: &mut App, client: &mut ApiClient, @@ -409,6 +484,7 @@ pub(super) async fn handle_save_timer_with_action( } app::SaveAction::SaveAndStop => { app.timer_state = app::TimerState::Stopped; + app.timer_size = app::TimerSize::Normal; app.absolute_start = None; app.local_start = None; if let Some(idx) = app.focused_this_week_index { diff --git a/toki-tui/src/runtime/views/history.rs b/toki-tui/src/runtime/views/history.rs index 40ff7c77..56206adc 100644 --- a/toki-tui/src/runtime/views/history.rs +++ b/toki-tui/src/runtime/views/history.rs @@ -118,6 +118,40 @@ 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(), + ); + } 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()); + } + } + } _ => {} } } diff --git a/toki-tui/src/runtime/views/timer.rs b/toki-tui/src/runtime/views/timer.rs index c78032ea..277e7303 100644 --- a/toki-tui/src/runtime/views/timer.rs +++ b/toki-tui/src/runtime/views/timer.rs @@ -134,6 +134,32 @@ pub(super) fn handle_timer_key(key: KeyEvent, app: &mut App, action_tx: &ActionT app.enter_delete_confirm(app::DeleteOrigin::Timer); } 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) { + 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(); + 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 a0bbe803..6eb33a4c 100644 --- a/toki-tui/src/ui/history_view.rs +++ b/toki-tui/src/ui/history_view.rs @@ -222,6 +222,10 @@ 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 "), + Span::styled("R", Style::default().fg(Color::Yellow)), + 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 77356478..a60d55ee 100644 --- a/toki-tui/src/ui/timer_view.rs +++ b/toki-tui/src/ui/timer_view.rs @@ -36,7 +36,7 @@ fn render_timer(frame: &mut Frame, area: ratatui::layout::Rect, app: &App) { let border_style = if is_focused { Style::default().fg(Color::Magenta) } else if is_running { - Style::default().fg(Color::Green) + Style::default().fg(Color::White) } else { Style::default() }; @@ -118,8 +118,8 @@ fn render_project(frame: &mut Frame, area: ratatui::layout::Rect, app: &App) { // Magenta border and white text when focused (takes priority) (Style::default().fg(Color::Magenta), Color::White) } else if !is_empty { - // Green border when project/activity selected and not focused - (Style::default().fg(Color::Green), Color::White) + // White border when project/activity selected and not focused + (Style::default().fg(Color::White), Color::White) } else { // Default border when empty and not focused (Style::default(), Color::White) @@ -154,8 +154,8 @@ fn render_description(frame: &mut Frame, area: ratatui::layout::Rect, app: &App) let border_style = if is_focused { Style::default().fg(Color::Magenta) } else if !is_empty { - // Green border when annotation has content and not focused - Style::default().fg(Color::Green) + // White border when note has content and not focused + Style::default().fg(Color::White) } else { // Default when empty and not focused Style::default() @@ -190,6 +190,8 @@ pub fn render_status(frame: &mut Frame, area: ratatui::layout::Rect, app: &App) let is_error = status_lower.contains("error") || status_lower.contains("warning") || status_lower.contains("no active timer") + || status_lower.contains("no running timer") + || status_lower.contains("timer already running") || status_lower.contains("cannot save") || status_lower.contains("please select") || status_lower.contains("cancelled"); @@ -200,7 +202,8 @@ pub fn render_status(frame: &mut Frame, area: ratatui::layout::Rect, app: &App) || status_lower.contains("started") || status_lower.contains("stopped") || status_lower.contains("cleared") - || status_lower.contains("loaded"); + || status_lower.contains("loaded") + || status_lower.contains("resumed"); let (border_style, text_color) = if is_error { (Style::default().fg(Color::Red), Color::Red) @@ -248,7 +251,11 @@ fn render_controls(frame: &mut Frame, area: ratatui::layout::Rect) { Span::styled("S", Style::default().fg(Color::Yellow)), Span::raw(": Statistics "), Span::styled("T", Style::default().fg(Color::Yellow)), - Span::raw(": Toggle timer size "), + Span::raw(": Toggle size "), + Span::styled("Y", Style::default().fg(Color::Yellow)), + Span::raw(": Copy "), + 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("Esc", Style::default().fg(Color::Yellow)), From 5d66dd3be4cab53ac2529341c2fef2c544f9dd2d Mon Sep 17 00:00:00 2001 From: Alexander Hall Date: Tue, 3 Mar 2026 13:22:20 +0100 Subject: [PATCH 2/2] fix: rename keybindings for clarity --- toki-tui/README.md | 6 +++--- toki-tui/src/runtime/views/timer.rs | 2 +- toki-tui/src/ui/history_view.rs | 2 +- toki-tui/src/ui/statistics_view.rs | 4 ++-- toki-tui/src/ui/timer_view.rs | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/toki-tui/README.md b/toki-tui/README.md index 8a3ab317..deaafdcf 100644 --- a/toki-tui/README.md +++ b/toki-tui/README.md @@ -72,8 +72,8 @@ task_filter = "+work" ## Standard key bindings -| Key | Action | -| ---------------- | ------------------ | +| Key | Action | +| -------------- | ------------------ | | `Space` | Start / stop timer | | `Ctrl+S` | Save (options) | | `Ctrl+X` | Clear | @@ -82,6 +82,6 @@ task_filter = "+work" | `P` | Project | | `N` | Note | | `T` | Toggle timer size | -| `S` | Statistics | +| `S` | Stats | | `Esc` | Exit / cancel | | `Q` | Quit | diff --git a/toki-tui/src/runtime/views/timer.rs b/toki-tui/src/runtime/views/timer.rs index 277e7303..aeb2736a 100644 --- a/toki-tui/src/runtime/views/timer.rs +++ b/toki-tui/src/runtime/views/timer.rs @@ -121,7 +121,7 @@ pub(super) fn handle_timer_key(key: KeyEvent, app: &mut App, action_tx: &ActionT KeyCode::Char('t') | KeyCode::Char('T') => { app.toggle_timer_size(); } - // S: Open Statistics view (unmodified only - Ctrl+S is save) + // S: Open Stats view (unmodified only - Ctrl+S is save) KeyCode::Char('s') | KeyCode::Char('S') if !key.modifiers.contains(KeyModifiers::CONTROL) => { diff --git a/toki-tui/src/ui/history_view.rs b/toki-tui/src/ui/history_view.rs index 6eb33a4c..6a260109 100644 --- a/toki-tui/src/ui/history_view.rs +++ b/toki-tui/src/ui/history_view.rs @@ -223,7 +223,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("Y", Style::default().fg(Color::Yellow)), - Span::raw(": Copy "), + Span::raw(": Copy to running timer "), Span::styled("R", Style::default().fg(Color::Yellow)), Span::raw(": Resume "), Span::styled("H / Esc", Style::default().fg(Color::Yellow)), diff --git a/toki-tui/src/ui/statistics_view.rs b/toki-tui/src/ui/statistics_view.rs index 8e3ee594..60a6a04b 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 "Statistics" box + // Outer "Stats" box let stats_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Color::White)) .title(Span::styled( - " Statistics ", + " Stats ", 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 a60d55ee..80529a69 100644 --- a/toki-tui/src/ui/timer_view.rs +++ b/toki-tui/src/ui/timer_view.rs @@ -249,11 +249,11 @@ fn render_controls(frame: &mut Frame, area: ratatui::layout::Rect) { Span::styled("H", Style::default().fg(Color::Yellow)), Span::raw(": History "), Span::styled("S", Style::default().fg(Color::Yellow)), - Span::raw(": Statistics "), + Span::raw(": Stats "), Span::styled("T", Style::default().fg(Color::Yellow)), Span::raw(": Toggle size "), Span::styled("Y", Style::default().fg(Color::Yellow)), - Span::raw(": Copy "), + Span::raw(": Copy to running timer "), Span::styled("R", Style::default().fg(Color::Yellow)), Span::raw(": Resume "), Span::styled("Z", Style::default().fg(Color::Yellow)),