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:
Hunter Bown
2026-04-11 20:20:18 -05:00
parent cbcf35c1bd
commit f4dbf828c9
9 changed files with 95 additions and 579 deletions
+14 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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"
-1
View File
@@ -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
-3
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+1 -12
View File
@@ -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 {
-12
View File
@@ -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,
);
}