Polish TUI UX and fix shortcuts

This commit is contained in:
Hunter Bown
2026-01-28 09:02:49 -06:00
parent a5c02c0eb4
commit cbe2a30ea4
8 changed files with 485 additions and 144 deletions
+54 -1
View File
@@ -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());
}
}
+10
View File
@@ -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;
+1 -2
View File
@@ -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!(
+16 -4
View File
@@ -52,8 +52,18 @@ impl HistoryCell {
pub fn lines(&self, width: u16) -> Vec<Line<'static>> {
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 {
+11 -2
View File
@@ -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);
}
}
+218 -81
View File
@@ -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<Span>;
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<Span<'static>> {
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<Instant>) -> &'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<Instant>) -> &'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<Instant>) -> &'static str {
const TAGLINES: [&str; 5] = [
"Thinking",
+73 -32
View File
@@ -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 (autocopies on release)",
));
help_lines.push(Line::from(""));
let total_lines = help_lines.len();
let visible_lines = (popup_height as usize).saturating_sub(3);
+102 -22
View File
@@ -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<u8>,
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<Span<'static>> {
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<Span<'static>> {
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);