610 lines
24 KiB
Rust
610 lines
24 KiB
Rust
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))
|
||
));
|
||
}
|
||
}
|