From ade6406229b56ba470a6721f2cca69e7fa863acf Mon Sep 17 00:00:00 2001 From: Vinh Nguyen <1097578+vinhnx@users.noreply.github.com> Date: Wed, 8 Oct 2025 11:22:04 +0700 Subject: [PATCH 1/2] fix(ui): surface welcome highlights in header --- Cargo.lock | 1 + Cargo.toml | 1 + src/agent/runloop/ui.rs | 1 + src/agent/runloop/unified/turn.rs | 5 - src/agent/runloop/welcome.rs | 614 ++++++--------------------- vtcode-core/src/config/constants.rs | 3 +- vtcode-core/src/core/agent/runner.rs | 5 +- vtcode-core/src/ui/tui.rs | 6 +- vtcode-core/src/ui/tui/session.rs | 153 ++++++- vtcode-core/src/ui/tui/types.rs | 8 + 10 files changed, 303 insertions(+), 494 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 26c9d5bc9..f76699daf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3920,6 +3920,7 @@ dependencies = [ "toml", "tracing", "tracing-subscriber", + "unicode-width 0.1.14", "update-informer", "url", "vtcode-core", diff --git a/Cargo.toml b/Cargo.toml index 8de36cfac..722618bb7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,6 +74,7 @@ anstyle-ls = "1.0" agent-client-protocol = "0.4.5" percent-encoding = "2.3" url = "2.5" +unicode-width = "0.1" [features] default = ["tool-chat"] diff --git a/src/agent/runloop/ui.rs b/src/agent/runloop/ui.rs index 97d4eca19..c3b734b03 100644 --- a/src/agent/runloop/ui.rs +++ b/src/agent/runloop/ui.rs @@ -242,6 +242,7 @@ pub(crate) fn build_inline_header_context( tools: tools_value, languages: languages_value, mcp: mcp_value, + highlights: session_bootstrap.header_highlights.clone(), }) } diff --git a/src/agent/runloop/unified/turn.rs b/src/agent/runloop/unified/turn.rs index b9c2cec50..bfad5247b 100644 --- a/src/agent/runloop/unified/turn.rs +++ b/src/agent/runloop/unified/turn.rs @@ -1839,11 +1839,6 @@ pub(crate) async fn run_single_agent_loop_unified( reasoning_label.clone(), )?; handle.set_header_context(header_context); - if let Some(text) = session_bootstrap.welcome_text.as_ref() { - renderer.line(MessageStyle::Response, text)?; - renderer.line_if_not_empty(MessageStyle::Output)?; - } - // MCP events are now rendered as message blocks in the conversation history if let Some(message) = session_archive_error.take() { diff --git a/src/agent/runloop/welcome.rs b/src/agent/runloop/welcome.rs index 6afee0f77..5755621a6 100644 --- a/src/agent/runloop/welcome.rs +++ b/src/agent/runloop/welcome.rs @@ -1,36 +1,27 @@ -use std::env; -use std::env::VarError; use std::path::Path; -use std::time::Duration; use tracing::warn; -use update_informer::{Check, registry}; -use vtcode_core::config::constants::{ - env as env_constants, project_doc as project_doc_constants, ui as ui_constants, -}; +use unicode_width::UnicodeWidthStr; +use vtcode_core::config::constants::{project_doc as project_doc_constants, ui as ui_constants}; use vtcode_core::config::core::AgentOnboardingConfig; use vtcode_core::config::loader::VTCodeConfig; use vtcode_core::config::types::AgentConfig as CoreAgentConfig; use vtcode_core::project_doc; use vtcode_core::ui::slash::SLASH_COMMANDS; -use vtcode_core::ui::styled::Styles; -use vtcode_core::ui::theme; +use vtcode_core::ui::tui::InlineHeaderHighlight; use vtcode_core::utils::utils::{ ProjectOverview, build_project_overview, summarize_workspace_languages, }; -const PACKAGE_NAME: &str = env!("CARGO_PKG_NAME"); -const PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION"); - #[derive(Default, Clone)] pub(crate) struct SessionBootstrap { - pub welcome_text: Option, pub placeholder: Option, pub prompt_addendum: Option, pub language_summary: Option, pub mcp_enabled: Option, pub mcp_providers: Option>, pub mcp_error: Option, + pub header_highlights: Vec, } pub(crate) fn prepare_session_bootstrap( @@ -60,22 +51,10 @@ pub(crate) fn prepare_session_bootstrap( None }; - let update_notice = if onboarding_cfg.enabled { - compute_update_notice() + let header_highlights = if onboarding_cfg.enabled { + build_header_highlights(&onboarding_cfg, project_overview.as_ref()) } else { - None - }; - - let welcome_text = if onboarding_cfg.enabled { - Some(render_welcome_text( - &onboarding_cfg, - project_overview.as_ref(), - language_summary.as_deref(), - guideline_highlights.as_deref(), - update_notice.as_deref(), - )) - } else { - None + Vec::new() }; let prompt_addendum = if onboarding_cfg.enabled { @@ -103,107 +82,109 @@ pub(crate) fn prepare_session_bootstrap( }; SessionBootstrap { - welcome_text, placeholder, prompt_addendum, language_summary, mcp_enabled: vt_cfg.map(|cfg| cfg.mcp.enabled), mcp_providers: vt_cfg.map(|cfg| cfg.mcp.providers.clone()), mcp_error, + header_highlights, } } -fn render_welcome_text( +fn build_header_highlights( onboarding_cfg: &AgentOnboardingConfig, overview: Option<&ProjectOverview>, - language_summary: Option<&str>, - guideline_highlights: Option<&[String]>, - update_notice: Option<&str>, -) -> String { - let mut lines = Vec::new(); - // Skip intro_text and use the fancy banner instead +) -> Vec { + let mut highlights = Vec::new(); - if let Some(notice) = update_notice { - lines.push(notice.to_string()); + if onboarding_cfg.include_project_overview { + if let Some(project) = overview.and_then(project_overview_highlight) { + highlights.push(project); + } } - let mut sections: Vec = Vec::new(); + if let Some(commands) = slash_commands_highlight() { + highlights.push(commands); + } - if onboarding_cfg.include_project_overview - && let Some(project) = overview - { - let summary = project.short_for_display(); - let mut details: Vec = summary - .lines() - .map(|line| line.trim()) - .filter(|line| !line.is_empty()) - .map(|line| line.to_string()) - .collect(); - - if let Some(first) = details.first_mut() { - *first = format!("**{}**", first); - } + highlights +} - if !details.is_empty() { - let mut section = Vec::with_capacity(details.len() + 1); - section.push(style_section_title("Project Overview")); - section.extend(details); - sections.push(SectionBlock::new(section, SectionSpacing::Normal)); - } - } +fn project_overview_highlight(project: &ProjectOverview) -> Option { + let summary = project.short_for_display(); + let lines: Vec = summary + .lines() + .map(|line| line.trim()) + .filter(|line| !line.is_empty()) + .map(|line| line.to_string()) + .collect(); - if onboarding_cfg.include_language_summary - && let Some(summary) = language_summary - { - let trimmed = summary.trim(); - if !trimmed.is_empty() { - add_section( - &mut sections, - style_section_title("Detected Languages"), - vec![trimmed.to_string()], - SectionSpacing::Normal, - ); - } + if lines.is_empty() { + None + } else { + Some(InlineHeaderHighlight { + title: "Project Overview".to_string(), + lines, + }) } +} - if onboarding_cfg.include_guideline_highlights - && let Some(highlights) = guideline_highlights - && !highlights.is_empty() - { - add_guideline_sections(&mut sections, highlights); +fn slash_commands_highlight() -> Option { + let limit = ui_constants::WELCOME_SLASH_COMMAND_LIMIT; + if limit == 0 { + return None; } - if onboarding_cfg.include_usage_tips_in_welcome { - add_list_section( - &mut sections, - "Usage Tips", - &onboarding_cfg.usage_tips, - SectionSpacing::Compact, - ); - } + let mut commands: Vec<(String, String)> = SLASH_COMMANDS + .iter() + .take(limit) + .map(|info| { + let command = format!( + "{}{}", + ui_constants::WELCOME_SLASH_COMMAND_PREFIX, + info.name + ); + let description = info.description.trim().to_string(); + (command, description) + }) + .collect(); - if onboarding_cfg.include_recommended_actions_in_welcome { - add_list_section( - &mut sections, - "Suggested Next Actions", - &onboarding_cfg.recommended_actions, - SectionSpacing::Compact, - ); + if commands.is_empty() { + return None; } - add_keyboard_shortcut_section(&mut sections); - add_slash_command_section(&mut sections); + let indent = ui_constants::WELCOME_SLASH_COMMAND_INDENT; + let max_width = commands + .iter() + .map(|(command, _)| UnicodeWidthStr::width(command.as_str())) + .max() + .unwrap_or(0); - let mut previous_spacing: Option = None; - for section in sections { - if !lines.is_empty() && section.spacing.needs_blank_line(previous_spacing) { - lines.push(String::new()); + let mut lines = Vec::new(); + let intro = ui_constants::WELCOME_SLASH_COMMAND_INTRO.trim(); + if !intro.is_empty() { + lines.push(format!("{}{}", indent, intro)); + } + + for (command, description) in commands.drain(..) { + let command_width = UnicodeWidthStr::width(command.as_str()); + let padding = max_width.saturating_sub(command_width); + let padding_spaces = " ".repeat(padding); + if description.is_empty() { + lines.push(format!("{}{}{}", indent, command, padding_spaces)); + } else { + lines.push(format!( + "{}{}{} {}", + indent, command, padding_spaces, description + )); } - lines.extend(section.lines); - previous_spacing = Some(section.spacing); } - lines.join("\n") + Some(InlineHeaderHighlight { + title: ui_constants::WELCOME_SLASH_COMMAND_SECTION_TITLE.to_string(), + lines, + }) } fn extract_guideline_highlights( @@ -311,268 +292,6 @@ fn collect_non_empty_entries(items: &[String]) -> Vec<&str> { .collect() } -fn style_section_title(title: &str) -> String { - let primary = theme::active_styles().primary.bold(); - let prefix = Styles::render(&primary); - let reset = Styles::render_reset(); - format!("{prefix}{title}{reset}") -} - -fn add_section( - sections: &mut Vec, - title: impl Into, - body: Vec, - spacing: SectionSpacing, -) { - if body.is_empty() { - return; - } - - let title = title.into(); - let mut section = Vec::with_capacity(body.len() + 1); - section.push(title.to_string()); - section.extend(body); - sections.push(SectionBlock::new(section, spacing)); -} - -fn add_list_section( - sections: &mut Vec, - title: &str, - items: &[String], - spacing: SectionSpacing, -) { - let entries = collect_non_empty_entries(items); - if entries.is_empty() { - return; - } - - let body = entries - .into_iter() - .map(|entry| format!("- {}", entry)) - .collect(); - - add_section(sections, style_section_title(title), body, spacing); -} - -fn add_guideline_sections(sections: &mut Vec, highlights: &[String]) { - let entries: Vec = highlights - .iter() - .map(|item| item.trim()) - .filter(|item| !item.is_empty()) - .map(|item| item.to_string()) - .collect(); - - if entries.is_empty() { - return; - } - - for entry in entries { - if let Some((title, detail)) = entry.split_once(':') { - let title = title.trim_matches('*').trim(); - let mut lines = vec![style_section_title(title)]; - let detail = detail.trim(); - if !detail.is_empty() { - lines.push(detail.to_string()); - } - sections.push(SectionBlock::new(lines, SectionSpacing::Normal)); - } else { - let title = entry.trim_matches('*').trim(); - if title.is_empty() { - continue; - } - sections.push(SectionBlock::new( - vec![style_section_title(title)], - SectionSpacing::Normal, - )); - } - } -} - -fn add_keyboard_shortcut_section(sections: &mut Vec) { - let hint = ui_constants::HEADER_SHORTCUT_HINT.trim(); - if hint.is_empty() { - return; - } - - let trimmed = hint - .strip_prefix(ui_constants::WELCOME_SHORTCUT_HINT_PREFIX) - .map(str::trim) - .unwrap_or(hint); - - if trimmed.is_empty() { - return; - } - - let entries: Vec = trimmed - .split(ui_constants::WELCOME_SHORTCUT_SEPARATOR) - .map(str::trim) - .filter(|part| !part.is_empty()) - .filter_map(|part| { - let formatted = format_shortcut_entry(part); - if formatted.is_empty() { - None - } else { - Some(format!( - "{}{}", - ui_constants::WELCOME_SHORTCUT_INDENT, - formatted - )) - } - }) - .collect(); - - if entries.is_empty() { - return; - } - - add_section( - sections, - style_section_title(ui_constants::WELCOME_SHORTCUT_SECTION_TITLE), - entries, - SectionSpacing::Flush, - ); -} - -fn add_slash_command_section(sections: &mut Vec) { - let limit = ui_constants::WELCOME_SLASH_COMMAND_LIMIT; - if limit == 0 { - return; - } - - let command_iter = SLASH_COMMANDS.iter().take(limit); - - let entries: Vec = command_iter - .map(|info| { - let command = format!( - "{}{}", - ui_constants::WELCOME_SLASH_COMMAND_PREFIX, - info.name - ); - format!( - "{} `{}` {}", - ui_constants::WELCOME_SLASH_COMMAND_INDENT, - command, - info.description - ) - }) - .collect(); - - if entries.is_empty() { - return; - } - - let mut body = Vec::with_capacity(entries.len() + 1); - let intro = ui_constants::WELCOME_SLASH_COMMAND_INTRO.trim(); - if !intro.is_empty() { - body.push(format!( - "{}{}", - ui_constants::WELCOME_SLASH_COMMAND_INDENT, - intro - )); - } - body.extend(entries); - - add_section( - sections, - style_section_title(ui_constants::WELCOME_SLASH_COMMAND_SECTION_TITLE), - body, - SectionSpacing::Compact, - ); -} - -fn format_shortcut_entry(entry: &str) -> String { - if let Some((keys, action)) = entry.split_once(" to ") { - let keys = keys.trim(); - let action = action.trim(); - if action.is_empty() { - format!("`{}`", keys) - } else { - format!("`{}` to {}", keys, action) - } - } else if let Some((keys, rest)) = entry.split_once(' ') { - let keys = keys.trim(); - let rest = rest.trim(); - if rest.is_empty() { - format!("`{}`", keys) - } else { - format!("`{}` {}", keys, rest) - } - } else { - let trimmed = entry.trim(); - if trimmed.is_empty() { - String::new() - } else { - format!("`{}`", trimmed) - } - } -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum SectionSpacing { - Normal, - Compact, - Flush, -} - -struct SectionBlock { - lines: Vec, - spacing: SectionSpacing, -} - -impl SectionBlock { - fn new(lines: Vec, spacing: SectionSpacing) -> Self { - Self { lines, spacing } - } -} - -impl SectionSpacing { - fn needs_blank_line(self, previous: Option) -> bool { - match self { - SectionSpacing::Normal => true, - SectionSpacing::Compact => previous != Some(SectionSpacing::Compact), - SectionSpacing::Flush => false, - } - } -} - -fn compute_update_notice() -> Option { - if !should_check_for_updates() { - return None; - } - - let informer = update_informer::new(registry::Crates, PACKAGE_NAME, PACKAGE_VERSION) - .interval(Duration::ZERO); - - match informer.check_version() { - Ok(Some(new_version)) => { - let install_command = format!("cargo install {} --locked --force", PACKAGE_NAME); - Some(format!( - "Update available: {} {} → {}. Upgrade with `{}`.", - PACKAGE_NAME, PACKAGE_VERSION, new_version, install_command - )) - } - Ok(None) => None, - Err(err) => { - warn!(%err, "update check failed"); - None - } - } -} - -fn should_check_for_updates() -> bool { - match env::var(env_constants::UPDATE_CHECK) { - Ok(value) => { - let normalized = value.trim().to_ascii_lowercase(); - !matches!(normalized.as_str(), "0" | "false" | "off" | "no") - } - Err(VarError::NotPresent) => true, - Err(VarError::NotUnicode(_)) => { - warn!("update check env var contains invalid unicode"); - false - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -584,37 +303,9 @@ mod tests { use vtcode_core::config::types::{ ModelSelectionSource, ReasoningEffortLevel, UiSurfacePreference, }; - use vtcode_core::ui::{styled::Styles, theme}; - - fn strip_ansi_codes(input: &str) -> String { - let mut output = String::with_capacity(input.len()); - let mut chars = input.chars(); - while let Some(ch) = chars.next() { - if ch == '\u{1b}' { - if let Some(next) = chars.next() { - if next == '[' { - while let Some(terminator) = chars.next() { - if ('@'..='~').contains(&terminator) { - break; - } - } - continue; - } - } - } - output.push(ch); - } - output - } #[test] fn test_prepare_session_bootstrap_builds_sections() { - let key = env_constants::UPDATE_CHECK; - let previous = std::env::var(key).ok(); - unsafe { - std::env::set_var(key, "off"); - } - let tmp = tempdir().unwrap(); fs::write( tmp.path().join("Cargo.toml"), @@ -660,71 +351,56 @@ mod tests { let bootstrap = prepare_session_bootstrap(&runtime_cfg, Some(&vt_cfg), None); - let welcome = bootstrap.welcome_text.expect("welcome text"); - let plain = strip_ansi_codes(&welcome); - let styled_title = theme::active_styles().primary.bold(); - let prefix = Styles::render(&styled_title); - let reset = Styles::render_reset(); - let styled_shortcuts = format!( - "{prefix}{}{reset}", - ui_constants::WELCOME_SHORTCUT_SECTION_TITLE + assert_eq!(bootstrap.header_highlights.len(), 2); + + let overview = &bootstrap.header_highlights[0]; + assert_eq!(overview.title, "Project Overview"); + assert!( + overview + .lines + .iter() + .any(|line| line.contains("Project: demo v0.1.0")) + ); + assert!( + overview + .lines + .iter() + .any(|line| line.contains("Demo project")) + ); + assert!( + overview + .lines + .iter() + .any(|line| line.contains(&tmp.path().display().to_string())) ); - let styled_overview = style_section_title("Project Overview"); - assert!(welcome.contains(&styled_overview)); - assert!(welcome.contains("**Project:")); - assert!(welcome.contains("Tip one")); - assert!(welcome.contains("Follow workspace guidelines")); - let styled_follow = style_section_title("Follow workspace guidelines"); - assert!(welcome.contains(&styled_follow)); - assert!(welcome.contains(&styled_shortcuts)); - assert!(plain.contains("Keyboard Shortcuts")); - assert!(!plain.contains("Key Guidelines")); - assert!(plain.contains("Project Overview")); - let styled_slash_commands = - style_section_title(ui_constants::WELCOME_SLASH_COMMAND_SECTION_TITLE); - assert!(welcome.contains(&styled_slash_commands)); - assert!(welcome.contains(ui_constants::WELCOME_SLASH_COMMAND_INTRO)); - let init_command = format!( - "{} `{}init`", - ui_constants::WELCOME_SLASH_COMMAND_INDENT, - ui_constants::WELCOME_SLASH_COMMAND_PREFIX + let slash_commands = &bootstrap.header_highlights[1]; + assert_eq!( + slash_commands.title, + ui_constants::WELCOME_SLASH_COMMAND_SECTION_TITLE + ); + assert!( + slash_commands + .lines + .iter() + .any(|line| line.contains("/init")) ); - assert!(welcome.contains(&init_command)); - let help_command = format!( - "{} `{}help`", - ui_constants::WELCOME_SLASH_COMMAND_INDENT, - ui_constants::WELCOME_SLASH_COMMAND_PREFIX + assert!( + slash_commands + .lines + .iter() + .any(|line| line.contains("/config")) ); - assert!(!welcome.contains(&help_command)); - assert!(welcome.contains("`Ctrl+Enter`")); - assert!(!plain.contains("\n\nKey Guidelines")); - assert!(!plain.contains("\n\nKeyboard Shortcuts")); let prompt = bootstrap.prompt_addendum.expect("prompt addendum"); assert!(prompt.contains("## SESSION CONTEXT")); assert!(prompt.contains("Suggested Next Actions")); assert_eq!(bootstrap.placeholder.as_deref(), Some("Type your plan")); - if let Some(value) = previous { - unsafe { - std::env::set_var(key, value); - } - } else { - unsafe { - std::env::remove_var(key); - } - } } #[test] fn test_welcome_hides_optional_sections_by_default() { - let key = env_constants::UPDATE_CHECK; - let previous = std::env::var(key).ok(); - unsafe { - std::env::set_var(key, "off"); - } - let tmp = tempdir().unwrap(); fs::write( tmp.path().join("Cargo.toml"), @@ -751,46 +427,28 @@ mod tests { let vt_cfg = VTCodeConfig::default(); let bootstrap = prepare_session_bootstrap(&runtime_cfg, Some(&vt_cfg), None); - let welcome = bootstrap.welcome_text.expect("welcome text"); - let plain = strip_ansi_codes(&welcome); - let styled_title = theme::active_styles().primary.bold(); - let prefix = Styles::render(&styled_title); - let reset = Styles::render_reset(); - let styled_shortcuts = format!( - "{prefix}{}{reset}", - ui_constants::WELCOME_SHORTCUT_SECTION_TITLE + + assert_eq!(bootstrap.header_highlights.len(), 2); + let overview = &bootstrap.header_highlights[0]; + assert_eq!(overview.title, "Project Overview"); + assert!( + overview + .lines + .iter() + .any(|line| line.contains("Project: demo v0.1.0")) ); - assert!(!welcome.contains("Usage Tips")); - assert!(!welcome.contains("Suggested Next Actions")); - assert!(welcome.contains(&styled_shortcuts)); - assert!(plain.contains("Keyboard Shortcuts")); - assert!(!plain.contains("\n\nKeyboard Shortcuts")); - assert!(welcome.contains("Slash Commands")); - assert!(welcome.contains(ui_constants::WELCOME_SLASH_COMMAND_INTRO)); - let command_entry = format!( - "{} `{}command`", - ui_constants::WELCOME_SLASH_COMMAND_INDENT, - ui_constants::WELCOME_SLASH_COMMAND_PREFIX + let slash_commands = &bootstrap.header_highlights[1]; + assert_eq!( + slash_commands.title, + ui_constants::WELCOME_SLASH_COMMAND_SECTION_TITLE ); - assert!(welcome.contains(&command_entry)); - let help_entry = format!( - "{} `{}help`", - ui_constants::WELCOME_SLASH_COMMAND_INDENT, - ui_constants::WELCOME_SLASH_COMMAND_PREFIX + assert!( + slash_commands + .lines + .iter() + .any(|line| line.contains("/init")) ); - assert!(!welcome.contains(&help_entry)); - assert!(welcome.contains("`Esc`")); - - if let Some(value) = previous { - unsafe { - std::env::set_var(key, value); - } - } else { - unsafe { - std::env::remove_var(key); - } - } } #[test] diff --git a/vtcode-core/src/config/constants.rs b/vtcode-core/src/config/constants.rs index 59d9b05b3..ef8bdd295 100644 --- a/vtcode-core/src/config/constants.rs +++ b/vtcode-core/src/config/constants.rs @@ -316,7 +316,7 @@ pub mod ui { pub const INLINE_CONTENT_MIN_WIDTH: u16 = 48; pub const INLINE_STACKED_NAVIGATION_PERCENT: u16 = INLINE_NAVIGATION_PERCENT; pub const INLINE_SCROLLBAR_EDGE_PADDING: u16 = 1; - pub const INLINE_TRANSCRIPT_BOTTOM_PADDING: u16 = 2; + pub const INLINE_TRANSCRIPT_BOTTOM_PADDING: u16 = 6; pub const INLINE_PREVIEW_MAX_CHARS: usize = 56; pub const INLINE_PREVIEW_ELLIPSIS: &str = "…"; pub const INLINE_AGENT_MESSAGE_LEFT_PADDING: &str = " "; @@ -350,6 +350,7 @@ pub mod ui { pub const HEADER_SHORTCUT_HINT: &str = "Shortcuts: Ctrl+Enter to submit • Esc to cancel • Ctrl+C to interrupt"; pub const HEADER_META_SEPARATOR: &str = " "; + pub const WELCOME_TEXT_WIDTH: usize = 80; pub const WELCOME_SHORTCUT_SECTION_TITLE: &str = "Keyboard Shortcuts"; pub const WELCOME_SHORTCUT_HINT_PREFIX: &str = "Shortcuts:"; pub const WELCOME_SHORTCUT_SEPARATOR: &str = "•"; diff --git a/vtcode-core/src/core/agent/runner.rs b/vtcode-core/src/core/agent/runner.rs index 217b8d938..9881c3268 100644 --- a/vtcode-core/src/core/agent/runner.rs +++ b/vtcode-core/src/core/agent/runner.rs @@ -344,10 +344,7 @@ impl AgentRunner { parallel_tool_config: Some( crate::llm::provider::ParallelToolConfig::anthropic_optimized(), ), - reasoning_effort: if self - .provider_client - .supports_reasoning_effort(&self.model) - { + reasoning_effort: if self.provider_client.supports_reasoning_effort(&self.model) { self.reasoning_effort } else { None diff --git a/vtcode-core/src/ui/tui.rs b/vtcode-core/src/ui/tui.rs index d2c86a40f..1478f884f 100644 --- a/vtcode-core/src/ui/tui.rs +++ b/vtcode-core/src/ui/tui.rs @@ -10,9 +10,9 @@ mod types; pub use style::{convert_style, theme_from_styles}; pub use types::{ - InlineCommand, InlineEvent, InlineHandle, InlineHeaderContext, InlineListItem, - InlineListSelection, InlineMessageKind, InlineSegment, InlineSession, InlineTextStyle, - InlineTheme, + InlineCommand, InlineEvent, InlineHandle, InlineHeaderContext, InlineHeaderHighlight, + InlineListItem, InlineListSelection, InlineMessageKind, InlineSegment, InlineSession, + InlineTextStyle, InlineTheme, }; use tui::run_tui; diff --git a/vtcode-core/src/ui/tui/session.rs b/vtcode-core/src/ui/tui/session.rs index 681fab376..3a53984d2 100644 --- a/vtcode-core/src/ui/tui/session.rs +++ b/vtcode-core/src/ui/tui/session.rs @@ -19,8 +19,8 @@ use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use super::types::{ - InlineCommand, InlineEvent, InlineHeaderContext, InlineListItem, InlineListSelection, - InlineMessageKind, InlineSegment, InlineTextStyle, InlineTheme, + InlineCommand, InlineEvent, InlineHeaderContext, InlineHeaderHighlight, InlineListItem, + InlineListSelection, InlineMessageKind, InlineSegment, InlineTextStyle, InlineTheme, }; use crate::config::constants::ui; use crate::ui::slash::{SlashCommandInfo, suggestions_for}; @@ -669,7 +669,20 @@ impl Session { } fn header_lines(&self) -> Vec> { - vec![self.header_title_line(), self.header_meta_line()] + let mut lines = vec![self.header_title_line(), self.header_meta_line()]; + if !self.header_context.highlights.is_empty() { + lines.push(Line::default()); + } + + for (index, highlight) in self.header_context.highlights.iter().enumerate() { + lines.push(self.header_highlight_title_line(highlight)); + lines.extend(self.header_highlight_body_lines(highlight)); + if index + 1 < self.header_context.highlights.len() { + lines.push(Line::default()); + } + } + + lines } fn header_height_from_lines(&self, width: u16, lines: &[Line<'static>]) -> u16 { @@ -939,6 +952,29 @@ impl Session { Line::from(spans) } + fn header_highlight_title_line(&self, highlight: &InlineHeaderHighlight) -> Line<'static> { + let mut style = self.header_secondary_style(); + style = style.add_modifier(Modifier::BOLD); + Line::from(vec![Span::styled(highlight.title.clone(), style)]) + } + + fn header_highlight_body_lines(&self, highlight: &InlineHeaderHighlight) -> Vec> { + if highlight.lines.is_empty() { + return vec![Line::default()]; + } + + highlight + .lines + .iter() + .map(|line| { + Line::from(vec![Span::styled( + line.clone(), + self.header_primary_style(), + )]) + }) + .collect() + } + fn push_meta_entry(&self, spans: &mut Vec>, label: &str, value: &str) { spans.push(Span::styled( format!("{label}: "), @@ -2779,6 +2815,9 @@ impl Session { } let mut lines = self.wrap_line(base_line, max_width); + if !lines.is_empty() { + lines = self.justify_wrapped_lines(lines, max_width, message.kind); + } if lines.is_empty() { lines.push(Line::default()); } @@ -2894,6 +2933,73 @@ impl Session { rows } + fn justify_wrapped_lines( + &self, + lines: Vec>, + max_width: usize, + kind: InlineMessageKind, + ) -> Vec> { + if max_width == 0 || kind != InlineMessageKind::Agent { + return lines; + } + + let total = lines.len(); + let mut justified = Vec::with_capacity(total); + for (index, line) in lines.into_iter().enumerate() { + let is_last = index + 1 == total; + if self.should_justify_message_line(&line, max_width, is_last) { + justified.push(self.justify_message_line(&line, max_width)); + } else { + justified.push(line); + } + } + + justified + } + + fn should_justify_message_line( + &self, + line: &Line<'static>, + max_width: usize, + is_last: bool, + ) -> bool { + if is_last || max_width == 0 { + return false; + } + if line.spans.len() != 1 { + return false; + } + let text = line.spans[0].content.as_ref(); + if text.trim().is_empty() { + return false; + } + if text.starts_with(char::is_whitespace) { + return false; + } + let trimmed = text.trim(); + if trimmed.starts_with(|ch: char| matches!(ch, '-' | '*' | '`' | '>' | '#')) { + return false; + } + if trimmed.contains("```") { + return false; + } + let width = UnicodeWidthStr::width(trimmed); + if width >= max_width || width < max_width / 2 { + return false; + } + + justify_plain_text(text, max_width).is_some() + } + + fn justify_message_line(&self, line: &Line<'static>, max_width: usize) -> Line<'static> { + let span = &line.spans[0]; + if let Some(justified) = justify_plain_text(span.content.as_ref(), max_width) { + Line::from(vec![Span::styled(justified, span.style)]) + } else { + line.clone() + } + } + fn prepare_transcript_scroll( &mut self, total_rows: usize, @@ -2924,6 +3030,47 @@ impl Session { } } +fn justify_plain_text(text: &str, max_width: usize) -> Option { + let trimmed = text.trim(); + let words: Vec<&str> = trimmed.split_whitespace().collect(); + if words.len() <= 1 { + return None; + } + + let total_word_width: usize = words.iter().map(|word| UnicodeWidthStr::width(*word)).sum(); + if total_word_width >= max_width { + return None; + } + + let gaps = words.len() - 1; + let spaces_needed = max_width.saturating_sub(total_word_width); + if spaces_needed <= gaps { + return None; + } + + let base_space = spaces_needed / gaps; + if base_space == 0 { + return None; + } + let extra = spaces_needed % gaps; + + let mut output = String::with_capacity(max_width + gaps); + for (index, word) in words.iter().enumerate() { + output.push_str(word); + if index < gaps { + let mut count = base_space; + if index < extra { + count += 1; + } + for _ in 0..count { + output.push(' '); + } + } + } + + Some(output) +} + #[cfg(test)] mod tests { use super::*; diff --git a/vtcode-core/src/ui/tui/types.rs b/vtcode-core/src/ui/tui/types.rs index 243a410d9..99b6b6ee4 100644 --- a/vtcode-core/src/ui/tui/types.rs +++ b/vtcode-core/src/ui/tui/types.rs @@ -14,6 +14,7 @@ pub struct InlineHeaderContext { pub tools: String, pub languages: String, pub mcp: String, + pub highlights: Vec, } impl Default for InlineHeaderContext { @@ -63,10 +64,17 @@ impl Default for InlineHeaderContext { tools, languages, mcp, + highlights: Vec::new(), } } } +#[derive(Clone, Default)] +pub struct InlineHeaderHighlight { + pub title: String, + pub lines: Vec, +} + #[derive(Clone, Default, PartialEq)] pub struct InlineTextStyle { pub color: Option, From eb689b12d54d5308dd8c7b553502bd5143b18253 Mon Sep 17 00:00:00 2001 From: Vinh Nguyen <1097578+vinhnx@users.noreply.github.com> Date: Wed, 8 Oct 2025 11:34:52 +0700 Subject: [PATCH 2/2] Prevent justifying lines inside fenced code blocks --- vtcode-core/src/ui/tui/session.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/vtcode-core/src/ui/tui/session.rs b/vtcode-core/src/ui/tui/session.rs index 3a53984d2..0583ab5a3 100644 --- a/vtcode-core/src/ui/tui/session.rs +++ b/vtcode-core/src/ui/tui/session.rs @@ -2945,13 +2945,39 @@ impl Session { let total = lines.len(); let mut justified = Vec::with_capacity(total); + let mut in_fenced_block = false; for (index, line) in lines.into_iter().enumerate() { let is_last = index + 1 == total; - if self.should_justify_message_line(&line, max_width, is_last) { + let mut next_in_fenced_block = in_fenced_block; + let mut combined_text: Option = None; + let line_text = if line.spans.len() == 1 { + line.spans[0].content.as_ref() + } else { + combined_text = Some( + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(), + ); + combined_text.as_deref().unwrap() + }; + let trimmed_start = line_text.trim_start(); + let is_fence_line = + trimmed_start.starts_with("```") || trimmed_start.starts_with("~~~"); + if is_fence_line { + next_in_fenced_block = !in_fenced_block; + } + + if !in_fenced_block + && !is_fence_line + && self.should_justify_message_line(&line, max_width, is_last) + { justified.push(self.justify_message_line(&line, max_width)); } else { justified.push(line); } + + in_fenced_block = next_in_fenced_block; } justified