Skip to content

Commit a4992cf

Browse files
committed
feat(tui): remove redundant metadata from multiple places
The "Build · Model · Duration" metadata line displayed after every assistant response duplicates information already shown in the status line above the prompt. Removed the redundant mode, model, and duration display while keeping the "interrupted" indicator since it's specific to individual messages. fix(tui): improve error message rendering on window resize Error messages now stay visible and properly positioned on window resize by: - Removing hard requirement for 4+ rows, adapting notification height to available space - Positioning notifications at bottom if insufficient top space - Adding bounds checking for buffer cell mutations to prevent out-of-bounds access - Only rendering text rows that fit within the viewport fix(tui): use width-aware truncation for error messages Error messages were wrapping across multiple lines in the notification toast because truncation was based on character count, not display width. Unicode characters (like icons) take up more display cells than their character count. Changed to use `unicode_width` to calculate actual display width and truncate messages based on visual width rather than character count, ensuring they stay on a single line. fix(tui): remove redundant model/provider from welcome and status line Remove model, provider, and current working directory from the welcome-back section and the status line above the prompt input, as this information is already shown in the footer. Also, add padding to the footer (1 char on each side and 1 line bottom padding). Add leading space to status line and trailing space to shortcuts hint for better visual spacing. fix(tui): reset error modal scroll offset when a new error notification arrives Previously the scroll offset was only reset at dismiss time, so a new short error appearing after a scrolled-down long error would render mid-scroll, hiding the start of the message. The new push_notification() wrapper on App resets error_modal_scroll_offset to 0 whenever an Error-kind notification is pushed. fix(tui): use actual notification duration for progress bar fill The progress bar previously assumed all timed notifications last 5 seconds, causing the bar to fill only a fraction of its range for shorter notifications (e.g. a 2s notification would only reach 40% fill). Now pushed_at is stored on Notification and the bar fraction is computed from the real total duration. fix(tui): minor render.rs cleanup - Extract "thinking"/"thinking…" filter strings to STATUS_THINKING / STATUS_THINKING_ELLIPSIS constants used in both should_render_status_row and render_status_row - Remove no-op area.width.min(area.width) in render_welcome_box a
1 parent 3474a0e commit a4992cf

4 files changed

Lines changed: 468 additions & 464 deletions

File tree

src-rust/crates/tui/src/app.rs

