aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-26 15:49:07 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-26 15:50:06 +0300
commit1b980eaa9d4cbc050b6b81525e4b6b72ae853be5 (patch)
tree48afc3595519c9e229e22528ecc229a66f9bd874 /src
parent57c880f0ebaa5011abc90f4e2fd4f84fc072ce35 (diff)
feat: show clipboard copy feedback
Diffstat (limited to 'src')
-rw-r--r--src/app/api.rs8
-rw-r--r--src/app/mod.rs43
-rw-r--r--src/app/runtime.rs10
-rw-r--r--src/app/state.rs7
-rw-r--r--src/server/headless.rs25
-rw-r--r--src/ui.rs20
-rw-r--r--src/ui/status.rs62
7 files changed, 167 insertions, 8 deletions
diff --git a/src/app/api.rs b/src/app/api.rs
index c51a1bb0..2488ce40 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -16,6 +16,7 @@ impl App {
pub(crate) fn handle_internal_event(&mut self, ev: AppEvent) {
if let AppEvent::ClipboardWrite { content } = ev {
crate::selection::write_osc52_bytes(&content);
+ self.show_clipboard_feedback();
return;
}
@@ -177,6 +178,13 @@ impl App {
self.shutdown_detached_terminal_runtimes();
}
+ pub(crate) fn show_clipboard_feedback(&mut self) {
+ self.state.copy_feedback = Some(crate::app::state::CopyFeedback {
+ message: "copied to clipboard".to_string(),
+ });
+ self.copy_feedback_deadline = Some(Instant::now() + super::COPY_FEEDBACK_DURATION);
+ }
+
fn restore_overlay_after_exit(&mut self, overlay: OverlayPaneState) {
for temp_file in &overlay.temp_files {
let _ = std::fs::remove_file(temp_file);
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 534a81d8..1c221be8 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -38,6 +38,7 @@ const SESSION_SAVE_DEBOUNCE: Duration = Duration::from_secs(5);
const SIDEBAR_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350);
const PANE_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350);
const PANE_COPY_HIGHLIGHT_DURATION: Duration = Duration::from_millis(500);
+const COPY_FEEDBACK_DURATION: Duration = Duration::from_secs(2);
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture},
@@ -93,6 +94,7 @@ pub struct App {
pub(crate) last_terminal_size: Option<(u16, u16)>,
pub(crate) config_diagnostic_deadline: Option<Instant>,
pub(crate) toast_deadline: Option<Instant>,
+ pub(crate) copy_feedback_deadline: Option<Instant>,
pub(crate) last_git_remote_status_refresh: Instant,
pub(crate) git_refresh_in_flight: bool,
pub(crate) last_sidebar_divider_click: Option<Instant>,
@@ -467,6 +469,7 @@ impl App {
update_dismissed: false,
config_diagnostic,
toast: None,
+ copy_feedback: None,
outer_terminal_focus: None,
prefix_code,
prefix_mods,
@@ -547,6 +550,7 @@ impl App {
Self {
config_diagnostic_deadline: None,
toast_deadline: None,
+ copy_feedback_deadline: None,
state,
terminal_runtimes: restored_terminal_runtimes,
event_tx,
@@ -1465,6 +1469,45 @@ mod tests {
}
#[test]
+ fn clipboard_write_event_shows_feedback_toast() {
+ let mut app = test_app();
+
+ app.handle_internal_event(AppEvent::ClipboardWrite {
+ content: b"copied".to_vec(),
+ });
+
+ assert!(app.state.toast.is_none());
+ let feedback = app.state.copy_feedback.as_ref().expect("copy feedback");
+ assert_eq!(feedback.message, "copied to clipboard");
+ assert!(app.copy_feedback_deadline.is_some());
+ }
+
+ #[test]
+ fn clipboard_feedback_does_not_replace_notification_toast() {
+ let mut app = test_app();
+ app.state.toast = Some(crate::app::state::ToastNotification {
+ kind: crate::app::state::ToastKind::NeedsAttention,
+ title: "pi needs attention".to_string(),
+ context: "background · 2".to_string(),
+ target: None,
+ });
+ let original_toast = app.state.toast.clone();
+
+ app.handle_internal_event(AppEvent::ClipboardWrite {
+ content: b"copied".to_vec(),
+ });
+
+ assert_eq!(app.state.toast, original_toast);
+ assert_eq!(
+ app.state
+ .copy_feedback
+ .as_ref()
+ .map(|feedback| feedback.message.as_str()),
+ Some("copied to clipboard")
+ );
+ }
+
+ #[test]
fn internal_event_drain_limits_work_per_tick() {
let mut app = test_app();
for i in 0..=APP_EVENT_DRAIN_LIMIT {
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index bbef7444..2e1f4d81 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -163,6 +163,15 @@ impl App {
}
if self
+ .copy_feedback_deadline
+ .is_some_and(|deadline| now >= deadline)
+ {
+ self.copy_feedback_deadline = None;
+ self.state.copy_feedback = None;
+ changed = true;
+ }
+
+ if self
.next_animation_tick
.is_some_and(|deadline| now >= deadline)
{
@@ -431,6 +440,7 @@ impl App {
include_resize_poll.then_some(self.next_resize_poll),
self.config_diagnostic_deadline,
self.toast_deadline,
+ self.copy_feedback_deadline,
self.next_animation_tick,
self.git_refresh_deadline(),
self.next_auto_update_check,
diff --git a/src/app/state.rs b/src/app/state.rs
index 5e6aeafd..de1db052 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -1001,6 +1001,11 @@ pub struct ToastNotification {
pub target: Option<ToastTarget>,
}
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct CopyFeedback {
+ pub message: String,
+}
+
pub struct ReleaseNotesState {
pub version: String,
pub body: String,
@@ -1103,6 +1108,7 @@ pub struct AppState {
pub update_dismissed: bool,
pub config_diagnostic: Option<String>,
pub toast: Option<ToastNotification>,
+ pub copy_feedback: Option<CopyFeedback>,
/// Last reported focus state for the outer terminal hosting herdr.
/// None means unsupported or not yet reported, which preserves active-pane suppression.
pub outer_terminal_focus: Option<bool>,
@@ -1410,6 +1416,7 @@ impl AppState {
update_dismissed: false,
config_diagnostic: None,
toast: None,
+ copy_feedback: None,
outer_terminal_focus: None,
prefix_code: KeyCode::Char('b'),
prefix_mods: KeyModifiers::CONTROL,
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 4d9a2cc5..e65b741c 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -1150,8 +1150,8 @@ impl HeadlessServer {
let data = base64::engine::general_purpose::STANDARD.encode(content.as_slice());
self.send_to_client(client_id, ServerMessage::Clipboard { data });
}
- // ClipboardWrite doesn't change visual state — no render needed.
- false
+ self.app.show_clipboard_feedback();
+ true
}
AppEvent::StateChanged { pane_id, agent, .. } => {
// Capture toast before handling.
@@ -2428,6 +2428,16 @@ impl HeadlessServer {
if self
.app
+ .copy_feedback_deadline
+ .is_some_and(|deadline| now >= deadline)
+ {
+ self.app.copy_feedback_deadline = None;
+ self.app.state.copy_feedback = None;
+ changed = true;
+ }
+
+ if self
+ .app
.next_animation_tick
.is_some_and(|deadline| now >= deadline)
{
@@ -4544,7 +4554,16 @@ next_tab = ""
content: b"test".to_vec(),
});
- assert!(!changed);
+ assert!(changed);
+ assert_eq!(
+ server
+ .app
+ .state
+ .copy_feedback
+ .as_ref()
+ .map(|feedback| feedback.message.as_str()),
+ Some("copied to clipboard")
+ );
match read_server_message(
foreground_control_rx
.recv_timeout(Duration::from_millis(100))
diff --git a/src/ui.rs b/src/ui.rs
index 28504b9e..7b45c8c4 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -50,7 +50,10 @@ pub(crate) use self::scrollbar::{
};
use self::settings::render_settings_overlay;
use self::sidebar::{render_sidebar, render_sidebar_collapsed};
-use self::status::{render_config_diagnostic, render_toast_notification, toast_notification_rect};
+use self::status::{
+ render_config_diagnostic, render_copy_feedback, render_toast_notification,
+ toast_notification_rect,
+};
use self::tabs::render_tab_bar;
pub(crate) use self::{
dialogs::{
@@ -405,6 +408,7 @@ fn render_notifications(app: &AppState, frame: &mut Frame, terminal_area: Rect)
if let Some(message) = &app.config_diagnostic {
render_config_diagnostic(frame, terminal_area, message, &app.palette);
}
+ let mut copy_feedback_offset = u16::from(has_config_diagnostic);
if let Some(toast) = &app.toast {
if app.view.layout == ViewLayout::Mobile {
render_mobile_toast_banner(
@@ -423,6 +427,20 @@ fn render_notifications(app: &AppState, frame: &mut Frame, terminal_area: Rect)
&app.palette,
);
}
+ copy_feedback_offset =
+ copy_feedback_offset.saturating_add(if app.view.layout == ViewLayout::Mobile {
+ 1
+ } else {
+ toast_notification_rect(terminal_area, toast, has_config_diagnostic).height
+ });
+ }
+ if let Some(feedback) = &app.copy_feedback {
+ let area = if app.view.layout == ViewLayout::Mobile {
+ frame.area()
+ } else {
+ terminal_area
+ };
+ render_copy_feedback(frame, area, feedback, copy_feedback_offset, &app.palette);
}
}
diff --git a/src/ui/status.rs b/src/ui/status.rs
index 28f5d17b..8ec4f392 100644
--- a/src/ui/status.rs
+++ b/src/ui/status.rs
@@ -8,10 +8,23 @@ use ratatui::{
use super::widgets::panel_contrast_fg;
use crate::{
- app::state::{Palette, ToastKind, ToastNotification},
+ app::state::{CopyFeedback, Palette, ToastKind, ToastNotification},
detect::AgentState,
};
+pub(crate) fn copy_feedback_rect(area: Rect, feedback: &CopyFeedback, offset_rows: u16) -> Rect {
+ if area.width == 0 || area.height == 0 {
+ return Rect::default();
+ }
+
+ let content_width = feedback.message.len() as u16 + 4;
+ let width = content_width.min(area.width);
+ let height = 3u16.min(area.height);
+ let x = area.x + area.width.saturating_sub(width) / 2;
+ let y = area.y + area.height.saturating_sub(height + offset_rows);
+ Rect::new(x, y, width, height)
+}
+
pub(crate) fn toast_notification_rect(
area: Rect,
toast: &ToastNotification,
@@ -19,7 +32,8 @@ pub(crate) fn toast_notification_rect(
) -> Rect {
let content_width = (toast.title.len().max(toast.context.len()) as u16) + 4;
let width = content_width.saturating_add(2).min(area.width);
- let height = 4u16.min(area.height);
+ let content_height = if toast.context.is_empty() { 1 } else { 2 };
+ let height = (content_height + 2).min(area.height);
let x = area.x + area.width.saturating_sub(width);
let y = area.y
+ area
@@ -50,7 +64,7 @@ pub(super) fn render_toast_notification(
let inner = block.inner(toast_area);
frame.render_widget(block, toast_area);
- if inner.height < 2 {
+ if inner.height < 1 {
return;
}
@@ -71,7 +85,47 @@ pub(super) fn render_toast_notification(
]);
frame.render_widget(Paragraph::new(title), title_row);
- frame.render_widget(Paragraph::new(context), context_row);
+ if !toast.context.is_empty() && inner.height >= 2 {
+ frame.render_widget(Paragraph::new(context), context_row);
+ }
+}
+
+pub(super) fn render_copy_feedback(
+ frame: &mut Frame,
+ area: Rect,
+ feedback: &CopyFeedback,
+ offset_rows: u16,
+ p: &Palette,
+) {
+ let feedback_area = copy_feedback_rect(area, feedback, offset_rows);
+ if feedback_area.is_empty() {
+ return;
+ }
+
+ frame.render_widget(Clear, feedback_area);
+ let block = Block::default()
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(p.green))
+ .style(Style::default().bg(p.panel_bg));
+ let inner = block.inner(feedback_area);
+ frame.render_widget(block, feedback_area);
+
+ if inner.height == 0 {
+ return;
+ }
+
+ let text = Line::from(vec![
+ Span::styled("●", Style::default().fg(p.green).bg(p.panel_bg)),
+ Span::raw(" "),
+ Span::styled(
+ &feedback.message,
+ Style::default()
+ .fg(p.text)
+ .bg(p.panel_bg)
+ .add_modifier(Modifier::BOLD),
+ ),
+ ]);
+ frame.render_widget(Paragraph::new(text), inner);
}
pub(super) fn render_config_diagnostic(frame: &mut Frame, area: Rect, message: &str, p: &Palette) {