返回 DeepSeek-TUI-2026
header.rs
根目录 / crates / tui / src / tui / widgets / header.rs
1 //! Header bar widget displaying mode, workspace/model context, and session status.
2
3 use ratatui::{
4 buffer::Buffer,
5 layout::Rect,
6 style::{Color, Modifier, Style},
7 text::{Line, Span},
8 widgets::{Paragraph, Widget},
9 };
10 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
11
12 use crate::palette;
13 use crate::tui::app::AppMode;
14
15 use super::Renderable;
16
17 const CONTEXT_WARNING_THRESHOLD_PERCENT: f64 = 85.0;
18 const CONTEXT_CRITICAL_THRESHOLD_PERCENT: f64 = 95.0;
19 const CONTEXT_SIGNAL_WIDTH: usize = 4;
20
21 /// Data required to render the header bar.
22 pub struct HeaderData<'a> {
23 pub model: &'a str,
24 pub workspace_name: &'a str,
25 pub mode: AppMode,
26 pub is_streaming: bool,
27 pub background: ratatui::style::Color,
28 /// Total tokens used in this session (cumulative, for display).
29 pub total_tokens: u32,
30 /// Context window size for the model (if known).
31 pub context_window: Option<u32>,
32 /// Accumulated session cost in the active display currency.
33 pub session_cost: f64,
34 /// Active context input tokens used for context utilization. Callers should
35 /// pass a sanitized live-context estimate, not cumulative API usage.
36 pub last_prompt_tokens: Option<u32>,
37 /// Short label for the current reasoning-effort tier (e.g. "max", "high",
38 /// "off"). Rendered as a chip when space allows.
39 pub reasoning_effort_label: Option<&'a str>,
40 /// Short label for the active provider (e.g. "NIM"). When `None` (the
41 /// default-DeepSeek case), no provider chip is rendered. Surfaces the
42 /// fact that requests are going somewhere other than DeepSeek's API so
43 /// it's visible at a glance after a `/provider nvidia-nim`.
44 pub provider_label: Option<&'a str>,
45 }
46
47 impl<'a> HeaderData<'a> {
48 /// Create header data from common app fields.
49 #[must_use]
50 pub fn new(
51 mode: AppMode,
52 model: &'a str,
53 workspace_name: &'a str,
54 is_streaming: bool,
55 background: ratatui::style::Color,
56 ) -> Self {
57 Self {
58 model,
59 workspace_name,
60 mode,
61 is_streaming,
62 background,
63 total_tokens: 0,
64 context_window: None,
65 session_cost: 0.0,
66 last_prompt_tokens: None,
67 reasoning_effort_label: None,
68 provider_label: None,
69 }
70 }
71
72 /// Attach a short reasoning-effort label for the header chip.
73 #[must_use]
74 pub fn with_reasoning_effort(mut self, label: Option<&'a str>) -> Self {
75 self.reasoning_effort_label = label;
76 self
77 }
78
79 /// Attach a short provider label for the header chip. Pass `None` when on
80 /// the default DeepSeek provider so the chip is hidden.
81 #[must_use]
82 pub fn with_provider(mut self, label: Option<&'a str>) -> Self {
83 self.provider_label = label;
84 self
85 }
86
87 /// Set token/cost fields.
88 #[must_use]
89 pub fn with_usage(
90 mut self,
91 total_tokens: u32,
92 context_window: Option<u32>,
93 session_cost: f64,
94 active_context_input_tokens: Option<u32>,
95 ) -> Self {
96 self.total_tokens = total_tokens;
97 self.context_window = context_window;
98 self.session_cost = session_cost;
99 self.last_prompt_tokens = active_context_input_tokens;
100 self
101 }
102 }
103
104 /// Header bar widget (1 line height).
105 pub struct HeaderWidget<'a> {
106 data: HeaderData<'a>,
107 }
108
109 impl<'a> HeaderWidget<'a> {
110 #[must_use]
111 pub fn new(data: HeaderData<'a>) -> Self {
112 Self { data }
113 }
114
115 fn mode_color(mode: AppMode) -> Color {
116 match mode {
117 AppMode::Agent => palette::MODE_AGENT,
118 AppMode::Yolo => palette::MODE_YOLO,
119 AppMode::Plan => palette::MODE_PLAN,
120 }
121 }
122
123 fn mode_name(mode: AppMode) -> &'static str {
124 match mode {
125 AppMode::Agent => "Agent",
126 AppMode::Yolo => "Yolo",
127 AppMode::Plan => "Plan",
128 }
129 }
130
131 fn span_width(spans: &[Span<'_>]) -> usize {
132 spans.iter().map(|span| span.content.width()).sum()
133 }
134
135 fn truncate_to_width(text: &str, max_width: usize) -> String {
136 const ELLIPSIS: &str = "...";
137 let ellipsis_width = ELLIPSIS.width();
138
139 if text.width() <= max_width {
140 return text.to_string();
141 }
142 if max_width == 0 {
143 return String::new();
144 }
145 if max_width <= ellipsis_width {
146 return ".".repeat(max_width);
147 }
148
149 let mut truncated = String::new();
150 let mut width = 0;
151 for ch in text.chars() {
152 let ch_width = ch.width().unwrap_or(0);
153 if width + ch_width + ellipsis_width > max_width {
154 break;
155 }
156 truncated.push(ch);
157 width += ch_width;
158 }
159 truncated.push_str(ELLIPSIS);
160 truncated
161 }
162
163 fn context_percent(&self) -> Option<f64> {
164 let used = f64::from(self.data.last_prompt_tokens?);
165 let max = f64::from(self.data.context_window?);
166 if max <= 0.0 {
167 return None;
168 }
169 Some((used / max * 100.0).clamp(0.0, 100.0))
170 }
171
172 fn context_color(percent: f64) -> Color {
173 if percent >= CONTEXT_CRITICAL_THRESHOLD_PERCENT {
174 palette::STATUS_ERROR
175 } else if percent >= CONTEXT_WARNING_THRESHOLD_PERCENT {
176 palette::STATUS_WARNING
177 } else {
178 palette::DEEPSEEK_SKY
179 }
180 }
181
182 fn context_signal_spans(&self, show_percent: bool) -> Vec<Span<'static>> {
183 let Some(percent) = self.context_percent() else {
184 return Vec::new();
185 };
186
187 let color = Self::context_color(percent);
188 let filled = ((percent / 100.0) * CONTEXT_SIGNAL_WIDTH as f64)
189 .ceil()
190 .clamp(0.0, CONTEXT_SIGNAL_WIDTH as f64) as usize;
191 let empty = CONTEXT_SIGNAL_WIDTH.saturating_sub(filled);
192
193 let mut spans = Vec::new();
194 if show_percent {
195 spans.push(Span::styled(
196 format!("{percent:.0}%"),
197 Style::default().fg(color),
198 ));
199 spans.push(Span::raw(" "));
200 }
201 spans.push(Span::styled("▰".repeat(filled), Style::default().fg(color)));
202 spans.push(Span::styled(
203 "▱".repeat(empty),
204 Style::default().fg(palette::BORDER_COLOR),
205 ));
206 spans
207 }
208
209 fn context_percent_spans(&self) -> Vec<Span<'static>> {
210 let Some(percent) = self.context_percent() else {
211 return Vec::new();
212 };
213
214 vec![Span::styled(
215 format!("{percent:.0}%"),
216 Style::default().fg(Self::context_color(percent)),
217 )]
218 }
219
220 fn provider_chip_spans(&self) -> Vec<Span<'static>> {
221 let Some(label) = self.data.provider_label else {
222 return Vec::new();
223 };
224 let trimmed = label.trim();
225 if trimmed.is_empty() {
226 return Vec::new();
227 }
228 vec![Span::styled(
229 trimmed.to_string(),
230 Style::default()
231 .fg(palette::DEEPSEEK_SKY)
232 .add_modifier(Modifier::BOLD),
233 )]
234 }
235
236 fn effort_chip_spans(&self, include_prefix: bool) -> Vec<Span<'static>> {
237 let Some(label) = self.data.reasoning_effort_label else {
238 return Vec::new();
239 };
240 let trimmed = label.trim();
241 if trimmed.is_empty() {
242 return Vec::new();
243 }
244 let is_off = trimmed.eq_ignore_ascii_case("off");
245 let color = if is_off {
246 palette::TEXT_HINT
247 } else {
248 palette::DEEPSEEK_SKY
249 };
250 let body = if !include_prefix {
251 trimmed.to_string()
252 } else if trimmed.eq_ignore_ascii_case("max") || trimmed.eq_ignore_ascii_case("maximum") {
253 format!("\u{1F433} {trimmed}")
254 } else {
255 format!("\u{00B7} {trimmed}")
256 };
257 vec![Span::styled(body, Style::default().fg(color))]
258 }
259
260 fn status_variant(
261 &self,
262 show_stream_label: bool,
263 show_percent: bool,
264 show_signal: bool,
265 ) -> Vec<Span<'static>> {
266 let mut spans = Vec::new();
267
268 let provider_spans = self.provider_chip_spans();
269 let has_provider = !provider_spans.is_empty();
270 if has_provider {
271 spans.extend(provider_spans);
272 }
273
274 let effort_spans = self.effort_chip_spans(true);
275 let has_effort = !effort_spans.is_empty();
276 if has_effort {
277 if has_provider {
278 spans.push(Span::raw(" "));
279 }
280 spans.extend(effort_spans);
281 }
282
283 if self.data.is_streaming {
284 if has_effort || has_provider {
285 spans.push(Span::raw(" "));
286 }
287 spans.push(Span::styled(
288 "●",
289 Style::default()
290 .fg(palette::DEEPSEEK_SKY)
291 .add_modifier(Modifier::BOLD),
292 ));
293 if show_stream_label {
294 spans.push(Span::raw(" "));
295 spans.push(Span::styled(
296 "Live",
297 Style::default().fg(palette::TEXT_SOFT),
298 ));
299 }
300 }
301
302 let context_spans = if show_signal {
303 self.context_signal_spans(show_percent)
304 } else if show_percent {
305 self.context_percent_spans()
306 } else {
307 Vec::new()
308 };
309 if !context_spans.is_empty() {
310 if !spans.is_empty() {
311 spans.push(Span::raw(" "));
312 }
313 spans.extend(context_spans);
314 }
315
316 spans
317 }
318
319 fn right_spans(&self, max_width: usize) -> Vec<Span<'static>> {
320 let candidates = [
321 self.status_variant(true, true, true),
322 self.status_variant(false, true, true),
323 self.status_variant(false, true, false),
324 self.status_variant(false, false, true),
325 ];
326
327 candidates
328 .into_iter()
329 .find(|spans| Self::span_width(spans) <= max_width)
330 .unwrap_or_default()
331 }
332
333 fn metadata_spans(&self, max_width: usize) -> Vec<Span<'static>> {
334 let workspace = self.data.workspace_name.trim();
335 let model = self.data.model.trim();
336
337 if max_width < 4 || (workspace.is_empty() && model.is_empty()) {
338 return Vec::new();
339 }
340
341 if workspace.is_empty() {
342 return vec![Span::styled(
343 Self::truncate_to_width(model, max_width),
344 Style::default().fg(palette::TEXT_HINT),
345 )];
346 }
347
348 if model.is_empty() || max_width < 12 {
349 return vec![Span::styled(
350 Self::truncate_to_width(workspace, max_width),
351 Style::default().fg(palette::TEXT_SECONDARY),
352 )];
353 }
354
355 let separator_width = 3; // " · "
356 if workspace.width() + separator_width + model.width() <= max_width {
357 return vec![
358 Span::styled(
359 workspace.to_string(),
360 Style::default().fg(palette::TEXT_SECONDARY),
361 ),
362 Span::styled(" · ", Style::default().fg(palette::TEXT_HINT)),
363 Span::styled(model.to_string(), Style::default().fg(palette::TEXT_HINT)),
364 ];
365 }
366
367 let content_width = max_width.saturating_sub(separator_width);
368 if content_width < 9 {
369 return vec![Span::styled(
370 Self::truncate_to_width(workspace, max_width),
371 Style::default().fg(palette::TEXT_SECONDARY),
372 )];
373 }
374
375 let workspace_width = workspace.width();
376 let model_width = model.width();
377 let total_width = workspace_width + model_width;
378 let min_workspace = 4;
379 let min_model = 4;
380
381 let proportional_workspace =
382 ((content_width as f64 * workspace_width as f64) / total_width as f64).round() as usize;
383 let workspace_budget =
384 proportional_workspace.clamp(min_workspace, content_width.saturating_sub(min_model));
385 let model_budget = content_width.saturating_sub(workspace_budget);
386
387 vec![
388 Span::styled(
389 Self::truncate_to_width(workspace, workspace_budget),
390 Style::default().fg(palette::TEXT_SECONDARY),
391 ),
392 Span::styled(" · ", Style::default().fg(palette::TEXT_HINT)),
393 Span::styled(
394 Self::truncate_to_width(model, model_budget),
395 Style::default().fg(palette::TEXT_HINT),
396 ),
397 ]
398 }
399
400 fn left_spans(&self, max_width: usize) -> Vec<Span<'static>> {
401 if max_width == 0 {
402 return Vec::new();
403 }
404
405 let mode_label = Self::mode_name(self.data.mode);
406 let mode_style = Style::default()
407 .fg(Self::mode_color(self.data.mode))
408 .add_modifier(Modifier::BOLD);
409
410 if max_width < mode_label.width() {
411 let fallback = self
412 .data
413 .mode
414 .label()
415 .chars()
416 .next()
417 .unwrap_or('?')
418 .to_string();
419 return vec![Span::styled(fallback, mode_style)];
420 }
421
422 let mut spans = vec![Span::styled(mode_label.to_string(), mode_style)];
423 let metadata_width = max_width
424 .saturating_sub(mode_label.width())
425 .saturating_sub(2);
426 let metadata = if metadata_width >= 4 {
427 self.metadata_spans(metadata_width)
428 } else {
429 Vec::new()
430 };
431
432 if !metadata.is_empty() {
433 spans.push(Span::raw(" "));
434 spans.extend(metadata);
435 }
436
437 spans
438 }
439 }
440
441 impl Renderable for HeaderWidget<'_> {
442 fn render(&self, area: Rect, buf: &mut Buffer) {
443 if area.height == 0 || area.width == 0 {
444 return;
445 }
446
447 let available = area.width as usize;
448 let right_budget = available.saturating_sub(6);
449 let right_spans = self.right_spans(right_budget);
450 let right_width = Self::span_width(&right_spans);
451 let spacer_min = usize::from(right_width > 0);
452 let left_budget = available.saturating_sub(right_width + spacer_min);
453 let left_spans = self.left_spans(left_budget);
454 let left_width = Self::span_width(&left_spans);
455 let spacer_width = available.saturating_sub(left_width + right_width);
456
457 let mut spans = left_spans;
458 if spacer_width > 0 {
459 spans.push(Span::raw(" ".repeat(spacer_width)));
460 }
461 spans.extend(right_spans);
462
463 let line = Line::from(spans);
464 let paragraph = Paragraph::new(line).style(Style::default().bg(self.data.background));
465 paragraph.render(area, buf);
466 }
467
468 fn desired_height(&self, _width: u16) -> u16 {
469 1
470 }
471 }
472
473 #[cfg(test)]
474 mod tests {
475 use super::{HeaderData, HeaderWidget, Renderable};
476 use crate::palette;
477 use crate::tui::app::AppMode;
478 use ratatui::{buffer::Buffer, layout::Rect};
479
480 fn render_header(data: HeaderData<'_>, width: u16) -> String {
481 let widget = HeaderWidget::new(data);
482 let area = Rect::new(0, 0, width, 1);
483 let mut buf = Buffer::empty(area);
484 widget.render(area, &mut buf);
485
486 (0..width).map(|x| buf[(x, 0)].symbol()).collect::<String>()
487 }
488
489 #[test]
490 fn wide_header_shows_plain_mode_and_single_metadata_cluster() {
491 let rendered = render_header(
492 HeaderData::new(
493 AppMode::Agent,
494 "deepseek-v4-pro",
495 "deepseek-tui",
496 false,
497 palette::DEEPSEEK_INK,
498 ),
499 72,
500 );
501
502 assert!(rendered.contains("Agent"));
503 assert!(rendered.contains("deepseek-tui"));
504 assert!(rendered.contains("deepseek-v4-pro"));
505 assert!(!rendered.contains("Plan"));
506 assert!(!rendered.contains("Yolo"));
507 }
508
509 #[test]
510 fn streaming_header_integrates_live_state_with_context_signal() {
511 let rendered = render_header(
512 HeaderData::new(
513 AppMode::Plan,
514 "deepseek-v4-pro",
515 "workspace",
516 true,
517 palette::DEEPSEEK_INK,
518 )
519 .with_usage(42_000, Some(128_000), 0.0, Some(48_000)),
520 72,
521 );
522
523 assert!(rendered.contains("Live"));
524 assert!(rendered.contains("38%"));
525 assert!(rendered.contains("▰"));
526 }
527
528 #[test]
529 fn narrow_header_keeps_context_percent_visible() {
530 let rendered = render_header(
531 HeaderData::new(AppMode::Agent, "", "", true, palette::DEEPSEEK_INK).with_usage(
532 0,
533 Some(128_000),
534 0.0,
535 Some(48_000),
536 ),
537 14,
538 );
539
540 assert!(rendered.contains('%'));
541 }
542
543 #[test]
544 fn narrow_header_falls_back_to_mode_without_rendering_all_modes() {
545 let rendered = render_header(
546 HeaderData::new(
547 AppMode::Yolo,
548 "deepseek-v4-flash",
549 "repo",
550 true,
551 palette::DEEPSEEK_INK,
552 )
553 .with_usage(1_000, Some(10_000), 0.0, Some(4_000)),
554 8,
555 );
556
557 assert!(rendered.trim_start().starts_with('Y'));
558 assert!(!rendered.contains("Plan"));
559 assert!(!rendered.contains("Agent"));
560 }
561
562 #[test]
563 fn header_hides_context_signal_when_usage_snapshot_is_missing() {
564 let rendered = render_header(
565 HeaderData::new(
566 AppMode::Agent,
567 "deepseek-v4-flash",
568 "repo",
569 false,
570 palette::DEEPSEEK_INK,
571 ),
572 48,
573 );
574
575 assert!(!rendered.contains('%'));
576 assert!(!rendered.contains("▰"));
577 }
578
579 #[test]
580 fn header_caps_context_signal_at_hundred_percent() {
581 let rendered = render_header(
582 HeaderData::new(
583 AppMode::Agent,
584 "deepseek-v4-flash",
585 "repo",
586 false,
587 palette::DEEPSEEK_INK,
588 )
589 .with_usage(1_000, Some(128_000), 0.0, Some(320_000)),
590 48,
591 );
592
593 assert!(rendered.contains("100%"));
594 assert!(!rendered.contains("250%"));
595 }
596
597 #[test]
598 fn header_shows_provider_chip_when_set() {
599 let rendered = render_header(
600 HeaderData::new(
601 AppMode::Agent,
602 "deepseek-ai/deepseek-v4-flash",
603 "deepseek-tui",
604 false,
605 palette::DEEPSEEK_INK,
606 )
607 .with_provider(Some("NIM")),
608 72,
609 );
610 assert!(
611 rendered.contains("NIM"),
612 "expected NIM chip in header, got: {rendered}"
613 );
614 }
615
616 #[test]
617 fn header_hides_provider_chip_when_default_deepseek() {
618 let rendered = render_header(
619 HeaderData::new(
620 AppMode::Agent,
621 "deepseek-v4-pro",
622 "deepseek-tui",
623 false,
624 palette::DEEPSEEK_INK,
625 ),
626 72,
627 );
628 // Sanity: no `NIM` text leaks in when provider is None.
629 assert!(!rendered.contains("NIM"));
630 }
631 }
632
632 lines RUST