Release v0.3.6: UI polish - welcome banner, context progress bar, remove custom scrollbar
This commit is contained in:
@@ -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
@@ -646,7 +646,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui"
|
||||
version = "0.3.5"
|
||||
version = "0.3.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user