From cbe2a30ea4dcf20ef4e215b50e4b382933ef1885 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Wed, 28 Jan 2026 09:02:49 -0600 Subject: [PATCH] Polish TUI UX and fix shortcuts --- src/commands/mod.rs | 55 ++++++- src/palette.rs | 10 ++ src/tools/swarm.rs | 3 +- src/tui/history.rs | 20 ++- src/tui/transcript.rs | 13 +- src/tui/ui.rs | 299 +++++++++++++++++++++++++++----------- src/tui/views/mod.rs | 105 +++++++++---- src/tui/widgets/header.rs | 124 +++++++++++++--- 8 files changed, 485 insertions(+), 144 deletions(-) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 39712da8..7cb70fa5 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -321,7 +321,8 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { "save" => session::save(app, arg), "sessions" | "resume" => session::sessions(app), "load" => { - if app.mode == AppMode::Rlm { + let force_rlm = arg.is_some_and(|raw| raw.trim_start().starts_with('@')); + if app.mode == AppMode::Rlm || force_rlm { rlm::load(app, arg) } else { session::load(app, arg) @@ -383,3 +384,55 @@ pub fn commands_matching(prefix: &str) -> Vec<&'static CommandInfo> { }) .collect() } + +#[cfg(test)] +mod tests { + use super::execute; + use crate::config::Config; + use crate::tui::app::{App, AppMode, TuiOptions}; + use std::fs; + use std::path::PathBuf; + use tempfile::tempdir; + + fn make_test_app(workspace: PathBuf) -> App { + let skills_dir = workspace.join("skills"); + let _ = fs::create_dir_all(&skills_dir); + let options = TuiOptions { + model: "test-model".to_string(), + workspace, + allow_shell: false, + use_alt_screen: false, + max_subagents: 1, + skills_dir, + memory_path: PathBuf::from("memory.md"), + notes_path: PathBuf::from("notes.txt"), + mcp_config_path: PathBuf::from("mcp.json"), + use_memory: false, + start_in_agent_mode: false, + skip_onboarding: true, + yolo: false, + resume_session_id: None, + }; + App::new(options, &Config::default()) + } + + #[test] + fn load_at_path_uses_rlm_outside_rlm_mode() { + let tmp = tempdir().expect("tempdir"); + let file = tmp.path().join("example.txt"); + fs::write(&file, "hello").expect("write"); + + let mut app = make_test_app(tmp.path().to_path_buf()); + app.mode = AppMode::Normal; + + let result = execute("/load @example.txt", &mut app); + let message = result.message.unwrap_or_default(); + assert!( + message.starts_with("Loaded "), + "expected RLM load message, got: {message}" + ); + + let session = app.rlm_session.lock().expect("lock session"); + assert!(!session.contexts.is_empty()); + } +} diff --git a/src/palette.rs b/src/palette.rs index acb3516c..230b7780 100644 --- a/src/palette.rs +++ b/src/palette.rs @@ -6,6 +6,7 @@ pub const DEEPSEEK_BLUE_RGB: (u8, u8, u8) = (53, 120, 229); // #3578E5 pub const DEEPSEEK_SKY_RGB: (u8, u8, u8) = (106, 174, 242); #[allow(dead_code)] pub const DEEPSEEK_AQUA_RGB: (u8, u8, u8) = (54, 187, 212); +#[allow(dead_code)] pub const DEEPSEEK_NAVY_RGB: (u8, u8, u8) = (24, 63, 138); pub const DEEPSEEK_INK_RGB: (u8, u8, u8) = (11, 21, 38); pub const DEEPSEEK_SLATE_RGB: (u8, u8, u8) = (18, 28, 46); @@ -24,6 +25,7 @@ pub const DEEPSEEK_AQUA: Color = Color::Rgb( DEEPSEEK_AQUA_RGB.1, DEEPSEEK_AQUA_RGB.2, ); +#[allow(dead_code)] pub const DEEPSEEK_NAVY: Color = Color::Rgb( DEEPSEEK_NAVY_RGB.0, DEEPSEEK_NAVY_RGB.1, @@ -49,6 +51,14 @@ pub const STATUS_ERROR: Color = DEEPSEEK_RED; #[allow(dead_code)] pub const STATUS_INFO: Color = DEEPSEEK_BLUE; +// Mode-specific accent colors for mode badges +pub const MODE_NORMAL: Color = Color::Gray; +pub const MODE_AGENT: Color = Color::Rgb(80, 150, 255); // Bright blue +pub const MODE_YOLO: Color = Color::Rgb(255, 100, 100); // Warning red +pub const MODE_PLAN: Color = Color::Rgb(255, 170, 60); // Orange +pub const MODE_RLM: Color = Color::Rgb(180, 100, 255); // Purple (was INK!) +pub const MODE_DUO: Color = Color::Rgb(100, 220, 180); // Teal + pub const SELECTION_BG: Color = Color::Rgb(26, 44, 74); pub const COMPOSER_BG: Color = DEEPSEEK_SLATE; diff --git a/src/tools/swarm.rs b/src/tools/swarm.rs index 40a184d1..def00f4c 100644 --- a/src/tools/swarm.rs +++ b/src/tools/swarm.rs @@ -636,8 +636,7 @@ fn validate_swarm_tasks(tasks: &[SwarmTaskSpec]) -> Result<(), ToolError> { if matches!(task.agent_type, Some(SubAgentType::Custom)) { let tools = task .allowed_tools - .as_ref() - .map(Vec::as_slice) + .as_deref() .unwrap_or(&[]); if tools.is_empty() { return Err(ToolError::invalid_input(format!( diff --git a/src/tui/history.rs b/src/tui/history.rs index 294a26b3..2e4586e1 100644 --- a/src/tui/history.rs +++ b/src/tui/history.rs @@ -52,8 +52,18 @@ impl HistoryCell { pub fn lines(&self, width: u16) -> Vec> { match self { HistoryCell::User { content } => render_message("You", content, user_style(), width), - HistoryCell::Assistant { content, .. } => { - render_message("DeepSeek", content, assistant_style(), width) + HistoryCell::Assistant { content, streaming } => { + let mut lines = render_message("DeepSeek", content, assistant_style(), width); + if *streaming { + // Add blinking cursor to last line + if let Some(last) = lines.last_mut() { + last.spans.push(Span::styled( + "▋", + Style::default().fg(palette::DEEPSEEK_SKY), + )); + } + } + lines } HistoryCell::System { content } => { render_message("System", content, system_style(), width) @@ -1220,11 +1230,13 @@ fn truncate_text(text: &str, max_len: usize) -> String { } fn user_style() -> Style { - Style::default().fg(palette::DEEPSEEK_BLUE) + Style::default() + .fg(palette::TEXT_PRIMARY) + .add_modifier(Modifier::BOLD) } fn assistant_style() -> Style { - Style::default().fg(palette::DEEPSEEK_BLUE) + Style::default().fg(palette::DEEPSEEK_SKY) } fn system_style() -> Style { diff --git a/src/tui/transcript.rs b/src/tui/transcript.rs index b69e5358..c23bef85 100644 --- a/src/tui/transcript.rs +++ b/src/tui/transcript.rs @@ -1,7 +1,9 @@ //! Cached transcript rendering for the TUI. -use ratatui::text::Line; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use crate::palette; use crate::tui::history::{HistoryCell, TranscriptRenderOptions}; use crate::tui::scrolling::TranscriptLineMeta; @@ -60,7 +62,14 @@ impl TranscriptViewCache { } if cell_index + 1 < cells.len() && !cell.is_stream_continuation() { - lines.push(Line::from("")); + // Add subtle horizontal separator between messages + let separator = Span::styled( + "─".repeat(usize::from(width)), + Style::default() + .fg(palette::TEXT_MUTED) + .add_modifier(Modifier::DIM), + ); + lines.push(Line::from(separator)); meta.push(TranscriptLineMeta::Spacer); } } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 5157cb60..dad7b027 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -719,6 +719,15 @@ async fn run_event_loop( continue; } + if key.code == KeyCode::Char('/') && key.modifiers.contains(KeyModifiers::CONTROL) { + if app.view_stack.top_kind() == Some(ModalKind::Help) { + app.view_stack.pop(); + } else { + app.view_stack.push(HelpView::new()); + } + continue; + } + if !app.view_stack.is_empty() { let events = app.view_stack.handle_key(key); handle_view_events(app, &engine_handle, events).await; @@ -781,6 +790,12 @@ async fn run_event_loop( return Ok(()); } } + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if app.input.is_empty() { + let _ = engine_handle.send(Op::Shutdown).await; + return Ok(()); + } + } KeyCode::Esc => { if app.is_loading { engine_handle.cancel(); @@ -813,6 +828,12 @@ async fn run_event_loop( } } // Input handling + KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.insert_char('\n'); + } + KeyCode::Enter if key.modifiers.contains(KeyModifiers::ALT) => { + app.insert_char('\n'); + } KeyCode::Enter => { if let Some(input) = app.submit_input() { if handle_plan_choice(app, &engine_handle, &input).await? { @@ -871,6 +892,16 @@ async fn run_event_loop( } } } else { + // Global @ file completion - works in any mode + if let Some(path) = input.trim().strip_prefix('@') { + let command = format!("/load @{path}"); + let result = commands::execute(&command, app); + if let Some(msg) = result.message { + app.add_message(HistoryCell::System { content: msg }); + } + continue; + } + if app.mode == AppMode::Rlm && app.rlm_repl_active { if rlm_repl_should_route_to_chat(app, &input) { app.rlm_repl_active = false; @@ -883,17 +914,6 @@ async fn run_event_loop( } } - if app.mode == AppMode::Rlm - && let Some(path) = input.trim().strip_prefix('@') - { - let command = format!("/load @{path}"); - let result = commands::execute(&command, app); - if let Some(msg) = result.message { - app.add_message(HistoryCell::System { content: msg }); - } - continue; - } - let queued = if let Some(mut draft) = app.queued_draft.take() { draft.display = input; draft @@ -965,6 +985,13 @@ async fn run_event_loop( KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { app.clear_input(); } + KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => { + let new_mode = match app.mode { + AppMode::Agent => AppMode::Normal, + _ => AppMode::Agent, + }; + app.set_mode(new_mode); + } KeyCode::Char('v') if is_paste_shortcut(&key) => { app.paste_from_clipboard(); } @@ -1972,7 +1999,13 @@ fn render_status_indicator(f: &mut Frame, area: Rect, app: &App, queued: &[Strin None }; let elapsed = app.turn_started_at.map(format_elapsed); - let spinner = deepseek_squiggle(app.turn_started_at); + // Use typing indicator when streaming content, otherwise use whale spinner + let has_streaming_content = app.streaming_message_index.is_some(); + let spinner = if has_streaming_content { + typing_indicator(app.turn_started_at) + } else { + deepseek_squiggle(app.turn_started_at) + }; let label = if app.show_thinking { deepseek_thinking_label(app.turn_started_at) } else { @@ -2050,93 +2083,175 @@ fn render_status_indicator(f: &mut Frame, area: Rect, app: &App, queued: &[Strin } fn render_footer(f: &mut Frame, area: Rect, app: &App) { - let mut spans = vec![ - Span::styled( - format!(" {} ", app.mode.label()), - mode_badge_style(app.mode), - ), - Span::raw(" | "), - Span::styled( - context_indicator(app), - Style::default().fg(palette::TEXT_MUTED), - ), - ]; - - if let Some((label, style)) = rlm_usage_badge(app) { - spans.push(Span::raw(" | ")); - spans.push(Span::styled(label, style)); - } - - if let (Some(prompt), Some(completion)) = (app.last_prompt_tokens, app.last_completion_tokens) { - spans.push(Span::raw(" | ")); - spans.push(Span::styled( - format!("last tokens in/out: {prompt}/{completion}"), - Style::default().fg(palette::TEXT_MUTED), - )); - } - + let width = area.width; + let available = width as usize; + + // Build left side: [MODE] context_bar + let context_text = context_indicator(app); + let mode_badge = Span::styled( + format!(" {} ", app.mode.label()), + mode_badge_style(app.mode), + ); + + let context_info = Span::styled( + context_text.clone(), + Style::default().fg(palette::TEXT_MUTED), + ); + + // Calculate widths for left side + let mode_width = mode_badge.content.width(); + let context_width = context_text.width(); + let left_min_width = mode_width + 1 + context_width; // mode + space + context + + // Build right side: key hints and other info + let mut right_spans = Vec::new(); + + // Add scroll info if applicable let can_scroll = app.last_transcript_total > app.last_transcript_visible; - if can_scroll { - spans.push(Span::raw(" | ")); - spans.push(Span::styled( - "Alt+Up/Down scroll", - Style::default().fg(palette::TEXT_MUTED), - )); - } - if can_scroll && !matches!(app.transcript_scroll, TranscriptScroll::ToBottom) { - spans.push(Span::raw(" | ")); - spans.push(Span::styled( - format!( - "Scrolled {}/{}", - app.last_transcript_top + 1, - app.last_transcript_total.max(1) - ), - Style::default().fg(palette::TEXT_MUTED), + right_spans.push(Span::styled( + format!("{}% ", app.last_transcript_top + 1), + Style::default().fg(palette::TEXT_DIM), )); } - + + // Add selection hint if active if app.transcript_selection.is_active() { - spans.push(Span::raw(" | ")); - spans.push(Span::styled( + right_spans.push(Span::styled( copy_selection_hint(), + Style::default().fg(palette::TEXT_DIM), + )); + right_spans.push(Span::raw(" ")); + } + + // Add RLM usage badge when in RLM mode + if app.mode == AppMode::Rlm { + if let Some((badge, style)) = rlm_usage_badge(app) { + right_spans.push(Span::styled(badge, style)); + right_spans.push(Span::styled( + " · ", + Style::default().fg(palette::TEXT_DIM), + )); + } + } + + // Add key hints + right_spans.extend(footer_key_hints(width, app)); + + // Calculate right side width + let spans_width = |spans: &[Span]| -> usize { + spans.iter().map(|s| s.content.width()).sum() + }; + let mut right_width = spans_width(&right_spans); + + // Determine layout based on available space + let left_spans: Vec; + let left_width: usize; + + if width >= 80 { + // Wide: show full context bar + left_spans = vec![mode_badge, Span::raw(" "), context_info]; + left_width = left_min_width; + } else if width >= 50 { + // Medium: show percentage only + let percent = get_context_percent(app); + let short_context = format!("{}%", percent); + let short_width = mode_width + 1 + short_context.width(); + left_spans = vec![mode_badge, Span::raw(" "), Span::styled( + short_context, Style::default().fg(palette::TEXT_MUTED), - )); + )]; + left_width = short_width; + } else { + // Narrow: just mode badge + left_spans = vec![mode_badge]; + left_width = mode_width; } - spans.extend(footer_key_hints(area.width, app)); - + // Trim right side if it doesn't fit + let available_for_right = available.saturating_sub(left_width); + if right_width > available_for_right { + while right_width > available_for_right && !right_spans.is_empty() { + if let Some(span) = right_spans.pop() { + right_width = right_width.saturating_sub(span.content.width()); + } + } + while let Some(last) = right_spans.last() { + let content = last.content.as_ref(); + if content.trim().is_empty() || content == " · " { + let span = right_spans.pop().unwrap(); + right_width = right_width.saturating_sub(span.content.width()); + } else { + break; + } + } + } + let mid_spacing = available.saturating_sub(left_width + right_width); + + // Combine all spans + let mut all_spans = left_spans; + + // Add spacing between left and right + if mid_spacing > 0 { + all_spans.push(Span::raw(" ".repeat(mid_spacing))); + } + + // Add right side spans + all_spans.extend(right_spans); + + // Add status message if present (replaces everything) if let Some(ref msg) = app.status_message { - spans.push(Span::raw(" | ")); - spans.push(Span::styled( - msg, - Style::default().fg(palette::DEEPSEEK_SKY), - )); + let status_span = Span::styled(msg, Style::default().fg(palette::DEEPSEEK_SKY)); + all_spans = vec![status_span]; } - - let footer = Paragraph::new(Line::from(spans)); + + let footer = Paragraph::new(Line::from(all_spans)); f.render_widget(footer, area); } +/// Get context usage percentage for compact display +fn get_context_percent(app: &App) -> u8 { + let used = if app.total_conversation_tokens > 0 { + Some(i64::from(app.total_conversation_tokens)) + } else { + estimated_context_tokens(app) + }; + + if let Some(max) = context_window_for_model(&app.model) { + if let Some(used) = used { + let max_i64 = i64::from(max); + let remaining = (max_i64 - used).max(0); + let percent_remaining = ((remaining.saturating_mul(100) + max_i64 / 2) / max_i64).clamp(0, 100); + 100 - percent_remaining as u8 + } else { + 0 + } + } else { + 0 + } +} + fn footer_key_hints(width: u16, app: &App) -> Vec> { let mut hints = vec!["F1 help", "Ctrl+R sessions", "l pager", "Ctrl+V paste"]; if app.transcript_selection.is_active() { hints.push("Enter preview"); } - let max_hints = if width < 70 { + let max_hints = if width < 60 { 1 - } else if width < 100 { + } else if width < 90 { 2 - } else if width < 130 { + } else if width < 120 { 3 } else { hints.len() }; let mut spans = Vec::new(); - for hint in hints.into_iter().take(max_hints) { - spans.push(Span::raw(" | ")); + for (idx, hint) in hints.into_iter().take(max_hints).enumerate() { + if idx > 0 { + spans.push(Span::styled(" · ", Style::default().fg(palette::TEXT_DIM))); + } spans.push(Span::styled( hint.to_string(), Style::default().fg(palette::TEXT_DIM), @@ -2180,12 +2295,12 @@ fn rlm_usage_badge(app: &App) -> Option<(String, Style)> { fn mode_color(mode: AppMode) -> Color { match mode { - AppMode::Normal => palette::DEEPSEEK_SLATE, - AppMode::Agent => palette::DEEPSEEK_BLUE, - AppMode::Yolo => palette::DEEPSEEK_SKY, - AppMode::Plan => palette::DEEPSEEK_SKY, - AppMode::Rlm => palette::DEEPSEEK_INK, - AppMode::Duo => palette::DEEPSEEK_NAVY, + AppMode::Normal => palette::MODE_NORMAL, + AppMode::Agent => palette::MODE_AGENT, + AppMode::Yolo => palette::MODE_YOLO, + AppMode::Plan => palette::MODE_PLAN, + AppMode::Rlm => palette::MODE_RLM, + AppMode::Duo => palette::MODE_DUO, } } @@ -2213,6 +2328,17 @@ fn prompt_for_mode(mode: AppMode, rlm_repl_active: bool) -> &'static str { } } +fn render_context_bar(percentage: u8, width: usize) -> String { + let filled = (percentage as usize * width / 100).min(width); + let empty = width - filled; + format!( + "[{}{}] {}%", + "█".repeat(filled), + "░".repeat(empty), + percentage + ) +} + fn context_indicator(app: &App) -> String { let used = if app.total_conversation_tokens > 0 { Some(i64::from(app.total_conversation_tokens)) @@ -2224,15 +2350,16 @@ fn context_indicator(app: &App) -> String { if let Some(used) = used { let max_i64 = i64::from(max); let remaining = (max_i64 - used).max(0); - let percent = ((remaining.saturating_mul(100) + max_i64 / 2) / max_i64).clamp(0, 100); - format!("{percent}% context left") + let percent_remaining = ((remaining.saturating_mul(100) + max_i64 / 2) / max_i64).clamp(0, 100); + let percent_used = (100 - percent_remaining) as u8; + render_context_bar(percent_used, 10) } else { - "100% context left".to_string() + render_context_bar(0, 10) } } else if let Some(used) = used { format!("{used} used") } else { - "100% context left".to_string() + render_context_bar(0, 10) } } @@ -2269,6 +2396,16 @@ fn deepseek_squiggle(start: Option) -> &'static str { FRAMES[idx] } +/// Braille pattern frames for typing/thinking indicator animation. +const TYPING_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +/// Returns the typing indicator frame based on elapsed time. +fn typing_indicator(start: Option) -> &'static str { + let elapsed_ms = start.map_or(0, |t| t.elapsed().as_millis()); + let idx = ((elapsed_ms / 80) as usize) % TYPING_FRAMES.len(); + TYPING_FRAMES[idx] +} + fn deepseek_thinking_label(start: Option) -> &'static str { const TAGLINES: [&str; 5] = [ "Thinking", diff --git a/src/tui/views/mod.rs b/src/tui/views/mod.rs index 4be19117..5b2c75c4 100644 --- a/src/tui/views/mod.rs +++ b/src/tui/views/mod.rs @@ -202,6 +202,75 @@ impl ModalView for HelpView { Style::default().fg(palette::DEEPSEEK_BLUE).bold(), )]), Line::from(""), + Line::from(vec![Span::styled( + "=== Navigation ===", + Style::default().fg(palette::DEEPSEEK_SKY).bold(), + )]), + Line::from(" Up / Down - Scroll transcript (or navigate history)"), + Line::from(" Ctrl+Up / Ctrl+Down - Navigate input history"), + Line::from(" Alt+Up / Alt+Down - Scroll transcript"), + Line::from(" PageUp / PageDown - Scroll transcript by page"), + Line::from(" Home / End - Jump to top / bottom of transcript"), + Line::from(""), + Line::from(vec![Span::styled( + "=== Input Editing ===", + Style::default().fg(palette::DEEPSEEK_SKY).bold(), + )]), + Line::from(" Left / Right - Move cursor"), + Line::from(" Ctrl+A / Ctrl+E - Jump to start / end of line"), + Line::from(" Backspace / Delete - Delete character before / after cursor"), + Line::from(" Ctrl+U - Clear entire input line"), + Line::from(""), + Line::from(vec![Span::styled( + "=== Multi-line Input ===", + Style::default().fg(palette::DEEPSEEK_SKY).bold(), + )]), + Line::from(" Ctrl+J / Alt+Enter - Insert newline (without submitting)"), + Line::from(""), + Line::from(vec![Span::styled( + "=== Actions ===", + Style::default().fg(palette::DEEPSEEK_SKY).bold(), + )]), + Line::from(" Enter - Submit message"), + Line::from(" Esc - Cancel request / clear input"), + Line::from(" Ctrl+C - Cancel request or exit application"), + Line::from(" Ctrl+D - Exit when input is empty"), + Line::from(" l - Open pager for last message (when input empty)"), + Line::from(" Enter (selection) - Open pager for selected text"), + Line::from(""), + Line::from(vec![Span::styled( + "=== Modes ===", + Style::default().fg(palette::DEEPSEEK_SKY).bold(), + )]), + Line::from(" Tab - Cycle through modes"), + Line::from(" Ctrl+X - Toggle between Agent and Normal modes"), + Line::from(""), + Line::from(vec![Span::styled( + "=== Sessions ===", + Style::default().fg(palette::DEEPSEEK_SKY).bold(), + )]), + Line::from(" Ctrl+R - Open session picker"), + Line::from(""), + Line::from(vec![Span::styled( + "=== Clipboard ===", + Style::default().fg(palette::DEEPSEEK_SKY).bold(), + )]), + Line::from(" Ctrl+V - Paste from clipboard (Cmd+V on macOS)"), + Line::from(" Ctrl+Shift+C - Copy selection (Cmd+C on macOS)"), + Line::from(""), + Line::from(vec![Span::styled( + "=== Help ===", + Style::default().fg(palette::DEEPSEEK_SKY).bold(), + )]), + Line::from(" F1 / Ctrl+/ - Toggle this help view"), + Line::from(""), + Line::from(vec![Span::styled( + "=== Mouse ===", + Style::default().fg(palette::DEEPSEEK_SKY).bold(), + )]), + Line::from(" Scroll wheel - Scroll transcript"), + Line::from(" Drag to select - Select text (auto-copies on release)"), + Line::from(""), Line::from(vec![Span::styled( "Modes:", Style::default().fg(palette::DEEPSEEK_SKY).bold(), @@ -226,12 +295,12 @@ impl ModalView for HelpView { "RLM / Aleph:", Style::default().fg(palette::DEEPSEEK_SKY).bold(), )])); - help_lines.push(Line::from(" /rlm or /aleph - enter external memory mode")); + help_lines.push(Line::from(" /rlm or /aleph - Enter external memory mode")); help_lines.push(Line::from( - " /load @path - load a file into RLM context", + " /load @path - Load a file into RLM context", )); - help_lines.push(Line::from(" /repl - toggle expression mode")); - help_lines.push(Line::from(" /status - show contexts and usage")); + help_lines.push(Line::from(" /repl - Toggle expression mode")); + help_lines.push(Line::from(" /status - Show contexts and usage")); help_lines.push(Line::from("")); help_lines.push(Line::from(vec![Span::styled( @@ -243,34 +312,6 @@ impl ModalView for HelpView { )); help_lines.push(Line::from(" mcp_* - Tools exposed by MCP servers")); help_lines.push(Line::from("")); - help_lines.push(Line::from(vec![Span::styled( - "Keyboard Shortcuts:", - Style::default().fg(palette::DEEPSEEK_SKY).bold(), - )])); - help_lines.push(Line::from(" Enter - Send message")); - help_lines.push(Line::from(" Esc - Cancel request / clear input")); - help_lines.push(Line::from(" Ctrl+C - Exit")); - help_lines.push(Line::from(" Ctrl+U - Clear input line")); - help_lines.push(Line::from(" Ctrl+A / E - Move to start/end of line")); - help_lines.push(Line::from(" Alt+Up/Down - Scroll transcript")); - help_lines.push(Line::from(" Ctrl+R - Session picker")); - help_lines.push(Line::from(" l - Pager for last message")); - help_lines.push(Line::from(" Enter (sel) - Pager for selection")); - help_lines.push(Line::from( - " Ctrl+Shift+C - Copy selection (Cmd+C on macOS)", - )); - help_lines.push(Line::from(" Ctrl+V - Paste (Cmd+V on macOS)")); - help_lines.push(Line::from(" F1 - Toggle this help")); - help_lines.push(Line::from("")); - help_lines.push(Line::from(vec![Span::styled( - "Mouse:", - Style::default().fg(palette::DEEPSEEK_SKY).bold(), - )])); - help_lines.push(Line::from(" Scroll wheel - Scroll transcript")); - help_lines.push(Line::from( - " Drag select - Select text (auto‑copies on release)", - )); - help_lines.push(Line::from("")); let total_lines = help_lines.len(); let visible_lines = (popup_height as usize).saturating_sub(3); diff --git a/src/tui/widgets/header.rs b/src/tui/widgets/header.rs index a41aff45..2793322c 100644 --- a/src/tui/widgets/header.rs +++ b/src/tui/widgets/header.rs @@ -17,7 +17,9 @@ use super::Renderable; /// Data required to render the header bar. pub struct HeaderData<'a> { pub model: &'a str, + pub mode: AppMode, pub is_streaming: bool, + pub context_percent: Option, pub background: ratatui::style::Color, } @@ -25,15 +27,24 @@ impl<'a> HeaderData<'a> { /// Create header data from common app fields. #[must_use] pub fn new( - _mode: AppMode, + mode: AppMode, model: &'a str, - _context_used: u32, + context_used: u32, is_streaming: bool, background: ratatui::style::Color, ) -> Self { + // Calculate context percentage + let context_percent = crate::models::context_window_for_model(model).map(|max| { + let max_u32 = max; + let pct = (context_used * 100 / max_u32.max(1)).min(100) as u8; + pct + }); + Self { model, + mode, is_streaming, + context_percent, background, } } @@ -41,7 +52,7 @@ impl<'a> HeaderData<'a> { /// Header bar widget (1 line height). /// -/// Layout: `[MODE] | model-name | Context: XX% | [streaming indicator]` +/// Layout: `[MODE] model-name | Context: XX% [streaming indicator]` pub struct HeaderWidget<'a> { data: HeaderData<'a>, } @@ -52,11 +63,35 @@ impl<'a> HeaderWidget<'a> { Self { data } } + /// Get the color for a mode. + fn mode_color(mode: AppMode) -> ratatui::style::Color { + match mode { + AppMode::Normal => palette::MODE_NORMAL, + AppMode::Agent => palette::MODE_AGENT, + AppMode::Yolo => palette::MODE_YOLO, + AppMode::Plan => palette::MODE_PLAN, + AppMode::Rlm => palette::MODE_RLM, + AppMode::Duo => palette::MODE_DUO, + } + } + + /// Build the mode badge span. + fn mode_badge(&self) -> Span<'static> { + let label = self.data.mode.label(); + let color = Self::mode_color(self.data.mode); + Span::styled( + format!("[{label}]"), + Style::default() + .fg(color) + .add_modifier(Modifier::BOLD), + ) + } + /// Build the model name span. fn model_span(&self) -> Span<'static> { // Truncate long model names - let display_name = if self.data.model.len() > 20 { - format!("{}...", &self.data.model[..17]) + let display_name = if self.data.model.len() > 25 { + format!("{}...", &self.data.model[..22]) } else { self.data.model.to_string() }; @@ -64,6 +99,23 @@ impl<'a> HeaderWidget<'a> { Span::styled(display_name, Style::default().fg(palette::TEXT_MUTED)) } + /// Build the context usage span. + fn context_span(&self) -> Option> { + let pct = self.data.context_percent?; + let color = if pct < 50 { + palette::TEXT_DIM + } else if pct < 80 { + palette::STATUS_WARNING + } else { + palette::STATUS_ERROR + }; + + Some(Span::styled( + format!(" {pct}% "), + Style::default().fg(color), + )) + } + /// Build the streaming indicator span. fn streaming_indicator(&self) -> Option> { if !self.data.is_streaming { @@ -71,7 +123,7 @@ impl<'a> HeaderWidget<'a> { } Some(Span::styled( - " streaming... ", + "●", Style::default() .fg(palette::DEEPSEEK_SKY) .add_modifier(Modifier::BOLD), @@ -85,42 +137,70 @@ impl Renderable for HeaderWidget<'_> { return; } - // Build left section: model name only (Mode is in footer) - let mut left_spans = vec![self.model_span()]; + // Build left section: mode badge + model name + let mode_span = self.mode_badge(); + let model_span = self.model_span(); - // Build right section: streaming indicator + // Build right section: context percentage + streaming indicator + let context_span = self.context_span(); let streaming_span = self.streaming_indicator(); // Calculate widths - let left_width: usize = left_spans.iter().map(|s| s.content.width()).sum(); - let right_width = streaming_span.as_ref().map_or(0, |s| s.content.width()); + let mode_width = mode_span.content.width(); + let model_width = model_span.content.width(); + let context_width = context_span.as_ref().map_or(0, |s| s.content.width()); + let streaming_width = streaming_span.as_ref().map_or(0, |s| s.content.width()); + + let left_width = mode_width + 1 + model_width; // mode + space + model + let right_width = context_width + streaming_width; - let total_content = left_width + right_width + 2; // + padding let available = area.width as usize; // Build final line based on available space let mut spans = Vec::new(); - if available >= total_content { - // Full layout: left | (spacer) | right - spans.append(&mut left_spans); + if available >= left_width + right_width + 2 { + // Full layout: [MODE] model | (spacer) | context streaming + spans.push(mode_span); + spans.push(Span::raw(" ")); + spans.push(model_span); - // Spacer + // Spacer to push right elements to the end let padding_needed = available.saturating_sub(left_width + right_width); if padding_needed > 0 { spans.push(Span::raw(" ".repeat(padding_needed))); } - // Add streaming on right + // Add context percentage + if let Some(context) = context_span { + spans.push(context); + } + + // Add streaming indicator if let Some(streaming) = streaming_span { spans.push(streaming); } - } else if available >= left_width { - // Minimal: just model - spans.append(&mut left_spans); + } else if available >= mode_width + 1 + model_width.min(10) { + // Compact layout: [MODE] truncated_model + spans.push(mode_span); + spans.push(Span::raw(" ")); + // Truncate model if needed + let model_str = self.data.model; + let display_model = if model_str.len() > 10 { + format!("{}...", &model_str[..7]) + } else { + model_str.to_string() + }; + spans.push(Span::styled(display_model, Style::default().fg(palette::TEXT_MUTED))); + } else if available >= mode_width { + // Minimal: just mode badge + spans.push(mode_span); } else { - // Ultra-minimal: just model - spans.push(self.model_span()); + // Ultra-minimal: truncated mode + spans.push(Span::styled( + &self.data.mode.label()[..1.min(self.data.mode.label().len())], + Style::default().fg(Self::mode_color(self.data.mode)), + )); } let line = Line::from(spans);