diff --git a/src/app.rs b/src/app.rs index d0ca3a2..032f421 100644 --- a/src/app.rs +++ b/src/app.rs @@ -99,7 +99,7 @@ pub(crate) struct App { pending_project_path: Option, project_name_input: String, model_download: ModelDownload, - pub(super) composer: String, + pub(super) composer: text_editor::Content, pub(super) queued_inputs: VecDeque, pub(super) conversation: Vec, /// Follow appended chat content until the user scrolls away from the tail. @@ -172,7 +172,7 @@ pub(crate) struct App { struct ChatSnapshot { selected_project: Option, selected_session: Option, - composer: String, + composer: text_editor::Content, queued_inputs: VecDeque, conversation: Vec, chat_follow_tail: bool, @@ -418,7 +418,7 @@ pub(crate) enum Message { CancelDeleteArtifact, StopModelDownload, DownloadProgressTick, - ComposerChanged(String), + ComposerAction(text_editor::Action), TranscriptAction(usize, text_editor::Action), ToggleReasoning(usize), OpenLink(markdown::Uri), @@ -554,7 +554,7 @@ impl App { pending_project_path: None, project_name_input: String::new(), model_download: ModelDownload::Idle, - composer: String::new(), + composer: text_editor::Content::new(), queued_inputs: VecDeque::new(), conversation: Vec::new(), chat_follow_tail: true, @@ -698,7 +698,7 @@ impl App { pending_project_path: None, project_name_input: String::new(), model_download: ModelDownload::Idle, - composer: String::new(), + composer: text_editor::Content::new(), queued_inputs: VecDeque::new(), conversation: Vec::new(), chat_follow_tail: true, @@ -1466,7 +1466,7 @@ impl App { } } Message::DownloadProgressTick => self.update_download_progress(), - Message::ComposerChanged(value) => self.composer = value, + Message::ComposerAction(action) => self.composer.perform(action), Message::TranscriptAction(index, action) => { if !action.is_edit() && let Some(message) = self.conversation.get_mut(index) @@ -1505,11 +1505,11 @@ impl App { .action(&surface_id, &component_id, context_path.as_deref()) { Ok(action) => { - self.composer = format!( + self.composer = text_editor::Content::with_text(&format!( "A2UI client event:\n{}\nA2UI client metadata:\n{}", serde_json::to_string(&action).unwrap_or_default(), self.a2ui.client_metadata() - ); + )); self.chat_follow_tail = true; self.start_generation(); return scroll_chat_to_end(); @@ -1809,7 +1809,7 @@ impl App { self.conversation.clear(); self.context_notice = None; self.clear_a2ui(); - self.composer.clear(); + self.composer = text_editor::Content::new(); self.queued_inputs.clear(); self.system_prompt_seen_at = 0; self.context_used = 0; @@ -2036,7 +2036,7 @@ impl App { format!("Could not restore some A2UI history: {}", errors.join("; ")) }); self.sync_a2ui_renderer_state(); - self.composer.clear(); + self.composer = text_editor::Content::new(); self.remember_project(project_id); self.selected_session = Some(session_id); self.system_prompt_seen_at = 0; @@ -2110,7 +2110,7 @@ impl App { self.conversation.clear(); self.context_notice = None; self.clear_a2ui(); - self.composer.clear(); + self.composer = text_editor::Content::new(); self.queued_inputs.clear(); self.system_prompt_seen_at = 0; self.context_used = 0; diff --git a/src/app/generation.rs b/src/app/generation.rs index 12dd468..82ce90c 100644 --- a/src/app/generation.rs +++ b/src/app/generation.rs @@ -441,13 +441,13 @@ impl App { if self.selected_project.is_none() { return; } - let prompt = self.composer.trim().to_owned(); + let prompt = self.composer.text().trim().to_owned(); if prompt.is_empty() { return; } #[cfg(target_os = "macos")] if prompt == "/compact" { - self.composer.clear(); + self.composer = text_editor::Content::new(); if self.generating { self.manual_compaction_queued = true; self.activity = Some("Compaction queued for the next safe point…".into()); @@ -460,7 +460,7 @@ impl App { } if self.generating { self.queued_inputs.push_back(prompt); - self.composer.clear(); + self.composer = text_editor::Content::new(); self.activity = Some(format!( "{} queued input{}", self.queued_inputs.len(), @@ -689,7 +689,7 @@ impl App { self.conversation.push(ChatMessage::from(message)); } assistant.reasoning_open = assistant_reasoning; - self.composer.clear(); + self.composer = text_editor::Content::new(); self.conversation.push(user); self.conversation.push(assistant); self.generating = true; @@ -1167,7 +1167,7 @@ impl App { } if let Some(prompt) = queued_prompt(self.queued_inputs.drain(..)) { self.finish_turn_summary(); - self.composer = prompt; + self.composer = text_editor::Content::with_text(&prompt); self.start_generation(); } } @@ -1588,7 +1588,7 @@ impl App { match request.pending { PendingContinuation::None => self.start_next_queued(), PendingContinuation::User(prompt) => { - self.composer = prompt; + self.composer = text_editor::Content::with_text(&prompt); self.skip_compaction_once = true; self.start_generation(); } @@ -1608,8 +1608,8 @@ impl App { Err(error) => { self.generating = false; if let PendingContinuation::User(prompt) = request.pending { - if self.composer.trim().is_empty() { - self.composer = prompt; + if self.composer.text().trim().is_empty() { + self.composer = text_editor::Content::with_text(&prompt); } else { self.queued_inputs.push_front(prompt); } diff --git a/src/app/projects.rs b/src/app/projects.rs index a601b72..eb45cd9 100644 --- a/src/app/projects.rs +++ b/src/app/projects.rs @@ -175,7 +175,7 @@ impl App { self.chat_follow_tail = true; self.context_notice = None; self.clear_a2ui(); - self.composer.clear(); + self.composer = text_editor::Content::new(); self.queued_inputs.clear(); self.system_prompt_seen_at = 0; self.context_used = 0; @@ -190,7 +190,7 @@ impl App { self.chat_follow_tail = true; self.context_notice = None; self.clear_a2ui(); - self.composer.clear(); + self.composer = text_editor::Content::new(); self.queued_inputs.clear(); self.system_prompt_seen_at = 0; self.context_used = 0; diff --git a/src/app/view.rs b/src/app/view.rs index 07dc908..b9d47c2 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -1012,6 +1012,14 @@ fn danger_button_style(theme: &Theme, status: button::Status) -> button::Style { style } +fn stop_button_style(theme: &Theme, status: button::Status) -> button::Style { + let mut style = action_button_style(theme, status); + if matches!(status, button::Status::Hovered | button::Status::Pressed) { + style.text_color = theme.palette().danger; + } + style +} + fn segmented_control_style(_: &Theme) -> container::Style { container::Style { background: Some(Background::Color(Color::from_rgb8(24, 24, 26))), diff --git a/src/app/view/chat.rs b/src/app/view/chat.rs index 7d50b76..c091741 100644 --- a/src/app/view/chat.rs +++ b/src/app/view/chat.rs @@ -217,38 +217,36 @@ impl App { // 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) + 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) - .into() + .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("Stop").size(12)).on_press(Message::StopGeneration) - } else if self.composer.trim().is_empty() { + 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)) @@ -370,7 +368,7 @@ impl App { container(composer_content,) .padding(16) .width(Length::Fill) - .style(overview_style), + .style(preference_group_style), ] .height(Length::Fill) .spacing(8); @@ -407,6 +405,21 @@ impl App { } } +fn composer_enter_binding( + key: &iced::keyboard::Key, + modifiers: iced::keyboard::Modifiers, +) -> Option> { + 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, @@ -532,7 +545,7 @@ fn tool_cards(cards: Vec) -> Element<'static, Message> { #[cfg(test)] mod tests { - use super::format_token_count; + use super::*; #[test] fn chat_divider_groups_token_counts_by_thousands() { @@ -541,4 +554,17 @@ mod tests { 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)) + )); + } } diff --git a/src/app/view/git.rs b/src/app/view/git.rs index 6042f11..9c0a007 100644 --- a/src/app/view/git.rs +++ b/src/app/view/git.rs @@ -400,6 +400,7 @@ fn diff_half(line: Option<&GitDiffLine>, old: bool) -> Element<'static, Message> line.map_or_else( || { container(Space::new().width(Length::Fill)) + .height(24) .width(Length::Fill) .into() },