Files
DS4Server/src/app/view/chat.rs
2026-08-02 09:47:28 +02:00

610 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
&self.conversation[index - 1].tool_approval_reasons,
)
.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,
&message.tool_approval_reasons,
)
};
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 = text_editor(&self.composer)
.placeholder(if self.generating {
"Add guidance to the queue…"
} else {
"Ask DS4Server anything…"
})
.height(Length::Shrink)
.max_height(160)
.padding(12)
.size(14)
.wrapping(iced::widget::text::Wrapping::Word)
.style(selectable_text_style);
let composer: Element<'_, Message> = if self.modal_open() {
composer.into()
} else {
composer
.id(composer_id())
.on_action(Message::ComposerAction)
.key_binding(|event| {
composer_enter_binding(&event.key, event.modifiers)
.or_else(|| text_editor::Binding::from_key_press(event))
})
.into()
};
let action = if self.generating {
action_button(text("").size(13))
.padding(8)
.style(stop_button_style)
.on_press(Message::StopGeneration)
} else if self.composer.text().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::<String>() + "";
}
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::<Vec<_>>();
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])
});
let reasoning_mode = self.config.generation.effective_reasoning_mode();
let reasoning_control: Element<'_, Message> = if self.active_chat_count() > 0 {
text(reasoning_mode.to_string()).size(12).into()
} else {
pick_list(
self.config.generation.supported_reasoning_modes(),
Some(reasoning_mode),
Message::ReasoningModeChanged,
)
.text_size(12)
.padding([2, 6])
.into()
};
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,
pick_list(
&PERMISSION_MODES[..],
Some(self.permission_mode),
Message::PermissionModeChanged,
)
.text_size(12)
.padding([2, 6]),
reasoning_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(preference_group_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 composer_enter_binding(
key: &iced::keyboard::Key,
modifiers: iced::keyboard::Modifiers,
) -> Option<text_editor::Binding<Message>> {
match key.as_ref() {
iced::keyboard::Key::Named(iced::keyboard::key::Named::Enter) if modifiers.shift() => {
Some(text_editor::Binding::Enter)
}
iced::keyboard::Key::Named(iced::keyboard::key::Named::Enter) => {
Some(text_editor::Binding::Custom(Message::SubmitPrompt))
}
_ => None,
}
}
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<crate::agent::ToolCard>) -> 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 name = row![text(card.call.name).size(13)]
.spacing(5)
.align_y(Alignment::Center);
if let Some(reason) = &card.approval_reason {
name = name.push(
tooltip(
icon(ICON_ROBOT, 13),
container(text(reason.clone()).size(12))
.padding(8)
.max_width(320)
.style(preference_group_style),
tooltip::Position::Top,
)
.gap(5),
);
}
let mut content = column![
row![
name,
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::*;
#[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");
}
#[test]
fn composer_uses_shift_enter_for_line_breaks() {
let enter = iced::keyboard::Key::Named(iced::keyboard::key::Named::Enter);
assert!(matches!(
composer_enter_binding(&enter, iced::keyboard::Modifiers::SHIFT),
Some(text_editor::Binding::Enter)
));
assert!(matches!(
composer_enter_binding(&enter, iced::keyboard::Modifiers::empty()),
Some(text_editor::Binding::Custom(Message::SubmitPrompt))
));
}
}