use super::*; use iced::widget::column; impl App { pub(super) fn chat_detail(&self) -> Element<'_, Message> { let Some(item) = self.selected_project() else { let open_project_content = row![icon(ICON_FOLDER_PLUS, 17), text("Open project…"),] .spacing(8) .align_y(Alignment::Center); let open_project = if self.database.is_some() && !self.choosing_folder { action_button(open_project_content).on_press(Message::ChooseProjectFolder) } else { action_button(open_project_content) }; return container( column![ icon(ICON_SPARK, 36), text("Start a local coding session").size(28), text("Choose a project folder to create your first session.").size(14), Space::new().height(10), open_project, ] .spacing(10) .align_x(Alignment::Center), ) .center_x(Length::Fill) .center_y(Length::Fill) .into(); }; let project = &item.project; let active_title = self.active_session_title(item); let selected_title = active_title.unwrap_or(project.name.as_str()); let mut header = row![icon(ICON_FOLDER, 19), text(selected_title).size(18)]; if let Some(session) = self.selected_session(item) { header = header.push( button(icon(ICON_MORE, 18)) .on_press(Message::OpenSessionMenu(session.id)) .style(button::text), ); } let header = header .push(Space::new().width(Length::Fill)) .spacing(10) .align_y(Alignment::Center); let body: Element<'_, Message> = if let Some(title) = active_title { let mut messages = column![].spacing(12); if self.conversation.is_empty() { messages = messages.push( column![ text(title).size(26), text("Run DeepSeek locally with the Rust Metal engine.").size(14), ] .spacing(8), ); } else { let markdown_style = markdown::Style::from_palette(app_theme().palette()); for (index, message) in self.conversation.iter().enumerate() { if message.compaction { messages = messages.push( container( column![ text("Context compacted").size(11), text(&message.content).size(13).color(muted_text()), ] .spacing(5), ) .padding(14) .width(Length::Fill) .style(preference_group_style), ); continue; } if message.system { continue; } if message.tool && index > 0 && !crate::agent::stored_tool_cards( self.config.model, &self.conversation[index - 1].content, None, ) .is_empty() { continue; } if message.user && let Some(stats) = message.generation_stats { messages = messages.push(generation_summary(stats)); } let label = if message.user { "You" } else if message.tool { "Tool" } else { "DS4" }; let active = self.generating && index + 1 == self.conversation.len(); let mut body = column![text(label).size(11)].spacing(5); if let Some(reasoning) = &message.reasoning { let reasoning_label = match (message.reasoning_open, message.reasoning_complete, active) { (true, false, true) => "▾ Thinking", (false, false, true) => "› Thinking", (true, false, false) => "▾ Reasoning (stopped)", (false, false, false) => "› Reasoning (stopped)", (true, true, _) => "▾ Reasoning", (false, true, _) => "› Reasoning", }; body = body.push( button(text(reasoning_label).size(12)) .padding(0) .style(button::text) .on_press(Message::ToggleReasoning(index)), ); if message.reasoning_open { body = body.push( text(if reasoning.is_empty() && active { "Thinking…" } else { reasoning }) .size(13) .color(muted_text()), ); } } if !message.content.is_empty() { if message.user || message.tool || message.markdown.items().is_empty() { body = body.push( text_editor(&message.transcript) .id(iced::widget::Id::from(format!( "transcript-{}-{index}", message.id ))) .on_action(move |action| { Message::TranscriptAction(index, action) }) .padding(0) .size(14) .font(if message.tool { iced::Font::MONOSPACE } else { iced::Font::DEFAULT }) .style(selectable_text_style), ); } else { body = body.push( markdown::view( message.markdown.items(), markdown::Settings::with_text_size(14, markdown_style), ) .map(Message::OpenLink), ); } } else if active && message.reasoning.is_none() { body = body.push( text(self.activity.as_deref().unwrap_or("Loading model…")).size(14), ); } if !message.user { let stored_result = self .conversation .get(index + 1) .filter(|message| message.tool) .map(|message| message.content.as_str()); let cards = if stored_result.is_none() && { #[cfg(target_os = "macos")] { !self.tool_cards.is_empty() } #[cfg(not(target_os = "macos"))] { false } } { #[cfg(target_os = "macos")] { self.tool_cards.clone() } #[cfg(not(target_os = "macos"))] { Vec::new() } } else { crate::agent::stored_tool_cards( self.config.model, &message.content, stored_result, ) }; if !cards.is_empty() { body = body.push(tool_cards(cards)); } } let message_body = container(body).padding(14).width(Length::Fill); messages = messages.push(if message.user { message_body.style(chat_message_style) } else { message_body }); } if let Some(activity) = &self.activity { messages = messages.push( container(text(activity).size(13).color(muted_text())) .padding(12) .width(Length::Fill) .style(preference_group_style), ); } } // Tab walks every focusable widget of every window, so a composer // left behind an open dialog would take a turn in that dialog's // field order. Behind a modal it becomes a plain look-alike that // cannot be focused; the modal dims it either way. let composer: Element<'_, Message> = if self.modal_open() { container( text(if self.composer.is_empty() { "Ask DS4Server anything…" } else { &self.composer }) .size(14) .color(muted_text()), ) .padding(12) .width(Length::Fill) .into() } else { text_input( if self.generating { "Add guidance to the queue…" } else { "Ask DS4Server anything…" }, &self.composer, ) .id(composer_id()) .on_input(Message::ComposerChanged) .on_submit(Message::SubmitPrompt) .padding(12) .size(14) .into() }; let action = if self.generating { action_button(text("Stop").size(12)).on_press(Message::StopGeneration) } else if self.composer.trim().is_empty() { action_button(icon(ICON_SEND, 18)).padding(8) } else { action_button(icon(ICON_SEND, 18)) .padding(8) .on_press(Message::SubmitPrompt) }; let context_fraction = if self.context_limit == 0 { 0.0 } else { self.context_used.min(self.context_limit) as f32 / self.context_limit as f32 }; let mut composer_content = column![composer].spacing(6); for queued in &self.queued_inputs { let mut queued = queued.replace('\n', " "); if queued.chars().count() > 120 { queued = queued.chars().take(119).collect::() + "…"; } composer_content = composer_content.push( text(format!("Queued · {queued}")) .size(12) .color(muted_text()), ); } let project_control: Element<'_, Message> = if self.selected_session.is_none() { let choices = self .projects .iter() .map(|item| ProjectChoice { id: item.project.id, name: item.project.name.clone(), }) .collect::>(); let selected = choices .iter() .find(|choice| choice.id == project.id) .cloned(); pick_list(choices, selected, |choice| { Message::DraftProjectChanged(choice.id) }) .text_size(12) .padding([2, 6]) .into() } else { row![icon(ICON_FOLDER, 14), text(&project.name).size(12)] .spacing(4) .align_y(Alignment::Center) .into() }; let branch_control = self.git_states.get(&project.id).map(|state| { pick_list( state.branches.clone(), state.current.clone(), Message::SwitchGitBranch, ) .placeholder(&state.label) .text_size(12) .padding([2, 6]) }); composer_content = composer_content.push( row![ icon(ICON_PAPERCLIP, 19), tooltip( context_pie(context_fraction, 19), container( text(format!( "{} / {} tokens ({:.0}%)", self.context_used, self.context_limit, context_fraction * 100.0 )) .size(12) ) .padding(10) .style(preference_group_style), tooltip::Position::Top, ) .gap(6), text(self.tokens_per_second.map_or_else( || "— tok/s".to_owned(), |speed| format!("{speed:.1} tok/s") )) .size(11) .color(muted_text()), Space::new().width(Length::Fill), project_control, branch_control, icon(ICON_MODEL, 16), text(self.config.model.to_string()).size(12), action, ] .spacing(6) .align_y(Alignment::Center), ); if let Some(notice) = &self.context_notice { messages = messages.push( container( column![ text("Context rebuild").size(11), text(notice).size(13).color(muted_text()), ] .spacing(5), ) .padding(14) .width(Length::Fill) .style(preference_group_style), ); } let transcript = if self.chat_follow_tail { scrollable(messages).anchor_bottom() } else { scrollable(messages) } .id(chat_scroll_id()) .on_scroll(Message::ChatScrolled) .height(Length::Fill); let conversation = column![ transcript, container(composer_content,) .padding(16) .width(Length::Fill) .style(overview_style), ] .height(Length::Fill) .spacing(8); container(conversation) .max_width(860) .center_x(Length::Fill) .height(Length::Fill) .into() } else { container( column![ text(if item.sessions.is_empty() { "No sessions yet" } else { "Choose a session" }) .size(24), text("Use the new session icon next to the project, or pick one from the sidebar.") .size(14), ] .spacing(8) .align_x(Alignment::Center), ) .center_x(Length::Fill) .center_y(Length::Fill) .into() }; container(column![header, body].spacing(24)) .width(Length::Fill) .height(Length::Fill) .padding(24) .into() } } fn selectable_text_style( theme: &Theme, _status: iced::widget::text_editor::Status, ) -> iced::widget::text_editor::Style { iced::widget::text_editor::Style { background: Background::Color(Color::TRANSPARENT), border: Border::default(), placeholder: muted_text(), value: theme.palette().text, selection: theme.extended_palette().primary.weak.color, } } fn generation_summary(stats: crate::app::generation::GenerationStats) -> Element<'static, Message> { column![ row![ Space::new().width(Length::Fill), text(format!( "{} · {} input · {} cached · {} output", format_duration(stats.duration_ms as f64 / 1_000.0), format_token_count(stats.input_tokens), format_token_count(stats.cached_tokens), format_token_count(stats.output_tokens), )) .size(11) .color(muted_text()), ], rule::horizontal(1), ] .spacing(5) .padding(Padding::ZERO.right(24)) .into() } fn format_token_count(value: u32) -> String { let digits = value.to_string(); let mut output = String::with_capacity(digits.len() + digits.len() / 3); for (index, digit) in digits.chars().enumerate() { if index > 0 && (digits.len() - index).is_multiple_of(3) { output.push(','); } output.push(digit); } output } fn tool_cards(cards: Vec) -> Element<'static, Message> { let mut rows = column![].spacing(0); for (index, card) in cards.into_iter().enumerate() { if index > 0 { rows = rows.push(rule::horizontal(1)); } let parameters = crate::agent::tool_parameters(&card.call); let call = crate::agent::tool_call_text(&card.call); let copy_call = tooltip( action_button(text("Copy call").size(11)) .padding([5, 9]) .on_press(Message::CopyToolText(call)), container(text("Copy tool name and all arguments").size(11)) .padding(8) .style(preference_group_style), tooltip::Position::Top, ) .gap(6); let copy_result = action_button(text("Copy result").size(11)).padding([5, 9]); let copy_result = if let Some(result) = &card.result { copy_result.on_press(Message::CopyToolText(result.clone())) } else { copy_result }; let copy_result = tooltip( copy_result, container(text("Copy the complete tool result").size(11)) .padding(8) .style(preference_group_style), tooltip::Position::Top, ) .gap(6); let mut actions = row![copy_call, copy_result] .spacing(6) .align_y(Alignment::Center); if let Some(path) = card .result .as_deref() .and_then(crate::agent::tool_output_path) { actions = actions.push( tooltip( action_button(text("Open output").size(11)) .padding([5, 9]) .on_press(Message::OpenToolOutput(path)), container(text("Open the complete output file").size(11)) .padding(8) .style(preference_group_style), tooltip::Position::Top, ) .gap(6), ); } let mut content = column![ row![ text(card.call.name).size(13), Space::new().width(Length::Fill), text(card.state.label()).size(11).color(muted_text()), actions, ] .spacing(8) .align_y(Alignment::Center), text(parameters).size(12).color(muted_text()), ] .spacing(5); if let Some(result) = card.result { let bounded = crate::agent::bounded_tool_text(&result, 1_200); content = content.push(text(bounded).font(iced::Font::MONOSPACE).size(12)); } rows = rows.push(container(content).padding(10).width(Length::Fill)); } container(rows) .width(Length::Fill) .style(preference_group_style) .into() } #[cfg(test)] mod tests { use super::format_token_count; #[test] fn chat_divider_groups_token_counts_by_thousands() { assert_eq!(format_token_count(0), "0"); assert_eq!(format_token_count(999), "999"); assert_eq!(format_token_count(1_000), "1,000"); assert_eq!(format_token_count(12_345_678), "12,345,678"); } }