use std::sync::Arc;
use anstyle::{
Ansi256Color, AnsiColor, Color as AnsiColorEnum, Effects, RgbColor, Style as AnsiStyle,
};
use vtcode_core::ui::theme;
use vtcode_tui::app::{InlineLinkRange, InlineLinkTarget, InlineSegment, InlineTextStyle};
use vtcode_tui::core::convert_style;
use vtcode_tui::ui::syntax_highlight;
pub(super) struct PtyLineStyles {
pub(super) output: Arc<InlineTextStyle>,
pub(super) glyph: Arc<InlineTextStyle>,
pub(super) verb: Arc<InlineTextStyle>,
pub(super) command: Arc<InlineTextStyle>,
pub(super) args: Arc<InlineTextStyle>,
pub(super) keyword: Arc<InlineTextStyle>,
pub(super) variable: Arc<InlineTextStyle>,
pub(super) string: Arc<InlineTextStyle>,
pub(super) option: Arc<InlineTextStyle>,
pub(super) truncation: Arc<InlineTextStyle>,
}
impl PtyLineStyles {
pub(super) fn new() -> Self {
let theme_styles = theme::active_styles();
let output = Arc::new(convert_style(theme_styles.tool_detail.dimmed()));
let glyph = Arc::new(convert_style(theme_styles.tool_detail.dimmed()));
let verb = Arc::new(convert_style(
AnsiStyle::new()
.fg_color(Some(AnsiColorEnum::Ansi(AnsiColor::Magenta)))
.effects(Effects::BOLD),
));
let command = Arc::new(convert_style(
AnsiStyle::new()
.fg_color(Some(AnsiColorEnum::Ansi(AnsiColor::Green)))
.effects(Effects::BOLD),
));
let args = Arc::new(convert_style(
AnsiStyle::new()
.fg_color(Some(AnsiColorEnum::Ansi(AnsiColor::White)))
.effects(Effects::DIMMED),
));
let keyword = Arc::new(convert_style(
AnsiStyle::new()
.fg_color(Some(AnsiColorEnum::Ansi(AnsiColor::Magenta)))
.effects(Effects::BOLD),
));
let variable = Arc::new(convert_style(
AnsiStyle::new().fg_color(Some(AnsiColorEnum::Ansi(AnsiColor::Yellow))),
));
let string = Arc::new(convert_style(
AnsiStyle::new().fg_color(Some(AnsiColorEnum::Ansi(AnsiColor::Yellow))),
));
let option = Arc::new(convert_style(
AnsiStyle::new().fg_color(Some(AnsiColorEnum::Ansi(AnsiColor::Red))),
));
let truncation = Arc::new(convert_style(theme_styles.tool_detail.dimmed()));
Self {
output,
glyph,
verb,
command,
args,
keyword,
variable,
string,
option,
truncation,
}
}
}
fn is_bash_keyword(token: &str) -> bool {
matches!(
token,
"if" | "then"
| "else"
| "elif"
| "fi"
| "for"
| "in"
| "do"
| "done"
| "while"
| "until"
| "case"
| "esac"
| "function"
| "select"
| "time"
| "coproc"
| "{"
| "}"
| "[["
| "]]"
)
}
fn is_command_separator(token: &str) -> bool {
matches!(token, "|" | "||" | "&&" | ";" | ";;" | "&")
}
pub(super) fn tokenize_preserve_whitespace(text: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut in_single = false;
let mut in_double = false;
let mut escaped = false;
let mut token_start: Option<usize> = None;
let mut token_is_whitespace = false;
for (idx, ch) in text.char_indices() {
if escaped {
escaped = false;
} else if ch == '\\' && !in_single {
escaped = true;
} else if ch == '\'' && !in_double {
in_single = !in_single;
} else if ch == '"' && !in_single {
in_double = !in_double;
}
let is_whitespace = !in_single && !in_double && ch.is_whitespace();
match token_start {
None => {
token_start = Some(idx);
token_is_whitespace = is_whitespace;
}
Some(start) if token_is_whitespace != is_whitespace => {
parts.push(&text[start..idx]);
token_start = Some(idx);
token_is_whitespace = is_whitespace;
}
_ => {}
}
}
if let Some(start) = token_start {
parts.push(&text[start..]);
}
parts
}
fn style_for_token<'a>(
token: &'a str,
expect_command: &mut bool,
styles: &'a PtyLineStyles,
) -> Arc<InlineTextStyle> {
if token.trim().is_empty() {
return Arc::clone(&styles.output);
}
if is_command_separator(token) {
*expect_command = true;
return Arc::clone(&styles.args);
}
if token.starts_with('"')
|| token.starts_with('\'')
|| token.ends_with('"')
|| token.ends_with('\'')
{
*expect_command = false;
return Arc::clone(&styles.string);
}
if token.starts_with('$') || token.contains("=$") || token.starts_with("${") {
*expect_command = false;
return Arc::clone(&styles.variable);
}
if token.starts_with('-') && token.len() > 1 {
*expect_command = false;
return Arc::clone(&styles.option);
}
if is_bash_keyword(token) {
*expect_command = true;
return Arc::clone(&styles.keyword);
}
if *expect_command {
*expect_command = false;
return Arc::clone(&styles.command);
}
Arc::clone(&styles.args)
}
fn bash_segments(text: &str, styles: &PtyLineStyles, expect_command: bool) -> Vec<InlineSegment> {
let mut segments = Vec::new();
let mut command_expected = expect_command;
for token in tokenize_preserve_whitespace(text) {
segments.push(InlineSegment {
text: token.to_string(),
style: style_for_token(token, &mut command_expected, styles),
});
}
segments
}
fn shell_syntax_segments(
text: &str,
styles: &PtyLineStyles,
expect_command: bool,
) -> Vec<InlineSegment> {
let semantic = bash_segments(text, styles, expect_command);
let Some(highlighted) = syntax_highlight::highlight_line_to_anstyle_segments(
text,
Some("bash"),
syntax_highlight::get_active_syntax_theme(),
true,
) else {
return semantic;
};
if highlighted.is_empty() {
return semantic;
}
let converted = highlighted
.into_iter()
.map(|(style, text)| InlineSegment {
text,
style: Arc::new(convert_style(style).merge_color(styles.args.color)),
})
.collect::<Vec<_>>();
let converted_text = converted
.iter()
.map(|segment| segment.text.as_str())
.collect::<String>();
if converted_text != text {
return semantic;
}
let non_ws_count = semantic
.iter()
.filter(|segment| !segment.text.trim().is_empty())
.count();
if non_ws_count > 1 {
let mut first: Option<&InlineTextStyle> = None;
let mut has_distinct = false;
for style in converted
.iter()
.filter(|segment| !segment.text.trim().is_empty())
.map(|segment| segment.style.as_ref())
{
if let Some(seed) = first {
if style != seed {
has_distinct = true;
break;
}
} else {
first = Some(style);
}
}
if !has_distinct {
return semantic;
}
}
converted
}
fn ansi_color_from_ansi_code(code: u16) -> Option<AnsiColorEnum> {
let color = match code {
30 | 90 => AnsiColor::Black,
31 | 91 => AnsiColor::Red,
32 | 92 => AnsiColor::Green,
33 | 93 => AnsiColor::Yellow,
34 | 94 => AnsiColor::Blue,
35 | 95 => AnsiColor::Magenta,
36 | 96 => AnsiColor::Cyan,
37 | 97 => AnsiColor::White,
_ => return None,
};
Some(AnsiColorEnum::Ansi(color))
}
fn clear_sgr_effects(effects: &mut Effects, code: u16) {
match code {
22 => {
let _ = effects.remove(Effects::BOLD);
let _ = effects.remove(Effects::DIMMED);
}
23 => {
let _ = effects.remove(Effects::ITALIC);
}
24 => {
let _ = effects.remove(Effects::UNDERLINE);
}
_ => {}
}
}
fn apply_sgr_codes(sequence: &str, current: &mut InlineTextStyle, fallback: &InlineTextStyle) {
let params: Vec<u16> = if sequence.trim().is_empty() {
vec![0]
} else {
sequence
.split(';')
.map(|value| value.parse::<u16>().unwrap_or(0))
.collect()
};
let mut index = 0usize;
while index < params.len() {
let code = params[index];
match code {
0 => *current = fallback.clone(),
1 => current.effects |= Effects::BOLD,
2 => current.effects |= Effects::DIMMED,
3 => current.effects |= Effects::ITALIC,
4 => current.effects |= Effects::UNDERLINE,
22..=24 => clear_sgr_effects(&mut current.effects, code),
30..=37 | 90..=97 => current.color = ansi_color_from_ansi_code(code),
39 => current.color = fallback.color,
40..=47 | 100..=107 => {
let fg_code = code - 10;
current.bg_color = ansi_color_from_ansi_code(fg_code);
}
49 => current.bg_color = fallback.bg_color,
38 | 48 => {
let is_fg = code == 38;
if let Some(mode) = params.get(index + 1).copied() {
match mode {
5 => {
if let Some(value) = params.get(index + 2).copied() {
let color = AnsiColorEnum::Ansi256(Ansi256Color(value as u8));
if is_fg {
current.color = Some(color);
} else {
current.bg_color = Some(color);
}
index += 2;
}
}
2 => {
if index + 4 < params.len() {
let r = params[index + 2] as u8;
let g = params[index + 3] as u8;
let b = params[index + 4] as u8;
let color = AnsiColorEnum::Rgb(RgbColor(r, g, b));
if is_fg {
current.color = Some(color);
} else {
current.bg_color = Some(color);
}
index += 4;
}
}
_ => {}
}
}
}
_ => {}
}
index += 1;
}
}
fn sgr_payload(sequence: &str) -> Option<&str> {
if sequence.starts_with("\u{1b}[") && sequence.ends_with('m') {
Some(&sequence[2..sequence.len().saturating_sub(1)])
} else {
None
}
}
fn parse_osc8_target(sequence: &str) -> Option<Option<String>> {
let payload = sequence.strip_prefix("\u{1b}]8;")?;
let payload = payload
.strip_suffix("\u{1b}\\")
.or_else(|| payload.strip_suffix('\u{7}'))?;
let (_, uri) = payload.split_once(';')?;
if uri.is_empty() {
Some(None)
} else {
Some(Some(uri.to_string()))
}
}
fn ansi_output_segments(
text: &str,
styles: &PtyLineStyles,
) -> Option<(Vec<InlineSegment>, Vec<InlineLinkRange>)> {
if !text.contains('\u{1b}') {
return None;
}
let mut segments = Vec::new();
let mut link_ranges = Vec::new();
let mut current = styles.output.as_ref().clone();
let fallback = styles.output.as_ref().clone();
let mut active_link: Option<String> = None;
let mut visible_offset = 0usize;
let mut index = 0usize;
let mut text_buffer = String::new();
while index < text.len() {
let Some(remaining) = text.get(index..) else {
break;
};
let Some(first) = remaining.as_bytes().first() else {
break;
};
if *first == 0x1b {
if !text_buffer.is_empty() {
let text = std::mem::take(&mut text_buffer);
let end = visible_offset + text.len();
if let Some(url) = active_link.clone() {
link_ranges.push(InlineLinkRange {
start: visible_offset,
end,
target: InlineLinkTarget::Url(url),
});
}
segments.push(InlineSegment {
text,
style: Arc::new(current.clone()),
});
visible_offset = end;
}
if let Some(len) = vtcode_core::utils::ansi_parser::parse_ansi_sequence(remaining) {
if let Some(sequence) = remaining.get(..len) {
if let Some(payload) = sgr_payload(sequence) {
apply_sgr_codes(payload, &mut current, &fallback);
} else if let Some(target) = parse_osc8_target(sequence) {
active_link = target;
}
}
index += len;
continue;
}
text_buffer.push_str(remaining);
index = text.len();
continue;
}
let mut chars = remaining.chars();
if let Some(ch) = chars.next() {
text_buffer.push(ch);
index += ch.len_utf8();
} else {
break;
}
}
if !text_buffer.is_empty() {
let end = visible_offset + text_buffer.len();
if let Some(url) = active_link {
link_ranges.push(InlineLinkRange {
start: visible_offset,
end,
target: InlineLinkTarget::Url(url),
});
}
segments.push(InlineSegment {
text: text_buffer,
style: Arc::new(current),
});
}
if segments.is_empty() {
return None;
}
Some((
segments
.into_iter()
.filter(|segment| !segment.text.is_empty())
.collect(),
link_ranges,
))
}
fn append_output_segments_with_ansi(
segments: &mut Vec<InlineSegment>,
link_ranges: &mut Vec<InlineLinkRange>,
text: &str,
styles: &PtyLineStyles,
) {
if let Some((mut ansi_segments, ansi_links)) = ansi_output_segments(text, styles) {
segments.append(&mut ansi_segments);
link_ranges.extend(ansi_links);
} else {
segments.push(InlineSegment {
text: text.to_string(),
style: Arc::clone(&styles.output),
});
}
}
pub(super) fn line_to_segments(
line: &str,
styles: &PtyLineStyles,
) -> (Vec<InlineSegment>, Vec<InlineLinkRange>) {
if let Some(command_text) = line.strip_prefix("• Ran ") {
let mut segments = vec![
InlineSegment {
text: "• ".to_string(),
style: Arc::clone(&styles.glyph),
},
InlineSegment {
text: "Ran".to_string(),
style: Arc::clone(&styles.verb),
},
InlineSegment {
text: " ".to_string(),
style: Arc::clone(&styles.output),
},
];
segments.extend(shell_syntax_segments(command_text, styles, true));
return (segments, Vec::new());
}
if let Some(text) = line.strip_prefix(" │ ") {
let mut segments = vec![
InlineSegment {
text: " ".to_string(),
style: Arc::clone(&styles.output),
},
InlineSegment {
text: "│".to_string(),
style: Arc::clone(&styles.glyph),
},
InlineSegment {
text: " ".to_string(),
style: Arc::clone(&styles.output),
},
];
segments.extend(shell_syntax_segments(text, styles, false));
return (segments, Vec::new());
}
if let Some(text) = line.strip_prefix(" └ ") {
let mut segments = vec![
InlineSegment {
text: " ".to_string(),
style: Arc::clone(&styles.output),
},
InlineSegment {
text: "└".to_string(),
style: Arc::clone(&styles.glyph),
},
InlineSegment {
text: " ".to_string(),
style: Arc::clone(&styles.output),
},
];
let mut link_ranges = Vec::new();
append_output_segments_with_ansi(&mut segments, &mut link_ranges, text, styles);
return (segments, shift_link_ranges(&link_ranges, 4));
}
if line.trim_start().starts_with('…') {
return (
vec![InlineSegment {
text: line.to_string(),
style: Arc::clone(&styles.truncation),
}],
Vec::new(),
);
}
if let Some(text) = line.strip_prefix(" ") {
let mut segments = vec![InlineSegment {
text: " ".to_string(),
style: Arc::clone(&styles.output),
}];
let mut link_ranges = Vec::new();
append_output_segments_with_ansi(&mut segments, &mut link_ranges, text, styles);
return (segments, shift_link_ranges(&link_ranges, 4));
}
(
vec![InlineSegment {
text: line.to_string(),
style: Arc::clone(&styles.output),
}],
Vec::new(),
)
}
fn shift_link_ranges(ranges: &[InlineLinkRange], by: usize) -> Vec<InlineLinkRange> {
ranges
.iter()
.cloned()
.map(|mut range| {
range.start += by;
range.end += by;
range
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pty_output_extracts_osc8_hyperlinks() {
let styles = PtyLineStyles::new();
let (segments, link_ranges) = line_to_segments(
" └ Go \u{1b}]8;;https://example.com/docs\u{1b}\\docs\u{1b}]8;;\u{1b}\\ now",
&styles,
);
let text = segments
.iter()
.map(|segment| segment.text.as_str())
.collect::<String>();
assert_eq!(text, " └ Go docs now");
assert_eq!(link_ranges.len(), 1);
assert_eq!(link_ranges[0].start, 7);
assert_eq!(link_ranges[0].end, 11);
assert!(matches!(
&link_ranges[0].target,
InlineLinkTarget::Url(url) if url == "https://example.com/docs"
));
}
}