Compare commits

...
6 Commits
Author SHA1 Message Date
Hunter Bown 3c3c3f49ce feat: polish TUI with Kimi-style footer, streaming thinking blocks, and cleanup
- Redesign footer: live clock, lowercase mode badges, color-coded context %
- Stream thinking/reasoning blocks in real-time with sidebar style and cursor
- Replace ThinkingSummary with richer Thinking variant in history cells
- Remove dead code (unused footer helpers, context bar, copy hint)
- Bump version to 0.3.3
2026-01-29 11:27:38 -06:00
Hunter Bown a7c5ef9374 Fix formatting 2026-01-28 10:05:25 -06:00
Hunter Bown 55148906f2 Fix clippy warnings
- Remove unnecessary to_string() in client.rs
- Derive Default for SubAgentType instead of manual impl
- Remove redundant let binding in header.rs
2026-01-28 10:01:50 -06:00
Hunter Bown df45aece8f Apply cargo fmt formatting fixes 2026-01-28 09:59:35 -06:00
Hunter Bown 32443ae91b Fix CI: Remove redundant toolchain input from dtolnay/rust-toolchain@stable
The dtolnay/rust-toolchain action already specifies the toolchain version
in the action reference (@stable). Adding toolchain: stable as an input
was causing 'toolchain is a required input' errors.

Fixed in:
- ci.yml
- release.yml
- crates-publish.yml
2026-01-28 09:58:28 -06:00
Hunter Bown 4ae8c8a3fa Fix CI toolchain inputs and normalize working set paths 2026-01-28 09:56:34 -06:00
14 changed files with 264 additions and 256 deletions

No files matched your search

