Skip to content

Commit fdeffc1

Browse files
committed
feat(tui): P0-2 markdown rendering + P0-3 status bar
P0-2: Wire markdown rendering into content area - Assistant responses now rendered through MarkdownRenderer - Headings, bold, italic, code blocks, lists all styled - System lines ([tool], [error], [session]) keep prefix-based styling - Syntax highlighting via SyntaxHighlighter for fenced code blocks P0-3: Rich status bar replacing help text - When spinner inactive: shows "model │ Xk in · Yk out" - Thinking state shown inline: "│ thinking (Ns)" - Token counts formatted: 1234→"1.2k", 1234567→"1.2M" - Bottom bar kept for context-sensitive help (? for shortcuts)
1 parent 150ba40 commit fdeffc1

1 file changed

Lines changed: 129 additions & 14 deletions

File tree

crates/tui/src/app.rs

Lines changed: 129 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -735,11 +735,19 @@ impl App {
735735
buf,
736736
);
737737

738-
// Thinking state indicator (overlays into status area)
739-
render_thinking_state(&self.thinking, layout.status, buf);
740-
741-
// Status line / spinner
742-
Widget::render(&self.spinner, layout.status, buf);
738+
// Status line: spinner (when active) or status bar (model/tokens/cost)
739+
if self.spinner.is_active() {
740+
Widget::render(&self.spinner, layout.status, buf);
741+
} else {
742+
render_status_line(
743+
&self.model_name,
744+
self.total_input_tokens,
745+
self.total_output_tokens,
746+
&self.thinking,
747+
layout.status,
748+
buf,
749+
);
750+
}
743751

