Footer polish: remove FOOTER_HINT, simplify footer rendering
- Remove FOOTER_HINT color constant from palette - Drop footer clock label and related synchronization logic - Simplify footer status line layout and narrow-terminal handling - Update tests to align with simplified footer logic - Remove empty state placeholder text for cleaner UI - Bump version to 0.3.33
This commit is contained in:
+14
-1
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.33] - 2026-04-11
|
||||
|
||||
### Changed
|
||||
- Footer polish: simplified footer rendering, removed footer clock label, updated status line layout
|
||||
- Palette cleanup: removed `FOOTER_HINT` color constant
|
||||
|
||||
### Removed
|
||||
- `FOOTER_HINT` color constant from palette (use `TEXT_MUTED` or `TEXT_HINT` instead)
|
||||
|
||||
### Fixed
|
||||
- Test updates to align with simplified footer logic
|
||||
- Empty state placeholder text removed for cleaner UI
|
||||
|
||||
## [0.3.32] - 2026-04-11
|
||||
|
||||
### Added
|
||||
@@ -460,4 +473,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
[0.1.7]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.1.6...v0.1.7
|
||||
[0.1.6]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.1.5...v0.1.6
|
||||
[0.1.5]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.1.0...v0.1.5
|
||||
[0.1.0]: https://github.com/Hmbown/DeepSeek-TUI/releases/tag/v0.1.0
|
||||
[0.1.0]: https://github.com/Hmbown/DeepSeek-TUI/releases/tag/v0.1.0
|
||||
Generated
+13
-13
@@ -806,7 +806,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-agent"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
dependencies = [
|
||||
"deepseek-config",
|
||||
"serde",
|
||||
@@ -814,7 +814,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-app-server"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -837,7 +837,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-config"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dirs",
|
||||
@@ -848,7 +848,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-core"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -867,7 +867,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-execpolicy"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deepseek-protocol",
|
||||
@@ -876,7 +876,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-hooks"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -890,7 +890,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-mcp"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deepseek-protocol",
|
||||
@@ -900,7 +900,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-protocol"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -908,7 +908,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-state"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -920,7 +920,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tools"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -933,7 +933,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -987,7 +987,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui-cli"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -1005,7 +1005,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui-core"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ default-members = ["crates/cli", "crates/app-server", "crates/tui"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/Hmbown/DeepSeek-TUI"
|
||||
|
||||
@@ -48,7 +48,6 @@ pub const TEXT_BODY: Color = Color::White;
|
||||
pub const TEXT_SECONDARY: Color = Color::Rgb(192, 192, 192); // #C0C0C0
|
||||
pub const TEXT_HINT: Color = Color::Rgb(160, 160, 160); // #A0A0A0
|
||||
pub const TEXT_ACCENT: Color = DEEPSEEK_SKY;
|
||||
pub const FOOTER_HINT: Color = Color::Rgb(180, 190, 208); // #B4BED0
|
||||
pub const SELECTION_TEXT: Color = Color::White;
|
||||
pub const TEXT_SOFT: Color = Color::Rgb(214, 223, 235); // #D6DFEB
|
||||
|
||||
|
||||
@@ -429,8 +429,6 @@ pub struct App {
|
||||
pub is_compacting: bool,
|
||||
/// Timestamp of the last user message send (for brief visual feedback).
|
||||
pub last_send_at: Option<Instant>,
|
||||
/// Cached footer clock label so idle sessions still repaint when the minute changes.
|
||||
pub footer_clock_label: String,
|
||||
}
|
||||
|
||||
/// Message queued while the engine is busy.
|
||||
@@ -682,7 +680,6 @@ impl App {
|
||||
thinking_started_at: None,
|
||||
is_compacting: false,
|
||||
last_send_at: None,
|
||||
footer_clock_label: chrono::Local::now().format("%H:%M").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+50
-408
@@ -7,7 +7,6 @@ use std::process::Command;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::Local;
|
||||
use crossterm::{
|
||||
event::{
|
||||
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
|
||||
@@ -87,21 +86,15 @@ use super::widgets::{
|
||||
|
||||
const SLASH_MENU_LIMIT: usize = 6;
|
||||
const MIN_CHAT_HEIGHT: u16 = 3;
|
||||
const MIN_COMPOSER_HEIGHT: u16 = 3;
|
||||
const MIN_COMPOSER_HEIGHT: u16 = 2;
|
||||
const CONTEXT_WARNING_THRESHOLD_PERCENT: f64 = 85.0;
|
||||
const CONTEXT_CRITICAL_THRESHOLD_PERCENT: f64 = 95.0;
|
||||
const UI_IDLE_POLL_MS: u64 = 48;
|
||||
const UI_ACTIVE_POLL_MS: u64 = 24;
|
||||
const UI_DEEPSEEK_SQUIGGLE_MS: u64 = 320;
|
||||
const UI_STATUS_ANIMATION_MS: u64 = 360;
|
||||
const WORKSPACE_CONTEXT_REFRESH_SECS: u64 = 15;
|
||||
const SIDEBAR_VISIBLE_MIN_WIDTH: u16 = 100;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct StatusLayoutPlan {
|
||||
status_height: u16,
|
||||
}
|
||||
|
||||
/// Run the interactive TUI event loop.
|
||||
///
|
||||
/// # Examples
|
||||
@@ -843,7 +836,6 @@ async fn run_event_loop(
|
||||
let now = Instant::now();
|
||||
app.flush_paste_burst_if_due(now);
|
||||
app.sync_status_message_to_toasts();
|
||||
sync_footer_clock(app);
|
||||
let allow_workspace_context_refresh =
|
||||
!app.is_loading && !has_running_agents && !app.is_compacting;
|
||||
refresh_workspace_context_if_needed(app, now, allow_workspace_context_refresh);
|
||||
@@ -2243,23 +2235,6 @@ async fn handle_plan_choice(
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn chat_height_floor(body_height: u16) -> u16 {
|
||||
body_height
|
||||
.saturating_sub(MIN_COMPOSER_HEIGHT)
|
||||
.clamp(1, MIN_CHAT_HEIGHT)
|
||||
}
|
||||
|
||||
fn status_row_budget(
|
||||
terminal_height: u16,
|
||||
header_height: u16,
|
||||
footer_height: u16,
|
||||
composer_height: u16,
|
||||
) -> u16 {
|
||||
let body_height = terminal_height.saturating_sub(header_height + footer_height);
|
||||
let chat_floor = chat_height_floor(body_height);
|
||||
body_height.saturating_sub(composer_height.max(MIN_COMPOSER_HEIGHT) + chat_floor)
|
||||
}
|
||||
|
||||
fn running_agent_count(app: &App) -> usize {
|
||||
let mut ids: std::collections::HashSet<&str> =
|
||||
app.agent_progress.keys().map(String::as_str).collect();
|
||||
@@ -2273,43 +2248,6 @@ fn running_agent_count(app: &App) -> usize {
|
||||
ids.len()
|
||||
}
|
||||
|
||||
fn active_agent_rows(app: &App, limit: usize) -> Vec<(String, String)> {
|
||||
if limit == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut rows = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
for agent in app
|
||||
.subagent_cache
|
||||
.iter()
|
||||
.filter(|agent| matches!(agent.status, SubAgentStatus::Running))
|
||||
{
|
||||
let detail = app
|
||||
.agent_progress
|
||||
.get(&agent.agent_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| summarize_tool_output(&agent.assignment.objective));
|
||||
rows.push((agent.agent_id.clone(), summarize_tool_output(&detail)));
|
||||
seen.insert(agent.agent_id.clone());
|
||||
if rows.len() >= limit {
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
let mut extras: Vec<(String, String)> = app
|
||||
.agent_progress
|
||||
.iter()
|
||||
.filter(|(id, _)| !seen.contains(id.as_str()))
|
||||
.map(|(id, status)| (id.clone(), summarize_tool_output(status)))
|
||||
.collect();
|
||||
extras.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
rows.extend(extras.into_iter().take(limit.saturating_sub(rows.len())));
|
||||
rows
|
||||
}
|
||||
|
||||
fn reconcile_subagent_activity_state(app: &mut App) {
|
||||
let running_agents: Vec<(String, String)> = app
|
||||
.subagent_cache
|
||||
@@ -2338,30 +2276,6 @@ fn reconcile_subagent_activity_state(app: &mut App) {
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_status_layout(
|
||||
app: &App,
|
||||
terminal_height: u16,
|
||||
composer_height: u16,
|
||||
) -> StatusLayoutPlan {
|
||||
let status_budget = status_row_budget(terminal_height, 1, 1, composer_height);
|
||||
if status_budget == 0 {
|
||||
return StatusLayoutPlan { status_height: 0 };
|
||||
}
|
||||
|
||||
let active_details = usize::from(app.is_loading || app.is_compacting)
|
||||
+ usize::from(app.queued_draft.is_some())
|
||||
+ usize::from(running_agent_count(app) > 0)
|
||||
+ usize::from(matches!(
|
||||
app.view_stack.top_kind(),
|
||||
Some(ModalKind::Approval | ModalKind::Elevation)
|
||||
));
|
||||
let requested_rows = 1 + active_details.min(2);
|
||||
let status_height =
|
||||
u16::try_from(requested_rows.min(usize::from(status_budget))).unwrap_or(status_budget);
|
||||
|
||||
StatusLayoutPlan { status_height }
|
||||
}
|
||||
|
||||
fn render(f: &mut Frame, app: &mut App) {
|
||||
let size = f.area();
|
||||
|
||||
@@ -2379,16 +2293,8 @@ fn render(f: &mut Frame, app: &mut App) {
|
||||
let footer_height = 1;
|
||||
let body_height = size.height.saturating_sub(header_height + footer_height);
|
||||
let slash_menu_entries = visible_slash_menu_entries(app, SLASH_MENU_LIMIT);
|
||||
let composer_for_budget = {
|
||||
let max_composer_height = body_height
|
||||
.saturating_sub(chat_height_floor(body_height))
|
||||
.max(MIN_COMPOSER_HEIGHT);
|
||||
let composer_widget = ComposerWidget::new(app, max_composer_height, &slash_menu_entries);
|
||||
composer_widget.desired_height(size.width)
|
||||
};
|
||||
let status_layout = compute_status_layout(app, size.height, composer_for_budget);
|
||||
let composer_max_height = body_height
|
||||
.saturating_sub(status_layout.status_height + chat_height_floor(body_height))
|
||||
.saturating_sub(MIN_CHAT_HEIGHT)
|
||||
.max(MIN_COMPOSER_HEIGHT);
|
||||
let composer_height = {
|
||||
let composer_widget = ComposerWidget::new(app, composer_max_height, &slash_menu_entries);
|
||||
@@ -2398,11 +2304,10 @@ fn render(f: &mut Frame, app: &mut App) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(header_height), // Header
|
||||
Constraint::Min(1), // Chat area
|
||||
Constraint::Length(status_layout.status_height), // Status indicator
|
||||
Constraint::Length(composer_height), // Composer
|
||||
Constraint::Length(footer_height), // Footer
|
||||
Constraint::Length(header_height), // Header
|
||||
Constraint::Min(1), // Chat area
|
||||
Constraint::Length(composer_height), // Composer
|
||||
Constraint::Length(footer_height), // Footer
|
||||
])
|
||||
.split(size);
|
||||
|
||||
@@ -2464,24 +2369,19 @@ fn render(f: &mut Frame, app: &mut App) {
|
||||
}
|
||||
}
|
||||
|
||||
// Render status
|
||||
if status_layout.status_height > 0 {
|
||||
render_status_indicator(f, chunks[2], app);
|
||||
}
|
||||
|
||||
// Render composer
|
||||
let cursor_pos = {
|
||||
let composer_widget = ComposerWidget::new(app, composer_max_height, &slash_menu_entries);
|
||||
let buf = f.buffer_mut();
|
||||
composer_widget.render(chunks[3], buf);
|
||||
composer_widget.cursor_pos(chunks[3])
|
||||
composer_widget.render(chunks[2], buf);
|
||||
composer_widget.cursor_pos(chunks[2])
|
||||
};
|
||||
if let Some(cursor_pos) = cursor_pos {
|
||||
f.set_cursor_position(cursor_pos);
|
||||
}
|
||||
|
||||
// Render footer
|
||||
render_footer(f, chunks[4], app);
|
||||
render_footer(f, chunks[3], app);
|
||||
|
||||
if !app.view_stack.is_empty() {
|
||||
let buf = f.buffer_mut();
|
||||
@@ -3235,191 +3135,6 @@ fn resume_terminal(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_status_indicator(f: &mut Frame, area: Rect, app: &App) {
|
||||
if area.height == 0 || area.width == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut lines = vec![status_summary_line(app, area.width)];
|
||||
let detail_budget = usize::from(area.height.saturating_sub(1));
|
||||
lines.extend(status_detail_lines(app, area.width, detail_budget));
|
||||
|
||||
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
|
||||
f.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
fn approval_mode_summary(app: &App) -> &'static str {
|
||||
match app.approval_mode {
|
||||
ApprovalMode::Auto => "auto",
|
||||
ApprovalMode::Suggest => "review",
|
||||
ApprovalMode::Never => "off",
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_short_name(app: &App) -> String {
|
||||
app.workspace
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("workspace")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn current_run_state(app: &App) -> (&'static str, ratatui::style::Color) {
|
||||
if app.is_compacting {
|
||||
("Compacting", palette::STATUS_WARNING)
|
||||
} else if app.is_loading {
|
||||
("Working", palette::DEEPSEEK_SKY)
|
||||
} else if running_agent_count(app) > 0 {
|
||||
("Agents active", palette::DEEPSEEK_SKY)
|
||||
} else if app.queued_draft.is_some() {
|
||||
("Editing queue", palette::STATUS_WARNING)
|
||||
} else {
|
||||
("Ready", palette::TEXT_MUTED)
|
||||
}
|
||||
}
|
||||
|
||||
fn status_summary_line(app: &App, width: u16) -> Line<'static> {
|
||||
let queue = app.queued_message_count();
|
||||
let running_tasks = app
|
||||
.task_panel
|
||||
.iter()
|
||||
.filter(|task| task.status == "running")
|
||||
.count();
|
||||
let active_agents = running_agent_count(app);
|
||||
let (state, state_color) = current_run_state(app);
|
||||
let mut parts = vec![workspace_short_name(app)];
|
||||
if queue > 0 {
|
||||
parts.push(format!("queue {queue}"));
|
||||
}
|
||||
if !matches!(app.approval_mode, ApprovalMode::Suggest) {
|
||||
parts.push(format!("approvals {}", approval_mode_summary(app)));
|
||||
}
|
||||
if running_tasks > 0 {
|
||||
parts.push(format!(
|
||||
"{} task{}",
|
||||
running_tasks,
|
||||
if running_tasks == 1 { "" } else { "s" }
|
||||
));
|
||||
}
|
||||
if active_agents > 0 {
|
||||
parts.push(format!(
|
||||
"{} agent{}",
|
||||
active_agents,
|
||||
if active_agents == 1 { "" } else { "s" }
|
||||
));
|
||||
}
|
||||
if width >= 100
|
||||
&& let Some(workspace_context) = app.workspace_context.as_ref()
|
||||
{
|
||||
parts.push(workspace_context.to_string());
|
||||
}
|
||||
let text = parts.join(" · ");
|
||||
|
||||
let available_width = if state == "Ready" {
|
||||
usize::from(width)
|
||||
} else {
|
||||
usize::from(width).saturating_sub(state.len() + 3)
|
||||
};
|
||||
let mut spans = vec![Span::styled(
|
||||
truncate_line_to_width(&text, available_width.max(1)),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)];
|
||||
if state != "Ready" {
|
||||
spans.push(Span::styled(
|
||||
" · ",
|
||||
Style::default().fg(palette::TEXT_DIM),
|
||||
));
|
||||
spans.push(Span::styled(
|
||||
state.to_string(),
|
||||
Style::default().fg(state_color),
|
||||
));
|
||||
}
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
fn status_detail_lines(app: &App, width: u16, budget: usize) -> Vec<Line<'static>> {
|
||||
if budget == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut lines = Vec::new();
|
||||
|
||||
if app.is_loading && lines.len() < budget {
|
||||
let header = app
|
||||
.reasoning_header
|
||||
.as_deref()
|
||||
.filter(|header| !header.trim().is_empty())
|
||||
.unwrap_or("streaming response");
|
||||
let spinner = if app.low_motion {
|
||||
"·"
|
||||
} else {
|
||||
deepseek_squiggle(app.turn_started_at)
|
||||
};
|
||||
let elapsed = app.turn_started_at.map(format_elapsed).unwrap_or_default();
|
||||
let detail = if elapsed.is_empty() {
|
||||
format!("{spinner} {header} · Esc interrupts")
|
||||
} else {
|
||||
format!("{spinner} {header} · {elapsed} · Esc interrupts")
|
||||
};
|
||||
lines.push(Line::from(Span::styled(
|
||||
truncate_line_to_width(&detail, usize::from(width)),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)));
|
||||
}
|
||||
|
||||
if app.is_compacting && lines.len() < budget {
|
||||
lines.push(Line::from(Span::styled(
|
||||
truncate_line_to_width(
|
||||
"Compacting context · summarizing older turns · Esc interrupts",
|
||||
usize::from(width),
|
||||
),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)));
|
||||
}
|
||||
|
||||
if let Some(draft) = app.queued_draft.as_ref()
|
||||
&& lines.len() < budget
|
||||
{
|
||||
lines.push(Line::from(Span::styled(
|
||||
truncate_line_to_width(
|
||||
&format!("Editing queued draft · {}", draft.display),
|
||||
usize::from(width),
|
||||
),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)));
|
||||
}
|
||||
|
||||
if running_agent_count(app) > 0 && lines.len() < budget {
|
||||
let active_rows = active_agent_rows(app, 1);
|
||||
if let Some((id, status)) = active_rows.first() {
|
||||
lines.push(Line::from(Span::styled(
|
||||
truncate_line_to_width(
|
||||
&format!("Agent {id} · {}", status.lines().next().unwrap_or(status)),
|
||||
usize::from(width),
|
||||
),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(
|
||||
app.view_stack.top_kind(),
|
||||
Some(ModalKind::Approval | ModalKind::Elevation)
|
||||
) && lines.len() < budget
|
||||
{
|
||||
lines.push(Line::from(Span::styled(
|
||||
truncate_line_to_width(
|
||||
"Review open request · Esc closes the overlay",
|
||||
usize::from(width),
|
||||
),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)));
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn status_color(level: StatusToastLevel) -> ratatui::style::Color {
|
||||
match level {
|
||||
StatusToastLevel::Info => palette::DEEPSEEK_SKY,
|
||||
@@ -3435,13 +3150,17 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) {
|
||||
return;
|
||||
}
|
||||
|
||||
let percent = context_usage_snapshot(app)
|
||||
.map(|(_, _, pct)| pct)
|
||||
.unwrap_or(0.0);
|
||||
let right_spans = footer_context_spans(percent, available_width);
|
||||
let right_spans = if app.session_cost > 0.001 {
|
||||
vec![Span::styled(
|
||||
format!("${:.2}", app.session_cost),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let right_width = spans_width(&right_spans);
|
||||
let active_status = app.active_status_toast();
|
||||
let min_gap = if available_width < 60 { 1 } else { 2 };
|
||||
let min_gap = if right_width > 0 { 2 } else { 0 };
|
||||
let max_left_width = available_width
|
||||
.saturating_sub(right_width)
|
||||
.saturating_sub(min_gap)
|
||||
@@ -3449,8 +3168,6 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) {
|
||||
|
||||
let left_spans = if let Some(toast) = active_status.as_ref() {
|
||||
footer_toast_spans(toast, max_left_width)
|
||||
} else if available_width < 60 {
|
||||
footer_narrow_status_spans(app, max_left_width)
|
||||
} else {
|
||||
footer_status_line_spans(app, max_left_width)
|
||||
};
|
||||
@@ -3477,88 +3194,60 @@ fn footer_toast_spans(
|
||||
)]
|
||||
}
|
||||
|
||||
fn footer_narrow_status_spans(app: &App, max_width: usize) -> Vec<Span<'static>> {
|
||||
let (mode_label, mode_color) = footer_mode_style(app);
|
||||
let (status_label, status_color) = footer_state_label(app);
|
||||
let mode_width = mode_label.width();
|
||||
|
||||
if max_width <= mode_width || status_label == "ready" {
|
||||
return vec![Span::styled(
|
||||
truncate_line_to_width(mode_label, max_width.max(1)),
|
||||
Style::default().fg(mode_color),
|
||||
)];
|
||||
}
|
||||
|
||||
let status_width = max_width.saturating_sub(mode_width + 1);
|
||||
let truncated_status = truncate_line_to_width(status_label, status_width.max(1));
|
||||
|
||||
vec![
|
||||
Span::styled(mode_label.to_string(), Style::default().fg(mode_color)),
|
||||
Span::raw(" "),
|
||||
Span::styled(truncated_status, Style::default().fg(status_color)),
|
||||
]
|
||||
}
|
||||
|
||||
fn footer_status_line_spans(app: &App, max_width: usize) -> Vec<Span<'static>> {
|
||||
if max_width == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let time_label = app.footer_clock_label.clone();
|
||||
let (mode_label, mode_color) = footer_mode_style(app);
|
||||
let (status_label, status_color) = footer_state_label(app);
|
||||
let fixed_width = time_label.width()
|
||||
+ 2
|
||||
+ mode_label.width()
|
||||
+ 2
|
||||
+ "agent (".width()
|
||||
+ ", ".width()
|
||||
+ status_label.width()
|
||||
+ 1;
|
||||
let sep = " \u{00B7} ";
|
||||
let show_status = status_label != "ready";
|
||||
|
||||
if max_width <= fixed_width {
|
||||
return footer_narrow_status_spans(app, max_width);
|
||||
let fixed_width = mode_label.width()
|
||||
+ sep.width()
|
||||
+ if show_status {
|
||||
sep.width() + status_label.width()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
if max_width <= mode_label.width() {
|
||||
return vec![Span::styled(
|
||||
truncate_line_to_width(mode_label, max_width),
|
||||
Style::default().fg(mode_color),
|
||||
)];
|
||||
}
|
||||
|
||||
let model_width = max_width.saturating_sub(fixed_width).max(1);
|
||||
let model_label = truncate_line_to_width(&app.model, model_width);
|
||||
let model_budget = max_width.saturating_sub(fixed_width).max(1);
|
||||
let model_label = truncate_line_to_width(&app.model, model_budget);
|
||||
|
||||
vec![
|
||||
Span::styled(time_label, Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::raw(" "),
|
||||
let mut spans = vec![
|
||||
Span::styled(mode_label.to_string(), Style::default().fg(mode_color)),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
"agent".to_string(),
|
||||
Style::default().fg(palette::FOOTER_HINT),
|
||||
),
|
||||
Span::styled(" (".to_string(), Style::default().fg(palette::TEXT_DIM)),
|
||||
Span::styled(sep.to_string(), Style::default().fg(palette::TEXT_DIM)),
|
||||
Span::styled(model_label, Style::default().fg(palette::TEXT_HINT)),
|
||||
Span::styled(", ".to_string(), Style::default().fg(palette::TEXT_DIM)),
|
||||
Span::styled(status_label.to_string(), Style::default().fg(status_color)),
|
||||
Span::styled(")".to_string(), Style::default().fg(palette::TEXT_DIM)),
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
fn sync_footer_clock(app: &mut App) {
|
||||
sync_footer_clock_to(app, Local::now().format("%H:%M").to_string());
|
||||
}
|
||||
|
||||
fn sync_footer_clock_to(app: &mut App, time_label: String) {
|
||||
if app.footer_clock_label == time_label {
|
||||
return;
|
||||
if show_status {
|
||||
spans.push(Span::styled(
|
||||
sep.to_string(),
|
||||
Style::default().fg(palette::TEXT_DIM),
|
||||
));
|
||||
spans.push(Span::styled(
|
||||
status_label.to_string(),
|
||||
Style::default().fg(status_color),
|
||||
));
|
||||
}
|
||||
|
||||
app.footer_clock_label = time_label;
|
||||
app.needs_redraw = true;
|
||||
spans
|
||||
}
|
||||
|
||||
fn footer_state_label(app: &App) -> (&'static str, ratatui::style::Color) {
|
||||
if app.is_compacting {
|
||||
return ("compacting", palette::STATUS_WARNING);
|
||||
return ("compacting \u{238B}", palette::STATUS_WARNING);
|
||||
}
|
||||
if app.is_loading {
|
||||
return ("thinking", palette::STATUS_WARNING);
|
||||
return ("thinking \u{238B}", palette::STATUS_WARNING);
|
||||
}
|
||||
if running_agent_count(app) > 0 {
|
||||
return ("working", palette::DEEPSEEK_SKY);
|
||||
@@ -3619,37 +3308,6 @@ fn format_context_budget(used: i64, max: u32) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn context_color_for_percent(percent: f64) -> ratatui::style::Color {
|
||||
if percent >= CONTEXT_CRITICAL_THRESHOLD_PERCENT {
|
||||
palette::STATUS_ERROR
|
||||
} else if percent >= CONTEXT_WARNING_THRESHOLD_PERCENT {
|
||||
palette::STATUS_WARNING
|
||||
} else {
|
||||
palette::DEEPSEEK_SKY
|
||||
}
|
||||
}
|
||||
|
||||
fn footer_context_spans(percent: f64, max_width: usize) -> Vec<Span<'static>> {
|
||||
let color = context_color_for_percent(percent);
|
||||
let value = format!("{percent:.1}%");
|
||||
let full_width = "context: ".width() + value.width();
|
||||
|
||||
if max_width >= full_width {
|
||||
return vec![
|
||||
Span::styled(
|
||||
"context: ".to_string(),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
),
|
||||
Span::styled(value, Style::default().fg(color)),
|
||||
];
|
||||
}
|
||||
|
||||
vec![Span::styled(
|
||||
truncate_line_to_width(&value, max_width.max(1)),
|
||||
Style::default().fg(color),
|
||||
)]
|
||||
}
|
||||
|
||||
fn spans_width(spans: &[Span<'_>]) -> usize {
|
||||
spans.iter().map(|span| span.content.width()).sum()
|
||||
}
|
||||
@@ -3795,22 +3453,6 @@ fn should_auto_compact_before_send(app: &App) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn format_elapsed(start: Instant) -> String {
|
||||
let elapsed = start.elapsed().as_secs();
|
||||
if elapsed >= 60 {
|
||||
format!("{}m{:02}s", elapsed / 60, elapsed % 60)
|
||||
} else {
|
||||
format!("{elapsed}s")
|
||||
}
|
||||
}
|
||||
|
||||
fn deepseek_squiggle(start: Option<Instant>) -> &'static str {
|
||||
const FRAMES: [&str; 4] = ["·", "◦", "•", "◦"];
|
||||
let elapsed_ms = start.map_or(0, |t| t.elapsed().as_millis());
|
||||
let idx = ((elapsed_ms / u128::from(UI_DEEPSEEK_SQUIGGLE_MS)) as usize) % FRAMES.len();
|
||||
FRAMES[idx]
|
||||
}
|
||||
|
||||
fn status_animation_interval_ms(app: &App) -> u64 {
|
||||
if app.low_motion {
|
||||
2_400
|
||||
|
||||
+16
-128
@@ -242,59 +242,6 @@ fn running_agent_count_unions_cache_and_progress() {
|
||||
assert_eq!(running_agent_count(&app), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_status_layout_reserves_extra_rows_for_active_state() {
|
||||
let app = create_test_app();
|
||||
let baseline = compute_status_layout(&app, 30, 3);
|
||||
assert_eq!(baseline.status_height, 1);
|
||||
|
||||
let mut with_agents = create_test_app();
|
||||
with_agents
|
||||
.agent_progress
|
||||
.insert("agent_a".to_string(), "running".to_string());
|
||||
let active = compute_status_layout(&with_agents, 30, 3);
|
||||
assert!(active.status_height > baseline.status_height);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_summary_line_mentions_queue_and_approval_mode() {
|
||||
let mut app = create_test_app();
|
||||
app.approval_mode = crate::tui::approval::ApprovalMode::Auto;
|
||||
app.queue_message(crate::tui::app::QueuedMessage::new(
|
||||
"queued message".to_string(),
|
||||
None,
|
||||
));
|
||||
let summary = status_summary_line(&app, 120);
|
||||
let summary_text = summary
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>();
|
||||
assert!(summary_text.contains("queue 1"));
|
||||
assert!(summary_text.contains("approvals auto"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_agent_rows_prefers_cache_order_and_progress_text() {
|
||||
let mut app = create_test_app();
|
||||
app.subagent_cache = vec![
|
||||
make_subagent("agent_a", crate::tools::subagent::SubAgentStatus::Running),
|
||||
make_subagent("agent_b", crate::tools::subagent::SubAgentStatus::Running),
|
||||
];
|
||||
app.agent_progress
|
||||
.insert("agent_b".to_string(), "step 2".to_string());
|
||||
app.agent_progress
|
||||
.insert("agent_c".to_string(), "queued".to_string());
|
||||
|
||||
let rows = active_agent_rows(&app, 3);
|
||||
assert_eq!(rows.len(), 3);
|
||||
assert_eq!(rows[0].0, "agent_a");
|
||||
assert!(rows[0].1.contains("objective-agent_a"));
|
||||
assert_eq!(rows[1].0, "agent_b");
|
||||
assert_eq!(rows[1].1, "step 2");
|
||||
assert_eq!(rows[2].0, "agent_c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {
|
||||
let mut app = create_test_app();
|
||||
@@ -335,38 +282,28 @@ fn footer_state_label_prefers_compacting_then_thinking() {
|
||||
assert_eq!(footer_state_label(&app).0, "ready");
|
||||
|
||||
app.is_loading = true;
|
||||
assert_eq!(footer_state_label(&app).0, "thinking");
|
||||
assert!(footer_state_label(&app).0.starts_with("thinking"));
|
||||
|
||||
app.is_compacting = true;
|
||||
assert_eq!(footer_state_label(&app).0, "compacting");
|
||||
assert!(footer_state_label(&app).0.starts_with("compacting"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_context_spans_uses_decimal_context_label() {
|
||||
let full = spans_text(&footer_context_spans(12.34, 32));
|
||||
assert_eq!(full, "context: 12.3%");
|
||||
|
||||
let compact = spans_text(&footer_context_spans(12.34, 6));
|
||||
assert_eq!(compact, "12.3%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_narrow_status_spans_hides_ready_state_but_shows_activity() {
|
||||
fn footer_status_line_spans_show_mode_model_and_status() {
|
||||
let mut app = create_test_app();
|
||||
assert_eq!(spans_text(&footer_narrow_status_spans(&app, 24)), "agent");
|
||||
app.model = "deepseek-chat".to_string();
|
||||
|
||||
let idle = spans_text(&footer_status_line_spans(&app, 60));
|
||||
assert!(idle.contains("agent"));
|
||||
assert!(idle.contains("deepseek-chat"));
|
||||
assert!(idle.contains("\u{00B7}"));
|
||||
assert!(!idle.contains("ready"));
|
||||
|
||||
app.is_loading = true;
|
||||
assert_eq!(
|
||||
spans_text(&footer_narrow_status_spans(&app, 24)),
|
||||
"agent thinking"
|
||||
);
|
||||
|
||||
app.is_loading = false;
|
||||
app.is_compacting = true;
|
||||
assert_eq!(
|
||||
spans_text(&footer_narrow_status_spans(&app, 24)),
|
||||
"agent compacting"
|
||||
);
|
||||
let active = spans_text(&footer_status_line_spans(&app, 60));
|
||||
assert!(active.contains("agent"));
|
||||
assert!(active.contains("deepseek-chat"));
|
||||
assert!(active.contains("thinking"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -375,26 +312,9 @@ fn footer_status_line_spans_truncate_long_model_names() {
|
||||
app.model = "deepseek-reasoner-with-an-extremely-long-model-name".to_string();
|
||||
app.is_loading = true;
|
||||
|
||||
let line = spans_text(&footer_status_line_spans(&app, 48));
|
||||
assert!(line.contains("agent ("));
|
||||
assert!(line.contains(", thinking)"));
|
||||
let line = spans_text(&footer_status_line_spans(&app, 40));
|
||||
assert!(line.contains("..."));
|
||||
assert!(UnicodeWidthStr::width(line.as_str()) <= 48);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_footer_clock_to_marks_redraw_only_when_minute_changes() {
|
||||
let mut app = create_test_app();
|
||||
app.footer_clock_label = "12:00".to_string();
|
||||
app.needs_redraw = false;
|
||||
|
||||
sync_footer_clock_to(&mut app, "12:00".to_string());
|
||||
assert_eq!(app.footer_clock_label, "12:00");
|
||||
assert!(!app.needs_redraw);
|
||||
|
||||
sync_footer_clock_to(&mut app, "12:01".to_string());
|
||||
assert_eq!(app.footer_clock_label, "12:01");
|
||||
assert!(app.needs_redraw);
|
||||
assert!(UnicodeWidthStr::width(line.as_str()) <= 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -611,21 +531,6 @@ fn apply_slash_menu_selection_appends_space_for_arg_commands() {
|
||||
assert_eq!(app.input, "/model ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_layout_budget_preserves_chat_and_composer_on_tiny_heights() {
|
||||
let mut app = create_test_app();
|
||||
app.is_loading = true;
|
||||
for idx in 0..5 {
|
||||
app.queue_message(crate::tui::app::QueuedMessage::new(
|
||||
format!("queued message {idx}"),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
let layout = compute_status_layout(&app, 9, 3);
|
||||
assert_eq!(layout.status_height, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_context_refresh_is_deferred_while_ui_is_busy() {
|
||||
let repo = init_git_repo();
|
||||
@@ -761,23 +666,6 @@ fn api_key_validation_warns_without_blocking_unusual_formats() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_detail_lines_show_queue_draft_when_editing() {
|
||||
let mut app = create_test_app();
|
||||
app.queued_draft = Some(crate::tui::app::QueuedMessage::new(
|
||||
"refine the queued prompt".to_string(),
|
||||
None,
|
||||
));
|
||||
let details = status_detail_lines(&app, 120, 2);
|
||||
assert!(!details.is_empty());
|
||||
let text = details[0]
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>();
|
||||
assert!(text.contains("Editing queued draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_to_adjacent_tool_cell_finds_next_and_previous() {
|
||||
let mut app = create_test_app();
|
||||
|
||||
@@ -773,7 +773,7 @@ fn build_empty_state_lines(app: &App, area: Rect) -> Vec<Line<'static>> {
|
||||
let left_padding = usize::from(area.width.saturating_sub(body_width as u16) / 2);
|
||||
let inset = " ".repeat(left_padding);
|
||||
|
||||
let mut body = vec![
|
||||
let body = vec![
|
||||
Line::from(Span::styled(
|
||||
format!("{inset}DeepSeek TUI"),
|
||||
Style::default().fg(palette::DEEPSEEK_BLUE).bold(),
|
||||
@@ -782,19 +782,8 @@ fn build_empty_state_lines(app: &App, area: Rect) -> Vec<Line<'static>> {
|
||||
format!("{inset}{workspace_name} · {}", app.model),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)),
|
||||
Line::from(""),
|
||||
];
|
||||
|
||||
for line in wrap_text(
|
||||
"Start in plain language. The transcript stays clear until the first real turn.",
|
||||
body_width,
|
||||
) {
|
||||
body.push(Line::from(Span::styled(
|
||||
format!("{inset}{line}"),
|
||||
Style::default().fg(palette::TEXT_PRIMARY),
|
||||
)));
|
||||
}
|
||||
|
||||
let top_padding = usize::from(area.height.saturating_sub(body.len() as u16) / 3);
|
||||
let mut lines = Vec::new();
|
||||
for _ in 0..top_padding {
|
||||
|
||||
@@ -198,16 +198,4 @@ fn contrast_guardrails_for_key_ui_pairs() {
|
||||
palette::DEEPSEEK_INK,
|
||||
min_readable,
|
||||
);
|
||||
assert_min_contrast(
|
||||
"FOOTER_HINT on DEEPSEEK_INK",
|
||||
palette::FOOTER_HINT,
|
||||
palette::DEEPSEEK_INK,
|
||||
min_readable,
|
||||
);
|
||||
assert_min_contrast(
|
||||
"FOOTER_HINT on DEEPSEEK_SLATE",
|
||||
palette::FOOTER_HINT,
|
||||
palette::DEEPSEEK_SLATE,
|
||||
min_readable,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user