+1
View File
@@ -50,6 +50,7 @@ TODO*.md
todo*.md
CLAUDE.md
NEXT_SESSION.md
AI_HANDOFF.md
.codex/
docs/rlm-paper.txt
Generated
+1 -1
View File
@@ -646,7 +646,7 @@ dependencies = [
[[package]]
name = "deepseek-tui"
version = "0.3.2"
version = "0.3.3"
dependencies = [
"anyhow",
"arboard",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deepseek-tui"
version = "0.3.2"
version = "0.3.3"
edition = "2024"
description = "Unofficial DeepSeek CLI - Just run 'deepseek' to start chatting"
license = "MIT"
+17 -12
View File
@@ -467,11 +467,14 @@ fn build_chat_messages(
tool_use_id,
content,
} => {
tool_results.push((tool_use_id.clone(), json!({
"role": "tool",
"tool_call_id": tool_use_id,
"content": content,
})));
tool_results.push((
tool_use_id.clone(),
json!({
"role": "tool",
"tool_call_id": tool_use_id,
"content": content,
}),
));
}
}
}
@@ -504,7 +507,7 @@ fn build_chat_messages(
if !tool_results.is_empty() {
if pending_tool_calls.is_empty() {
logging::warn("Dropping tool results without matching tool_calls".to_string());
logging::warn("Dropping tool results without matching tool_calls");
} else {
for (tool_id, tool_msg) in tool_results {
if pending_tool_calls.remove(&tool_id) {
@@ -853,9 +856,10 @@ mod tests {
}];
let out = build_chat_messages(None, &messages, "deepseek-chat");
assert!(!out.iter().any(|value| {
value.get("role").and_then(Value::as_str) == Some("tool")
}));
assert!(
!out.iter()
.any(|value| { value.get("role").and_then(Value::as_str) == Some("tool") })
);
}
#[test]
@@ -879,9 +883,10 @@ mod tests {
];
let out = build_chat_messages(None, &messages, "deepseek-chat");
assert!(out.iter().any(|value| {
value.get("role").and_then(Value::as_str) == Some("tool")
}));
assert!(
out.iter()
.any(|value| { value.get("role").and_then(Value::as_str) == Some("tool") })
);
let assistant = out
.iter()
.find(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
+1 -1
View File
@@ -158,7 +158,7 @@ pub fn export(app: &mut App, path: Option<&str>) -> CommandResult {
HistoryCell::User { content } => ("**You:**", content.clone()),
HistoryCell::Assistant { content, .. } => ("**Assistant:**", content.clone()),
HistoryCell::System { content } => ("*System:*", content.clone()),
HistoryCell::ThinkingSummary { summary } => ("*Thinking:*", summary.clone()),
HistoryCell::Thinking { content, .. } => ("*Thinking:*", content.clone()),
HistoryCell::Tool(tool) => ("**Tool:**", render_tool_cell(tool, 80)),
};
+26 -2
View File
@@ -351,11 +351,20 @@ fn enforce_tool_call_pairs(messages: &[Message], pinned_indices: &mut BTreeSet<u
return;
}
// Build maps for tool calls and results
let mut tool_call_indices: HashMap<String, usize> = HashMap::new();
let mut tool_result_indices: HashMap<String, usize> = HashMap::new();
for (idx, msg) in messages.iter().enumerate() {
for block in &msg.content {
if let ContentBlock::ToolUse { id, .. } = block {
tool_call_indices.insert(id.clone(), idx);
match block {
ContentBlock::ToolUse { id, .. } => {
tool_call_indices.insert(id.clone(), idx);
}
ContentBlock::ToolResult { tool_use_id, .. } => {
tool_result_indices.insert(tool_use_id.clone(), idx);
}
_ => {}
}
}
}
@@ -363,6 +372,8 @@ fn enforce_tool_call_pairs(messages: &[Message], pinned_indices: &mut BTreeSet<u
let mut to_add = Vec::new();
let mut to_remove = Vec::new();
// Pass 1: If a tool result is pinned, ensure its tool call is also pinned.
// If the tool call is not found, remove the orphaned result.
for &idx in pinned_indices.iter() {
let msg = &messages[idx];
let mut tool_ids = Vec::new();
@@ -387,6 +398,19 @@ fn enforce_tool_call_pairs(messages: &[Message], pinned_indices: &mut BTreeSet<u
}
}
// Pass 2: If a tool call is pinned, ensure its tool result is also pinned.
// This prevents "orphaned tool calls" API errors.
for &idx in pinned_indices.iter() {
let msg = &messages[idx];
for block in &msg.content {
if let ContentBlock::ToolUse { id, .. } = block {
if let Some(result_idx) = tool_result_indices.get(id).copied() {
to_add.push(result_idx);
}
}
}
}
for idx in to_add {
pinned_indices.insert(idx);
}
+25 -11
View File
@@ -608,9 +608,8 @@ fn ensure_parent_dir(path: &Path) -> Result<()> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).with_context(|| {
format!("Failed to create directory for {}", parent.display())
})?;
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory for {}", parent.display()))?;
}
Ok(())
}
@@ -701,9 +700,7 @@ fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> {
println!(
"{}",
"DeepSeek Setup"
.truecolor(aqua_r, aqua_g, aqua_b)
.bold()
"DeepSeek Setup".truecolor(aqua_r, aqua_g, aqua_b).bold()
);
println!("{}", "==============".truecolor(sky_r, sky_g, sky_b));
println!("Workspace: {}", workspace.display());
@@ -740,7 +737,10 @@ fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> {
println!(" ✓ Overwrote example skill at {}", skill_path.display());
}
WriteStatus::SkippedExists => {
println!(" · Example skill already exists at {}", skill_path.display());
println!(
" · Example skill already exists at {}",
skill_path.display()
);
}
}
if args.local {
@@ -795,7 +795,11 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
dirs::home_dir().map_or_else(|| PathBuf::from(".deepseek"), |h| h.join(".deepseek"));
let config_path = config_path_override
.map(PathBuf::from)
.or_else(|| std::env::var("DEEPSEEK_CONFIG_PATH").ok().map(PathBuf::from))
.or_else(|| {
std::env::var("DEEPSEEK_CONFIG_PATH")
.ok()
.map(PathBuf::from)
})
.unwrap_or_else(|| default_config_dir.join("config.toml"));
if config_path.exists() {
@@ -885,9 +889,15 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
println!("{}", "MCP Servers:".bold());
let features = config.features();
if features.enabled(Feature::Mcp) {
println!(" {} MCP feature flag enabled", "✓".truecolor(aqua_r, aqua_g, aqua_b));
println!(
" {} MCP feature flag enabled",
"✓".truecolor(aqua_r, aqua_g, aqua_b)
);
} else {
println!(" {} MCP feature flag disabled", "!".truecolor(sky_r, sky_g, sky_b));
println!(
" {} MCP feature flag disabled",
"!".truecolor(sky_r, sky_g, sky_b)
);
}
let mcp_config_path = config.mcp_config_path();
@@ -975,7 +985,11 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
);
}
println!(" {} selected skills dir: {}", "·".dimmed(), selected_skills_dir.display());
println!(
" {} selected skills dir: {}",
"·".dimmed(),
selected_skills_dir.display()
);
if !local_skills_dir.exists() && !global_skills_dir.exists() {
println!(" Run `deepseek setup --skills` (or add --local for ./skills).");
}
+5 -5
View File
@@ -53,11 +53,11 @@ 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 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;
+2 -7
View File
@@ -39,10 +39,11 @@ const RESULT_POLL_INTERVAL: Duration = Duration::from_millis(250);
// === Types ===
/// Sub-agent execution types with specialized behavior and tool access.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "snake_case")]
pub enum SubAgentType {
/// General purpose - full tool access for multi-step tasks.
#[default]
General,
/// Fast exploration - read-only tools for codebase search.
Explore,
@@ -127,12 +128,6 @@ impl SubAgentType {
}
}
impl Default for SubAgentType {
fn default() -> Self {
Self::General
}
}
/// Status of a sub-agent execution.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum SubAgentStatus {
+1 -4
View File
@@ -634,10 +634,7 @@ fn validate_swarm_tasks(tasks: &[SwarmTaskSpec]) -> Result<(), ToolError> {
)));
}
if matches!(task.agent_type, Some(SubAgentType::Custom)) {
let tools = task
.allowed_tools
.as_deref()
.unwrap_or(&[]);
let tools = task.allowed_tools.as_deref().unwrap_or(&[]);
if tools.is_empty() {
return Err(ToolError::invalid_input(format!(
"task '{id}' requires allowed_tools for custom type"
+39 -7
View File
@@ -28,7 +28,7 @@ pub enum HistoryCell {
User { content: String },
Assistant { content: String, streaming: bool },
System { content: String },
ThinkingSummary { summary: String },
Thinking { content: String, streaming: bool },
Tool(ToolCell),
}
@@ -68,8 +68,17 @@ impl HistoryCell {
HistoryCell::System { content } => {
render_message("System", content, system_style(), width)
}
HistoryCell::ThinkingSummary { summary } => {
render_message("Thinking", summary, thinking_style(), width)
HistoryCell::Thinking { content, streaming } => {
let mut lines = render_thinking(content, width);
if *streaming {
if let Some(last) = lines.last_mut() {
last.spans.push(Span::styled(
"▋",
Style::default().fg(palette::DEEPSEEK_SKY),
));
}
}
lines
}
HistoryCell::Tool(cell) => cell.lines(width),
}
@@ -81,7 +90,7 @@ impl HistoryCell {
options: TranscriptRenderOptions,
) -> Vec<Line<'static>> {
match self {
HistoryCell::ThinkingSummary { .. } if !options.show_thinking => Vec::new(),
HistoryCell::Thinking { .. } if !options.show_thinking => Vec::new(),
HistoryCell::Tool(cell) if !options.show_tool_details => {
let mut lines = cell.lines(width);
if lines.len() > 2 {
@@ -140,9 +149,10 @@ pub fn history_cells_from_message(msg: &Message) -> Vec<HistoryCell> {
if !thinking_blocks.is_empty() {
let reasoning = thinking_blocks.join("\n");
if let Some(summary) = extract_reasoning_summary(&reasoning) {
cells.push(HistoryCell::ThinkingSummary { summary });
}
cells.push(HistoryCell::Thinking {
content: reasoning,
streaming: false,
});
}
cells
@@ -958,6 +968,7 @@ pub fn output_is_image(output: &str) -> bool {
.any(|ext| lower.contains(ext))
}
#[cfg(test)]
#[must_use]
pub fn extract_reasoning_summary(text: &str) -> Option<String> {
let mut lines = text.lines().peekable();
@@ -999,6 +1010,27 @@ pub fn extract_reasoning_summary(text: &str) -> Option<String> {
}
}
fn render_thinking(content: &str, width: u16) -> Vec<Line<'static>> {
let style = thinking_style();
let prefix = "│ ";
let content_width = usize::from(width.saturating_sub(2).max(1));
let rendered = markdown_render::render_markdown(content, content_width as u16, style);
let mut lines = Vec::new();
for line in rendered {
let mut spans = vec![Span::styled(prefix, style)];
spans.extend(line.spans);
lines.push(Line::from(spans));
}
if lines.is_empty() {
lines.push(Line::from(vec![Span::styled(prefix, style)]));
}
lines
}
fn render_message(prefix: &str, content: &str, style: Style, width: u16) -> Vec<Line<'static>> {
let prefix_width = UnicodeWidthStr::width(prefix);
let prefix_width_u16 = u16::try_from(prefix_width.saturating_add(2)).unwrap_or(u16::MAX);
+138 -198
View File
@@ -7,6 +7,7 @@ use std::path::{Path, PathBuf};
use std::time::Instant;
use anyhow::Result;
use chrono::Local;
use crossterm::{
event::{
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
@@ -59,8 +60,8 @@ use super::approval::{
use super::history::{
DiffPreviewCell, ExecCell, ExecSource, ExploringCell, ExploringEntry, GenericToolCell,
HistoryCell, McpToolCell, PatchSummaryCell, PlanStep, PlanUpdateCell, ReviewCell, ToolCell,
ToolStatus, ViewImageCell, WebSearchCell, extract_reasoning_summary,
history_cells_from_message, summarize_mcp_output, summarize_tool_args, summarize_tool_output,
ToolStatus, ViewImageCell, WebSearchCell, history_cells_from_message, summarize_mcp_output,
summarize_tool_args, summarize_tool_output,
};
use super::views::{HelpView, ModalKind, ViewEvent};
use super::widgets::{ChatWidget, ComposerWidget, HeaderData, HeaderWidget, Renderable};
@@ -320,17 +321,36 @@ async fn run_event_loop(
EngineEvent::ThinkingStarted { .. } => {
app.reasoning_buffer.clear();
app.reasoning_header = None;
app.add_message(HistoryCell::Thinking {
content: String::new(),
streaming: true,
});
app.streaming_message_index = Some(app.history.len().saturating_sub(1));
}
EngineEvent::ThinkingDelta { content, .. } => {
app.reasoning_buffer.push_str(&content);
if app.reasoning_header.is_none() {
app.reasoning_header = extract_reasoning_header(&app.reasoning_buffer);
}
if let Some(index) = app.streaming_message_index {
if let Some(HistoryCell::Thinking { content: c, .. }) =
app.history.get_mut(index)
{
c.push_str(&content);
}
}
}
EngineEvent::ThinkingComplete { .. } => {
if let Some(summary) = extract_reasoning_summary(&app.reasoning_buffer) {
app.add_message(HistoryCell::ThinkingSummary { summary });
if let Some(index) = app.streaming_message_index.take() {
if let Some(HistoryCell::Thinking { streaming, .. }) =
app.history.get_mut(index)
{
*streaming = false;
}
}
if !app.reasoning_buffer.is_empty() {
app.last_reasoning = Some(app.reasoning_buffer.clone());
}
@@ -2084,182 +2104,141 @@ fn render_status_indicator(f: &mut Frame, area: Rect, app: &App, queued: &[Strin
fn render_footer(f: &mut Frame, area: Rect, app: &App) {
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 && !matches!(app.transcript_scroll, TranscriptScroll::ToBottom) {
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() {
right_spans.push(Span::styled(
copy_selection_hint(),
Style::default().fg(palette::TEXT_DIM),
));
right_spans.push(Span::raw(" "));
}
let available_width = width as usize;
// 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;
}
// 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)
// Status message override (Toast)
if let Some(ref msg) = app.status_message {
let status_span = Span::styled(msg, Style::default().fg(palette::DEEPSEEK_SKY));
all_spans = vec![status_span];
f.render_widget(Paragraph::new(Line::from(vec![status_span])), area);
return;
}
// 1. Time (Left)
let time_str = Local::now().format("%H:%M").to_string();
let time_span = Span::styled(
format!("{} ", time_str),
Style::default().fg(palette::TEXT_DIM),
);
// 2. Mode (Left) - Lowercase, colored
let mode_str = app.mode.label().to_lowercase();
let mode_style = mode_badge_style(app.mode);
let mode_span = Span::styled(format!("{} ", mode_str), mode_style);
// 3. Agent Info (Left)
let model = &app.model;
let status_suffix = if app.is_loading { ", thinking" } else { "" };
let agent_text = format!("agent ({}{})", model, status_suffix);
let agent_span = Span::styled(agent_text, Style::default().fg(palette::TEXT_DIM));
// Left side assembly
let left_spans = vec![time_span, mode_span, agent_span];
// 4. Context (Right)
let percent = get_context_percent_decimal(app);
let context_text = format!("context: {:.1}%", percent);
let context_style = if percent > 90.0 {
Style::default().fg(palette::STATUS_ERROR)
} else if percent > 75.0 {
Style::default().fg(palette::STATUS_WARNING)
} else {
Style::default().fg(palette::TEXT_DIM)
};
let context_span = Span::styled(context_text, context_style);
// 5. Right side extras (Scroll, Selection, RLM) - Minimalist
let mut right_extras = Vec::new();
// Scroll %
let can_scroll = app.last_transcript_total > app.last_transcript_visible;
if can_scroll && !matches!(app.transcript_scroll, TranscriptScroll::ToBottom) {
right_extras.push(Span::styled(
format!(" {}% ", app.last_transcript_top + 1),
Style::default().fg(palette::TEXT_DIM),
));
}
// Selection
if app.transcript_selection.is_active() {
right_extras.push(Span::styled(
" [SEL] ",
Style::default().fg(palette::TEXT_DIM),
));
}
// RLM Badge
if app.mode == AppMode::Rlm {
if let Some((badge, style)) = rlm_usage_badge(app) {
right_extras.push(Span::styled(" ", Style::default()));
right_extras.push(Span::styled(badge, style));
}
}
// Assemble Right Side
// context_span is always last
let mut right_spans = right_extras;
right_spans.push(Span::raw(" ")); // Space before context
right_spans.push(context_span);
// Calculate Widths
let left_width: usize = left_spans.iter().map(|s| s.content.width()).sum();
let right_width: usize = right_spans.iter().map(|s| s.content.width()).sum();
// Spacer
let spacer_width = available_width.saturating_sub(left_width + right_width);
let mut all_spans = left_spans;
if spacer_width > 0 {
all_spans.push(Span::raw(" ".repeat(spacer_width)));
all_spans.extend(right_spans);
} else {
// Fallback for narrow screens: Drop agent info
let simple_left = vec![
Span::styled(
format!("{} ", time_str),
Style::default().fg(palette::TEXT_DIM),
),
Span::styled(format!("{} ", mode_str), mode_style),
];
let simple_right = vec![Span::styled(
format!(" {:.0}%", percent),
Style::default().fg(palette::TEXT_DIM),
)];
let sl_width: usize = simple_left.iter().map(|s| s.content.width()).sum();
let sr_width: usize = simple_right.iter().map(|s| s.content.width()).sum();
let sp_width = available_width.saturating_sub(sl_width + sr_width);
all_spans = simple_left;
all_spans.push(Span::raw(" ".repeat(sp_width)));
all_spans.extend(simple_right);
}
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 {
fn get_context_percent_decimal(app: &App) -> f32 {
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
let max_f64 = max as f64;
let used_f64 = used as f64;
let percent = (used_f64 / max_f64) * 100.0;
percent.clamp(0.0, 100.0) as f32
} else {
0
0.0
}
} else {
0
0.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 < 60 {
1
} else if width < 90 {
2
} else if width < 120 {
3
} else {
hints.len()
};
let mut spans = Vec::new();
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),
));
}
spans
}
fn rlm_usage_badge(app: &App) -> Option<(String, Style)> {
let session = app.rlm_session.lock().ok()?;
let usage = &session.usage;
@@ -2328,41 +2307,6 @@ 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))
} 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);
let percent_used = (100 - percent_remaining) as u8;
render_context_bar(percent_used, 10)
} else {
render_context_bar(0, 10)
}
} else if let Some(used) = used {
format!("{used} used")
} else {
render_context_bar(0, 10)
}
}
fn estimated_context_tokens(app: &App) -> Option<i64> {
let mut total_chars = estimate_message_chars(&app.api_messages);
@@ -2705,10 +2649,6 @@ fn is_paste_shortcut(key: &KeyEvent) -> bool {
key.modifiers.contains(KeyModifiers::CONTROL)
}
fn copy_selection_hint() -> &'static str {
"Release to copy selection"
}
fn should_scroll_with_arrows(_app: &App) -> bool {
false
}
+6 -6
View File
@@ -36,8 +36,7 @@ impl<'a> HeaderData<'a> {
// 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
(context_used * 100 / max_u32.max(1)).min(100) as u8
});
Self {
@@ -81,9 +80,7 @@ impl<'a> HeaderWidget<'a> {
let color = Self::mode_color(self.data.mode);
Span::styled(
format!("[{label}]"),
Style::default()
.fg(color)
.add_modifier(Modifier::BOLD),
Style::default().fg(color).add_modifier(Modifier::BOLD),
)
}
@@ -191,7 +188,10 @@ impl Renderable for HeaderWidget<'_> {
} else {
model_str.to_string()
};
spans.push(Span::styled(display_model, Style::default().fg(palette::TEXT_MUTED)));
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);
+1 -1
View File
@@ -389,7 +389,7 @@ fn clean_relative(path: &Path) -> PathBuf {
}
fn path_to_string(path: &Path) -> Option<String> {
path.as_os_str().to_str().map(ToOwned::to_owned)
path.as_os_str().to_str().map(|s| s.replace('\\', "/"))
}
fn extract_paths_from_message(message: &Message) -> Vec<String> {