Skip to content

Commit 694bfb7

Browse files
committed
Merge PR #280
2 parents 1eba3c0 + d42b9e3 commit 694bfb7

6 files changed

Lines changed: 241 additions & 20 deletions

File tree

cortex-engine/src/file_utils.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,20 @@ pub fn format_bytes(bytes: u64) -> String {
141141
}
142142
}
143143

144-
/// Read a file to string.
144+
/// Read a file to string with normalized line endings.
145+
///
146+
/// This function reads the file content and normalizes Windows CRLF line
147+
/// endings to Unix LF, ensuring consistent handling across platforms.
145148
pub async fn read_string(path: impl AsRef<Path>) -> Result<String> {
149+
let content = fs::read_to_string(path.as_ref()).await?;
150+
// Normalize CRLF to LF for consistent cross-platform handling
151+
Ok(content.replace("\r\n", "\n").replace('\r', "\n"))
152+
}
153+
154+
/// Read a file to string without line ending normalization.
155+
///
156+
/// Use this when you need to preserve the original line endings.
157+
pub async fn read_string_raw(path: impl AsRef<Path>) -> Result<String> {
146158
fs::read_to_string(path.as_ref()).await.map_err(Into::into)
147159
}
148160

cortex-tui/src/runner/terminal.rs

Lines changed: 126 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
3131
use std::io::{self, IsTerminal, Stdout, stdout};
3232
use std::panic;
33+
use std::sync::Mutex;
3334
use std::sync::atomic::{AtomicBool, Ordering};
3435