Lines changed: 34 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2150,20 +2150,20 @@ impl App {
21502150
// Try xclip/xsel/pbcopy/clip.exe for clipboard; fall back to notification.
21512151
let copied = try_copy_to_clipboard(&text);
21522152
if copied {
2153-
self.notifications.push(
2153+
self.push_notification(
21542154
NotificationKind::Info,
21552155
"Copied to clipboard.".to_string(),
21562156
Some(3),
21572157
);
21582158
} else {
2159-
self.notifications.push(
2159+
self.push_notification(
21602160
NotificationKind::Info,
21612161
format!("Last response: {} chars (clipboard unavailable)", text.len()),
21622162
Some(5),
21632163
);
21642164
}
21652165
} else {
2166-
self.notifications.push(
2166+
self.push_notification(
21672167
NotificationKind::Warning,
21682168
"No assistant message to copy.".to_string(),
21692169
Some(3),
@@ -2569,6 +2569,15 @@ impl App {
25692569

25702570
/// Push a synthetic system annotation into the conversation pane.
25712571
/// It will appear after the current last message.
2572+
/// Push a notification and, for Error-kind notifications, reset the error
2573+
/// modal scroll offset so a newly arrived error is always shown from the top.
2574+
pub fn push_notification(&mut self, kind: NotificationKind, msg: String, duration_secs: Option<u64>) {
2575+
if kind == NotificationKind::Error {
2576+
self.error_modal_scroll_offset = 0;
2577+
}
2578+
self.notifications.push(kind, msg, duration_secs);
2579+
}
2580+
25722581
pub fn push_system_message(&mut self, text: String, style: SystemMessageStyle) {
25732582
self.system_annotations.push(SystemAnnotation {
25742583
after_index: self.messages.len(),
@@ -2608,21 +2617,21 @@ impl App {
26082617
// Only escalate — never repeat a threshold already shown.
26092618
if pct >= 100 && self.token_warning_threshold_shown < 100 {
26102619
self.token_warning_threshold_shown = 100;
2611-
self.notifications.push(
2620+
self.push_notification(
26122621
NotificationKind::Error,
26132622
"Context window full. Running auto-compact\u{2026}".to_string(),
26142623
None,
26152624
);
26162625
} else if pct >= 95 && self.token_warning_threshold_shown < 95 {
26172626
self.token_warning_threshold_shown = 95;
2618-
self.notifications.push(
2627+
self.push_notification(
26192628
NotificationKind::Error,
26202629
"Context window 95% full! Run /compact now.".to_string(),
26212630
None, // persistent until dismissed
26222631
);
26232632
} else if pct >= 80 && self.token_warning_threshold_shown < 80 {
26242633
self.token_warning_threshold_shown = 80;
2625-
self.notifications.push(
2634+
self.push_notification(
26262635
NotificationKind::Warning,
26272636
"Context window 80% full. Consider /compact.".to_string(),
26282637
Some(30),
@@ -3137,14 +3146,14 @@ impl App {
31373146
KeyCode::Char('v') if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::SUPER) => {
31383147
if let Some(text) = crate::image_paste::read_clipboard_text() {
31393148
if text.is_empty() {
3140-
self.notifications.push(NotificationKind::Warning, "Clipboard is empty".to_string(), Some(2));
3149+
self.push_notification(NotificationKind::Warning, "Clipboard is empty".to_string(), Some(2));
31413150
} else {
31423151
for ch in text.chars() {
31433152
self.key_input_dialog.insert_char(ch);
31443153
}
31453154
}
31463155
} else {
3147-
self.notifications.push(NotificationKind::Warning, "Could not read clipboard".to_string(), Some(2));
3156+
self.push_notification(NotificationKind::Warning, "Could not read clipboard".to_string(), Some(2));
31483157
}
31493158
}
31503159
KeyCode::Char(c) => {
@@ -3561,13 +3570,13 @@ impl App {
35613570
}
35623571
KeyCode::Enter => {
35633572
if let Some(path) = self.perform_export() {
3564-
self.notifications.push(
3573+
self.push_notification(
35653574
NotificationKind::Info,
35663575
format!("Exported to {}", path),
35673576
Some(4),
35683577
);
35693578
} else {
3570-
self.notifications.push(
3579+
self.push_notification(
35713580
NotificationKind::Warning,
35723581
"Export failed: could not write file.".to_string(),
35733582
Some(4),
@@ -3892,7 +3901,7 @@ impl App {
38923901
}
38933902
});
38943903
}
3895-
self.notifications.push(
3904+
self.push_notification(
38963905
NotificationKind::Info,
38973906
"Recording\u{2026} (Alt+V to transcribe · Esc to cancel)".to_string(),
38983907
None,
@@ -3911,7 +3920,7 @@ impl App {
39113920
}
39123921
});
39133922
}
3914-
self.notifications.push(
3923+
self.push_notification(
39153924
NotificationKind::Info,
39163925
"Transcribing\u{2026}".to_string(),
39173926
Some(10),
@@ -3960,7 +3969,7 @@ impl App {
39603969
} else {
39613970
format!("Image attached: {}", label)
39623971
};
3963-
self.notifications.push(NotificationKind::Info, msg, Some(3));
3972+
self.push_notification(NotificationKind::Info, msg, Some(3));
39643973
} else if let Some(text) = read_clipboard_text().or_else(read_primary_text) {
39653974
self.handle_paste_data(text);
39663975
self.refresh_prompt_input();
@@ -4031,7 +4040,7 @@ impl App {
40314040
self.selection_focus = None;
40324041
*self.selection_text.borrow_mut() = String::new();
40334042
if copied {
4034-
self.notifications.push(NotificationKind::Info, "Copied to clipboard".to_string(), Some(2));
4043+
self.push_notification(NotificationKind::Info, "Copied to clipboard".to_string(), Some(2));
40354044
}
40364045
} else if self.is_streaming {
40374046
// Cancel streaming.
@@ -4632,7 +4641,7 @@ impl App {
46324641
self.messages.truncate(idx);
46334642
// Remove system annotations placed after the truncation point.
46344643
self.system_annotations.retain(|a| a.after_index <= idx);
4635-
self.notifications.push(
4644+
self.push_notification(
46364645
NotificationKind::Success,
46374646
format!("Rewound to message #{}", idx),
46384647
Some(4),
@@ -4702,7 +4711,7 @@ impl App {
47024711
}
47034712

47044713
// Start new sequence (or show message for wrong key)
4705-
self.notifications.push(NotificationKind::Info, exit_message(key_char).to_string(), Some(2));
4714+
self.push_notification(NotificationKind::Info, exit_message(key_char).to_string(), Some(2));
47064715
self.last_exit_key_warning = Some(std::time::Instant::now());
47074716
self.exit_key_sequence_start = Some(key_char);
47084717
}
@@ -4735,7 +4744,7 @@ impl App {
47354744
self.exit_key_sequence_start = None;
47364745
} else {
47374746
// First press or timeout expired: show exit confirmation.
4738-
self.notifications.push(NotificationKind::Info, "Press Ctrl+C again to exit".to_string(), Some(2));
4747+
self.push_notification(NotificationKind::Info, "Press Ctrl+C again to exit".to_string(), Some(2));
47394748
self.last_exit_key_warning = Some(std::time::Instant::now());
47404749
self.exit_key_sequence_start = Some('c');
47414750
}
@@ -5380,13 +5389,13 @@ impl App {
53805389

53815390
if let Some(text) = text {
53825391
if crate::message_copy::copy_to_clipboard(&text) {
5383-
self.notifications.push(
5392+
self.push_notification(
53845393
NotificationKind::Info,
53855394
format!("Copied {} chars to clipboard.", text.len()),
53865395
Some(3),
53875396
);
53885397
} else {
5389-
self.notifications.push(
5398+
self.push_notification(
53905399
NotificationKind::Warning,
53915400
"Failed to copy to clipboard.".to_string(),
53925401
Some(3),
@@ -5465,7 +5474,7 @@ impl App {
54655474
.to_string();
54665475
let img = PastedImage { path, label: label.clone(), dimensions: None };
54675476
self.prompt_input.add_image(img);
5468-
self.notifications.push(
5477+
self.push_notification(
54695478
crate::notifications::NotificationKind::Info,
54705479
format!("Image attached: {}", label),
54715480
Some(3),
@@ -5867,7 +5876,7 @@ impl App {
58675876
if !sel_text.is_empty() {
58685877
let copied = crate::image_paste::write_clipboard_text(&sel_text);
58695878
if copied {
5870-
self.notifications.push(
5879+
self.push_notification(
58715880
NotificationKind::Info,
58725881
"Copied to clipboard".to_string(),
58735882
Some(1),
@@ -6049,7 +6058,7 @@ impl App {
60496058
self.invalidate_transcript();
60506059
let err_msg = format!("Error: {}", msg);
60516060
self.push_assistant_message(err_msg.clone());
6052-
self.notifications.push(NotificationKind::Error, err_msg, None);
6061+
self.push_notification(NotificationKind::Error, err_msg, None);
60536062
}
60546063
QueryEvent::TokenWarning { state, pct_used } => {
60556064
// Push a notification for context window warnings (notification + threshold tracking).
@@ -6063,15 +6072,15 @@ impl App {
60636072
}
60646073
TokenWarningState::Warning if self.token_warning_threshold_shown < 80 => {
60656074
self.token_warning_threshold_shown = 80;
6066-
self.notifications.push(
6075+
self.push_notification(
60676076
NotificationKind::Warning,
60686077
format!("Context window {:.0}% full. Consider /compact.", pct_used * 100.0),
60696078
Some(30),
60706079
);
60716080
}
60726081
TokenWarningState::Critical if self.token_warning_threshold_shown < 95 => {
60736082
self.token_warning_threshold_shown = 95;
6074-
self.notifications.push(
6083+
self.push_notification(
60756084
NotificationKind::Error,
60766085
format!("Context window {:.0}% full! Run /compact now.", pct_used * 100.0),
60776086
None,
@@ -6193,7 +6202,7 @@ impl App {
61936202
VoiceEvent::Error(msg) => {
61946203
self.voice_recording = false;
61956204
self.voice_event_rx = None;
6196-
self.notifications.push(
6205+
self.push_notification(
61976206
NotificationKind::Warning,
61986207
format!("Voice: {}", msg),
61996208
Some(8),

src-rust/crates/tui/src/messages/mod.rs

Lines changed: 9 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -189,23 +189,6 @@ fn apply_block_style(mut line: Line<'static>, width: u16) -> Line<'static> {
189189
fn empty_block_line(width: u16) -> Line<'static> {
190190
apply_block_style(Line::from(""), width)
191191
}
192-
193-
fn short_model_name(model: &str) -> String {
194-
model
195-
.split_once('/')
196-
.map(|(_, model)| model)
197-
.unwrap_or(model)
198-
.to_string()
199-
}
200-
201-
fn title_case_mode(mode: &str) -> String {
202-
let mut chars = mode.chars();
203-
match chars.next() {
204-
Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
205-
None => String::new(),
206-
}
207-
}
208-
209192
fn render_attachment_chip(kind: &str, label: String) -> Line<'static> {
210193
render_attachment_chip_colored(kind, label, CLAUDE_ORANGE, Color::Black)
211194
}
@@ -241,47 +224,24 @@ fn user_metadata_line(_meta: Option<&TurnMetadata>) -> Option<Line<'static>> {
241224
pub fn render_transcript_assistant_meta(meta: Option<&TurnMetadata>, accent: Color) -> Option<Line<'static>> {
242225
let meta = meta?;
243226

244-
// Always show at least mode — matches OpenCode's "Build · Model · Duration"
245-
let mode = meta
246-
.agent_mode
247-
.as_deref()
248-
.filter(|m| !m.is_empty())
249-
.map(title_case_mode)
250-
.unwrap_or_else(|| "Build".to_string());
227+
// Only show interrupted status — mode, model, and duration are already
228+
// displayed in the status line above the prompt.
229+
if !meta.interrupted {
230+
return None;
231+
}
251232

252-
let mut spans = vec![
233+
let spans = vec![
253234
Span::styled(
254235
" \u{25a3} ",
255236
Style::default()
256237
.fg(accent)
257238
.add_modifier(Modifier::BOLD),
258239
),
259-
Span::styled(mode, Style::default().fg(TRANSCRIPT_TEXT)),
260-
];
261-
262-
if let Some(model) = meta.model_name.as_deref().filter(|m| !m.is_empty()) {
263-
spans.push(Span::styled(" \u{00b7} ", Style::default().fg(TRANSCRIPT_SUBTLE)));
264-
spans.push(Span::styled(
265-
short_model_name(model),
266-
Style::default().fg(TRANSCRIPT_MUTED),
267-
));
268-
}
269-
270-
if let Some(duration) = meta.duration.as_deref().filter(|d| !d.is_empty()) {
271-
spans.push(Span::styled(" \u{00b7} ", Style::default().fg(TRANSCRIPT_SUBTLE)));
272-
spans.push(Span::styled(
273-
duration.to_string(),
274-
Style::default().fg(TRANSCRIPT_MUTED),
275-
));
276-
}
277-
278-
if meta.interrupted {
279-
spans.push(Span::styled(" \u{00b7} ", Style::default().fg(TRANSCRIPT_SUBTLE)));
280-
spans.push(Span::styled(
240+
Span::styled(
281241
"interrupted",
282242
Style::default().fg(TRANSCRIPT_MUTED),
283-
));
284-
}
243+
),
244+
];
285245

286246
Some(Line::from(spans))
287247
}
@@ -2679,6 +2639,3 @@ mod tests {
26792639
assert_eq!(a_text, b_text);
26802640
}
26812641
}
2682-
2683-
2684-

0 commit comments

Comments
 (0)