From 2af8806cc23b20d3b48f8d2ec165ac8cf910bef1 Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Tue, 18 Aug 2026 19:03:26 +0800 Subject: [PATCH 1/2] feat(ui): start-chat modal and pane close controls, retire the sidebar-swap flow --- crates/runner-app/src/chat.rs | 145 +- crates/runner-app/src/main.rs | 95 +- crates/runner-app/src/modal_text_input.rs | 729 +++++++++ crates/runner-app/src/pane_layout.rs | 103 ++ crates/runner-app/src/panes.rs | 43 +- crates/runner-app/src/sidebar.rs | 135 +- crates/runner-app/src/start_chat.rs | 1674 +++++++++++++++++++++ crates/runner-app/tests/pane_layout.rs | 74 + docs/impls/gpui-rewrite/impl_log.md | 6 + 9 files changed, 2775 insertions(+), 229 deletions(-) create mode 100644 crates/runner-app/src/modal_text_input.rs create mode 100644 crates/runner-app/src/start_chat.rs diff --git a/crates/runner-app/src/chat.rs b/crates/runner-app/src/chat.rs index a59fd61..9b199bb 100644 --- a/crates/runner-app/src/chat.rs +++ b/crates/runner-app/src/chat.rs @@ -148,11 +148,11 @@ impl NativeRoot { if !self.tabs.activate(tab_id) { return; } - self.new_chat_target = None; self.layout_picker_open = false; match self.ensure_active_tab_attached(window, cx) { Ok(()) => { self.error = None; + self.remember_active_runner(); self.focus_active_terminal(window); } Err(error) => self.error = Some(error.to_string()), @@ -161,11 +161,26 @@ impl NativeRoot { } pub(crate) fn focus_pane(&mut self, pane_id: &str, cx: &mut Context) { + let runner_id = self.tabs.active().and_then(|layout| { + layout + .root + .leaves() + .into_iter() + .find(|leaf| leaf.id == pane_id) + .and_then(|leaf| leaf.session_id.as_deref()) + .map(|session_id| { + self.session_entry(session_id) + .and_then(|entry| entry.runner_id.clone()) + }) + }); if self .tabs .active_mut() .is_some_and(|layout| layout.focus_pane(pane_id)) { + if let Some(runner_id) = runner_id { + self.last_focused_runner_id = runner_id; + } cx.notify(); } } @@ -178,6 +193,9 @@ impl NativeRoot { cx: &mut Context, ) { self.focus_pane(pane_id, cx); + self.last_focused_runner_id = self + .session_entry(session_id) + .and_then(|entry| entry.runner_id.clone()); if let Some(chat) = self.attached.get(session_id) { chat.terminal_focus.focus(window); } @@ -267,98 +285,6 @@ impl NativeRoot { } } - pub(crate) fn begin_new_tab(&mut self, _: &NewTab, _: &mut Window, cx: &mut Context) { - self.new_chat_target = Some(NewChatTarget::NewTab); - self.layout_picker_open = false; - cx.notify(); - } - - pub(crate) fn begin_pane_chat(&mut self, pane_id: &str, cx: &mut Context) { - let Some(tab_id) = self.tabs.active_tab_id().map(str::to_owned) else { - return; - }; - self.new_chat_target = Some(NewChatTarget::Pane { - tab_id, - pane_id: pane_id.to_owned(), - }); - cx.notify(); - } - - pub(crate) fn start_chat( - &mut self, - runner_id: &str, - window: &mut Window, - cx: &mut Context, - ) { - let Some(target) = self.new_chat_target.clone() else { - return; - }; - let initial_size = match &target { - NewChatTarget::NewTab => (INITIAL_COLS, INITIAL_ROWS), - NewChatTarget::Pane { tab_id, pane_id } - if self.tabs.active_tab_id() == Some(tab_id.as_str()) => - { - self.tabs - .active() - .map(|layout| self.estimated_terminal_size(layout, pane_id, window)) - .unwrap_or((INITIAL_COLS, INITIAL_ROWS)) - } - NewChatTarget::Pane { .. } => { - self.error = Some("The target tab is no longer active".into()); - return; - } - }; - let mut spawned_id = None; - let result = (|| -> Result { - let spawned = runner_backend::ops::session::session_start_direct( - &self.core, - runner_id.to_owned(), - None, - None, - None, - None, - None, - Some(initial_size.0), - Some(initial_size.1), - )?; - spawned_id = Some(spawned.id.clone()); - self.refresh_sessions(); - match target { - NewChatTarget::NewTab => { - self.reload_tabs()?; - self.tabs.activate_session(&spawned.id); - } - NewChatTarget::Pane { pane_id, .. } => { - self.tabs.assign_to_active(&pane_id, &spawned.id)?; - self.persist_active_tab()?; - self.reload_tabs()?; - self.tabs.activate_session(&spawned.id); - } - } - self.new_chat_target = None; - self.ensure_active_tab_attached(window, cx)?; - Ok(spawned.id) - })(); - match result { - Ok(session_id) => { - self.error = None; - if let Some(chat) = self.attached.get(&session_id) { - chat.terminal_focus.focus(window); - } - } - Err(error) => { - if let Some(session_id) = spawned_id { - self.new_chat_target = None; - let _ = self.reload_tabs(); - self.tabs.activate_session(&session_id); - let _ = self.ensure_active_tab_attached(window, cx); - } - self.error = Some(error.to_string()); - } - } - cx.notify(); - } - pub(crate) fn resume_chat( &mut self, pane_id: &str, @@ -420,8 +346,9 @@ impl NativeRoot { Ok(empty_pane_id) => { self.error = None; if let Some(pane_id) = empty_pane_id { - self.begin_pane_chat(&pane_id, cx); + self.open_pane_chat_modal(&pane_id, window, cx); } else { + self.remember_active_runner(); self.focus_active_terminal(window); } } @@ -440,6 +367,36 @@ impl NativeRoot { Ok(()) } + pub(crate) fn close_pane( + &mut self, + pane_id: &str, + window: &mut Window, + cx: &mut Context, + ) { + let result = (|| -> Result { + let Some(layout) = self.tabs.active_mut() else { + return Ok(false); + }; + if !layout.close_pane(pane_id) { + return Ok(false); + } + self.persist_active_tab()?; + self.reload_tabs()?; + self.ensure_active_tab_attached(window, cx)?; + Ok(true) + })(); + match result { + Ok(true) => { + self.error = None; + self.remember_active_runner(); + self.focus_active_terminal(window); + } + Ok(false) => {} + Err(error) => self.error = Some(error.to_string()), + } + cx.notify(); + } + pub(crate) fn resize_split( &mut self, split_id: &str, diff --git a/crates/runner-app/src/main.rs b/crates/runner-app/src/main.rs index 50bd581..d62c6c6 100644 --- a/crates/runner-app/src/main.rs +++ b/crates/runner-app/src/main.rs @@ -27,11 +27,14 @@ use terminal_element::TerminalElement; actions!(runner_app_ui, [Quit, TermPaste, NewTab]); mod chat; +mod modal_text_input; mod panes; mod sidebar; +mod start_chat; use panes::pane_fractions; use sidebar::session_label; +use start_chat::StartChatModal; const INITIAL_COLS: u16 = 100; const INITIAL_ROWS: u16 = 30; @@ -48,12 +51,6 @@ struct AttachedChat { scroll_accumulator: f32, } -#[derive(Clone)] -enum NewChatTarget { - NewTab, - Pane { tab_id: String, pane_id: String }, -} - #[derive(Clone)] struct SplitResizeDrag { split_id: String, @@ -75,7 +72,8 @@ struct NativeRoot { attached: HashMap, root_focus: FocusHandle, waker: Arc, - new_chat_target: Option, + start_chat_modal: Option, + last_focused_runner_id: Option, layout_picker_open: bool, split_sizes_dirty: bool, error: Option, @@ -100,6 +98,35 @@ impl NativeRoot { }) .detach(); + let (runtime_event_tx, mut runtime_event_rx) = futures::channel::mpsc::unbounded::<()>(); + let mut app_events = core.events.subscribe(); + cx.background_spawn(async move { + loop { + match app_events.recv().await { + Ok(event) if event.name == "runtime/changed" => { + if runtime_event_tx.unbounded_send(()).is_err() { + break; + } + } + Ok(_) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {} + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + }) + .detach(); + cx.spawn(async move |weak, cx| { + while runtime_event_rx.next().await.is_some() { + while runtime_event_rx.try_recv().is_ok() {} + if weak + .update(cx, |this, cx| this.refresh_start_chat_runtimes(cx)) + .is_err() + { + break; + } + } + }) + .detach(); + let mut errors = Vec::new(); let sessions = match runner_backend::ops::session::session_list_recent_direct(&core) { Ok(sessions) => sessions, @@ -127,6 +154,11 @@ impl NativeRoot { }; let root_focus = cx.focus_handle(); + let last_focused_runner_id = tabs + .active() + .and_then(PaneLayout::focused_session_id) + .and_then(|session_id| sessions.iter().find(|entry| entry.session_id == session_id)) + .and_then(|entry| entry.runner_id.clone()); let mut root = Self { core, bridge, @@ -136,7 +168,8 @@ impl NativeRoot { attached: HashMap::new(), root_focus, waker, - new_chat_target: None, + start_chat_modal: None, + last_focused_runner_id, layout_picker_open: false, split_sizes_dirty: false, error: (!errors.is_empty()).then(|| errors.join("\n")), @@ -175,32 +208,38 @@ impl Render for NativeRoot { let sidebar = self.render_sidebar(cx); let workspace = self.render_active_tab(window, cx); + let modal = self + .start_chat_modal + .as_ref() + .map(|_| self.render_start_chat_modal(cx)); div() + .relative() .size_full() - .flex() .track_focus(&self.root_focus) .bg(theme::bg()) - .child(sidebar) .child( - div() - .flex_1() - .min_w(px(0.)) - .h_full() - .flex() - .flex_col() - .child(workspace) - .children(self.error.as_ref().map(|error| { - div() - .flex_none() - .px_3() - .py_2() - .bg(gpui::rgb(0x3b1d2b)) - .text_sm() - .text_color(gpui::rgb(0xf7768e)) - .child(SharedString::from(error.clone())) - })), + div().size_full().flex().child(sidebar).child( + div() + .flex_1() + .min_w(px(0.)) + .h_full() + .flex() + .flex_col() + .child(workspace) + .children(self.error.as_ref().map(|error| { + div() + .flex_none() + .px_3() + .py_2() + .bg(gpui::rgb(0x3b1d2b)) + .text_sm() + .text_color(gpui::rgb(0xf7768e)) + .child(SharedString::from(error.clone())) + })), + ), ) - .on_action(cx.listener(Self::begin_new_tab)) + .children(modal) + .on_action(cx.listener(Self::open_new_tab_modal)) } } diff --git a/crates/runner-app/src/modal_text_input.rs b/crates/runner-app/src/modal_text_input.rs new file mode 100644 index 0000000..37f7ffa --- /dev/null +++ b/crates/runner-app/src/modal_text_input.rs @@ -0,0 +1,729 @@ +use std::ops::Range; + +use gpui::prelude::*; +use gpui::{ + canvas, div, px, AnyElement, App, Bounds, ClipboardItem, Context, CursorStyle, + ElementInputHandler, EntityInputHandler, FocusHandle, Focusable, KeyDownEvent, MouseButton, + Pixels, Point, Render, SharedString, UTF16Selection, Window, +}; +use unicode_segmentation::UnicodeSegmentation as _; + +use runner_app::text_util; + +use crate::{terminal_element, theme}; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +struct Selection { + anchor: usize, + caret: usize, +} + +impl Selection { + fn range(self) -> Range { + self.anchor.min(self.caret)..self.anchor.max(self.caret) + } + + fn is_empty(self) -> bool { + self.anchor == self.caret + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct MarkedText { + range: Range, + original: String, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +struct TextBuffer { + text: String, + selection: Selection, + marked: Option, + edited: bool, +} + +impl TextBuffer { + fn reset(&mut self, text: impl Into) { + self.text = text.into(); + self.move_to_end(); + self.marked = None; + self.edited = false; + } + + fn move_to_end(&mut self) { + let end = self.text.len(); + self.selection = Selection { + anchor: end, + caret: end, + }; + } + + fn unmark_text(&mut self) { + self.marked = None; + } + + fn text_for_range( + &self, + range_utf16: Range, + adjusted_range: &mut Option>, + ) -> String { + let range = text_util::range_from_utf16(&self.text, &range_utf16); + adjusted_range.replace(text_util::range_to_utf16(&self.text, &range)); + self.text[range].to_string() + } + + fn selected_text_range(&self) -> UTF16Selection { + UTF16Selection { + range: text_util::range_to_utf16(&self.text, &self.selection.range()), + reversed: self.selection.caret < self.selection.anchor, + } + } + + fn marked_text_range(&self) -> Option> { + self.marked + .as_ref() + .map(|marked| text_util::range_to_utf16(&self.text, &marked.range)) + } + + fn replace_text_in_range(&mut self, range_utf16: Option>, new_text: &str) -> bool { + let range = self.resolve_range(range_utf16); + let new_text = single_line(new_text); + let changed = self.text[range.clone()] != new_text; + self.text.replace_range(range.clone(), &new_text); + let end = range.start + new_text.len(); + self.selection = Selection { + anchor: end, + caret: end, + }; + self.marked = None; + self.edited |= changed; + changed + } + + fn replace_and_mark_text_in_range( + &mut self, + range_utf16: Option>, + new_text: &str, + new_selected_range_utf16: Option>, + ) -> bool { + let range = self.resolve_range(range_utf16); + let original = self + .marked + .as_ref() + .filter(|marked| marked.range == range) + .map(|marked| marked.original.clone()) + .unwrap_or_else(|| self.text[range.clone()].to_string()); + let new_text = single_line(new_text); + let changed = self.text[range.clone()] != new_text; + self.text.replace_range(range.clone(), &new_text); + let marked_range = range.start..range.start + new_text.len(); + self.marked = (!new_text.is_empty()).then_some(MarkedText { + range: marked_range.clone(), + original, + }); + self.selection = new_selected_range_utf16 + .map(|selection| { + let selection = text_util::range_from_utf16(&new_text, &selection); + Selection { + anchor: range.start + selection.start, + caret: range.start + selection.end, + } + }) + .unwrap_or_else(|| Selection { + anchor: marked_range.end, + caret: marked_range.end, + }); + self.edited |= changed; + changed + } + + fn character_index_utf16(&self) -> usize { + text_util::offset_to_utf16(&self.text, self.selection.caret) + } + + fn resolve_range(&self, range_utf16: Option>) -> Range { + range_utf16 + .map(|range| text_util::range_from_utf16(&self.text, &range)) + .or_else(|| self.marked.as_ref().map(|marked| marked.range.clone())) + .unwrap_or_else(|| self.selection.range()) + } + + fn selected_text(&self) -> Option<&str> { + let range = self.selection.range(); + (!range.is_empty()).then(|| &self.text[range]) + } + + fn replace_selection(&mut self, new_text: &str) -> bool { + self.replace_text_in_range(None, new_text) + } + + fn select_all(&mut self) { + self.selection = Selection { + anchor: 0, + caret: self.text.len(), + }; + self.marked = None; + } + + fn move_left(&mut self, boundary: Boundary, extend: bool) { + if !extend && !self.selection.is_empty() { + let start = self.selection.range().start; + self.selection = Selection { + anchor: start, + caret: start, + }; + self.marked = None; + return; + } + let target = match boundary { + Boundary::Grapheme => { + text_util::prev_grapheme_boundary(&self.text, self.selection.caret) + } + Boundary::Word => previous_word_boundary(&self.text, self.selection.caret), + Boundary::Line => 0, + }; + self.move_to(target, extend); + } + + fn move_right(&mut self, boundary: Boundary, extend: bool) { + if !extend && !self.selection.is_empty() { + let end = self.selection.range().end; + self.selection = Selection { + anchor: end, + caret: end, + }; + self.marked = None; + return; + } + let target = match boundary { + Boundary::Grapheme => { + text_util::next_grapheme_boundary(&self.text, self.selection.caret) + } + Boundary::Word => next_word_boundary(&self.text, self.selection.caret), + Boundary::Line => self.text.len(), + }; + self.move_to(target, extend); + } + + fn move_to(&mut self, target: usize, extend: bool) { + let target = target.min(self.text.len()); + if extend { + self.selection.caret = target; + } else { + self.selection = Selection { + anchor: target, + caret: target, + }; + } + self.marked = None; + } + + fn delete_left(&mut self, boundary: Boundary) -> bool { + let selection = self.selection.range(); + let range = if selection.is_empty() { + let start = match boundary { + Boundary::Grapheme => { + text_util::prev_grapheme_boundary(&self.text, selection.start) + } + Boundary::Word => previous_word_boundary(&self.text, selection.start), + Boundary::Line => 0, + }; + start..selection.start + } else { + selection + }; + self.delete_range(range) + } + + fn delete_right(&mut self, boundary: Boundary) -> bool { + let selection = self.selection.range(); + let range = if selection.is_empty() { + let end = match boundary { + Boundary::Grapheme => text_util::next_grapheme_boundary(&self.text, selection.end), + Boundary::Word => next_word_boundary(&self.text, selection.end), + Boundary::Line => self.text.len(), + }; + selection.end..end + } else { + selection + }; + self.delete_range(range) + } + + fn delete_range(&mut self, range: Range) -> bool { + if range.is_empty() { + return false; + } + self.text.replace_range(range.clone(), ""); + self.selection = Selection { + anchor: range.start, + caret: range.start, + }; + self.marked = None; + self.edited = true; + true + } +} + +#[derive(Clone, Copy)] +enum Boundary { + Grapheme, + Word, + Line, +} + +pub(crate) struct ModalTextInput { + focus_handle: FocusHandle, + buffer: TextBuffer, + placeholder: SharedString, + monospace: bool, +} + +impl ModalTextInput { + pub(crate) fn new( + focus_handle: FocusHandle, + text: impl Into, + placeholder: impl Into, + monospace: bool, + ) -> Self { + let mut buffer = TextBuffer::default(); + buffer.reset(text); + Self { + focus_handle, + buffer, + placeholder: placeholder.into(), + monospace, + } + } + + pub(crate) fn text(&self) -> &str { + &self.buffer.text + } + + pub(crate) fn edited(&self) -> bool { + self.buffer.edited + } + + pub(crate) fn is_composing(&self) -> bool { + self.buffer.marked.is_some() + } + + pub(crate) fn focus_handle(&self) -> FocusHandle { + self.focus_handle.clone() + } + + pub(crate) fn reset(&mut self, text: impl Into, cx: &mut Context) { + self.buffer.reset(text); + cx.notify(); + } + + pub(crate) fn set_placeholder( + &mut self, + placeholder: impl Into, + cx: &mut Context, + ) { + self.placeholder = placeholder.into(); + cx.notify(); + } + + fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context) { + if event.keystroke.key == "enter" { + if !enter_should_submit(self.is_composing()) { + cx.stop_propagation(); + } + return; + } + let handled = handle_key_down(&mut self.buffer, event, cx); + if handled { + cx.stop_propagation(); + cx.notify(); + } + } + + fn on_mouse_down( + &mut self, + _: &gpui::MouseDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + self.focus_handle.focus(window); + self.buffer.move_to_end(); + cx.notify(); + } + + fn render_text(&self, focused: bool) -> AnyElement { + if self.buffer.text.is_empty() { + return div() + .flex() + .items_center() + .min_w(px(0.)) + .text_color(theme::muted()) + .when(focused, |text| text.child(input_caret())) + .child(self.placeholder.clone()) + .into_any_element(); + } + + let selection = focused.then_some(self.buffer.selection.range()); + let marked = focused + .then(|| { + self.buffer + .marked + .as_ref() + .map(|marked| marked.range.clone()) + }) + .flatten(); + let caret = self.buffer.selection.caret; + let mut content = div() + .flex() + .items_center() + .min_w(px(0.)) + .whitespace_nowrap(); + for (start, grapheme) in self.buffer.text.grapheme_indices(true) { + if focused && caret == start { + content = content.child(input_caret()); + } + let end = start + grapheme.len(); + content = content.child( + div() + .when( + selection + .as_ref() + .is_some_and(|range| range.start < end && start < range.end), + |text| text.bg(gpui::rgba(0x7aa2f744)), + ) + .when( + marked + .as_ref() + .is_some_and(|range| range.start < end && start < range.end), + |text| text.border_b_1().border_color(theme::accent()), + ) + .child(grapheme.to_string()), + ); + } + if focused && caret == self.buffer.text.len() { + content = content.child(input_caret()); + } + content.into_any_element() + } +} + +impl EntityInputHandler for ModalTextInput { + fn text_for_range( + &mut self, + range: Range, + adjusted_range: &mut Option>, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + Some(self.buffer.text_for_range(range, adjusted_range)) + } + + fn selected_text_range( + &mut self, + _ignore_disabled_input: bool, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + Some(self.buffer.selected_text_range()) + } + + fn marked_text_range( + &self, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + self.buffer.marked_text_range() + } + + fn unmark_text(&mut self, _window: &mut Window, cx: &mut Context) { + self.buffer.unmark_text(); + cx.notify(); + } + + fn replace_text_in_range( + &mut self, + range: Option>, + text: &str, + _window: &mut Window, + cx: &mut Context, + ) { + self.buffer.replace_text_in_range(range, text); + cx.notify(); + } + + fn replace_and_mark_text_in_range( + &mut self, + range: Option>, + new_text: &str, + new_selected_range: Option>, + _window: &mut Window, + cx: &mut Context, + ) { + self.buffer + .replace_and_mark_text_in_range(range, new_text, new_selected_range); + cx.notify(); + } + + fn bounds_for_range( + &mut self, + _range_utf16: Range, + element_bounds: Bounds, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + Some(element_bounds) + } + + fn character_index_for_point( + &mut self, + _point: Point, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + Some(self.buffer.character_index_utf16()) + } +} + +impl Focusable for ModalTextInput { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for ModalTextInput { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let focused = self.focus_handle.is_focused(window); + let input_entity = cx.entity(); + div() + .relative() + .flex() + .items_center() + .min_w(px(0.)) + .w_full() + .h(px(36.)) + .px_3() + .overflow_hidden() + .rounded_md() + .border_1() + .border_color(if focused { + theme::muted() + } else { + theme::border() + }) + .bg(theme::bg()) + .track_focus(&self.focus_handle) + .cursor(CursorStyle::IBeam) + .on_key_down(cx.listener(Self::on_key_down)) + .on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down)) + .text_size(px(13.)) + .text_color(theme::text()) + .when(self.monospace, |input| { + input.font_family(terminal_element::FONT_FAMILY) + }) + .child(self.render_text(focused)) + .child( + canvas( + |_, _, _| {}, + move |bounds, _, window, cx| { + let focus = input_entity.read(cx).focus_handle.clone(); + window.handle_input( + &focus, + ElementInputHandler::new(bounds, input_entity.clone()), + cx, + ); + }, + ) + .absolute() + .top_0() + .right_0() + .bottom_0() + .left_0(), + ) + } +} + +fn handle_key_down(input: &mut TextBuffer, event: &KeyDownEvent, cx: &mut Context) -> bool { + let key = event.keystroke.key.as_str(); + let modifiers = event.keystroke.modifiers; + if modifiers.platform { + return match key { + "a" => { + input.select_all(); + true + } + "c" => { + if let Some(text) = input.selected_text() { + cx.write_to_clipboard(ClipboardItem::new_string(text.to_string())); + } + true + } + "x" => { + if let Some(text) = input.selected_text() { + cx.write_to_clipboard(ClipboardItem::new_string(text.to_string())); + input.delete_left(Boundary::Grapheme); + } + true + } + "v" => { + if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) { + input.replace_selection(&text); + } + true + } + "left" => { + input.move_left(Boundary::Line, modifiers.shift); + true + } + "right" => { + input.move_right(Boundary::Line, modifiers.shift); + true + } + "backspace" => { + input.delete_left(Boundary::Line); + true + } + "delete" => { + input.delete_right(Boundary::Line); + true + } + _ => false, + }; + } + if modifiers.alt { + return match key { + "left" => { + input.move_left(Boundary::Word, modifiers.shift); + true + } + "right" => { + input.move_right(Boundary::Word, modifiers.shift); + true + } + "backspace" => { + input.delete_left(Boundary::Word); + true + } + "delete" => { + input.delete_right(Boundary::Word); + true + } + _ => false, + }; + } + if modifiers.control { + return false; + } + if modifiers.function { + return match key { + "left" => { + input.move_left(Boundary::Line, modifiers.shift); + true + } + "right" => { + input.move_right(Boundary::Line, modifiers.shift); + true + } + "backspace" | "delete" => { + input.delete_right(Boundary::Grapheme); + true + } + _ => false, + }; + } + match key { + "left" => { + input.move_left(Boundary::Grapheme, modifiers.shift); + true + } + "right" => { + input.move_right(Boundary::Grapheme, modifiers.shift); + true + } + "home" => { + input.move_left(Boundary::Line, modifiers.shift); + true + } + "end" => { + input.move_right(Boundary::Line, modifiers.shift); + true + } + "backspace" => { + input.delete_left(Boundary::Grapheme); + true + } + "delete" => { + input.delete_right(Boundary::Grapheme); + true + } + _ => false, + } +} + +fn enter_should_submit(composing: bool) -> bool { + !composing +} + +fn input_caret() -> impl IntoElement { + div().flex_none().w(px(1.)).h(px(16.)).bg(theme::accent()) +} + +fn single_line(text: &str) -> String { + text.replace(['\r', '\n'], " ") +} + +fn previous_word_boundary(text: &str, position: usize) -> usize { + text.split_word_bound_indices() + .take_while(|(start, _)| *start < position) + .filter(|(_, segment)| is_word(segment)) + .map(|(start, _)| start) + .fold(0, |_, start| start) +} + +fn next_word_boundary(text: &str, position: usize) -> usize { + text.split_word_bound_indices() + .find(|(start, segment)| *start + segment.len() > position && is_word(segment)) + .map(|(start, segment)| start + segment.len()) + .unwrap_or(text.len()) +} + +fn is_word(segment: &str) -> bool { + segment + .chars() + .any(|character| character.is_alphanumeric() || character == '_') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn marked_text_uses_utf16_offsets_and_blocks_enter_until_committed() { + let mut input = TextBuffer::default(); + input.reset("王菲"); + input.selection = Selection { + anchor: "王".len(), + caret: "王".len(), + }; + + assert!(input.replace_and_mark_text_in_range(None, "bianji", Some(6..6))); + assert_eq!(input.text, "王bianji菲"); + assert_eq!(input.marked_text_range(), Some(1..7)); + assert!(!enter_should_submit(input.marked.is_some())); + + assert!(input.replace_and_mark_text_in_range(None, "编辑", Some(2..2))); + assert!(!input.replace_text_in_range(None, "编辑")); + assert_eq!(input.text, "王编辑菲"); + assert!(enter_should_submit(input.marked.is_some())); + } + + #[test] + fn deletion_respects_grapheme_clusters() { + let mut input = TextBuffer::default(); + input.reset("A👨‍👩‍👧‍👦B"); + input.move_left(Boundary::Grapheme, false); + + assert!(input.delete_left(Boundary::Grapheme)); + assert_eq!(input.text, "AB"); + assert!(input.edited); + } +} diff --git a/crates/runner-app/src/pane_layout.rs b/crates/runner-app/src/pane_layout.rs index b7bd1fa..aa3c12f 100644 --- a/crates/runner-app/src/pane_layout.rs +++ b/crates/runner-app/src/pane_layout.rs @@ -44,6 +44,17 @@ impl PresetKind { Self::Main2 | Self::Cols3 | Self::Rows3 => 3, } } + + fn split_id_prefix(self) -> &'static str { + match self { + Self::Single => "single", + Self::Cols2 => "cols-2", + Self::Rows2 => "rows-2", + Self::Main2 => "main-2", + Self::Cols3 => "cols-3", + Self::Rows3 => "rows-3", + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -310,6 +321,37 @@ impl PaneLayout { } } + pub fn close_pane(&mut self, pane_id: &str) -> bool { + if matches!(self.root, PaneNode::Leaf(_)) { + return false; + } + + let Some(mut root) = remove_pane(&self.root, pane_id) else { + return false; + }; + if root == self.root { + return false; + } + + let preset = derive_preset(&root); + canonicalize_split_ids(&mut root, preset); + let focused_pane_id = root + .leaves() + .into_iter() + .find(|leaf| leaf.id == self.focused_pane_id) + .or_else(|| root.leaves().into_iter().next()) + .expect("collapsed pane tree has a leaf") + .id + .clone(); + self.preset = preset; + self.root = root; + self.focused_pane_id = focused_pane_id; + if self.preset == PresetKind::Single { + self.name = None; + } + true + } + pub fn set_split_sizes(&mut self, split_id: &str, sizes: [f32; 2]) -> bool { if !valid_sizes(sizes) { return false; @@ -506,6 +548,67 @@ fn build_preset_tree(preset: PresetKind, slots: &[Option]) -> PaneNode { } } +fn remove_pane(node: &PaneNode, pane_id: &str) -> Option { + match node { + PaneNode::Leaf(leaf) => (leaf.id != pane_id).then(|| node.clone()), + PaneNode::Split(split) => { + let a = remove_pane(&split.a, pane_id); + let b = remove_pane(&split.b, pane_id); + match (a, b) { + (None, None) => None, + (Some(node), None) | (None, Some(node)) => Some(node), + (Some(a), Some(b)) => { + if &a == split.a.as_ref() && &b == split.b.as_ref() { + Some(node.clone()) + } else { + Some(PaneNode::Split(PaneSplit { + id: split.id.clone(), + orientation: split.orientation, + sizes: split.sizes, + a: Box::new(a), + b: Box::new(b), + })) + } + } + } + } + } +} + +fn derive_preset(root: &PaneNode) -> PresetKind { + let PaneNode::Split(split) = root else { + return PresetKind::Single; + }; + match (&*split.a, &*split.b) { + (PaneNode::Leaf(_), PaneNode::Leaf(_)) => match split.orientation { + SplitOrientation::Row => PresetKind::Cols2, + SplitOrientation::Column => PresetKind::Rows2, + }, + (_, PaneNode::Split(inner)) => match (split.orientation, inner.orientation) { + (SplitOrientation::Row, SplitOrientation::Column) => PresetKind::Main2, + (SplitOrientation::Row, SplitOrientation::Row) => PresetKind::Cols3, + (SplitOrientation::Column, _) => PresetKind::Rows3, + }, + (PaneNode::Split(_), PaneNode::Leaf(_)) => match split.orientation { + SplitOrientation::Row => PresetKind::Cols3, + SplitOrientation::Column => PresetKind::Rows3, + }, + } +} + +fn canonicalize_split_ids(root: &mut PaneNode, preset: PresetKind) { + let PaneNode::Split(outer) = root else { + return; + }; + outer.id = format!("{}:outer", preset.split_id_prefix()); + if let PaneNode::Split(inner) = outer.a.as_mut() { + inner.id = format!("{}:inner", preset.split_id_prefix()); + } + if let PaneNode::Split(inner) = outer.b.as_mut() { + inner.id = format!("{}:inner", preset.split_id_prefix()); + } +} + fn valid_sizes(sizes: [f32; 2]) -> bool { sizes .iter() diff --git a/crates/runner-app/src/panes.rs b/crates/runner-app/src/panes.rs index 86cbc9e..d68f383 100644 --- a/crates/runner-app/src/panes.rs +++ b/crates/runner-app/src/panes.rs @@ -94,7 +94,7 @@ impl NativeRoot { .hover(|button| button.bg(theme::border())) .child("New tab ⌘T") .on_click(cx.listener(|this, _, window, cx| { - this.begin_new_tab(&NewTab, window, cx); + this.open_new_tab_modal(&NewTab, window, cx); })), ), ), @@ -272,6 +272,8 @@ impl NativeRoot { let pane_id = leaf.id.clone(); let pane_id_for_focus = pane_id.clone(); let header = grouped.then(|| { + let empty = leaf.session_id.is_none(); + let close_pane_id = pane_id.clone(); let label = leaf .session_id .as_deref() @@ -307,6 +309,27 @@ impl NativeRoot { .text_color(theme::text()) .child(label), ) + .when(empty, |header| { + header.child( + div() + .id(SharedString::from(format!("close-pane-{close_pane_id}"))) + .ml_auto() + .flex() + .items_center() + .justify_center() + .size(px(24.)) + .rounded_md() + .cursor_pointer() + .text_sm() + .text_color(theme::muted()) + .hover(|button| button.bg(theme::border()).text_color(theme::text())) + .child("×") + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_click(cx.listener(move |this, _, window, cx| { + this.close_pane(&close_pane_id, window, cx); + })), + ) + }) }); let body: AnyElement = if let Some(session_id) = leaf.session_id.as_deref() { @@ -449,8 +472,16 @@ impl NativeRoot { div() .flex_1() .flex() + .flex_col() .items_center() .justify_center() + .gap_3() + .child( + div() + .text_sm() + .text_color(theme::muted()) + .child("No chat in this pane"), + ) .child( div() .id(SharedString::from(format!("new-chat-{pane_id}"))) @@ -464,10 +495,16 @@ impl NativeRoot { .text_color(theme::accent()) .hover(|button| button.bg(theme::border())) .child("New chat") - .on_click(cx.listener(move |this, _, _, cx| { - this.begin_pane_chat(&new_chat_pane_id, cx); + .on_click(cx.listener(move |this, _, window, cx| { + this.open_pane_chat_modal(&new_chat_pane_id, window, cx); })), ) + .child( + div() + .text_xs() + .text_color(theme::muted()) + .child("or pick a chat from the sidebar"), + ) .into_any_element() }; diff --git a/crates/runner-app/src/sidebar.rs b/crates/runner-app/src/sidebar.rs index 3664e11..96bde6e 100644 --- a/crates/runner-app/src/sidebar.rs +++ b/crates/runner-app/src/sidebar.rs @@ -38,110 +38,37 @@ impl NativeRoot { }) .collect::>(); - let list: AnyElement = if let Some(target) = self.new_chat_target.as_ref() { - let heading = match target { - NewChatTarget::NewTab => "NEW TAB", - NewChatTarget::Pane { .. } => "NEW CHAT IN PANE", - }; - div() - .flex_1() - .min_h(px(0.)) - .flex() - .flex_col() - .child( + let list = div() + .id("tab-list") + .flex_1() + .min_h(px(0.)) + .overflow_y_scroll() + .px_2() + .children(tab_rows.into_iter().enumerate().map( + |(index, (tab_id, label, pane_count, running))| { + let selected = active_tab_id.as_deref() == Some(&tab_id); + let click_id = tab_id.clone(); div() - .px_4() - .pb_2() - .flex() - .items_center() - .justify_between() - .child(div().text_xs().text_color(theme::muted()).child(heading)) - .child( - div() - .id("cancel-new-chat") - .px_2() - .py_1() - .rounded_md() - .cursor_pointer() - .text_xs() - .text_color(theme::muted()) - .hover(|button| button.bg(theme::border())) - .child("Cancel") - .on_click(cx.listener(|this, _, _, cx| { - this.new_chat_target = None; - cx.notify(); - })), - ), - ) - .child( - div() - .id("new-chat-runner-list") - .flex_1() - .overflow_y_scroll() - .px_2() - .children(self.runners.iter().enumerate().map(|(index, runner)| { - let runner_id = runner.id.clone(); - div() - .id(("new-chat-runner", index)) - .w_full() - .mb_1() - .px_3() - .py_2() - .rounded_md() - .cursor_pointer() - .hover(|row| row.bg(theme::border())) - .child( - div() - .text_sm() - .text_color(theme::text()) - .child(format!("@{}", runner.handle)), - ) - .child( - div() - .text_xs() - .text_color(theme::muted()) - .child(runner.display_name.clone()), - ) - .on_click(cx.listener(move |this, _, window, cx| { - this.start_chat(&runner_id, window, cx); - })) - })), - ) - .into_any_element() - } else { - div() - .id("tab-list") - .flex_1() - .min_h(px(0.)) - .overflow_y_scroll() - .px_2() - .children(tab_rows.into_iter().enumerate().map( - |(index, (tab_id, label, pane_count, running))| { - let selected = active_tab_id.as_deref() == Some(&tab_id); - let click_id = tab_id.clone(); - div() - .id(("direct-tab", index)) - .w_full() - .mb_1() - .px_3() - .py_2() - .rounded_md() - .cursor_pointer() - .when(selected, |row| row.bg(theme::border())) - .hover(|row| row.bg(theme::border())) - .child(div().text_sm().text_color(theme::text()).child(label)) - .child(div().text_xs().text_color(theme::muted()).child(format!( - "{} · {pane_count} {}", - if running { "running" } else { "stopped" }, - if pane_count == 1 { "pane" } else { "panes" } - ))) - .on_click(cx.listener(move |this, _, window, cx| { - this.activate_tab(&click_id, window, cx); - })) - }, - )) - .into_any_element() - }; + .id(("direct-tab", index)) + .w_full() + .mb_1() + .px_3() + .py_2() + .rounded_md() + .cursor_pointer() + .when(selected, |row| row.bg(theme::border())) + .hover(|row| row.bg(theme::border())) + .child(div().text_sm().text_color(theme::text()).child(label)) + .child(div().text_xs().text_color(theme::muted()).child(format!( + "{} · {pane_count} {}", + if running { "running" } else { "stopped" }, + if pane_count == 1 { "pane" } else { "panes" } + ))) + .on_click(cx.listener(move |this, _, window, cx| { + this.activate_tab(&click_id, window, cx); + })) + }, + )); div() .w(px(SIDEBAR_WIDTH)) @@ -178,7 +105,7 @@ impl NativeRoot { .hover(|button| button.bg(theme::border())) .child("+ New") .on_click(cx.listener(|this, _, window, cx| { - this.begin_new_tab(&NewTab, window, cx); + this.open_new_tab_modal(&NewTab, window, cx); })), ), ) diff --git a/crates/runner-app/src/start_chat.rs b/crates/runner-app/src/start_chat.rs new file mode 100644 index 0000000..6fccd24 --- /dev/null +++ b/crates/runner-app/src/start_chat.rs @@ -0,0 +1,1674 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use gpui::prelude::*; +use gpui::{ + anchored, deferred, div, point, px, relative, AnchoredPositionMode, AnyElement, Context, + FontWeight, KeyDownEvent, MouseButton, PathPromptOptions, SharedString, Window, +}; +use runner_backend::model::Runner; +use runner_backend::ops::runtime::{RuntimeCatalogEntry, RuntimeCatalogOption}; + +use crate::modal_text_input::ModalTextInput; + +use super::*; + +const START_CHAT_MODE_FILE: &str = "start-chat-mode"; +const MODAL_WIDTH: f32 = 560.; +const FIELD_WIDTH: f32 = 476.; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ChatMode { + Runner, + Runtime, +} + +impl ChatMode { + fn from_persisted(value: Option<&str>) -> Self { + if value.is_some_and(|value| value.trim() == "runtime") { + Self::Runtime + } else { + Self::Runner + } + } + + fn persisted(self) -> &'static str { + match self { + Self::Runner => "runner", + Self::Runtime => "runtime", + } + } +} + +#[derive(Clone)] +enum ChatTarget { + NewTab, + Pane { tab_id: String, pane_id: String }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ModalPicker { + Runner, + RunnerRuntime, + Runtime, + Model, + Effort, +} + +#[derive(Clone)] +struct ModalChoice { + value: String, + label: String, + description: Option, +} + +pub(crate) struct StartChatModal { + target: ChatTarget, + mode: ChatMode, + runners: Vec, + runtimes: Vec, + runner_id: Option, + runtime_name: Option, + runner_runtime_override: Option, + effort: String, + picker: Option, + title: Entity, + cwd: Entity, + model: Entity, + agents_checking: bool, + agents_error: Option, + submitting: bool, + error: Option, +} + +impl StartChatModal { + fn selected_runner(&self) -> Option<&Runner> { + self.runner_id + .as_deref() + .and_then(|runner_id| self.runners.iter().find(|runner| runner.id == runner_id)) + } + + fn selected_runtime(&self) -> Option<&RuntimeCatalogEntry> { + self.runtime_name + .as_deref() + .and_then(|name| self.runtimes.iter().find(|runtime| runtime.name == name)) + } + + fn override_runtime(&self) -> Option<&RuntimeCatalogEntry> { + self.runner_runtime_override + .as_deref() + .and_then(|name| self.runtimes.iter().find(|runtime| runtime.name == name)) + } + + fn active_runtime(&self) -> Option<&RuntimeCatalogEntry> { + match self.mode { + ChatMode::Runner => self.override_runtime(), + ChatMode::Runtime => self.selected_runtime(), + } + } + + fn can_submit(&self) -> bool { + !self.submitting + && match self.mode { + ChatMode::Runner => self.selected_runner().is_some(), + ChatMode::Runtime => self.selected_runtime().is_some(), + } + } + + fn is_composing(&self, cx: &Context) -> bool { + self.title.read(cx).is_composing() + || self.cwd.read(cx).is_composing() + || self.model.read(cx).is_composing() + } +} + +#[derive(Debug, Eq, PartialEq)] +enum StartRequest { + Runner { + runner_id: String, + runtime: Option, + model: Option, + effort: Option, + cwd: Option, + }, + Runtime { + runtime: String, + model: Option, + effort: Option, + cwd: Option, + }, +} + +impl NativeRoot { + pub(crate) fn open_new_tab_modal( + &mut self, + _: &NewTab, + window: &mut Window, + cx: &mut Context, + ) { + if self.start_chat_modal.is_some() { + return; + } + self.open_start_chat_modal(ChatTarget::NewTab, None, window, cx); + } + + pub(crate) fn open_pane_chat_modal( + &mut self, + pane_id: &str, + window: &mut Window, + cx: &mut Context, + ) { + let Some(tab_id) = self.tabs.active_tab_id().map(str::to_owned) else { + return; + }; + self.open_start_chat_modal( + ChatTarget::Pane { + tab_id, + pane_id: pane_id.to_owned(), + }, + self.last_focused_runner_id.clone(), + window, + cx, + ); + } + + fn open_start_chat_modal( + &mut self, + target: ChatTarget, + default_runner_id: Option, + window: &mut Window, + cx: &mut Context, + ) { + let mut error = None; + match runner_backend::ops::runner::runner_list(&self.core) { + Ok(runners) => self.runners = runners, + Err(load_error) => error = Some(load_error.to_string()), + } + + let (runtimes, agents_checking, agents_error) = load_selectable_runtimes(&self.core); + let persisted_mode = read_start_chat_mode(&self.core.app_data_dir); + let mode = if default_runner_id.is_some() { + ChatMode::Runner + } else { + persisted_mode + }; + let runner_id = default_runner_id + .filter(|runner_id| self.runners.iter().any(|runner| runner.id == *runner_id)) + .or_else(|| self.runners.first().map(|runner| runner.id.clone())); + let runtime_name = runtimes.first().map(|runtime| runtime.name.clone()); + let title = match mode { + ChatMode::Runner => runner_id + .as_deref() + .and_then(|runner_id| self.runners.iter().find(|runner| runner.id == runner_id)) + .map(|runner| default_title_for_runner(&runner.handle)) + .unwrap_or_default(), + ChatMode::Runtime => runtime_name + .as_deref() + .and_then(|name| runtimes.iter().find(|runtime| runtime.name == name)) + .map(|runtime| default_title_for_runtime(&runtime.display_name)) + .unwrap_or_default(), + }; + let cwd_placeholder = cwd_placeholder( + mode, + runner_id + .as_deref() + .and_then(|runner_id| self.runners.iter().find(|runner| runner.id == runner_id)), + ); + let title_input = cx.new(|input_cx| { + ModalTextInput::new(input_cx.focus_handle(), title, "e.g. quick-debug", false) + }); + let cwd_input = cx.new(|input_cx| { + ModalTextInput::new(input_cx.focus_handle(), "", cwd_placeholder, true) + }); + let model_input = + cx.new(|input_cx| ModalTextInput::new(input_cx.focus_handle(), "", "default", true)); + let title_focus = title_input.read(cx).focus_handle(); + + self.layout_picker_open = false; + self.start_chat_modal = Some(StartChatModal { + target, + mode, + runners: self.runners.clone(), + runtimes, + runner_id, + runtime_name, + runner_runtime_override: None, + effort: String::new(), + picker: None, + title: title_input, + cwd: cwd_input, + model: model_input, + agents_checking, + agents_error, + submitting: false, + error: error.take(), + }); + title_focus.focus(window); + cx.notify(); + } + + pub(crate) fn refresh_start_chat_runtimes(&mut self, cx: &mut Context) { + let Some(modal) = self.start_chat_modal.as_mut() else { + return; + }; + let (runtimes, agents_checking, agents_error) = load_selectable_runtimes(&self.core); + let catalog_loaded = agents_error.is_none(); + let previous_runtime = modal.runtime_name.clone(); + let previous_override = modal.runner_runtime_override.clone(); + modal.agents_checking = agents_checking; + modal.agents_error = agents_error; + + if catalog_loaded { + modal.runtimes = runtimes; + if modal + .runner_runtime_override + .as_ref() + .is_some_and(|name| !modal.runtimes.iter().any(|runtime| runtime.name == *name)) + { + modal.runner_runtime_override = None; + } + if modal + .runtime_name + .as_ref() + .is_none_or(|name| !modal.runtimes.iter().any(|runtime| runtime.name == *name)) + { + modal.runtime_name = modal.runtimes.first().map(|runtime| runtime.name.clone()); + } + if modal.runtime_name != previous_runtime + || modal.runner_runtime_override != previous_override + { + modal.effort.clear(); + modal + .model + .update(cx, |input, input_cx| input.reset("", input_cx)); + } + if modal.mode == ChatMode::Runtime && modal.runtime_name != previous_runtime { + let derived = modal + .selected_runtime() + .map(|runtime| default_title_for_runtime(&runtime.display_name)) + .unwrap_or_default(); + update_auto_title(&modal.title, derived, cx); + } + } + cx.notify(); + } + + pub(crate) fn remember_active_runner(&mut self) { + let Some(session_id) = self.active_focused_session_id() else { + return; + }; + self.last_focused_runner_id = self + .session_entry(&session_id) + .and_then(|entry| entry.runner_id.clone()); + } + + fn close_start_chat_modal(&mut self, window: &mut Window, cx: &mut Context) { + if self + .start_chat_modal + .as_ref() + .is_some_and(|modal| modal.submitting) + { + return; + } + self.start_chat_modal = None; + self.focus_active_terminal(window); + cx.notify(); + } + + fn set_start_chat_mode(&mut self, mode: ChatMode, cx: &mut Context) { + let Some(modal) = self.start_chat_modal.as_mut() else { + return; + }; + if modal.mode == mode || modal.submitting { + return; + } + modal.mode = mode; + modal.runner_runtime_override = None; + modal.effort.clear(); + modal.picker = None; + modal + .model + .update(cx, |input, input_cx| input.reset("", input_cx)); + let derived = match mode { + ChatMode::Runner => modal + .selected_runner() + .map(|runner| default_title_for_runner(&runner.handle)) + .unwrap_or_default(), + ChatMode::Runtime => modal + .selected_runtime() + .map(|runtime| default_title_for_runtime(&runtime.display_name)) + .unwrap_or_default(), + }; + update_auto_title(&modal.title, derived, cx); + let placeholder = cwd_placeholder(mode, modal.selected_runner()); + modal.cwd.update(cx, |input, input_cx| { + input.set_placeholder(placeholder, input_cx) + }); + let _ = write_start_chat_mode(&self.core.app_data_dir, mode); + cx.notify(); + } + + fn toggle_start_chat_picker(&mut self, picker: ModalPicker, cx: &mut Context) { + let Some(modal) = self.start_chat_modal.as_mut() else { + return; + }; + if modal.submitting { + return; + } + modal.picker = (modal.picker != Some(picker)).then_some(picker); + cx.notify(); + } + + fn select_start_chat_choice( + &mut self, + picker: ModalPicker, + value: &str, + cx: &mut Context, + ) { + let Some(modal) = self.start_chat_modal.as_mut() else { + return; + }; + match picker { + ModalPicker::Runner => { + modal.runner_id = Some(value.to_owned()); + let derived = modal + .selected_runner() + .map(|runner| default_title_for_runner(&runner.handle)) + .unwrap_or_default(); + update_auto_title(&modal.title, derived, cx); + let placeholder = cwd_placeholder(modal.mode, modal.selected_runner()); + modal.cwd.update(cx, |input, input_cx| { + input.set_placeholder(placeholder, input_cx) + }); + } + ModalPicker::RunnerRuntime => { + modal.runner_runtime_override = (!value.is_empty()).then(|| value.to_owned()); + modal.effort.clear(); + modal + .model + .update(cx, |input, input_cx| input.reset("", input_cx)); + } + ModalPicker::Runtime => { + modal.runtime_name = Some(value.to_owned()); + modal.effort.clear(); + modal + .model + .update(cx, |input, input_cx| input.reset("", input_cx)); + let derived = modal + .selected_runtime() + .map(|runtime| default_title_for_runtime(&runtime.display_name)) + .unwrap_or_default(); + update_auto_title(&modal.title, derived, cx); + } + ModalPicker::Model => { + modal.model.update(cx, |input, input_cx| { + input.reset(value.to_owned(), input_cx) + }); + } + ModalPicker::Effort => modal.effort = value.to_owned(), + } + modal.picker = None; + cx.notify(); + } + + fn browse_start_chat_cwd(&mut self, cx: &mut Context) { + if self + .start_chat_modal + .as_ref() + .is_some_and(|modal| modal.submitting) + { + return; + } + let Some(cwd_input) = self + .start_chat_modal + .as_ref() + .map(|modal| modal.cwd.clone()) + else { + return; + }; + let selected = cx.prompt_for_paths(PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: Some("Pick a working directory".into()), + }); + cx.spawn(async move |weak, cx| { + let result = selected + .await + .map_err(|error| error.to_string()) + .and_then(|result| result.map_err(|error| error.to_string())); + let _ = weak.update(cx, |this, cx| { + let Some(modal) = this.start_chat_modal.as_mut() else { + return; + }; + if modal.cwd != cwd_input { + return; + } + match result { + Ok(Some(paths)) => { + if let Some(path) = paths.into_iter().next() { + modal.cwd.update(cx, |input, input_cx| { + input.reset(path.to_string_lossy().into_owned(), input_cx) + }); + } + } + Ok(None) => {} + Err(error) => modal.error = Some(error), + } + cx.notify(); + }); + }) + .detach(); + } + + fn dismiss_start_chat_picker(&mut self, cx: &mut Context) { + if let Some(modal) = self.start_chat_modal.as_mut() { + if modal.picker.take().is_some() { + cx.notify(); + } + } + cx.stop_propagation(); + } + + fn on_start_chat_key_down( + &mut self, + event: &KeyDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event.keystroke.key.as_str() { + "escape" => { + cx.stop_propagation(); + self.close_start_chat_modal(window, cx); + } + "enter" + if self + .start_chat_modal + .as_ref() + .is_some_and(|modal| !modal.is_composing(cx)) => + { + cx.stop_propagation(); + self.submit_start_chat(window, cx); + } + _ => {} + } + } + + fn submit_start_chat(&mut self, window: &mut Window, cx: &mut Context) { + let Some(modal) = self.start_chat_modal.as_ref() else { + return; + }; + if !modal.can_submit() || modal.is_composing(cx) { + return; + } + let target = modal.target.clone(); + let cwd = normalized_value(modal.cwd.read(cx).text()); + let model = normalized_value(modal.model.read(cx).text()); + let effort = normalized_value(&modal.effort); + let title = modal.title.read(cx).text().trim().to_owned(); + let request = build_start_request( + modal.mode, + modal.selected_runner().map(|runner| runner.id.as_str()), + modal + .selected_runtime() + .map(|runtime| runtime.name.as_str()), + modal.runner_runtime_override.as_deref(), + model, + effort, + cwd, + ) + .expect("validated start chat selection"); + let initial_size = match &target { + ChatTarget::NewTab => (INITIAL_COLS, INITIAL_ROWS), + ChatTarget::Pane { tab_id, pane_id } + if self.tabs.active_tab_id() == Some(tab_id.as_str()) => + { + self.tabs + .active() + .map(|layout| self.estimated_terminal_size(layout, pane_id, window)) + .unwrap_or((INITIAL_COLS, INITIAL_ROWS)) + } + ChatTarget::Pane { .. } => { + if let Some(modal) = self.start_chat_modal.as_mut() { + modal.error = Some("The target tab is no longer active".into()); + } + cx.notify(); + return; + } + }; + if let Some(modal) = self.start_chat_modal.as_mut() { + modal.submitting = true; + modal.error = None; + } + cx.notify(); + + let mut spawned_id = None; + let mut rename_error = None; + let result = (|| -> Result { + let spawned = match request { + StartRequest::Runner { + runner_id, + runtime, + model, + effort, + cwd, + } => runner_backend::ops::session::session_start_direct( + &self.core, + runner_id, + runtime, + model, + effort, + None, + cwd, + Some(initial_size.0), + Some(initial_size.1), + )?, + StartRequest::Runtime { + runtime, + model, + effort, + cwd, + } => runner_backend::ops::session::session_start_runtime( + &self.core, + &runtime, + None, + cwd, + Some(initial_size.0), + Some(initial_size.1), + model, + effort, + )?, + }; + spawned_id = Some(spawned.id.clone()); + if !title.is_empty() { + if let Err(error) = runner_backend::ops::session::session_rename( + &self.core, + &spawned.id, + Some(title), + ) { + rename_error = Some(format!( + "Chat started, but its title could not be saved: {error}" + )); + } + } + self.refresh_sessions(); + match target { + ChatTarget::NewTab => { + self.reload_tabs()?; + self.tabs.activate_session(&spawned.id); + } + ChatTarget::Pane { pane_id, .. } => { + self.tabs.assign_to_active(&pane_id, &spawned.id)?; + self.persist_active_tab()?; + self.reload_tabs()?; + self.tabs.activate_session(&spawned.id); + } + } + self.ensure_active_tab_attached(window, cx)?; + Ok(spawned.id) + })(); + + match result { + Ok(session_id) => { + self.start_chat_modal = None; + self.error = rename_error; + self.remember_active_runner(); + if let Some(chat) = self.attached.get(&session_id) { + chat.terminal_focus.focus(window); + } + } + Err(start_error) => { + if let Some(session_id) = spawned_id { + self.start_chat_modal = None; + let _ = self.reload_tabs(); + self.tabs.activate_session(&session_id); + let _ = self.ensure_active_tab_attached(window, cx); + self.remember_active_runner(); + self.error = Some(start_error.to_string()); + } else if let Some(modal) = self.start_chat_modal.as_mut() { + modal.submitting = false; + modal.error = Some(start_error.to_string()); + } + } + } + cx.notify(); + } + + pub(crate) fn render_start_chat_modal(&self, cx: &mut Context) -> AnyElement { + let modal = self.start_chat_modal.as_ref().expect("modal is open"); + let selected_runner = modal.selected_runner().cloned(); + let selected_runtime = modal.selected_runtime().cloned(); + let override_runtime = modal.override_runtime().cloned(); + let active_runtime = modal.active_runtime().cloned(); + let mode = modal.mode; + let submitting = modal.submitting; + let can_submit = modal.can_submit(); + let picker = modal.picker; + let title_input = modal.title.clone(); + let cwd_input = modal.cwd.clone(); + let model_input = modal.model.clone(); + + let runner_choices = modal + .runners + .iter() + .map(|runner| ModalChoice { + value: runner.id.clone(), + label: format!("@{}", runner.handle), + description: Some(summarize_runner(runner)), + }) + .collect::>(); + let runtime_choices = modal + .runtimes + .iter() + .map(runtime_choice) + .collect::>(); + let mut runner_runtime_choices = vec![ModalChoice { + value: String::new(), + label: format!( + "Runner default{}", + selected_runner + .as_ref() + .map(|runner| format!( + " ({})", + runtime_display_name(&modal.runtimes, &runner.runtime) + )) + .unwrap_or_default() + ), + description: None, + }]; + runner_runtime_choices.extend(runtime_choices.clone()); + let runner_picker = self.render_modal_select( + "start-chat-runner", + ModalPicker::Runner, + selected_runner + .as_ref() + .map(|runner| format!("@{}", runner.handle)) + .unwrap_or_else(|| "No runners yet".into()), + selected_runner.as_ref().map(summarize_runner), + modal.runner_id.clone().unwrap_or_default(), + runner_choices, + FIELD_WIDTH, + true, + !submitting && !modal.runners.is_empty(), + picker, + cx, + ); + let runner_runtime_picker = self.render_modal_select( + "start-chat-runner-runtime", + ModalPicker::RunnerRuntime, + runner_runtime_choices + .iter() + .find(|choice| { + choice.value == modal.runner_runtime_override.as_deref().unwrap_or_default() + }) + .map(|choice| choice.label.clone()) + .unwrap_or_else(|| "Runner default".into()), + None, + modal.runner_runtime_override.clone().unwrap_or_default(), + runner_runtime_choices, + FIELD_WIDTH, + false, + !submitting, + picker, + cx, + ); + let runtime_picker = self.render_modal_select( + "start-chat-runtime", + ModalPicker::Runtime, + selected_runtime + .as_ref() + .map(|runtime| runtime.display_name.clone()) + .unwrap_or_else(|| "No agents detected".into()), + None, + modal.runtime_name.clone().unwrap_or_default(), + runtime_choices, + FIELD_WIDTH, + false, + !submitting && !modal.runtimes.is_empty(), + picker, + cx, + ); + + let runner_fields = div() + .flex() + .flex_col() + .gap_5() + .child(modal_field("Runner", None, runner_picker)) + .when(modal.runners.is_empty(), |fields| { + fields.child( + div() + .mt(px(-14.)) + .text_size(px(11.)) + .text_color(gpui::rgb(0xe0af68)) + .child("No runners yet. Create one from the runner page first."), + ) + }) + .child(modal_field( + "Agent", + Some("Overriding runs this persona on another agent; its model and effort become configurable below."), + runner_runtime_picker, + )) + .when_some(override_runtime, |fields, runtime| { + fields.child(self.render_model_effort_fields( + &runtime, + model_input.clone(), + &modal.effort, + picker, + submitting, + cx, + )) + }); + + let direct_fields = div() + .flex() + .flex_col() + .gap_5() + .child(modal_field("Agent", None, runtime_picker)) + .when(modal.runtimes.is_empty(), |fields| { + fields.child( + div() + .mt(px(-14.)) + .text_size(px(11.)) + .text_color(gpui::rgb(0xe0af68)) + .child(if modal.agents_checking { + "Detecting agents…" + } else { + "No enabled agents detected. Configure one in Settings → Agents." + }), + ) + }) + .when_some(modal.agents_error.clone(), |fields, error| { + fields.child( + div() + .mt(px(-14.)) + .text_size(px(11.)) + .text_color(gpui::rgb(0xf7768e)) + .child(error), + ) + }) + .when_some(active_runtime, |fields, runtime| { + fields.child(self.render_model_effort_fields( + &runtime, + model_input, + &modal.effort, + picker, + submitting, + cx, + )) + }); + + let content = + div() + .flex() + .flex_col() + .gap_5() + .children(modal.error.as_ref().map(|error| { + div() + .rounded_md() + .border_1() + .border_color(gpui::rgb(0x7a3448)) + .bg(gpui::rgb(0x3b1d2b)) + .px_3() + .py_2() + .text_xs() + .text_color(gpui::rgb(0xf7768e)) + .child(SharedString::from(error.clone())) + })) + .child( + div() + .flex() + .w_full() + .p(px(2.)) + .rounded_md() + .border_1() + .border_color(theme::border()) + .bg(theme::bg()) + .child(self.render_mode_button( + "Runner", + ChatMode::Runner, + mode, + submitting, + cx, + )) + .child(self.render_mode_button( + "Direct", + ChatMode::Runtime, + mode, + submitting, + cx, + )), + ) + .child(match mode { + ChatMode::Runner => runner_fields.into_any_element(), + ChatMode::Runtime => direct_fields.into_any_element(), + }) + .child(modal_field( + "Chat name", + Some("Optional. Leave blank to use the default label."), + title_input, + )) + .child(modal_field( + "Working directory", + None, + div() + .flex() + .items_center() + .gap_2() + .w_full() + .child(div().flex_1().min_w(px(0.)).child(cwd_input)) + .child( + modal_button("browse-start-chat-cwd", "Browse…", false, submitting) + .when(!submitting, |button| { + button.on_click(cx.listener(|this, _, _, cx| { + this.browse_start_chat_cwd(cx); + })) + }), + ), + )) + .child( + div() + .mt(px(-14.)) + .text_size(px(11.)) + .text_color(theme::muted()) + .child("Leave blank to use the default working directory."), + ); + + div() + .absolute() + .left_0() + .top_0() + .size_full() + .flex() + .items_center() + .justify_center() + .p_4() + .bg(gpui::rgba(0x00000099)) + .occlude() + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, window, cx| this.close_start_chat_modal(window, cx)), + ) + .child( + div() + .w(px(MODAL_WIDTH)) + .h(px(650.)) + .max_h(relative(0.85)) + .flex() + .flex_col() + .overflow_hidden() + .rounded_lg() + .border_1() + .border_color(theme::muted()) + .bg(theme::composer_bg()) + .shadow_lg() + .on_key_down(cx.listener(Self::on_start_chat_key_down)) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| this.dismiss_start_chat_picker(cx)), + ) + .child( + div() + .flex_none() + .h(px(74.)) + .px_6() + .flex() + .items_center() + .justify_between() + .border_b_1() + .border_color(theme::border()) + .child( + div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_size(px(16.)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme::text()) + .child("Start a chat"), + ) + .child( + div().text_xs().text_color(theme::muted()).child( + "Spawns a direct PTY in the selected directory.", + ), + ), + ) + .child( + div() + .id("close-start-chat") + .flex() + .items_center() + .justify_center() + .size(px(28.)) + .rounded_md() + .cursor_pointer() + .text_size(px(18.)) + .text_color(theme::muted()) + .hover(|button| { + button.bg(theme::border()).text_color(theme::text()) + }) + .child("×") + .when(!submitting, |button| { + button.on_click(cx.listener(|this, _, window, cx| { + this.close_start_chat_modal(window, cx); + })) + }), + ), + ) + .child( + div() + .id("start-chat-modal-content") + .flex_1() + .min_h(px(0.)) + .overflow_y_scroll() + .px_6() + .py_5() + .child(content), + ) + .child( + div() + .flex_none() + .h(px(64.)) + .px_6() + .flex() + .items_center() + .justify_end() + .gap_2() + .border_t_1() + .border_color(theme::border()) + .bg(theme::bg()) + .child( + modal_button("cancel-start-chat", "Cancel", false, submitting) + .when(!submitting, |button| { + button.on_click(cx.listener(|this, _, window, cx| { + this.close_start_chat_modal(window, cx); + })) + }), + ) + .child( + modal_button( + "submit-start-chat", + if submitting { + "Starting…" + } else { + "Start chat" + }, + true, + !can_submit, + ) + .when(can_submit, |button| { + button.on_click(cx.listener(|this, _, window, cx| { + this.submit_start_chat(window, cx); + })) + }), + ), + ), + ) + .into_any_element() + } + + fn render_mode_button( + &self, + label: &'static str, + mode: ChatMode, + active: ChatMode, + disabled: bool, + cx: &mut Context, + ) -> AnyElement { + div() + .id(match mode { + ChatMode::Runner => "start-chat-mode-runner", + ChatMode::Runtime => "start-chat-mode-runtime", + }) + .flex_1() + .flex() + .items_center() + .justify_center() + .h(px(30.)) + .rounded_md() + .text_xs() + .font_weight(FontWeight::SEMIBOLD) + .text_color(if active == mode { + theme::text() + } else { + theme::muted() + }) + .when(active == mode, |button| button.bg(theme::border())) + .when(!disabled, |button| { + button + .cursor_pointer() + .hover(|button| button.text_color(theme::text())) + .on_click(cx.listener(move |this, _, _, cx| { + this.set_start_chat_mode(mode, cx); + })) + }) + .child(label) + .into_any_element() + } + + #[allow(clippy::too_many_arguments)] + fn render_modal_select( + &self, + id: &'static str, + picker_kind: ModalPicker, + selected_label: String, + selected_description: Option, + selected_value: String, + choices: Vec, + width: f32, + detailed: bool, + enabled: bool, + open_picker: Option, + cx: &mut Context, + ) -> AnyElement { + let open = open_picker == Some(picker_kind); + let button = div() + .id(id) + .w(px(width)) + .h(px(if detailed { 52. } else { 36. })) + .px_3() + .flex() + .items_center() + .gap_3() + .rounded_md() + .border_1() + .border_color(if open { + theme::muted() + } else { + theme::border() + }) + .bg(theme::bg()) + .opacity(if enabled { 1. } else { 0.6 }) + .when(enabled, |button| { + button + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .cursor_pointer() + .hover(|button| button.border_color(theme::muted())) + .on_click(cx.listener(move |this, _, _, cx| { + this.toggle_start_chat_picker(picker_kind, cx); + })) + }) + .child( + div() + .flex_1() + .min_w(px(0.)) + .flex() + .flex_col() + .justify_center() + .child( + div() + .truncate() + .text_size(px(13.)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme::text()) + .child(selected_label), + ) + .children(selected_description.map(|description| { + div() + .truncate() + .text_size(px(11.)) + .text_color(theme::muted()) + .child(description) + })), + ) + .child( + div() + .flex_none() + .text_sm() + .text_color(theme::muted()) + .child("⌄"), + ); + + let menu = open.then(|| { + deferred( + anchored() + .position_mode(AnchoredPositionMode::Local) + .offset(point(px(0.), px(if detailed { 56. } else { 40. }))) + .child( + div() + .id("start-chat-options-menu") + .w(px(width)) + .max_h(px(224.)) + .overflow_y_scroll() + .p_1() + .rounded_md() + .border_1() + .border_color(theme::border()) + .bg(theme::composer_bg()) + .shadow_lg() + .occlude() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .children(choices.into_iter().enumerate().map( + move |(index, choice)| { + let active = choice.value == selected_value; + let value = choice.value.clone(); + div() + .id(SharedString::from(format!( + "start-chat-option-{}-{index}", + picker_kind as usize + ))) + .w_full() + .px_3() + .py_2() + .rounded_md() + .cursor_pointer() + .when(active, |row| row.bg(theme::border())) + .hover(|row| row.bg(theme::border())) + .child( + div() + .truncate() + .text_size(px(13.)) + .text_color(theme::text()) + .child(choice.label), + ) + .children(choice.description.map(|description| { + div() + .truncate() + .text_size(px(11.)) + .text_color(theme::muted()) + .child(description) + })) + .on_click(cx.listener(move |this, _, _, cx| { + this.select_start_chat_choice(picker_kind, &value, cx); + })) + }, + )), + ), + ) + .with_priority(2) + }); + div() + .relative() + .w(px(width)) + .child(button) + .children(menu) + .into_any_element() + } + + fn render_model_effort_fields( + &self, + runtime: &RuntimeCatalogEntry, + model_input: Entity, + effort: &str, + open_picker: Option, + submitting: bool, + cx: &mut Context, + ) -> AnyElement { + let effort_options = + effort_options_for_runtime(std::slice::from_ref(runtime), &runtime.name); + let has_effort = !effort_options.is_empty(); + let model_width = if has_effort { 232. } else { FIELD_WIDTH }; + let selected_model = model_input.read(cx).text().to_owned(); + let model_choices = runtime.models.iter().map(option_choice).collect::>(); + let model_menu = (open_picker == Some(ModalPicker::Model)).then(|| { + deferred( + anchored() + .position_mode(AnchoredPositionMode::Local) + .offset(point(px(0.), px(40.))) + .child( + div() + .id("start-chat-model-options-menu") + .w(px(model_width)) + .max_h(px(224.)) + .overflow_y_scroll() + .p_1() + .rounded_md() + .border_1() + .border_color(theme::border()) + .bg(theme::composer_bg()) + .shadow_lg() + .occlude() + .children(model_choices.into_iter().enumerate().map( + |(index, choice)| { + let active = choice.value == selected_model; + let value = choice.value.clone(); + div() + .id(("start-chat-model-option", index)) + .w_full() + .px_3() + .py_2() + .rounded_md() + .cursor_pointer() + .when(active, |row| row.bg(theme::border())) + .hover(|row| row.bg(theme::border())) + .child( + div() + .truncate() + .text_size(px(13.)) + .text_color(theme::text()) + .child(choice.label), + ) + .children(choice.description.map(|description| { + div() + .truncate() + .text_size(px(11.)) + .text_color(theme::muted()) + .child(description) + })) + .on_click(cx.listener(move |this, _, _, cx| { + cx.stop_propagation(); + this.select_start_chat_choice( + ModalPicker::Model, + &value, + cx, + ); + })) + }, + )), + ), + ) + .with_priority(2) + }); + let model = div() + .id("start-chat-model-field") + .relative() + .w(px(model_width)) + .flex() + .items_center() + .child(div().flex_1().min_w(px(0.)).child(model_input)) + .child( + div() + .id("start-chat-model-options") + .absolute() + .right(px(6.)) + .top(px(6.)) + .size(px(24.)) + .flex() + .items_center() + .justify_center() + .rounded_md() + .text_sm() + .text_color(theme::muted()) + .when(!submitting, |button| { + button + .cursor_pointer() + .hover(|button| button.bg(theme::border())) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_click(cx.listener(|this, _, _, cx| { + cx.stop_propagation(); + this.toggle_start_chat_picker(ModalPicker::Model, cx); + })) + }) + .child("⌄"), + ) + .children(model_menu); + + let fields = div() + .flex() + .items_start() + .gap_3() + .child(modal_field("Model", None, model)); + if !has_effort { + return fields.into_any_element(); + } + let effort_choices = effort_options.iter().map(option_choice).collect::>(); + let effort_label = effort_options + .iter() + .find(|option| option.value == effort) + .map(|option| option.label.clone()) + .unwrap_or_else(|| "default".into()); + fields + .child(modal_field( + "Thinking effort", + None, + self.render_modal_select( + "start-chat-effort", + ModalPicker::Effort, + effort_label, + None, + effort.to_owned(), + effort_choices, + 232., + false, + !submitting, + open_picker, + cx, + ), + )) + .into_any_element() + } +} + +fn modal_field( + label: &'static str, + subtitle: Option<&'static str>, + child: impl IntoElement, +) -> AnyElement { + div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_xs() + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme::text()) + .child(label), + ) + .child(child) + .children(subtitle.map(|subtitle| { + div() + .text_size(px(11.)) + .text_color(theme::muted()) + .child(subtitle) + })) + .into_any_element() +} + +fn modal_button( + id: &'static str, + label: &'static str, + primary: bool, + disabled: bool, +) -> gpui::Stateful { + div() + .id(id) + .h(px(34.)) + .px_4() + .flex() + .items_center() + .justify_center() + .rounded_md() + .border_1() + .border_color(if primary { + theme::accent() + } else { + theme::border() + }) + .bg(if primary { + theme::accent() + } else { + theme::composer_bg() + }) + .opacity(if disabled { 0.5 } else { 1. }) + .when(!disabled, |button| { + button.cursor_pointer().hover(|button| { + if primary { + button.opacity(0.9) + } else { + button.bg(theme::border()) + } + }) + }) + .text_size(px(13.)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(if primary { theme::bg() } else { theme::text() }) + .child(label) +} + +fn load_selectable_runtimes(core: &AppCore) -> (Vec, bool, Option) { + let checking = core + .runtime_discovery + .read() + .map(|discovery| discovery.checking) + .unwrap_or(false); + match runner_backend::ops::runtime::selectable_runtime_catalog(core, None) { + Ok(catalog) => (catalog, checking, None), + Err(error) => (Vec::new(), checking, Some(error.to_string())), + } +} + +fn runtime_choice(runtime: &RuntimeCatalogEntry) -> ModalChoice { + ModalChoice { + value: runtime.name.clone(), + label: runtime.display_name.clone(), + description: None, + } +} + +fn option_choice(option: &RuntimeCatalogOption) -> ModalChoice { + ModalChoice { + value: option.value.clone(), + label: option.label.clone(), + description: option.description.clone(), + } +} + +fn summarize_runner(runner: &Runner) -> String { + format!( + "{} · {}", + runner.runtime, + runner.working_dir.as_deref().unwrap_or("no working dir") + ) +} + +fn runtime_display_name(runtimes: &[RuntimeCatalogEntry], name: &str) -> String { + runtimes + .iter() + .find(|runtime| runtime.name == name) + .map(|runtime| runtime.display_name.clone()) + .or_else(|| { + runner_backend::ops::runtime::runtime_list() + .into_iter() + .find(|runtime| runtime.name == name) + .map(|runtime| runtime.display_name) + }) + .unwrap_or_else(|| name.to_owned()) +} + +fn default_title_for_runner(handle: &str) -> String { + format!("Chat with @{handle}") +} + +fn default_title_for_runtime(label: &str) -> String { + label.to_owned() +} + +fn auto_title_after_selection(edited: bool, current: &str, derived: String) -> String { + if edited { + current.to_owned() + } else { + derived + } +} + +fn update_auto_title( + title: &Entity, + derived: String, + cx: &mut Context, +) { + let (edited, next) = { + let input = title.read(cx); + ( + input.edited(), + auto_title_after_selection(input.edited(), input.text(), derived), + ) + }; + if edited { + return; + } + title.update(cx, |input, input_cx| input.reset(next, input_cx)); +} + +fn cwd_placeholder(mode: ChatMode, runner: Option<&Runner>) -> String { + match mode { + ChatMode::Runner => runner + .and_then(|runner| runner.working_dir.clone()) + .unwrap_or_else(|| "(no working directory)".into()), + ChatMode::Runtime => "(no working directory)".into(), + } +} + +fn effort_options_for_runtime<'a>( + runtimes: &'a [RuntimeCatalogEntry], + name: &str, +) -> &'a [RuntimeCatalogOption] { + runtimes + .iter() + .find(|runtime| runtime.name == name) + .map(|runtime| runtime.efforts.as_slice()) + .unwrap_or_default() +} + +fn normalized_value(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty()).then(|| value.to_owned()) +} + +fn build_start_request( + mode: ChatMode, + runner_id: Option<&str>, + runtime_name: Option<&str>, + runner_runtime_override: Option<&str>, + model: Option, + effort: Option, + cwd: Option, +) -> Option { + match mode { + ChatMode::Runner => runner_id.map(|runner_id| StartRequest::Runner { + runner_id: runner_id.to_owned(), + runtime: runner_runtime_override.map(str::to_owned), + model: runner_runtime_override.and(model), + effort: runner_runtime_override.and(effort), + cwd, + }), + ChatMode::Runtime => runtime_name.map(|runtime| StartRequest::Runtime { + runtime: runtime.to_owned(), + model, + effort, + cwd, + }), + } +} + +fn start_chat_mode_path(app_data_dir: &Path) -> PathBuf { + app_data_dir.join(START_CHAT_MODE_FILE) +} + +fn read_start_chat_mode(app_data_dir: &Path) -> ChatMode { + let value = fs::read_to_string(start_chat_mode_path(app_data_dir)).ok(); + ChatMode::from_persisted(value.as_deref()) +} + +fn write_start_chat_mode(app_data_dir: &Path, mode: ChatMode) -> std::io::Result<()> { + fs::write(start_chat_mode_path(app_data_dir), mode.persisted()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn runtime(name: &str, efforts: &[&str]) -> RuntimeCatalogEntry { + RuntimeCatalogEntry { + name: name.into(), + display_name: name.into(), + command: name.into(), + description: name.into(), + default_enabled: true, + available: true, + models: Vec::new(), + efforts: efforts + .iter() + .map(|value| RuntimeCatalogOption { + value: (*value).into(), + label: (*value).into(), + description: None, + }) + .collect(), + } + } + + #[test] + fn title_auto_derives_until_the_user_edits_it() { + assert_eq!( + auto_title_after_selection( + false, + "Chat with @coder", + default_title_for_runner("reviewer") + ), + "Chat with @reviewer" + ); + assert_eq!( + auto_title_after_selection(true, "my chat", default_title_for_runtime("Codex")), + "my chat" + ); + } + + #[test] + fn effort_options_follow_the_selected_runtime_catalog() { + let runtimes = [runtime("codex", &["", "low", "max"]), runtime("qoder", &[])]; + assert_eq!( + effort_options_for_runtime(&runtimes, "codex") + .iter() + .map(|option| option.value.as_str()) + .collect::>(), + ["", "low", "max"] + ); + assert!(effort_options_for_runtime(&runtimes, "qoder").is_empty()); + assert!(effort_options_for_runtime(&runtimes, "missing").is_empty()); + } + + #[test] + fn start_request_applies_overrides_only_to_the_active_runtime() { + assert_eq!( + build_start_request( + ChatMode::Runner, + Some("coder"), + Some("codex"), + None, + Some("gpt-5.6-sol".into()), + Some("high".into()), + Some("/repo".into()), + ), + Some(StartRequest::Runner { + runner_id: "coder".into(), + runtime: None, + model: None, + effort: None, + cwd: Some("/repo".into()), + }) + ); + assert_eq!( + build_start_request( + ChatMode::Runner, + Some("coder"), + Some("codex"), + Some("claude-code"), + Some("opus".into()), + Some("max".into()), + None, + ), + Some(StartRequest::Runner { + runner_id: "coder".into(), + runtime: Some("claude-code".into()), + model: Some("opus".into()), + effort: Some("max".into()), + cwd: None, + }) + ); + assert_eq!( + build_start_request( + ChatMode::Runtime, + Some("coder"), + Some("codex"), + Some("claude-code"), + Some("gpt-5.6-sol".into()), + Some("high".into()), + None, + ), + Some(StartRequest::Runtime { + runtime: "codex".into(), + model: Some("gpt-5.6-sol".into()), + effort: Some("high".into()), + cwd: None, + }) + ); + assert_eq!( + build_start_request(ChatMode::Runner, None, None, None, None, None, None), + None + ); + } + + #[test] + fn mode_preference_round_trips_and_invalid_values_fall_back_to_runner() { + let temp = tempfile::tempdir().unwrap(); + assert_eq!(read_start_chat_mode(temp.path()), ChatMode::Runner); + + write_start_chat_mode(temp.path(), ChatMode::Runtime).unwrap(); + assert_eq!(read_start_chat_mode(temp.path()), ChatMode::Runtime); + + fs::write(start_chat_mode_path(temp.path()), "unexpected").unwrap(); + assert_eq!(read_start_chat_mode(temp.path()), ChatMode::Runner); + } +} diff --git a/crates/runner-app/tests/pane_layout.rs b/crates/runner-app/tests/pane_layout.rs index 272abd9..7dcc75b 100644 --- a/crates/runner-app/tests/pane_layout.rs +++ b/crates/runner-app/tests/pane_layout.rs @@ -104,6 +104,80 @@ fn persisted_layout_round_trips_slots_and_per_split_sizes() { assert_eq!(outer.sizes, [70., 30.]); } +#[test] +fn close_pane_collapses_the_tree_and_keeps_sessions_in_the_surviving_order() { + let mut focused = PaneLayout::fresh(PresetKind::Cols2, Some("A"), &["A".into(), "B".into()]); + let focused_pane = focused.focused_pane_id.clone(); + assert!(focused.close_pane(&focused_pane)); + assert_eq!(focused.preset, PresetKind::Single); + assert_eq!(focused.session_ids(), ["B"]); + assert_eq!(focused.focused_pane_id, focused.root.leaves()[0].id); + + let mut main = PaneLayout::fresh( + PresetKind::Main2, + Some("A"), + &["A".into(), "B".into(), "C".into()], + ); + assert!(main.set_split_sizes("main-2:inner", [70., 30.])); + let big_pane = main.root.leaves()[0].id.clone(); + assert!(main.close_pane(&big_pane)); + assert_eq!(main.preset, PresetKind::Rows2); + assert_eq!(main.session_ids(), ["B", "C"]); + let PaneNode::Split(split) = &main.root else { + panic!("rows-2 must stay split"); + }; + assert_eq!(split.id, "rows-2:outer"); + assert_eq!(split.sizes, [70., 30.]); + + let restored = PaneLayout::from_node_row(&row("01K00000000000000000000000", 0, &main)).unwrap(); + assert_eq!(restored.preset, PresetKind::Rows2); + assert_eq!(restored.session_ids(), ["B", "C"]); + let PaneNode::Split(split) = restored.root else { + panic!("rows-2 must stay split"); + }; + assert_eq!(split.sizes, [70., 30.]); +} + +#[test] +fn close_pane_preserves_focus_when_another_pane_closes_and_single_is_a_noop() { + let mut main = PaneLayout::fresh( + PresetKind::Main2, + Some("A"), + &["A".into(), "B".into(), "C".into()], + ); + let original_focus = main.focused_pane_id.clone(); + let last_pane = main.root.leaves()[2].id.clone(); + assert!(main.close_pane(&last_pane)); + assert_eq!(main.preset, PresetKind::Cols2); + assert_eq!(main.session_ids(), ["A", "B"]); + assert_eq!(main.focused_pane_id, original_focus); + + let mut single = PaneLayout::fresh(PresetKind::Single, Some("A"), &["A".into()]); + let pane_id = single.focused_pane_id.clone(); + assert!(!single.close_pane(&pane_id)); + assert_eq!(single.session_ids(), ["A"]); +} + +#[test] +fn close_pane_handles_three_way_rows_and_columns_and_missing_ids() { + for preset in [PresetKind::Cols3, PresetKind::Rows3] { + let mut layout = + PaneLayout::fresh(preset, Some("A"), &["A".into(), "B".into(), "C".into()]); + let middle = layout.root.leaves()[1].id.clone(); + assert!(layout.close_pane(&middle)); + assert_eq!(layout.session_ids(), ["A", "C"]); + assert_eq!( + layout.preset, + if preset == PresetKind::Cols3 { + PresetKind::Cols2 + } else { + PresetKind::Rows2 + } + ); + assert!(!layout.close_pane("missing-pane")); + } +} + #[test] fn switching_tabs_preserves_each_tabs_sessions_focus_and_geometry() { let mut tab_a = PaneLayout::fresh(PresetKind::Cols2, Some("A"), &["A".into(), "B".into()]); diff --git a/docs/impls/gpui-rewrite/impl_log.md b/docs/impls/gpui-rewrite/impl_log.md index 3119418..73ebcff 100644 --- a/docs/impls/gpui-rewrite/impl_log.md +++ b/docs/impls/gpui-rewrite/impl_log.md @@ -194,3 +194,9 @@ M3's slices (0046 §Sequencing) run as serial codex-peer missions, one task at a - Added Qoder and TRAE end to end in the backend runtime registry, permission/model/effort argv adapters, resume and rollout-capture paths, MCP configuration, resize/resume output policies, and selectable availability-aware catalog. Main's model and effort option tables now live in backend ops for the next native Start Chat modal; Codex project trust is pre-seeded best-effort before each Codex spawn. - Direct chats accept runtime/model/effort overrides, slot model/effort layering matches main, runtime-only and options-only sessions persist enough state for resume, and changing a runner's runtime clears only inheriting slots' stale model/effort values. The React settings/picker components remain deferred because no native settings or Start Chat modal exists yet; M3's next modal task can consume the backend catalog and ops without reconstructing the tables. - Gates: `make verify` green (workspace check, 496 backend tests plus app/CLI/core/terminal suites, clippy `-D warnings`, fmt-check); the 10-test terminal fixture corpus stays green. The sandboxed run hit the expected Unix-socket `EPERM`; the identical permitted run passed with exit 0. + +## 2026-08-18 — Native Start Chat modal and pane close + +- Replaced the sidebar-swap new-chat skeleton with a GPUI modal matching `main`'s Runner and Direct flows, discovery-backed Agent/Model/Thinking effort controls, sticky auto-derived chat names, working-directory entry plus GPUI's asynchronous native folder selection, persisted mode preference, empty-pane runner preselection, and override-aware spawn/rename wiring. A reusable Pulse-pattern text input provides native IME composition for the modal fields and prevents Enter submission while composing. +- Ported `main`'s empty-pane close control and pure tree-collapse semantics, including sibling promotion, preset/name/focus rebuild, persistence, and leaving the removed pane's session alive. Unit coverage pins title derivation, effort lookup, mode persistence, IME Enter behavior, and pane-tree close/rebuild behavior. +- Gates: `make verify` green with exit 0 after granting the existing MCP Unix-socket test its required sandbox permission; the 10-test terminal fixture corpus stays green. From 0de116aa4f30b03215c31c5959b2790749cc05e6 Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Tue, 18 Aug 2026 19:03:26 +0800 Subject: [PATCH 2/2] docs(impls): re-scope M3 to backend-only, stage the M4 UI rebuild --- docs/impls/gpui-rewrite/plan.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/impls/gpui-rewrite/plan.md b/docs/impls/gpui-rewrite/plan.md index e0ae735..335ca20 100644 --- a/docs/impls/gpui-rewrite/plan.md +++ b/docs/impls/gpui-rewrite/plan.md @@ -146,8 +146,16 @@ Each milestone is a task branch off `gpui-nightly`, human-verified before merge. 1. **M0 — framework swap** ✓ (2026-08-17): `gpui` → `gpui-ce 0.3`; fixture corpus green; no behavior change. 2. **M1 — repo-and-below parity** ✓ (2026-08-17; A1–A3 + minimal B): protocol crates wholesale; db + migrations + repo verbatim; `ops/` adapter updated; sidebar rewired onto nodes far enough to compile and function. Gate: migration test on a *copy* of a production `runner.db`, then the A/B switch test — Tauri `main` build and GPUI build alternately opening the same copy. 3. **M2 — terminal split** ✓ (2026-08-17; C): extract `runner-terminal`, consolidate input encoding into `mappings`, move the fixture corpus. No rendering change; corpus green. -4. **M3 — feature-parity slices** (in progress; A4 + B function, dogfood order): session hardening → runtimes/model-effort pickers → node sidebar polish + pinned section → mission feed (read-mostly + channel composer) → pagination/update checks/misc. Run as serial codex-peer missions; task numbering (M3.1, M3.2, …) pinned in the [program log](impl_log.md)'s 2026-08-18 breakdown entry. Each slice daily-driven before the next. -5. **M4 — UI parity** (new 2026-08-18): bring the native app to full product-UI parity with `main` — this restores the surface breadth of the original Phase 4 list that M3's backend-first slices deliberately defer. Entry task is a **surface inventory**: walk `main`'s `src/` (7 pages, ~30 components + `settings/` + `ui/`) and the `design/*.pen` files, classify every surface as present / unstyled / missing in `runner-app` (the binary crate), and pin the resulting task list in the program log (continuing the serial-mission numbering). Known scope: chat surface + composer styling; tab bar and layout picker; sidebar visual polish beyond M3's functional parity; mission workspace UI; runners/crews CRUD pages; settings; modals and dialogs; command palette; themes as data (Tokyo Night + light); window chrome per §UI reference (hidden titlebar, traffic lights, drag areas — pulse pattern); multi-window; and **terminal-pane IME** (marked-text composition and candidate-window positioning in the focused terminal, committed UTF-8 forwarded through `SessionManager`, raw handling preserved for control/navigation/function keys — a cutover blocker carried from Phase 3). Exit gate: side-by-side parity review against the Tauri app on every surface — same surface, same flow, same copy — and the native app is the daily driver for all workflows, not just chats. +4. **M3 — backend parity slices** (in progress; re-scoped 2026-08-18, Jason: M3 is backend-only for a clean M3/M4 cut — the UI halves moved to M4): session hardening ✓ → runtimes ✓ → start-chat modal + pane controls (M4 pull-forward in flight; the one UI task closing M3) → router/inbox → mission-feed backend (0042/0044 backend halves) → pagination/update-checks/misc backend. Serial codex-peer missions; task numbering (M3.1, M3.2, …) pinned in the [program log](impl_log.md)'s 2026-08-18 breakdown entry. Backend groups are verified by their ported test suites; their observable daily-driven verification arrives with the M4 surfaces — an accepted deviation from the per-slice dogfood rule. +5. **M4 — UI rebuild to parity** (new 2026-08-18; staged 2026-08-18): the native UI today is the Phase 3 walking-skeleton look end to end — layout, spacing, colors, chrome, and copy all deviate from the Tauri app, and most product surfaces do not exist at all. M4 is therefore a **rebuild of the UI to `main`'s design**, not a polish pass: nothing currently rendered is presumed to survive as-is. Entry task: a **surface inventory** — walk `main`'s `src/` (7 pages, ~30 components + `settings/` + `ui/`) and `design/runner.pen`, classify every surface as functional-but-unstyled / partial / missing in `runner-app` (expect nearly everything in the last two buckets), and refine the stage task lists below in the program log. Stages, in order, each a serial mission run daily-driven: + - **M4.1 — App shell** (first, everything renders inside it): hide the native macOS titlebar entirely and own the header — pulse is the reference implementation (`TitlebarOptions` + traffic-light positioning in `pulse-app/src/main.rs`, titlebar drag areas + double-click zoom in `shell.rs`); the app frame per `main`'s `AppShell` (sidebar shell + header zones); the theme layer (design tokens from `runner.pen`'s variables and `main`'s Tailwind constants → Rust theme constants, Tokyo Night + light) with base typography/spacing; the app icon — reuse the existing Runner icon (`design/app-icon.svg`/`.png`, same identity as the Tauri app; dock/bundle wiring lands with the decision-10 nightly packaging). + - **M4.2 — Shared widget set**: buttons, text inputs (generalize the modal task's field), selects/dropdowns, cards, modal shell, pills/badges, scrollbars — sourced from `main`'s `ui/` components and `runner.pen`'s `cmp/` components. + - **M4.3 — Chat surface**: tab bar, pane chrome, layout picker restyle; node sidebar polish + pinned section (feature 43/44 behaviors — moved from M3 in the re-scope); attention indicators. + - **M4.4 — Mission surfaces**: mission workspace (topbar, tab strip, runners rail), mission feed UI (read-mostly chat-style feed + channel composer over M3's backend), Start Mission modal, inbox-blocked pill. + - **M4.5 — Runners / Crews / Projects**: list pages with search + pagination UI, detail pages and editors, start/create modals. + - **M4.6 — Settings, command palette, multi-window**: settings pages on `cmp/SettingsNav`, shortcut rebinding, theme picker, command palette, multi-window + window-state restore. + + (Terminal-pane IME, originally M4 scope and a cutover blocker, was pulled forward and shipped 2026-08-18; the start-chat modal + pane controls likewise ran ahead as the M3-closing task.) Exit gate: side-by-side parity review against the Tauri app on every surface — same surface, same flow, same copy, same look — and the native app is the daily driver for all workflows, not just chats. 6. **M5 — sweep + watermark** (was 0046's M4): diff `crates/runner-backend` against `main`'s `src-tauri/src` module-by-module; the diff should be adapter-shaped only. Record the synced `main` SHA in this doc as the watermark; subsequent `main` backend commits port promptly (schema/protocol) or per-slice (features). ### Phase 5 — App-shell services (replace what Tauri gave for free)