From 0c83efd19653653068ec951d98a8228eead7e2d4 Mon Sep 17 00:00:00 2001 From: ANAS Date: Wed, 24 Jun 2026 14:15:52 +0100 Subject: [PATCH] CLI: clipboard, rendering, session & deps Add clipboard support with arboard and update dependencies; bump apps/cli and libs/sdk to 0.1.15. Replace shell-based clipboard helper with arboard::Clipboard for more reliable copy operations and show clearer success/error history messages. Improve input and event handling: avoid submitting while generator is running, clear pending IDs when cancelling API-key input, refine up/down key behavior to respect cursor position vs. history scrolling, and ignore model header rows on menu clicks. Rendering improvements: only redraw when needed (render_dirty/needs_draw), adjust poll timeouts for smooth animation when generating, and better burst key detection. Session UI: add estimate_wrapped_line_height to compute line wrapping accurately, use text width for caching, add a vertical scrollbar column for the history view, and fix wrapped-height calculations. Update GitHub Actions release condition to allow publish to run on build success/failure unless the workflow was cancelled. Cargo.lock was updated to include new/updated crates required by these changes. --- .github/workflows/release.yml | 2 +- Cargo.lock | 188 +++++++++++++++++++++++++++++++++- apps/cli/Cargo.toml | 4 +- apps/cli/src/ui/events.rs | 71 ++++++++----- apps/cli/src/ui/menus.rs | 7 +- apps/cli/src/ui/render.rs | 54 +++++----- apps/cli/src/ui/session.rs | 129 +++++++++++++++++++++-- libs/sdk/Cargo.toml | 2 +- 8 files changed, 391 insertions(+), 66 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc19f70..729c351 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -126,7 +126,7 @@ jobs: publish: name: Publish Release needs: [build-cli, build-desktop] - if: always() && (needs.build-cli.result == 'success' || needs.build-desktop.result == 'success') + if: always() && !cancelled() && (needs.build-cli.result == 'success' || needs.build-cli.result == 'failure' || needs.build-desktop.result == 'success' || needs.build-desktop.result == 'failure') runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/Cargo.lock b/Cargo.lock index 732dafb..65da84f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -112,6 +112,26 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -447,6 +467,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -635,6 +661,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -791,6 +826,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -1227,6 +1268,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "event-listener" version = "5.4.1" @@ -1264,6 +1311,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -1588,6 +1641,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1791,6 +1854,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2116,6 +2190,20 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", + "tiff", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -2559,6 +2647,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.19.1" @@ -2693,6 +2791,7 @@ dependencies = [ "block2", "objc2", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation", ] @@ -3287,6 +3386,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.39.4" @@ -3521,9 +3632,10 @@ dependencies = [ [[package]] name = "routecode-cli" -version = "0.1.13" +version = "0.1.14" dependencies = [ "anyhow", + "arboard", "async-trait", "chrono", "clap", @@ -3543,7 +3655,7 @@ dependencies = [ [[package]] name = "routecode-sdk" -version = "0.1.13" +version = "0.1.14" dependencies = [ "anyhow", "async-stream", @@ -4756,6 +4868,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "tiktoken-rs" version = "0.5.9" @@ -5604,6 +5730,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi" version = "0.3.9" @@ -6314,6 +6446,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xattr" version = "1.6.1" @@ -6408,6 +6557,26 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -6486,6 +6655,21 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.11.0" diff --git a/apps/cli/Cargo.toml b/apps/cli/Cargo.toml index 7f30e1b..a4bb7d1 100644 --- a/apps/cli/Cargo.toml +++ b/apps/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "routecode-cli" -version = "0.1.14" +version = "0.1.15" edition = "2021" authors = ["SpeerX "] description = "CLI application for RouteCode" @@ -22,3 +22,5 @@ simplelog = "0.12" unicode-width = "0.1" reqwest = { version = "0.12", features = ["json"] } uuid = { version = "1.8", features = ["v4"] } +arboard = "3.4" + diff --git a/apps/cli/src/ui/events.rs b/apps/cli/src/ui/events.rs index 3e001ff..db2e0fa 100644 --- a/apps/cli/src/ui/events.rs +++ b/apps/cli/src/ui/events.rs @@ -70,13 +70,19 @@ pub(crate) async fn handle_key_event( .unwrap_or_default(); if app.user_msg_modal_selected == 0 { let text_clone = text.clone(); - tokio::task::spawn_blocking(move || { - if let Err(e) = copy_to_clipboard(&text_clone) { + match copy_to_clipboard(&text_clone) { + Ok(()) => { + app.history + .push(Message::system("Message copied to clipboard!".to_string())); + } + Err(e) => { log::error!("Clipboard copy failed: {}", e); + app.history.push(Message::system(format!( + "Failed to copy to clipboard: {}. Make sure a clipboard utility (e.g. xclip/wl-clipboard on Linux, clip on Windows, pbcopy on macOS) is installed.", + e + ))); } - }); - app.history - .push(Message::system("Message copied to clipboard!".to_string())); + } } else { app.history.truncate(msg_idx); app.input = tui_textarea::TextArea::from(text.lines().map(|s| s.to_string())); @@ -627,12 +633,17 @@ pub(crate) async fn handle_key_event( } else { app.is_inputting_api_key = false; app.api_key_input_stage = ApiKeyInputStage::None; + app.pending_provider_id = None; + app.pending_account_id = None; + app.pending_gateway_id = None; } } else { let input_text = app.input.lines().join("\n"); if !input_text.trim().is_empty() { if input_text.starts_with('/') { handle_command(app, &input_text).await; + } else if app.is_generating { + // Ignore normal text submissions while generating to avoid parallel tasks } else if !app.startup_ready { app.startup_input_buffer.push(input_text.clone()); app.history @@ -706,6 +717,7 @@ pub(crate) async fn handle_key_event( } else if app.is_inputting_api_key { app.is_inputting_api_key = false; app.api_key_input_stage = ApiKeyInputStage::None; + app.pending_provider_id = None; app.pending_account_id = None; app.pending_gateway_id = None; } else if app.is_generating { @@ -825,15 +837,16 @@ pub(crate) async fn handle_key_event( } app.menu_state.select(Some(new_selected)); } - } else if app.input.lines().len() == 1 && app.input.lines()[0].is_empty() - || app.history_scroll > 0 - || app.is_generating - || key.modifiers.contains(event::KeyModifiers::SHIFT) - { - app.history_scroll = app.history_scroll.saturating_sub(15); - app.auto_scroll = false; } else { - app.input.input(Event::Key(key)); + let (cursor_row, _) = app.input.cursor(); + if (app.input.lines().len() == 1 && app.input.lines()[0].is_empty()) + || (cursor_row == 0 && (app.history_scroll > 0 || app.is_generating || key.modifiers.contains(event::KeyModifiers::SHIFT))) + { + app.history_scroll = app.history_scroll.saturating_sub(15); + app.auto_scroll = false; + } else { + app.input.input(Event::Key(key)); + } } } KeyCode::Down => { @@ -887,17 +900,19 @@ pub(crate) async fn handle_key_event( } app.menu_state.select(Some(new_selected)); } - } else if app.input.lines().len() == 1 && app.input.lines()[0].is_empty() - || app.history_scroll < app.max_scroll - || app.is_generating - || key.modifiers.contains(event::KeyModifiers::SHIFT) - { - app.history_scroll = app.history_scroll.saturating_add(15); - if app.history_scroll >= app.max_scroll { - app.auto_scroll = true; - } } else { - app.input.input(Event::Key(key)); + let (cursor_row, _) = app.input.cursor(); + let lines_len = app.input.lines().len(); + if (lines_len == 1 && app.input.lines()[0].is_empty()) + || (cursor_row == lines_len - 1 && (app.history_scroll < app.max_scroll || app.is_generating || key.modifiers.contains(event::KeyModifiers::SHIFT))) + { + app.history_scroll = app.history_scroll.saturating_add(15); + if app.history_scroll >= app.max_scroll { + app.auto_scroll = true; + } + } else { + app.input.input(Event::Key(key)); + } } } KeyCode::Right if app.show_model_menu => { @@ -1002,7 +1017,10 @@ pub(crate) async fn handle_key_event( KeyCode::BackTab => { app.approval_mode = app.approval_mode.next(); let info = match app.approval_mode { - ApprovalMode::YOLO => "YOLO -- commands will auto-approve", + ApprovalMode::YOLO => { + app.orchestrator.exit_plan_mode(false); + "YOLO -- commands will auto-approve" + } ApprovalMode::Plan => { // Mirror the UI state into the orchestrator: enter // plan mode, force bash to read-only, reset @@ -1014,7 +1032,10 @@ pub(crate) async fn handle_key_event( "PLAN -- plan mode active: write tools hidden, bash read-only. \ Use exit_plan_mode (model) to unlock writes." } - ApprovalMode::Shell => "SHELL -- shell commands shown first, auto-approved", + ApprovalMode::Shell => { + app.orchestrator.exit_plan_mode(false); + "SHELL -- shell commands shown first, auto-approved" + } ApprovalMode::Normal => { // Leaving Plan mode (either toward YOLO/Shell or // back to Normal from a previous Plan): exit plan diff --git a/apps/cli/src/ui/menus.rs b/apps/cli/src/ui/menus.rs index 54a1184..cb8ba59 100644 --- a/apps/cli/src/ui/menus.rs +++ b/apps/cli/src/ui/menus.rs @@ -368,7 +368,12 @@ pub fn render_model_menu(f: &mut Frame, app: &mut App, _input_area: Rect) { && row < layout[1].y + layout[1].height { let idx = (row - layout[1].y) as usize + app.menu_state.offset(); - if idx < items_len { + if idx < items_len + && !matches!( + app.filtered_models.get(idx), + Some(ModelMenuItem::Header(_)) + ) + { app.menu_state.select(Some(idx)); } } diff --git a/apps/cli/src/ui/render.rs b/apps/cli/src/ui/render.rs index ef1bf38..3658c51 100644 --- a/apps/cli/src/ui/render.rs +++ b/apps/cli/src/ui/render.rs @@ -26,11 +26,20 @@ pub async fn run_app( let mut last_tick = std::time::Instant::now(); let tick_rate = std::time::Duration::from_millis(100); let render_rate = std::time::Duration::from_millis(16); // ~60 FPS for smooth rendering + let mut needs_draw = true; loop { - terminal.draw(|f| ui(f, &mut app))?; + if needs_draw || app.render_dirty { + terminal.draw(|f| ui(f, &mut app))?; + needs_draw = false; + } - let timeout = render_rate; + let time_to_next_tick = tick_rate.saturating_sub(last_tick.elapsed()); + let timeout = if app.is_generating || app.logo_anim_frames > 0 { + render_rate.min(time_to_next_tick) + } else { + time_to_next_tick + }; if event::poll(timeout)? { let mut events = Vec::new(); @@ -38,7 +47,18 @@ pub async fn run_app( events.push(event::read()?); } - let is_burst = events.len() > 1; + if !events.is_empty() { + needs_draw = true; + } + + let is_burst = events + .iter() + .filter(|e| match e { + Event::Key(key) => key.kind == KeyEventKind::Press, + _ => false, + }) + .count() + > 1; for event in events { match event { @@ -78,6 +98,7 @@ pub async fn run_app( } } + needs_draw = true; last_tick = std::time::Instant::now(); } @@ -559,28 +580,11 @@ fn render_confirmation_dialog(f: &mut Frame, message: &str) { } pub(crate) fn copy_to_clipboard(text: &str) -> std::io::Result<()> { - use std::io::Write; - use std::process::{Command, Stdio}; - let (prog, args): (&str, &[&str]) = if cfg!(target_os = "windows") { - ("clip", &[]) - } else if cfg!(target_os = "macos") { - ("pbcopy", &[]) - } else { - ("xclip", &["-selection", "clipboard"]) - }; - let mut child = Command::new(prog) - .args(args) - .stdin(Stdio::piped()) - .spawn()?; - if let Some(mut stdin) = child.stdin.take() { - stdin.write_all(text.as_bytes())?; - } - let result = child.wait(); - match result { - Ok(status) if status.success() => Ok(()), - Ok(_) => Err(std::io::Error::other("clipboard command failed")), - Err(e) => Err(e), - } + let mut clipboard = arboard::Clipboard::new() + .map_err(std::io::Error::other)?; + clipboard.set_text(text.to_string()) + .map_err(std::io::Error::other)?; + Ok(()) } fn render_user_msg_modal(f: &mut Frame, app: &mut App) { diff --git a/apps/cli/src/ui/session.rs b/apps/cli/src/ui/session.rs index 82c56ce..6b5f683 100644 --- a/apps/cli/src/ui/session.rs +++ b/apps/cli/src/ui/session.rs @@ -11,6 +11,85 @@ use ratatui::Frame; use routecode_sdk::core::{Message, Role}; use unicode_width::UnicodeWidthStr; +fn estimate_wrapped_line_height(line: &Line, calc_width: usize) -> usize { + let line_text: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); + if line_text.is_empty() { + return 1; + } + + let mut height = 0; + let mut current_line_width = 0; + let limit_width = calc_width.max(1); + + let mut words = Vec::new(); + let mut current_word = String::new(); + let mut is_space = false; + + for c in line_text.chars() { + let char_is_space = c == ' '; + if char_is_space != is_space && !current_word.is_empty() { + words.push((current_word.clone(), is_space)); + current_word.clear(); + } + is_space = char_is_space; + current_word.push(c); + } + if !current_word.is_empty() { + words.push((current_word, is_space)); + } + + for (word, is_sp) in words { + let word_width = word.width(); + + if word_width == 0 { + continue; + } + + if is_sp { + if current_line_width + word_width <= limit_width { + current_line_width += word_width; + } else { + let mut remaining_width = word_width; + if current_line_width > 0 { + let first_chunk = limit_width.saturating_sub(current_line_width); + remaining_width = remaining_width.saturating_sub(first_chunk); + height += 1; + } + while remaining_width > limit_width { + height += 1; + remaining_width = remaining_width.saturating_sub(limit_width); + } + current_line_width = remaining_width; + } + } else { + if current_line_width + word_width <= limit_width { + current_line_width += word_width; + } else { + if current_line_width > 0 { + height += 1; + } + + if word_width > limit_width { + let mut remaining_width = word_width; + while remaining_width > limit_width { + height += 1; + remaining_width = remaining_width.saturating_sub(limit_width); + } + current_line_width = remaining_width; + } else { + current_line_width = word_width; + } + } + } + } + + if current_line_width > 0 || height == 0 { + height += 1; + } + + height +} + pub fn ui_session(f: &mut Frame, app: &mut App, area: Rect) -> Rect { let input_height = (app.input.lines().len() as u16 + 2).min(12); let chunks = Layout::default() @@ -29,10 +108,11 @@ pub fn ui_session(f: &mut Frame, app: &mut App, area: Rect) -> Rect { let hovered_msg_idx = crate::ui::compute_message_hover(app, chunks[0]); + let text_width = chunks[0].width.saturating_sub(1); let needs_rebuild = app.render_dirty || app.cached_text.is_none() || app.history.len() != app.cached_history_len - || chunks[0].width != app.cached_width + || text_width != app.cached_width || is_collapsed != app.cached_is_collapsed || thinking_hovered != app.cached_thinking_hovered || hovered_msg_idx != app.cached_hovered_msg_idx; @@ -45,7 +125,7 @@ pub fn ui_session(f: &mut Frame, app: &mut App, area: Rect) -> Rect { let mut lines = Vec::new(); let mut total_height: usize = 0; let mut layout = Vec::new(); - let available_width = chunks[0].width.max(1) as usize; + let available_width = text_width.max(1) as usize; let calc_width = (available_width as f32 * 0.95).floor().max(1.0) as usize; for (msg_idx, m) in app.history.iter().enumerate() { @@ -59,12 +139,7 @@ pub fn ui_session(f: &mut Frame, app: &mut App, area: Rect) -> Rect { ); for line in msg_text.lines { - let line_width: usize = line.spans.iter().map(|s| s.content.width()).sum(); - let wrapped_height = if line_width == 0 { - 1 - } else { - (line_width + calc_width - 1) / calc_width.max(1) - }; + let wrapped_height = estimate_wrapped_line_height(&line, calc_width); let is_thinking = line.spans.iter().any(|span| { span.content.contains('\u{2502}') @@ -87,7 +162,7 @@ pub fn ui_session(f: &mut Frame, app: &mut App, area: Rect) -> Rect { total_height += 2; app.cached_history_len = app.history.len(); - app.cached_width = chunks[0].width; + app.cached_width = text_width; app.cached_is_collapsed = is_collapsed; app.cached_thinking_hovered = thinking_hovered; app.cached_hovered_msg_idx = hovered_msg_idx; @@ -113,13 +188,47 @@ pub fn ui_session(f: &mut Frame, app: &mut App, area: Rect) -> Rect { } } + let history_layout = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Min(1), + Constraint::Length(1), // Scrollbar column + ]) + .split(chunks[0]); + f.render_widget( Paragraph::new(history_text) .wrap(Wrap { trim: false }) .scroll((app.history_scroll, 0)), - chunks[0], + history_layout[0], ); + // Scrollbar rendering + let viewport_height = history_layout[1].height as usize; + if viewport_height > 0 { + let mut scrollbar_lines = Vec::new(); + + if max_scroll > 0 && total_height > viewport_height { + let thumb_height = ((viewport_height * viewport_height) / total_height).max(1); + let scrollable_track = viewport_height.saturating_sub(thumb_height); + let scroll_ratio = app.history_scroll as f64 / max_scroll as f64; + let thumb_pos = (scroll_ratio * scrollable_track as f64).round() as usize; + + for r in 0..viewport_height { + if r >= thumb_pos && r < thumb_pos + thumb_height { + scrollbar_lines.push(Line::from(Span::styled("█", Style::default().fg(COLOR_PRIMARY)))); + } else { + scrollbar_lines.push(Line::from(Span::styled("│", Style::default().fg(COLOR_DIM)))); + } + } + } else { + for _ in 0..viewport_height { + scrollbar_lines.push(Line::from(Span::styled("│", Style::default().fg(COLOR_DIM)))); + } + } + f.render_widget(Paragraph::new(scrollbar_lines), history_layout[1]); + } + f.render_widget( Block::default().style(Style::default().bg(COLOR_INPUT_BG)), chunks[1], diff --git a/libs/sdk/Cargo.toml b/libs/sdk/Cargo.toml index 5599deb..89820d5 100644 --- a/libs/sdk/Cargo.toml +++ b/libs/sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "routecode-sdk" -version = "0.1.14" +version = "0.1.15" edition = "2021" authors = ["SpeerX "] description = "Core logic for RouteCode"