Skip to content

Commit 60b5c1b

Browse files
authored
Merge pull request #104 from 7df-lab/dev/tui_header_box_0616
Remove model details from startup header and Add mode switch hint to status bar
2 parents 5bde223 + d3c7ed6 commit 60b5c1b

4 files changed

Lines changed: 49 additions & 110 deletions

File tree

crates/tui/src/bottom_pane/footer.rs

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ use crate::ui_consts::FOOTER_INDENT_COLS;
5050
use crossterm::event::KeyCode;
5151
use ratatui::buffer::Buffer;
5252
use ratatui::layout::Rect;
53+
use ratatui::style::Color;
54+
use ratatui::style::Style;
5355
use ratatui::style::Stylize;
5456
use ratatui::text::Line;
5557
use ratatui::text::Span;
@@ -90,6 +92,8 @@ pub(crate) struct FooterProps {
9092
}
9193

9294
const FOOTER_CONTEXT_GAP_COLS: u16 = 1;
95+
const MODE_SWITCH_HINT: &str = " SHIFT+TAB switch";
96+
const MODE_SWITCH_HINT_COLOR: Color = Color::Rgb(160, 163, 168);
9397

9498
/// Selects which footer content is rendered.
9599
///
@@ -238,7 +242,9 @@ struct LeftSideState {
238242
fn left_side_line(input_mode_indicator: Option<InputMode>, state: LeftSideState) -> Line<'static> {
239243
let mut line = Line::from("");
240244
if let Some(input_mode_indicator) = input_mode_indicator {
241-
line.push_span(input_mode_indicator.styled_span(state.show_cycle_hint));
245+
for span in mode_indicator_spans(input_mode_indicator, state.show_cycle_hint) {
246+
line.push_span(span);
247+
}
242248
}
243249

244250
let has_hint = !matches!(state.hint, SummaryHintKind::None);
@@ -265,6 +271,20 @@ fn left_side_line(input_mode_indicator: Option<InputMode>, state: LeftSideState)
265271
line
266272
}
267273

