use std::collections::HashSet;
use ratatui::style::Style;
use ratatui::text::Line;
use crate::widgets::chat::dashboard::{measure_dashboard, DashboardInfo};
use crate::widgets::chat::markdown::{filter_tool_json, md_lines};
use crate::widgets::chat::state::ChatViewState;
use crate::widgets::chat::types::{ContentBlock, MessageRole, ToolCallStatus};
pub(crate) fn measure_wrapped_height(lines: &[Line<'_>], _width: u16) -> u16 {
lines.len() as u16
}
#[derive(Clone)]
pub(crate) struct LayoutEntry {
pub y: u16,
pub height: u16,
pub kind: LayoutKind,
}
#[derive(Clone)]
pub(crate) enum LayoutKind {
Spacer,
Rule,
#[allow(dead_code)]
Label {
text: String,
style: Style,
},
Text {
lines: Vec<Line<'static>>,
is_user: bool,
},
ToolBox {
name: String,
arguments: String,
result: Option<(String, bool)>,
status: ToolCallStatus,
duration: Option<String>,
expanded: bool,
key: String,
},
ToolResultBox {
tool_name: String,
content: String,
is_error: bool,
},
ErrorBox {
title: String,
message: String,
retryable: bool,
},
Thinking {
content: String,
collapsed: bool,
key: String,
},
Image {
mime_type: String,
size_str: String,
},
#[allow(dead_code)]
Spinner {
frame: usize,
},
Dashboard {
info: DashboardInfo,
},
}
fn is_box_block(block: &ContentBlock) -> bool {
matches!(
block,
ContentBlock::ToolCall { .. }
| ContentBlock::ToolResult { .. }
| ContentBlock::Error { .. }
)
}
pub(crate) fn compute_layout(state: &ChatViewState, width: u16) -> Vec<LayoutEntry> {
let mut entries = Vec::new();
let mut y: u32 = 0;
let mut rendered_any_message = false;
let mut msg_idx: usize = 0;
'outer: for msg in &state.messages {
let has_visible_content = msg.content_blocks.iter().any(|b| match b {
ContentBlock::Text { content } => !content.trim().is_empty(),
ContentBlock::Thinking { content, .. } => !content.trim().is_empty(),
_ => true,
});
if !has_visible_content {
msg_idx += 1;
continue;
}
if rendered_any_message {
if y <= u16::MAX as u32 {
entries.push(LayoutEntry {
y: y as u16,
height: 1,
kind: LayoutKind::Spacer,
});
}
y += 1;
}
rendered_any_message = true;
if msg.role == MessageRole::User {
if y <= u16::MAX as u32 {
entries.push(LayoutEntry {
y: y as u16,
height: 1,
kind: LayoutKind::Rule,
});
}
y += 1;
}
let mut prev_was_box = false;
for (blk_idx, block) in msg.content_blocks.iter().enumerate() {
let is_empty = match block {
ContentBlock::Text { content } => content.trim().is_empty(),
ContentBlock::Thinking { content, .. } => content.trim().is_empty(),
_ => false,
};
if is_empty {
continue;
}
let is_box = is_box_block(block);
if is_box && prev_was_box {
if y <= u16::MAX as u32 {
entries.push(LayoutEntry {
y: y as u16,
height: 1,
kind: LayoutKind::Spacer,
});
}
y += 1;
}
prev_was_box = is_box;
let key = format!("{}:{}", msg_idx, blk_idx);
let mut kind = block_to_layout_kind(block, msg.role, width, &key);
#[allow(clippy::collapsible_match)]
match &mut kind {
LayoutKind::Thinking {
ref mut collapsed,
ref key,
..
} =>
{
#[allow(clippy::collapsible_match)]
if state.expanded_thinking.contains(key) {
*collapsed = false;
}
}
LayoutKind::ToolBox {
ref mut expanded,
ref key,
..
} =>
{
#[allow(clippy::collapsible_match)]
if state.expanded_tools.contains(key) {
*expanded = true;
}
}
_ => {}
}
let h = measure_kind(&kind, width, &state.expanded_thinking);
if y > u16::MAX as u32 {
break 'outer;
}
entries.push(LayoutEntry {
y: y as u16,
height: h,
kind,
});
y += h as u32;
}
msg_idx += 1;
}
if let Some(ref streaming) = state.streaming {
if rendered_any_message {
if y <= u16::MAX as u32 {
entries.push(LayoutEntry {
y: y as u16,
height: 1,
kind: LayoutKind::Spacer,
});
}
y += 1;
}
let mut prev_was_box = false;
for (blk_idx, block) in streaming.message.content_blocks.iter().enumerate() {
let is_empty = match block {
ContentBlock::Text { content } => content.trim().is_empty(),
ContentBlock::Thinking { content, .. } => content.trim().is_empty(),
_ => false,
};
if is_empty {
continue;
}
let is_box = is_box_block(block);
if is_box && prev_was_box {
if y <= u16::MAX as u32 {
entries.push(LayoutEntry {
y: y as u16,
height: 1,
kind: LayoutKind::Spacer,
});
}
y += 1;
}
prev_was_box = is_box;
let key = format!("s:{}", blk_idx);
let mut kind = block_to_layout_kind(block, MessageRole::Assistant, width, &key);
#[allow(clippy::collapsible_match)]
match &mut kind {
LayoutKind::Thinking {
ref mut collapsed,
ref key,
..
} =>
{
#[allow(clippy::collapsible_match)]
if state.expanded_thinking.contains(key) {
*collapsed = false;
}
}
LayoutKind::ToolBox {
ref mut expanded,
ref key,
..
} =>
{
#[allow(clippy::collapsible_match)]
if state.expanded_tools.contains(key) {
*expanded = true;
}
}
_ => {}
}
let h = measure_kind(&kind, width, &state.expanded_thinking);
if y <= u16::MAX as u32 {
entries.push(LayoutEntry {
y: y as u16,
height: h,
kind,
});
}
y += h as u32;
}
}
entries
}
fn block_to_layout_kind(
block: &ContentBlock,
role: MessageRole,
width: u16,
key: &str,
) -> LayoutKind {
match block {
ContentBlock::Text { content } => {
let wrap_w = if role == MessageRole::User {
width.saturating_sub(1)
} else {
width
};
let lines = md_lines(content, wrap_w);
LayoutKind::Text {
lines,
is_user: role == MessageRole::User,
}
}
ContentBlock::Thinking { content, collapsed } => {
LayoutKind::Thinking {
content: content.clone(),
collapsed: *collapsed,
key: key.to_string(),
}
}
ContentBlock::ToolCall {
name,
arguments,
result,
status,
duration,
..
} => LayoutKind::ToolBox {
name: name.clone(),
arguments: arguments.clone(),
result: result.clone(),
status: *status,
duration: duration.clone(),
expanded: false, key: key.to_string(),
},
ContentBlock::ToolResult {
tool_name,
content,
is_error,
} => LayoutKind::ToolResultBox {
tool_name: tool_name.clone(),
content: content.clone(),
is_error: *is_error,
},
ContentBlock::Error {
title,
message,
retryable,
} => LayoutKind::ErrorBox {
title: title.clone(),
message: message.clone(),
retryable: *retryable,
},
ContentBlock::Image {
mime_type,
base64_data,
} => {
let sz = base64_data.len() * 3 / 4;
let sz_str = if sz >= 1_048_576 {
format!("{:.1} MB", sz as f64 / 1_048_576.0)
} else if sz >= 1024 {
format!("{:.1} KB", sz as f64 / 1024.0)
} else {
format!("{} B", sz)
};
LayoutKind::Image {
mime_type: mime_type.clone(),
size_str: sz_str,
}
}
ContentBlock::Dashboard { info } => LayoutKind::Dashboard { info: info.clone() },
}
}
pub(crate) fn measure_kind(
kind: &LayoutKind,
width: u16,
expanded_thinking: &HashSet<String>,
) -> u16 {
match kind {
LayoutKind::Spacer
| LayoutKind::Rule
| LayoutKind::Label { .. }
| LayoutKind::Spinner { .. } => 1,
LayoutKind::Text { lines, is_user } => {
let w = if *is_user {
width.saturating_sub(1)
} else {
width
};
measure_wrapped_height(lines, w)
}
LayoutKind::ToolBox {
name,
arguments,
result,
duration,
expanded,
..
} => {
use crate::widgets::tool_renderer::{measure_call_height, measure_result_height};
let inner_w = width.saturating_sub(2) as usize;
let call_h = measure_call_height(name, arguments, inner_w);
let result_h = result.as_ref().map_or(0, |(r, is_err)| {
if *expanded {
let total = r.lines().count();
let shown = total.min(80);
let ellipsis = if total > 80 { 1 } else { 0 };
shown as u16 + ellipsis
} else if *is_err {
let total = r.lines().count();
total.min(4) as u16 + if total > 4 { 1 } else { 0 }
} else {
measure_result_height(name, r, false)
}
});
let separator_h = if result.is_some() { 1 } else { 0 };
let toggle_h = if result.is_some() { 1 } else { 0 };
let _ = duration;
2 + call_h + separator_h + result_h + toggle_h
}
LayoutKind::ToolResultBox { content, .. } => {
let n = content.lines().count().min(4);
1 + n as u16 + if content.lines().count() > 4 { 1 } else { 0 }
}
LayoutKind::ErrorBox {
message, retryable, ..
} => {
let n = message.lines().count().min(4);
2 + n as u16 + if *retryable { 1 } else { 0 }
}
LayoutKind::Thinking {
content,
collapsed,
key,
} => {
let is_expanded = expanded_thinking.contains(key);
if *collapsed && !is_expanded {
let filtered = filter_tool_json(content);
let line_count = filtered.lines().count();
1 + if line_count > 0 { 1 } else { 0 }
} else {
let filtered = filter_tool_json(content);
let md = md_lines(&filtered, width);
1 + md.len() as u16
}
}
LayoutKind::Image { .. } => 2,
LayoutKind::Dashboard { info } => measure_dashboard(info, width),
}
}