Polish TUI UX and fix shortcuts
This commit is contained in:
+54
-1
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
+102
-22
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user