Release v0.3.6: UI polish - welcome banner, context progress bar, remove custom scrollbar

This commit is contained in:
Hunter Bown
2026-02-02 12:03:19 -06:00
parent 3f8d04178c
commit 62d2877b3b
7 changed files with 70 additions and 186 deletions
+10
View File
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.3.6] - 2026-02-02
### Added
- New welcome banner on startup showing "Welcome to DeepSeek TUI!" with directory, session ID, and model info
- Visual context progress bar in footer showing usage with block characters [████░░░░░░] and percentage
### Changed
- Removed custom block-character scrollbar from chat area - now uses terminal's native scroll
- Simplified header bar: removed context percentage indicator (moved to footer as progress bar)
## [0.3.5] - 2026-01-30
### Added
Generated
+1 -1
View File
@@ -646,7 +646,7 @@ dependencies = [
[[package]]
name = "deepseek-tui"
version = "0.3.5"
version = "0.3.6"
dependencies = [
"anyhow",
"arboard",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deepseek-tui"
version = "0.3.5"
version = "0.3.6"
edition = "2024"
description = "Unofficial DeepSeek CLI - Just run 'deepseek' to start chatting"
license = "MIT"
+28 -19
View File
@@ -28,6 +28,28 @@ use crate::tui::selection::TranscriptSelection;
use crate::tui::transcript::TranscriptViewCache;
use crate::tui::views::ViewStack;
use std::sync::{Arc, Mutex};
use uuid::Uuid;
/// Format a nice welcome banner similar to Kimi CLI.
fn format_welcome_banner(model: &str, workspace: &PathBuf, session_id: &str, yolo: bool) -> String {
let mode_line = if yolo {
"\n\n🚀 YOLO mode — shell + trust + auto-approve enabled"
} else {
""
};
format!(
r#"Welcome to DeepSeek TUI!
Send /help for help information.{mode_line}
Directory: {}
Session: {}
Model: {}"#,
workspace.display(),
&session_id[..8],
model
)
}
// === Types ===
@@ -163,7 +185,6 @@ pub struct App {
pub transcript_cache: TranscriptViewCache,
pub transcript_selection: TranscriptSelection,
pub last_transcript_area: Option<Rect>,
pub last_scrollbar_area: Option<Rect>,
pub last_transcript_top: usize,
pub last_transcript_visible: usize,
pub last_transcript_total: usize,
@@ -370,18 +391,10 @@ impl App {
let history = if needs_onboarding {
Vec::new() // No welcome message during onboarding
} else {
let mode_msg = if yolo {
" | YOLO MODE (shell + trust + auto-approve)"
} else {
""
};
// Generate a session ID for welcome display
let session_id = Uuid::new_v4().to_string();
vec![HistoryCell::System {
content: format!(
"Welcome to DeepSeek! Model: {} | Workspace: {}{}",
model,
workspace.display(),
mode_msg
),
content: format_welcome_banner(&model, &workspace, &session_id, yolo),
}]
};
@@ -415,7 +428,6 @@ impl App {
transcript_cache: TranscriptViewCache::new(),
transcript_selection: TranscriptSelection::default(),
last_transcript_area: None,
last_scrollbar_area: None,
last_transcript_top: 0,
last_transcript_visible: 0,
last_transcript_total: 0,
@@ -516,12 +528,10 @@ impl App {
if let Err(err) = crate::tui::onboarding::mark_onboarded() {
self.status_message = Some(format!("Failed to mark onboarding: {err}"));
}
// Generate a session ID for welcome display
let session_id = Uuid::new_v4().to_string();
self.add_message(HistoryCell::System {
content: format!(
"Welcome to DeepSeek CLI! Model: {} | Workspace: {}",
self.model,
self.workspace.display()
),
content: format_welcome_banner(&self.model, &self.workspace, &session_id, self.yolo),
});
}
@@ -620,7 +630,6 @@ impl App {
// Clear stale layout info
self.last_transcript_area = None;
self.last_scrollbar_area = None;
self.last_transcript_top = 0;
self.last_transcript_visible = 0;
self.last_transcript_total = 0;
+24 -56
View File
@@ -1846,13 +1846,8 @@ fn render(f: &mut Frame, app: &mut App) {
// Render header
{
let header_data = HeaderData::new(
app.mode,
&app.model,
app.total_conversation_tokens,
app.is_loading,
app.ui_theme.header_bg,
);
let header_data =
HeaderData::new(app.mode, &app.model, app.is_loading, app.ui_theme.header_bg);
let header_widget = HeaderWidget::new(header_data);
let buf = f.buffer_mut();
header_widget.render(chunks[0], buf);
@@ -2186,17 +2181,25 @@ fn render_footer(f: &mut Frame, area: Rect, app: &App) {
// Left side assembly
let left_spans = vec![time_span, mode_span, agent_span];
// 4. Context (Right)
// 4. Context Progress Bar (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)
let bar_width = 10; // Width of the progress bar
let filled = ((percent / 100.0) * bar_width as f32).round() as usize;
let filled = filled.min(bar_width);
let empty = bar_width - filled;
let bar_color = if percent > 90.0 {
palette::STATUS_ERROR
} else if percent > 75.0 {
Style::default().fg(palette::STATUS_WARNING)
palette::STATUS_WARNING
} else {
Style::default().fg(palette::TEXT_DIM)
palette::DEEPSEEK_SKY
};
let context_span = Span::styled(context_text, context_style);
let bar_filled = "".repeat(filled);
let bar_empty = "".repeat(empty);
let context_text = format!("[{}{}] {:.0}%", bar_filled, bar_empty, percent);
let context_span = Span::styled(context_text, Style::default().fg(bar_color));
// 5. Right side extras (Scroll, Selection, RLM) - Minimalist
let mut right_extras = Vec::new();
@@ -2252,9 +2255,14 @@ fn render_footer(f: &mut Frame, area: Rect, app: &App) {
),
Span::styled(format!("{} ", mode_str), mode_style),
];
let bar_filled_narrow = "".repeat(filled.min(5));
let bar_empty_narrow = "".repeat(5 - filled.min(5));
let simple_right = vec![Span::styled(
format!(" {:.0}%", percent),
Style::default().fg(palette::TEXT_DIM),
format!(
"[{}{}] {:.0}%",
bar_filled_narrow, bar_empty_narrow, percent
),
Style::default().fg(bar_color),
)];
let sl_width: usize = simple_left.iter().map(|s| s.content.width()).sum();
@@ -2458,11 +2466,6 @@ fn handle_mouse_event(app: &mut App, mouse: MouseEvent) {
app.pending_scroll_delta += update.delta_lines;
}
MouseEventKind::Down(MouseButton::Left) => {
if is_inside_scrollbar(app, mouse) {
jump_scrollbar(app, mouse);
return;
}
if let Some(point) = selection_point_from_mouse(app, mouse) {
app.transcript_selection.anchor = Some(point);
app.transcript_selection.head = Some(point);
@@ -2482,11 +2485,6 @@ fn handle_mouse_event(app: &mut App, mouse: MouseEvent) {
}
}
MouseEventKind::Drag(MouseButton::Left) => {
if is_inside_scrollbar(app, mouse) {
jump_scrollbar(app, mouse);
return;
}
if app.transcript_selection.dragging
&& let Some(point) = selection_point_from_mouse(app, mouse)
{
@@ -2553,36 +2551,6 @@ fn selection_point_from_position(
})
}
fn is_inside_scrollbar(app: &App, mouse: MouseEvent) -> bool {
let Some(area) = app.last_scrollbar_area else {
return false;
};
mouse.column >= area.x
&& mouse.column < area.x + area.width
&& mouse.row >= area.y
&& mouse.row < area.y + area.height
}
fn jump_scrollbar(app: &mut App, mouse: MouseEvent) {
let Some(area) = app.last_scrollbar_area else {
return;
};
if app.last_transcript_total <= app.last_transcript_visible {
return;
}
let rel = usize::from(mouse.row.saturating_sub(area.y));
let height = usize::from(area.height.max(1));
let max_start = app
.last_transcript_total
.saturating_sub(app.last_transcript_visible)
.max(1);
let target = (rel.saturating_mul(max_start) + height / 2) / height;
if let Some(anchor) = TranscriptScroll::anchor_for(app.transcript_cache.line_meta(), target) {
app.transcript_scroll = anchor;
}
}
fn selection_has_content(app: &App) -> bool {
match app.transcript_selection.ordered_endpoints() {
Some((start, end)) => start != end,
+5 -38
View File
@@ -1,4 +1,4 @@
//! Header bar widget displaying mode, model, context usage, and streaming state.
//! Header bar widget displaying mode, model, and streaming state.
use ratatui::{
buffer::Buffer,
@@ -19,7 +19,6 @@ 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,
}
@@ -29,21 +28,13 @@ impl<'a> HeaderData<'a> {
pub fn new(
mode: AppMode,
model: &'a str,
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;
(context_used * 100 / max_u32.max(1)).min(100) as u8
});
Self {
model,
mode,
is_streaming,
context_percent,
background,
}
}
@@ -51,7 +42,7 @@ impl<'a> HeaderData<'a> {
/// Header bar widget (1 line height).
///
/// Layout: `[MODE] model-name | Context: XX% [streaming indicator]`
/// Layout: `[MODE] model-name | [streaming indicator]`
pub struct HeaderWidget<'a> {
data: HeaderData<'a>,
}
@@ -96,23 +87,6 @@ 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 {
@@ -138,18 +112,16 @@ impl Renderable for HeaderWidget<'_> {
let mode_span = self.mode_badge();
let model_span = self.model_span();
// Build right section: context percentage + streaming indicator
let context_span = self.context_span();
// Build right section: streaming indicator
let streaming_span = self.streaming_indicator();
// Calculate widths
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 right_width = streaming_width;
let available = area.width as usize;
@@ -157,7 +129,7 @@ impl Renderable for HeaderWidget<'_> {
let mut spans = Vec::new();
if available >= left_width + right_width + 2 {
// Full layout: [MODE] model | (spacer) | context streaming
// Full layout: [MODE] model | (spacer) | streaming
spans.push(mode_span);
spans.push(Span::raw(" "));
spans.push(model_span);
@@ -168,11 +140,6 @@ impl Renderable for HeaderWidget<'_> {
spans.push(Span::raw(" ".repeat(padding_needed)));
}
// Add context percentage
if let Some(context) = context_span {
spans.push(context);
}
// Add streaming indicator
if let Some(streaming) = streaming_span {
spans.push(streaming);
+1 -71
View File
@@ -21,32 +21,12 @@ use unicode_width::UnicodeWidthStr;
pub struct ChatWidget {
content_area: Rect,
scrollbar_area: Option<Rect>,
lines: Vec<Line<'static>>,
scrollbar: Option<ScrollbarState>,
}
struct ScrollbarState {
top: usize,
visible_lines: usize,
total_lines: usize,
}
impl ChatWidget {
pub fn new(app: &mut App, area: Rect) -> Self {
let mut content_area = area;
let mut scrollbar_area = None;
let show_scrollbar = area.width > 1 && area.height > 1;
if show_scrollbar {
content_area.width = content_area.width.saturating_sub(1);
scrollbar_area = Some(Rect {
x: content_area.x + content_area.width,
y: content_area.y,
width: 1,
height: content_area.height,
});
}
let content_area = area;
let render_options = app.transcript_render_options();
app.transcript_cache.ensure(
@@ -74,7 +54,6 @@ impl ChatWidget {
app.transcript_scroll = scroll_state;
app.last_transcript_area = Some(content_area);
app.last_scrollbar_area = scrollbar_area;
app.last_transcript_top = top;
app.last_transcript_visible = visible_lines;
app.last_transcript_total = total_lines;
@@ -94,17 +73,9 @@ impl ChatWidget {
pad_lines_to_bottom(&mut lines, visible_lines);
}
let scrollbar = scrollbar_area.map(|_| ScrollbarState {
top,
visible_lines,
total_lines,
});
Self {
content_area,
scrollbar_area,
lines,
scrollbar,
}
}
}
@@ -113,16 +84,6 @@ impl Renderable for ChatWidget {
fn render(&self, _area: Rect, buf: &mut Buffer) {
let paragraph = Paragraph::new(self.lines.clone());
paragraph.render(self.content_area, buf);
if let (Some(scrollbar_area), Some(scrollbar)) = (self.scrollbar_area, &self.scrollbar) {
render_scrollbar(
buf,
scrollbar_area,
scrollbar.top,
scrollbar.visible_lines,
scrollbar.total_lines,
);
}
}
fn desired_height(&self, _width: u16) -> u16 {
@@ -592,37 +553,6 @@ fn apply_selection_to_line(
result
}
fn render_scrollbar(buf: &mut Buffer, area: Rect, top: usize, visible: usize, total: usize) {
if total <= visible || area.height == 0 {
return;
}
let height = usize::from(area.height);
let max_start = total.saturating_sub(visible).max(1);
let thumb_height = visible
.saturating_mul(height)
.div_ceil(total)
.clamp(1, height);
let track = height.saturating_sub(thumb_height).max(1);
let thumb_start = (top.saturating_mul(track) + max_start / 2) / max_start;
let mut lines = Vec::new();
for row in 0..height {
let ch = if row >= thumb_start && row < thumb_start + thumb_height {
""
} else {
""
};
lines.push(Line::from(Span::styled(
ch,
Style::default().fg(palette::TEXT_MUTED),
)));
}
let scrollbar = Paragraph::new(lines);
scrollbar.render(area, buf);
}
fn composer_height(input: &str, width: u16, available_height: u16, prompt: &str) -> u16 {
let prompt_width = prompt.width();
let prompt_width_u16 = u16::try_from(prompt_width).unwrap_or(u16::MAX);