744752
// Separator above input
745753
render_separator(layout.separator_top, buf);
@@ -925,7 +933,7 @@ fn render_separator(area: Rect, buf: &mut Buffer) {
925933
///
926934
/// When thinking is active, shows `"Thinking... (Ns)"` with elapsed time.
927935
/// After thinking finishes, shows `"(thought for Ns)"` for 2 seconds.
928-
#[allow(clippy::cast_possible_truncation)]
936+
#[allow(dead_code, clippy::cast_possible_truncation)]
929937
fn render_thinking_state(thinking: &ThinkingState, area: Rect, buf: &mut Buffer) {
930938
if area.height == 0 || area.width == 0 {
931939
return;
@@ -1051,31 +1059,71 @@ fn render_content_scrolled(
10511059
return;
10521060
}
10531061

1054-
let lines: Vec<&str> = text.lines().collect();
1062+
// Render lines: system prefixed lines get color-coded styles,
1063+
// everything else goes through markdown rendering.
1064+
let theme = crate::theme::Theme::dark();
1065+
let highlighter = crate::components::syntax::SyntaxHighlighter::new();
1066+
let md_renderer = crate::components::markdown::MarkdownRenderer::new(&theme, &highlighter);
1067+
1068+
let mut rendered_lines: Vec<Line<'static>> = Vec::new();
1069+
1070+
// Split content into segments: system lines vs markdown blocks
1071+
let mut md_block = String::new();
1072+
for raw_line in text.lines() {
1073+
if is_system_line(raw_line) {
1074+
// Flush any accumulated markdown
1075+
if !md_block.is_empty() {
1076+
rendered_lines.extend(md_renderer.render(&md_block));
1077+
md_block.clear();
1078+
}
1079+
// Render system line with prefix-based styling
1080+
let style = classify_content_style(raw_line, styles);
1081+
rendered_lines.push(Line::from(Span::styled(raw_line.to_string(), style)));
1082+
} else {
1083+
md_block.push_str(raw_line);
1084+
md_block.push('\n');
1085+
}
1086+
}
1087+
// Flush remaining markdown
1088+
if !md_block.is_empty() {
1089+
rendered_lines.extend(md_renderer.render(&md_block));
1090+
}
1091+
10551092
let visible = area.height as usize;
1056-
// Show the last N lines minus scroll offset (auto-scroll to bottom)
1057-
let end = lines.len().saturating_sub(scroll_offset);
1093+
let end = rendered_lines.len().saturating_sub(scroll_offset);
10581094
let start = end.saturating_sub(visible);
10591095

1060-
for (i, line_text) in lines
1096+
for (i, line) in rendered_lines
10611097
.iter()
10621098
.skip(start)
1063-
.take(visible.min(end - start))
1099+
.take(visible.min(end.saturating_sub(start)))
10641100
.enumerate()
10651101
{
10661102
let y = area.y + i as u16;
1067-
let style = classify_content_style(line_text, styles);
1068-
let line_widget = Line::from(Span::styled(*line_text, style));
10691103
let line_area = Rect {
10701104
x: area.x,
10711105
y,
10721106
width: area.width,
10731107
height: 1,
10741108
};
1075-
Widget::render(line_widget, line_area, buf);
1109+
Widget::render(line.clone(), line_area, buf);
10761110
}
10771111
}
10781112

1113+
/// Check if a line is a system/tool prefix line (not markdown).
1114+
fn is_system_line(line: &str) -> bool {
1115+
let t = line.trim_start();
1116+
t.starts_with("[tool")
1117+
|| t.starts_with("[Error:")
1118+
|| t.starts_with("[warn]")
1119+
|| t.starts_with("[session]")
1120+
|| t.starts_with("[compact]")
1121+
|| t.starts_with("[interrupted]")
1122+
|| t.starts_with("❯ ")
1123+
|| t.starts_with("────")
1124+
|| t.starts_with("Welcome!")
1125+
}
1126+
10791127
/// Choose a style for a content line based on its prefix/content.
10801128
fn classify_content_style(line: &str, styles: &OutputStyles) -> Style {
10811129
let trimmed = line.trim_start();
@@ -1180,6 +1228,73 @@ fn classify_tool_risk(tool_name: &str) -> RiskLevel {
11801228
}
11811229
}
11821230

1231+
/// Render the status line: model name | token counts | thinking state.
1232+
///
1233+
/// Matches CC's `StatusLine` component showing operational data.
1234+
fn render_status_line(
1235+
model: &str,
1236+
input_tokens: u64,
1237+
output_tokens: u64,
1238+
thinking: &ThinkingState,
1239+
area: Rect,
1240+
buf: &mut Buffer,
1241+
) {
1242+
if area.width < 10 || area.height == 0 {
1243+
return;
1244+
}
1245+
1246+
let mut spans = vec![
1247+
Span::styled(model, Style::default().fg(Color::Cyan)),
1248+
Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
1249+
];
1250+
1251+
// Token counts
1252+
let in_str = format_token_count(input_tokens);
1253+
let out_str = format_token_count(output_tokens);
1254+
spans.push(Span::styled(
1255+
format!("{in_str} in"),
1256+
Style::default().fg(Color::DarkGray),
1257+
));
1258+
spans.push(Span::styled(" · ", Style::default().fg(Color::DarkGray)));
1259+
spans.push(Span::styled(
1260+
format!("{out_str} out"),
1261+
Style::default().fg(Color::DarkGray),
1262+
));
1263+
1264+
// Thinking state
1265+
match thinking {
1266+
ThinkingState::Thinking { started_at } => {
1267+
let elapsed = started_at.elapsed().as_secs();
1268+
spans.push(Span::styled(" │ ", Style::default().fg(Color::DarkGray)));
1269+
spans.push(Span::styled(
1270+
format!("thinking ({elapsed}s)"),
1271+
Style::default().fg(Color::Yellow),
1272+
));
1273+
}
1274+
ThinkingState::ThoughtFor { duration, .. } => {
1275+
spans.push(Span::styled(" │ ", Style::default().fg(Color::DarkGray)));
1276+
spans.push(Span::styled(
1277+
format!("thought for {}s", duration.as_secs()),
1278+
Style::default().fg(Color::DarkGray),
1279+
));
1280+
}
1281+
ThinkingState::Idle => {}
1282+
}
1283+
1284+
Widget::render(Line::from(spans), area, buf);
1285+
}
1286+
1287+
/// Format token count: 1234 → "1.2k", 500 → "500"
1288+
fn format_token_count(tokens: u64) -> String {
1289+
if tokens >= 1_000_000 {
1290+
format!("{:.1}M", tokens as f64 / 1_000_000.0)
1291+
} else if tokens >= 1000 {
1292+
format!("{:.1}k", tokens as f64 / 1000.0)
1293+
} else {
1294+
tokens.to_string()
1295+
}
1296+
}
1297+
11831298
fn render_bottom_bar(state: AppState, search_active: bool, area: Rect, buf: &mut Buffer) {
11841299
let line = if search_active {
11851300
Line::from(Span::styled(

0 commit comments

Comments
 (0)