274+
fn mode_indicator_spans(
275+
input_mode_indicator: InputMode,
276+
show_switch_hint: bool,
277+
) -> Vec<Span<'static>> {
278+
let mut spans = vec![input_mode_indicator.styled_span(/*show_cycle_hint*/ false)];
279+
if show_switch_hint && matches!(input_mode_indicator, InputMode::Build | InputMode::Plan) {
280+
spans.push(Span::styled(
281+
MODE_SWITCH_HINT,
282+
Style::default().fg(MODE_SWITCH_HINT_COLOR),
283+
));
284+
}
285+
spans
286+
}
287+
268288
pub(crate) enum SummaryLeft {
269289
Default,
270290
Custom(Line<'static>),
@@ -578,7 +598,10 @@ pub(crate) fn status_line_with_input_mode(
578598
) -> Option<Line<'static>> {
579599
let mut spans = Vec::new();
580600
if let Some(input_mode_indicator) = input_mode_indicator {
581-
spans.push(input_mode_indicator.styled_span(/*show_cycle_hint*/ false));
601+
spans.extend(mode_indicator_spans(
602+
input_mode_indicator,
603+
/*show_switch_hint*/ true,
604+
));
582605
}
583606
if let Some(status_line) = status_line {
584607
let status_line = status_line.dim();

crates/tui/src/chatwidget_tests.rs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1104,9 +1104,9 @@ fn paste_and_submit(widget: &mut ChatWidget, text: &str) {
11041104
}
11051105

11061106
/// Trace: L2-DES-TUI-003
1107-
/// Verifies: Mode labels render as the first bottom status-line field.
1107+
/// Verifies: Mode labels and switch hints render as the first bottom status-line field.
11081108
#[test]
1109-
fn mode_label_renders_at_left_of_status_line() {
1109+
fn mode_label_and_switch_hint_render_at_left_of_status_line() {
11101110
let model = Model {
11111111
slug: "test-model".to_string(),
11121112
display_name: "Test Model".to_string(),
@@ -1116,17 +1116,25 @@ fn mode_label_renders_at_left_of_status_line() {
11161116

11171117
let rows = rendered_rows(&widget, 100, 12);
11181118
let build_row = status_row_starting_with(&rows, "BUILD");
1119-
assert!(build_row.trim_start().starts_with("BUILD ·"));
1119+
assert!(
1120+
build_row
1121+
.trim_start()
1122+
.starts_with("BUILD SHIFT+TAB switch ·")
1123+
);
11201124

11211125
widget.handle_key_event(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT));
11221126
let rows = rendered_rows(&widget, 100, 12);
11231127
let plan_row = status_row_starting_with(&rows, "PLAN");
1124-
assert!(plan_row.trim_start().starts_with("PLAN ·"));
1128+
assert!(plan_row.trim_start().starts_with("PLAN SHIFT+TAB switch ·"));
11251129

11261130
widget.handle_key_event(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT));
11271131
let rows = rendered_rows(&widget, 100, 12);
11281132
let build_row = status_row_starting_with(&rows, "BUILD");
1129-
assert!(build_row.trim_start().starts_with("BUILD ·"));
1133+
assert!(
1134+
build_row
1135+
.trim_start()
1136+
.starts_with("BUILD SHIFT+TAB switch ·")
1137+
);
11301138
assert!(
11311139
rows.iter()
11321140
.all(|row| !row.trim_start().starts_with("SHELL")),

crates/tui/src/model_display_tests.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ fn widget_with_model(
6868
}
6969

7070
#[test]
71-
fn startup_header_and_summary_prefer_provider_request_model() {
71+
fn status_summary_prefers_provider_request_model() {
7272
let model = Model {
7373
slug: "deepseek-v4-flash".to_string(),
7474
display_name: "deepseek-v4-flash".to_string(),

crates/tui/src/startup_header.rs

Lines changed: 10 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,6 @@ pub(crate) const STARTUP_HEADER_ANIMATION_INTERVAL: Duration = Duration::from_mi
1515

1616
const STARTUP_HEADER_MAX_TOTAL_WIDTH: usize = 62;
1717
const STARTUP_HEADER_MIN_FULL_WIDTH: usize = 44;
18-
const META_GAP: usize = 2;
19-
const MODEL_LABEL: &str = "Model ";
20-
const REASONING_LABEL: &str = "Reasoning ";
2118
const DIRECTORY_LABEL: &str = "Workspace ";
2219
const DEVO_LOGO: [&str; 6] = [
2320
"██████╗ ███████╗██╗ ██╗ ██████╗",
@@ -85,8 +82,7 @@ fn build_full_header(data: StartupHeaderData<'_>, inner_width: usize) -> Vec<Lin
8582
let muted_style = Style::default().dim();
8683
let logo_style = Style::default().fg(data.accent_color).bold();
8784
let version = format!("v{}", data.version);
88-
let reasoning = sanitize_reasoning(data.reasoning);
89-
let mut lines = Vec::with_capacity(11);
85+
let mut lines = Vec::with_capacity(10);
9086

9187
lines.push(border_line('┏', '━', '┓', inner_width, border_style));
9288
for (idx, logo_line) in DEVO_LOGO.iter().enumerate() {
@@ -103,17 +99,6 @@ fn build_full_header(data: StartupHeaderData<'_>, inner_width: usize) -> Vec<Lin
10399
));
104100
}
105101
lines.push(border_line('┣', '━', '┫', inner_width, border_style));
106-
lines.push(content_line(
107-
build_model_reasoning_row(
108-
data.model,
109-
&reasoning,
110-
inner_width,
111-
muted_style,
112-
Style::default(),
113-
),
114-
inner_width,
115-
border_style,
116-
));
117102
lines.push(content_line(
118103
build_directory_row(data.directory, inner_width, muted_style),
119104
inner_width,
@@ -128,10 +113,8 @@ fn build_compact_header(data: StartupHeaderData<'_>, inner_width: usize) -> Vec<
128113
let muted_style = Style::default().dim();
129114
let accent_style = Style::default().fg(data.accent_color).bold();
130115
let version = format!("v{}", data.version);
131-
let reasoning = sanitize_reasoning(data.reasoning);
132116

133117
let title = truncate_right(&format!("Devo {version}"), inner_width);
134-
let model_reasoning = compact_model_reasoning(data.model, &reasoning, inner_width);
135118

136119
vec![
137120
border_line('┏', '━', '┓', inner_width, border_style),
@@ -144,11 +127,6 @@ fn build_compact_header(data: StartupHeaderData<'_>, inner_width: usize) -> Vec<
144127
border_style,
145128
),
146129
border_line('┣', '━', '┫', inner_width, border_style),
147-
content_line(
148-
vec![Span::styled(model_reasoning, Style::default())],
149-
inner_width,
150-
border_style,
151-
),
152130
content_line(
153131
build_directory_row(data.directory, inner_width, muted_style),
154132
inner_width,
@@ -180,41 +158,6 @@ fn build_logo_row(
180158
spans
181159
}
182160

183-
fn build_model_reasoning_row(
184-
model: &str,
185-
reasoning: &str,
186-
inner_width: usize,
187-
label_style: Style,
188-
value_style: Style,
189-
) -> Vec<Span<'static>> {
190-
let model_label_width = UnicodeWidthStr::width(MODEL_LABEL);
191-
let reasoning_label_width = UnicodeWidthStr::width(REASONING_LABEL);
192-
let fixed_width = model_label_width + META_GAP + reasoning_label_width;
193-
let remaining_width = inner_width.saturating_sub(fixed_width);
194-
let reasoning_width = UnicodeWidthStr::width(reasoning);
195-
let mut reasoning_budget = reasoning_width.min(remaining_width.saturating_sub(1).max(1));
196-
let mut model_budget = remaining_width.saturating_sub(reasoning_budget);
197-
198-
if model_budget == 0 && remaining_width > 0 {
199-
model_budget = 1;
200-
reasoning_budget = remaining_width.saturating_sub(model_budget);
201-
}
202-
203-
let model_text = truncate_right(model, model_budget);
204-
let reasoning_text = truncate_right(reasoning, reasoning_budget);
205-
let model_padding = model_budget.saturating_sub(UnicodeWidthStr::width(model_text.as_str()));
206-
207-
let mut spans = vec![
208-
Span::styled(MODEL_LABEL.to_string(), label_style),
209-
Span::styled(model_text, value_style),
210-
];
211-
push_spaces(&mut spans, model_padding);
212-
push_spaces(&mut spans, META_GAP);
213-
spans.push(Span::styled(REASONING_LABEL.to_string(), label_style));
214-
spans.push(Span::styled(reasoning_text, value_style));
215-
spans
216-
}
217-
218161
fn build_directory_row(
219162
directory: &Path,
220163
inner_width: usize,
@@ -228,40 +171,6 @@ fn build_directory_row(
228171
]
229172
}
230173

231-
fn compact_model_reasoning(model: &str, reasoning: &str, inner_width: usize) -> String {
232-
let separator = " / ";
233-
let separator_width = UnicodeWidthStr::width(separator);
234-
let reasoning_width = UnicodeWidthStr::width(reasoning);
235-
if inner_width <= separator_width {
236-
return truncate_right(reasoning, inner_width);
237-
}
238-
239-
let mut reasoning_budget = reasoning_width.min(inner_width.saturating_sub(separator_width + 1));
240-
let mut model_budget = inner_width.saturating_sub(separator_width + reasoning_budget);
241-
if model_budget == 0 {
242-
model_budget = 1.min(inner_width);
243-
reasoning_budget = inner_width.saturating_sub(separator_width + model_budget);
244-
}
245-
246-
let model_text = truncate_right(model, model_budget);
247-
let reasoning_text = truncate_right(reasoning, reasoning_budget);
248-
let mut out = model_text;
249-
if !reasoning_text.is_empty() && UnicodeWidthStr::width(out.as_str()) < inner_width {
250-
out.push_str(separator);
251-
out.push_str(&reasoning_text);
252-
}
253-
truncate_right(&out, inner_width)
254-
}
255-
256-
fn sanitize_reasoning(reasoning: &str) -> String {
257-
let trimmed = reasoning.trim();
258-
if trimmed.is_empty() {
259-
"unknown".to_string()
260-
} else {
261-
trimmed.to_string()
262-
}
263-
}
264-
265174
fn format_directory(directory: &Path, max_width: usize) -> String {
266175
if max_width == 0 {
267176
return String::new();
@@ -424,13 +333,14 @@ mod tests {
424333
"medium",
425334
Path::new("/Users/tester/Desktop/devo"),
426335
);
427-
assert_eq!(11, rows.len());
336+
assert_eq!(10, rows.len());
428337
assert!(rows[0].starts_with('┏'));
429338
assert!(rows[1].contains("██████"));
430339
assert!(rows[3].contains("v0.1.3"));
431-
assert!(rows[8].contains("Model"));
432-
assert!(rows[8].contains("Reasoning"));
433-
assert!(rows[9].contains("Workspace"));
340+
assert!(rows[8].contains("Workspace"));
341+
let rendered = rows.join("\n");
342+
assert!(!rendered.contains("Model"));
343+
assert!(!rendered.contains("Reasoning"));
434344
}
435345

436346
#[test]
@@ -441,7 +351,7 @@ mod tests {
441351
"medium",
442352
Path::new("/Users/tester/Desktop/devo"),
443353
);
444-
assert_eq!(6, rows.len());
354+
assert_eq!(5, rows.len());
445355
assert!(rows[1].contains("Devo v0.1.3"));
446356
assert!(rows[3].contains('/'));
447357
}
@@ -455,24 +365,22 @@ mod tests {
455365
Path::new("/Users/tester/Desktop/projects/devo/some/really/long/path"),
456366
);
457367
assert!(rows[8].contains('…'));
458-
assert!(rows[9].contains('…'));
459-
assert!(rows[9].contains("long/path"));
368+
assert!(rows[8].contains("long/path"));
460369
assert!(
461370
rows.iter()
462371
.all(|row| UnicodeWidthStr::width(row.as_str()) <= 60)
463372
);
464373
}
465374

466375
#[test]
467-
fn unknown_reasoning_and_windows_paths_are_supported() {
376+
fn windows_paths_are_supported() {
468377
let rows = rendered_strings(
469378
60,
470379
"gpt-5-high",
471380
"",
472381
Path::new(r"C:\Users\tester\Desktop\devo\long\workspace"),
473382
);
474-
assert!(rows[8].contains("unknown"));
475-
assert!(rows[9].contains("workspace"));
383+
assert!(rows[8].contains("workspace"));
476384
assert!(
477385
rows.iter()
478386
.all(|row| UnicodeWidthStr::width(row.as_str()) <= 60)

0 commit comments

Comments
 (0)