From 62d2877b3bb1ded0c96f455de1926d39e22a9c44 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 2 Feb 2026 12:03:19 -0600 Subject: [PATCH] Release v0.3.6: UI polish - welcome banner, context progress bar, remove custom scrollbar --- CHANGELOG.md | 10 +++++ Cargo.lock | 2 +- Cargo.toml | 2 +- src/tui/app.rs | 47 +++++++++++++---------- src/tui/ui.rs | 80 ++++++++++++--------------------------- src/tui/widgets/header.rs | 43 +++------------------ src/tui/widgets/mod.rs | 72 +---------------------------------- 7 files changed, 70 insertions(+), 186 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c344517b..fa88fc2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 1022fd53..759a1654 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -646,7 +646,7 @@ dependencies = [ [[package]] name = "deepseek-tui" -version = "0.3.5" +version = "0.3.6" dependencies = [ "anyhow", "arboard", diff --git a/Cargo.toml b/Cargo.toml index d91fdd31..4d95b6df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/tui/app.rs b/src/tui/app.rs index 21331bc3..fc0b2122 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -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, - pub last_scrollbar_area: Option, 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; diff --git a/src/tui/ui.rs b/src/tui/ui.rs index fb383c42..d4f36333 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -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, diff --git a/src/tui/widgets/header.rs b/src/tui/widgets/header.rs index 8ed72417..fe2ef83f 100644 --- a/src/tui/widgets/header.rs +++ b/src/tui/widgets/header.rs @@ -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, 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> { - 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> { 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); diff --git a/src/tui/widgets/mod.rs b/src/tui/widgets/mod.rs index 5b11d112..4b4e8250 100644 --- a/src/tui/widgets/mod.rs +++ b/src/tui/widgets/mod.rs @@ -21,32 +21,12 @@ use unicode_width::UnicodeWidthStr; pub struct ChatWidget { content_area: Rect, - scrollbar_area: Option, lines: Vec>, - scrollbar: Option, -} - -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);