3536
use anyhow::Result;
@@ -45,6 +46,9 @@ use crossterm::{
4546
use ratatui::Terminal;
4647
use ratatui::backend::CrosstermBackend;
4748

49+
/// Global storage for the original terminal title to restore on exit.
50+
static ORIGINAL_TITLE: Mutex<Option<String>> = Mutex::new(None);
51+
4852
/// Track whether the panic hook has been installed to avoid installing it multiple times.
4953
static PANIC_HOOK_INSTALLED: AtomicBool = AtomicBool::new(false);
5054

@@ -59,7 +63,7 @@ static PANIC_HOOK_INSTALLED: AtomicBool = AtomicBool::new(false);
5963
///
6064
/// ```rust,ignore
6165
/// // Guard is typically created internally by CortexTerminal
62-
/// let guard = TerminalGuard::new(true, true, true);
66+
/// let guard = TerminalGuard::new(true, true, true, true);
6367
/// // ... terminal operations ...
6468
/// // Terminal is restored when guard is dropped
6569
/// ```
@@ -70,6 +74,8 @@ pub struct TerminalGuard {
7074
mouse_capture: bool,
7175
/// Whether bracketed paste is enabled
7276
bracketed_paste: bool,
77+
/// Whether to restore the original title
78+
restore_title: bool,
7379
}
7480

7581
impl TerminalGuard {
@@ -80,11 +86,18 @@ impl TerminalGuard {
8086
/// * `alternate_screen` - Whether alternate screen mode is enabled
8187
/// * `mouse_capture` - Whether mouse capture is enabled
8288
/// * `bracketed_paste` - Whether bracketed paste mode is enabled
83-
fn new(alternate_screen: bool, mouse_capture: bool, bracketed_paste: bool) -> Self {
89+
/// * `restore_title` - Whether to restore the original title on cleanup
90+
fn new(
91+
alternate_screen: bool,
92+
mouse_capture: bool,
93+
bracketed_paste: bool,
94+
restore_title: bool,
95+
) -> Self {
8496
Self {
8597
alternate_screen,
8698
mouse_capture,
8799
bracketed_paste,
100+
restore_title,
88101
}
89102
}
90103
}
@@ -95,6 +108,7 @@ impl Drop for TerminalGuard {
95108
self.alternate_screen,
96109
self.mouse_capture,
97110
self.bracketed_paste,
111+
self.restore_title,
98112
);
99113
}
100114
}
@@ -281,10 +295,12 @@ impl CortexTerminal {
281295
let backend = CrosstermBackend::new(stdout());
282296
let terminal = Terminal::new(backend)?;
283297

298+
let restore_title = options.title.is_some();
284299
let guard = TerminalGuard::new(
285300
options.alternate_screen,
286301
options.mouse_capture,
287302
options.bracketed_paste,
303+
restore_title,
288304
);
289305

290306
Ok(Self {
@@ -453,6 +469,24 @@ fn init_terminal(options: &TerminalOptions) -> Result<()> {
453469
// Install panic hook to restore terminal on panic
454470
install_panic_hook();
455471

472+
// Save the original terminal title before we change it
473+
// We use the current working directory as a fallback since most terminals
474+
// set the title to something like "user@host: /path" which we can approximate
475+
if options.title.is_some() {
476+
if let Ok(mut guard) = ORIGINAL_TITLE.lock() {
477+
if guard.is_none() {
478+
// Try to get a reasonable default title to restore
479+
// Most terminals show something like the current directory or shell
480+
let fallback_title = std::env::current_dir()
481+
.map(|p| p.display().to_string())
482+
.unwrap_or_else(|_| String::new());
483+
if !fallback_title.is_empty() {
484+
*guard = Some(fallback_title);
485+
}
486+
}
487+
}
488+
}
489+
456490
// Enable raw mode
457491
enable_raw_mode()?;
458492

@@ -500,6 +534,7 @@ fn init_terminal(options: &TerminalOptions) -> Result<()> {
500534
/// * `alternate_screen` - Whether alternate screen was enabled
501535
/// * `mouse_capture` - Whether mouse capture was enabled
502536
/// * `bracketed_paste` - Whether bracketed paste was enabled
537+
/// * `restore_title` - Whether to restore the original window title
503538
///
504539
/// # Errors
505540
///
@@ -510,6 +545,7 @@ fn restore_terminal_impl(
510545
alternate_screen: bool,
511546
mouse_capture: bool,
512547
bracketed_paste: bool,
548+
restore_title: bool,
513549
) -> Result<()> {
514550
let mut stdout = stdout();
515551

@@ -531,6 +567,15 @@ fn restore_terminal_impl(
531567
execute!(stdout, LeaveAlternateScreen)?;
532568
}
533569

570+
// Restore original terminal title if we saved one
571+
if restore_title {
572+
if let Ok(guard) = ORIGINAL_TITLE.lock() {
573+
if let Some(ref title) = *guard {
574+
let _ = execute!(stdout, SetTitle(title));
575+
}
576+
}
577+
}
578+
534579
// Disable raw mode
535580
disable_raw_mode()?;
536581

@@ -546,7 +591,7 @@ fn restore_terminal_impl(
546591
///
547592
/// Returns an error if any terminal operation fails.
548593
pub fn restore_terminal() -> Result<()> {
549-
restore_terminal_impl(true, true, true)
594+
restore_terminal_impl(true, true, true, true)
550595
}
551596

552597
/// Install a panic hook that restores the terminal.
@@ -688,6 +733,82 @@ pub fn supports_true_color() -> bool {
688733
.unwrap_or(false)
689734
}
690735

736+
/// Check if clipboard is available.
737+
///
738+
/// This checks if the system clipboard can be accessed without relying on
739+
/// terminal escape sequences (OSC 52), which may leak as raw text in
740+
/// unsupported terminals like older PuTTY over SSH.
741+
///
742+
/// Returns `true` if a native clipboard is available (X11, Wayland, macOS, Windows).
743+
/// Returns `false` if we're likely in an SSH session without forwarding or
744+
/// in a terminal that doesn't have clipboard access.
745+
///
746+
/// Note: We never use OSC 52 escape sequences to avoid leaking raw escape
747+
/// sequences as visible text when the terminal doesn't support them.
748+
pub fn is_clipboard_available() -> bool {
749+
// arboard will fail if no clipboard is available
750+
// This is a quick check - actual clipboard operations may still fail
751+
arboard::Clipboard::new().is_ok()
752+
}
753+
754+
/// Safely copy text to clipboard without OSC 52 escape sequences.
755+
///
756+
/// This function uses native clipboard APIs (X11/Wayland on Linux, native on macOS/Windows)
757+
/// and never writes OSC 52 escape sequences that could leak as raw text in unsupported terminals.
758+
///
759+
/// # Arguments
760+
///
761+
/// * `text` - The text to copy to the clipboard
762+
///
763+
/// # Returns
764+
///
765+
/// Returns `true` if the text was successfully copied, `false` otherwise.
766+
/// Failures are logged as warnings but don't cause errors.
767+
pub fn safe_clipboard_copy(text: &str) -> bool {
768+
match arboard::Clipboard::new() {
769+
Ok(mut clipboard) => {
770+
#[cfg(target_os = "linux")]
771+
{
772+
use arboard::SetExtLinux;
773+
// On Linux, use wait() to ensure the clipboard manager receives the data
774+
match clipboard.set().wait().text(text) {
775+
Ok(_) => true,
776+
Err(e) => {
777+
tracing::warn!("Clipboard copy failed: {}", e);
778+
false
779+
}
780+
}
781+
}
782+
#[cfg(not(target_os = "linux"))]
783+
{
784+
match clipboard.set_text(text) {
785+
Ok(_) => true,
786+
Err(e) => {
787+
tracing::warn!("Clipboard copy failed: {}", e);
788+
false
789+
}
790+
}
791+
}
792+
}
793+
Err(e) => {
794+
tracing::debug!("Clipboard unavailable: {}", e);
795+
false
796+
}
797+
}
798+
}
799+
800+
/// Safely read text from clipboard.
801+
///
802+
/// # Returns
803+
///
804+
/// Returns the clipboard text content if available, None otherwise.
805+
pub fn safe_clipboard_paste() -> Option<String> {
806+
match arboard::Clipboard::new() {
807+
Ok(mut clipboard) => clipboard.get_text().ok(),
808+
Err(_) => None,
809+
}
810+
}
811+
691812
#[cfg(test)]
692813
mod tests {
693814
use super::*;
@@ -730,10 +851,11 @@ mod tests {
730851

731852
#[test]
732853
fn test_terminal_guard_creation() {
733-
let guard = TerminalGuard::new(true, true, true);
854+
let guard = TerminalGuard::new(true, true, true, true);
734855
assert!(guard.alternate_screen);
735856
assert!(guard.mouse_capture);
736857
assert!(guard.bracketed_paste);
858+
assert!(guard.restore_title);
737859
}
738860

739861
// Note: Tests that actually create terminals are difficult to run

cortex-tui/src/ui/consts.rs

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,57 @@ pub const SPINNER_INTERVAL_MS: u64 = 80;
7272
/// Tool execution spinner frames (half-circles for smooth rotation)
7373
pub const TOOL_SPINNER_FRAMES: &[char] = &['◐', '◑', '◒', '◓'];
7474

75-
/// Cursor blink interval in milliseconds.
75+
/// Default cursor blink interval in milliseconds.
7676
pub const CURSOR_BLINK_INTERVAL_MS: u64 = 500;
7777

78+
/// Gets the cursor blink interval respecting system accessibility settings.
79+
///
80+
/// On Linux/GNOME, attempts to read the `cursor-blink-time` setting.
81+
/// On macOS, checks accessibility preferences.
82+
/// Falls back to the default interval if the system setting cannot be read.
83+
///
84+
/// Returns 0 if cursor blinking should be disabled.
85+
pub fn get_system_cursor_blink_interval() -> u64 {
86+
// Check CORTEX_CURSOR_BLINK environment variable for user override
87+
if let Ok(val) = std::env::var("CORTEX_CURSOR_BLINK") {
88+
if val == "0" || val.to_lowercase() == "false" {
89+
return 0; // Disable blinking
90+
}
91+
if let Ok(ms) = val.parse::<u64>() {
92+
return ms;
93+
}
94+
}
95+
96+
// Try to detect system settings
97+
#[cfg(target_os = "linux")]
98+
{
99+
// Try GNOME settings via gsettings output parsing
100+
// cursor-blink-time is in milliseconds, cursor-blink is bool
101+
if let Ok(output) = std::process::Command::new("gsettings")
102+
.args(["get", "org.gnome.desktop.interface", "cursor-blink"])
103+
.output()
104+
{
105+
let stdout = String::from_utf8_lossy(&output.stdout);
106+
if stdout.trim() == "false" {
107+
return 0; // Blinking disabled
108+
}
109+
}
110+
111+
if let Ok(output) = std::process::Command::new("gsettings")
112+
.args(["get", "org.gnome.desktop.interface", "cursor-blink-time"])
113+
.output()
114+
{
115+
let stdout = String::from_utf8_lossy(&output.stdout);
116+
if let Ok(ms) = stdout.trim().parse::<u64>() {
117+
return ms;
118+
}
119+
}
120+
}
121+
122+
// Default interval
123+
CURSOR_BLINK_INTERVAL_MS
124+
}
125+
78126
/// Shimmer animation period in seconds.
79127
pub const SHIMMER_PERIOD_SECS: f32 = 2.0;
80128

cortex-tui/src/widgets/command_palette.rs

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -296,29 +296,44 @@ impl CommandPaletteState {
296296
self.cursor_pos = self.query.len();
297297
}
298298

299-
/// Moves selection up.
299+
/// Moves selection up with wrap-around.
300300
pub fn select_prev(&mut self) {
301301
let total = self.total_display_count();
302-
if total > 0 && self.selected_index > 0 {
302+
if total == 0 {
303+
return;
304+
}
305+
if self.selected_index > 0 {
303306
self.selected_index -= 1;
304-
305307
// Adjust scroll if needed
306308
if self.selected_index < self.scroll_offset {
307309
self.scroll_offset = self.selected_index;
308310
}
311+
} else {
312+
// Wrap to last item
313+
self.selected_index = total - 1;
314+
// Scroll to show last items
315+
if total > self.visible_count {
316+
self.scroll_offset = total - self.visible_count;
317+
}
309318
}
310319
}
311320

312-
/// Moves selection down.
321+
/// Moves selection down with wrap-around.
313322
pub fn select_next(&mut self) {
314323
let total = self.total_display_count();
315-
if total > 0 && self.selected_index < total.saturating_sub(1) {
324+
if total == 0 {
325+
return;
326+
}
327+
if self.selected_index < total.saturating_sub(1) {
316328
self.selected_index += 1;
317-
318329
// Adjust scroll if needed
319330
if self.selected_index >= self.scroll_offset + self.visible_count {
320331
self.scroll_offset = self.selected_index.saturating_sub(self.visible_count - 1);
321332
}
333+
} else {
334+
// Wrap to first item
335+
self.selected_index = 0;
336+
self.scroll_offset = 0;
322337
}
323338
}
324339

0 commit comments

Comments
 (0)