use crate::model::{EndState, Log, LogBmc, ModelManager, PinBmc, Run, RunningState, Task, TaskBmc};
use crate::support::text::truncate_with_ellipsis;
use crate::tui::core::{LinkZones, ScrollIden, UiAction};
use crate::tui::view::support::RectExt as _;
use crate::tui::view::{comp, support};
use crate::tui::{AppState, style};
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::Color;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Scrollbar, ScrollbarState, StatefulWidget, Widget as _};
use std::borrow::Cow;
pub struct TaskView;
impl TaskView {
const CONTENT_SCROLL_IDEN: ScrollIden = ScrollIden::TaskContent;
const SCROLL_IDENS: &[&ScrollIden] = &[&Self::CONTENT_SCROLL_IDEN];
pub fn clear_scroll_idens(state: &mut AppState) {
state.clear_scroll_zone_areas(Self::SCROLL_IDENS);
}
}
enum HeaderMode {
Full,
TokensOnly,
None,
}
impl StatefulWidget for TaskView {
type State = AppState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let has_many_tasks = state.tasks().len() > 1;
let use_full_header = has_many_tasks || state.current_run_is_in_nested_run_tree();
let header_mode = match (state.current_run_has_prompt_parts(), use_full_header) {
(None | Some(true), true) => HeaderMode::Full,
(None | Some(true), false) => HeaderMode::TokensOnly,
(Some(false), _) => HeaderMode::None,
};
let (header_height, header_spacing) = match header_mode {
HeaderMode::Full => (2, 1),
HeaderMode::TokensOnly => (1, 1),
HeaderMode::None => (0, 0),
};
let [header_a, _space_1, logs_a] = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![
Constraint::Length(header_height), Constraint::Max(header_spacing), Constraint::Fill(1), ])
.areas(area);
render_header(header_a, buf, state, header_mode);
render_body(logs_a, buf, state, false);
}
}
fn render_header(area: Rect, buf: &mut Buffer, state: &mut AppState, header_mode: HeaderMode) {
if matches!(header_mode, HeaderMode::None) {
return;
}
const L1_VAL_1_WIDTH: u16 = 26;
const L1_VAL_2_WIDTH: u16 = 12;
const L2_VAL_1_WIDTH: u16 = 26;
const L2_VAL_2_WIDTH: u16 = L1_VAL_2_WIDTH;
let completion_tk_width = Span::raw(state.current_task_completion_tokens_fmt())
.width()
.min(u16::MAX as usize) as u16;
let header_val_2_default_width = L1_VAL_2_WIDTH.max(L2_VAL_2_WIDTH);
let header_val_2_width = if completion_tk_width > header_val_2_default_width {
let fixed_width = 10 + L1_VAL_1_WIDTH + 7 + 13 + 5;
let max_width = area.width.saturating_sub(fixed_width);
completion_tk_width.min(max_width).max(header_val_2_default_width)
} else {
header_val_2_default_width
};
let [l1_label_1, l1_val_1, l1_label_2, l1_val_2, l1_label_3, l1_val_3] = Layout::default()
.direction(Direction::Horizontal)
.constraints(vec![
Constraint::Length(10), Constraint::Length(L1_VAL_1_WIDTH), Constraint::Length(7), Constraint::Length(header_val_2_width), Constraint::Length(13), Constraint::Fill(1), ])
.spacing(1)
.areas(area);
let [l2_label_1, l2_val_1, l2_label_2, l2_val_2, l2_label_3, l2_val_3] = Layout::default()
.direction(Direction::Horizontal)
.constraints(vec![
Constraint::Length(10), Constraint::Length(L2_VAL_1_WIDTH), Constraint::Length(7), Constraint::Length(header_val_2_width), Constraint::Length(13), Constraint::Fill(1), ])
.spacing(1)
.areas(area);
let mut current_row = 0;
let model_name = state.current_task_model_name();
let cost = state.current_task_cost_fmt();
let duration = state.current_task_duration_txt();
if matches!(header_mode, HeaderMode::Full) {
current_row += 1;
Paragraph::new("Model:")
.style(style::STL_FIELD_LBL)
.right_aligned()
.render(l1_label_1.x_row(current_row), buf);
Paragraph::new(model_name)
.style(style::STL_FIELD_VAL)
.render(l1_val_1.x_row(current_row).x_width(26), buf);
Paragraph::new(Span::styled(" Cost:", style::STL_FIELD_LBL))
.right_aligned()
.render(l1_label_2.x_row(current_row), buf);
Paragraph::new(cost)
.style(style::STL_FIELD_VAL)
.render(l1_val_2.x_row(current_row), buf);
Paragraph::new(" Duration:")
.style(style::STL_FIELD_LBL)
.right_aligned()
.render(l1_label_3.x_row(current_row), buf);
Paragraph::new(duration)
.style(style::STL_FIELD_VAL)
.render(l1_val_3.x_row(current_row), buf);
}
let prompt_tk = state.current_task_prompt_tokens_fmt();
let prompt_tk = truncate_with_ellipsis(&prompt_tk, L2_VAL_1_WIDTH as usize, ".)");
let completion_tk = state.current_task_completion_tokens_fmt();
let completion_tk = truncate_with_ellipsis(&completion_tk, header_val_2_width as usize, ".)");
let cache_info = state.current_task_cache_info_fmt();
if matches!(header_mode, HeaderMode::Full | HeaderMode::TokensOnly) {
current_row += 1;
Paragraph::new("Prompt:")
.style(style::STL_FIELD_LBL)
.right_aligned()
.render(l2_label_1.x_row(current_row), buf);
Paragraph::new(prompt_tk)
.style(style::STL_FIELD_VAL)
.render(l2_val_1.x_row(current_row), buf);
Paragraph::new("Compl:")
.style(style::STL_FIELD_LBL)
.right_aligned()
.render(l2_label_2.x_row(current_row), buf);
Paragraph::new(completion_tk)
.style(style::STL_FIELD_VAL)
.render(l2_val_2.x_row(current_row), buf);
if let Some(cache_info) = cache_info {
Paragraph::new(" Cache Info:")
.style(style::STL_FIELD_LBL)
.right_aligned()
.render(l2_label_3.x_row(current_row), buf);
Paragraph::new(cache_info)
.style(style::STL_FIELD_VAL)
.render(l2_val_3.x_row(current_row), buf);
}
}
}
fn render_body(area: Rect, buf: &mut Buffer, state: &mut AppState, show_steps: bool) {
const SCROLL_IDEN: ScrollIden = TaskView::CONTENT_SCROLL_IDEN;
state.set_scroll_area(SCROLL_IDEN, area);
let Some(run_item) = state.current_run_item() else {
Line::raw("No Current Run").render(area, buf);
return;
};
let run = run_item.run();
let Some(task) = state.current_task() else {
Line::raw("No Current Task").render(area, buf);
return;
};
let pins = match PinBmc::list_for_task(state.mm(), task.id) {
Ok(pins) => pins,
Err(err) => {
Paragraph::new(format!("PinBmc::list error. {err}")).render(area, buf);
return;
}
};
let logs = match LogBmc::list_for_task(state.mm(), task.id) {
Ok(logs) => logs,
Err(err) => {
Paragraph::new(format!("LogBmc::list error. {err}")).render(area, buf);
return;
}
};
let mut all_lines: Vec<Line> = Vec::new();
let max_width = area.width - 3;
let mut link_zones = LinkZones::default();
let path_color = (state.debug_clr() != 0).then(|| Color::Indexed(state.debug_clr()));
link_zones.set_current_line(all_lines.len());
support::extend_lines(
&mut all_lines,
comp::ui_for_pins_with_hover(&pins, max_width, &mut link_zones, path_color),
false,
);
link_zones.set_current_line(all_lines.len());
support::extend_lines(
&mut all_lines,
ui_for_input(state.mm(), task, max_width, &mut link_zones, path_color),
false,
);
link_zones.set_current_line(all_lines.len());
link_zones.set_current_line(all_lines.len());
support::extend_lines(
&mut all_lines,
ui_for_before_ai_logs(task, &logs, max_width, show_steps, &mut link_zones, path_color),
false,
);
if let Some(true) | None = state.current_run_has_prompt_parts() {
link_zones.set_current_line(all_lines.len());
support::extend_lines(
&mut all_lines,
ui_for_ai(run, task, max_width, &mut link_zones, path_color),
true,
);
}
link_zones.set_current_line(all_lines.len());
link_zones.set_current_line(all_lines.len());
support::extend_lines(
&mut all_lines,
ui_for_after_ai_logs(task, &logs, max_width, show_steps, &mut link_zones, path_color),
false,
);
if task.output_short.is_some() {
link_zones.set_current_line(all_lines.len());
support::extend_lines(
&mut all_lines,
ui_for_output(state.mm(), task, max_width, &mut link_zones, path_color),
false,
);
}
link_zones.set_current_line(all_lines.len());
if let Some(err_id) = task.end_err_id {
support::extend_lines(
&mut all_lines,
comp::ui_for_err_with_hover(state.mm(), err_id, max_width, &mut link_zones, path_color),
true,
);
}
link_zones.set_current_line(all_lines.len());
let line_count = all_lines.len();
let scroll = state.clamp_scroll(SCROLL_IDEN, line_count);
let zones = link_zones.into_zones();
let mut hovered_idx: Option<usize> = None;
let mut min_span_count = usize::MAX;
for (i, zone) in zones.iter().enumerate() {
if let Some(line) = all_lines.get_mut(zone.line_idx)
&& zone
.is_mouse_over(area, scroll, state.last_mouse_evt(), &mut line.spans)
.is_some()
&& zone.span_count < min_span_count
{
min_span_count = zone.span_count;
hovered_idx = Some(i);
}
}
if let Some(i) = hovered_idx {
let action = zones[i].action.clone();
let group_id = zones[i].group_id;
match group_id {
Some(gid) => {
for z in zones.iter().filter(|z| z.group_id == Some(gid)) {
if let Some(line) = all_lines.get_mut(z.line_idx)
&& let Some(hover_spans) = z.spans_slice_mut(&mut line.spans)
{
for span in hover_spans {
span.style.fg = Some(style::CLR_TXT_HOVER_TO_CLIP);
}
}
}
}
None => {
if let Some(line) = all_lines.get_mut(zones[i].line_idx)
&& let Some(hover_spans) = zones[i].spans_slice_mut(&mut line.spans)
{
for span in hover_spans {
span.style = style::style_text_path(true, None);
}
}
}
}
if state.is_mouse_up_only() && state.is_last_mouse_over(area) {
state.set_action(action);
state.clear_mouse_evts(true);
}
}
let p = Paragraph::new(all_lines).scroll((scroll, 0));
p.render(area, buf);
let content_size = line_count.saturating_sub(area.height as usize);
let mut scrollbar_state = ScrollbarState::new(content_size).position(scroll as usize);
let scrollbar = Scrollbar::default()
.orientation(ratatui::widgets::ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("▲"))
.end_symbol(Some("▼"));
scrollbar.render(area, buf, &mut scrollbar_state);
}
fn ui_for_input(
mm: &ModelManager,
task: &Task,
max_width: u16,
link_zones: &mut LinkZones,
path_color: Option<Color>,
) -> Vec<Line<'static>> {
let marker_txt = "Input:";
let marker_style = style::STL_SECTION_MARKER_INPUT;
match TaskBmc::get_input_for_display(mm, task) {
Ok(Some(content)) => {
let mut out = comp::ui_for_marker_section_str(
&content,
(marker_txt, marker_style),
max_width,
None,
Some(link_zones),
Some(UiAction::ToClipboardCopy(content.clone())),
path_color,
);
out.push(Line::default());
link_zones.inc_current_line_by(1);
out
}
Ok(None) => Vec::new(),
Err(err) => {
let mut out = comp::ui_for_marker_section_str(
&format!("Error getting input. {err}"),
(marker_txt, marker_style),
max_width,
None,
None,
None,
path_color,
);
if !out.is_empty() {
out.push(Line::default());
}
out
}
}
}
fn ui_for_ai(
run: &Run,
task: &Task,
max_width: u16,
link_zones: &mut LinkZones,
path_color: Option<Color>,
) -> Vec<Line<'static>> {
let marker_txt = "AI:";
let marker_style_active = style::STL_SECTION_MARKER_AI;
let marker_stype_inactive = style::STL_SECTION_MARKER;
let model_name = task
.model_ov
.as_ref()
.or(run.model.as_ref())
.map(|v| v.as_str())
.unwrap_or_default();
let model_names: Cow<str> = if let Some(model_upstream) = task.model_upstream.as_ref()
&& model_upstream != model_name
{
format!("{model_name} ({model_upstream})").into()
} else {
model_name.into()
};
let (content, style) = match task.ai_running_state() {
RunningState::Ended(Some(EndState::Cancel)) => (
Some(format!("■ AI request canceled {model_names}.")),
marker_style_active,
),
RunningState::Running => (
Some(format!("➜ Sending prompt to AI model {model_names}.")),
marker_style_active,
),
RunningState::Ended(Some(EndState::Ok)) => {
(
Some(format!("✔ AI model {model_names} responded.")),
marker_style_active,
)
}
RunningState::Ended(Some(EndState::Err)) => {
(
Some(format!("✘ AI model {model_names} responded with an error.")),
marker_style_active,
)
}
RunningState::NotScheduled => (
Some(". No instruction given. GenAI Skipped.".to_string()),
marker_stype_inactive,
),
_ => (None, marker_stype_inactive),
};
if let Some(content) = content {
comp::ui_for_marker_section_str(
&content,
(marker_txt, style),
max_width,
None,
Some(link_zones),
Some(UiAction::ToClipboardCopy(content.clone())),
path_color,
)
} else {
Vec::new()
}
}
fn ui_for_output(
mm: &ModelManager,
task: &Task,
max_width: u16,
link_zones: &mut LinkZones,
path_color: Option<Color>,
) -> Vec<Line<'static>> {
let marker_txt = "Output:";
let marker_style = style::STL_SECTION_MARKER_OUTPUT;
match TaskBmc::get_output_for_display(mm, task) {
Ok(Some(content)) => {
let mut out = comp::ui_for_marker_section_str(
&content,
(marker_txt, marker_style),
max_width,
None,
Some(link_zones),
Some(UiAction::ToClipboardCopy(content.clone())),
path_color,
);
out.push(Line::default());
link_zones.inc_current_line_by(1);
out
}
Ok(None) => Vec::new(),
Err(err) => {
let mut out = comp::ui_for_marker_section_str(
&format!("Error getting output. {err}"),
(marker_txt, marker_style),
max_width,
None,
None,
None,
path_color,
);
if !out.is_empty() {
out.push(Line::default());
}
out
}
}
}
fn ui_for_before_ai_logs(
task: &Task,
logs: &[Log],
max_width: u16,
show_steps: bool,
link_zones: &mut LinkZones,
path_color: Option<Color>,
) -> Vec<Line<'static>> {
let ai_start: i64 = task.ai_start.map(|v| v.as_i64()).unwrap_or(i64::MAX);
let iter = logs.iter().filter(|v| v.ctime.as_i64() < ai_start);
comp::ui_for_logs_with_hover(iter, max_width, None, show_steps, link_zones, path_color)
}
fn ui_for_after_ai_logs(
task: &Task,
logs: &[Log],
max_width: u16,
show_steps: bool,
link_zones: &mut LinkZones,
path_color: Option<Color>,
) -> Vec<Line<'static>> {
let ai_start: i64 = task.ai_start.map(|v| v.as_i64()).unwrap_or(i64::MAX);
let iter = logs.iter().filter(|v| v.ctime.as_i64() > ai_start);
comp::ui_for_logs_with_hover(iter, max_width, None, show_steps, link_zones, path_color)
}
#[allow(unused)]
fn first_line_truncate(s: &str, max: usize) -> String {
s.lines().next().unwrap_or("").chars().take(max).collect()
}