From 1f361b7f6c1677a6317f6d313a79ae30f0537f4f Mon Sep 17 00:00:00 2001 From: Bambang Tri Rahmat Doni Date: Tue, 1 Sep 2026 11:13:02 +0700 Subject: [PATCH 1/6] feat(shell): enable multi-line composer input with submit-on-enter. Configure the workspace composer for auto-growing multi-line entry so Enter can submit while Shift+Enter inserts a newline. --- src/app/shell/main_workspace_panel.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/app/shell/main_workspace_panel.rs b/src/app/shell/main_workspace_panel.rs index a40ed9d..6110362 100644 --- a/src/app/shell/main_workspace_panel.rs +++ b/src/app/shell/main_workspace_panel.rs @@ -31,9 +31,17 @@ pub struct MainWorkspacePanel { input: gpui::Entity, } +const COMPOSER_MIN_ROWS: usize = 1; +const COMPOSER_MAX_ROWS: usize = 6; + impl MainWorkspacePanel { pub fn new(window: &mut Window, theme: WorkspaceTheme, cx: &mut Context) -> Self { - let input = cx.new(|cx| InputState::new(window, cx).placeholder(COMPOSER_PLACEHOLDER)); + let input = cx.new(|cx| { + InputState::new(window, cx) + .auto_grow(COMPOSER_MIN_ROWS, COMPOSER_MAX_ROWS) + .submit_on_enter(true) + .placeholder(COMPOSER_PLACEHOLDER) + }); Self { focus_handle: cx.focus_handle(), theme, From 1e58d48c478799850107788f9eece2187983e77a Mon Sep 17 00:00:00 2001 From: Bambang Tri Rahmat Doni Date: Tue, 1 Sep 2026 11:13:09 +0700 Subject: [PATCH 2/6] fix(shell): let workspace composer auto-grow with content. Remove the fixed input height and bottom-align the send button so the composer can expand as users add lines. --- src/app/shell/main_workspace_panel.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/app/shell/main_workspace_panel.rs b/src/app/shell/main_workspace_panel.rs index 6110362..4aa42c9 100644 --- a/src/app/shell/main_workspace_panel.rs +++ b/src/app/shell/main_workspace_panel.rs @@ -211,7 +211,7 @@ fn composer_bar( mono: SharedString, pad: f32, ) -> impl IntoElement { - const COMPOSER_HEIGHT: f32 = 56.; + const COMPOSER_MIN_HEIGHT: f32 = 56.; const COMPOSER_TEXT: f32 = 16.; let surface = theme.surface(BackgroundToken::Secondary); @@ -232,13 +232,12 @@ fn composer_bar( h_flex() .w_full() .gap(px(SpacingToken::S1.value())) - .items_center() + .items_end() .child( div().flex_1().min_w_0().child( Input::new(input) .large() .w_full() - .h(px(COMPOSER_HEIGHT)) .text_size(px(COMPOSER_TEXT)) .bordered(true) .appearance(true) @@ -250,8 +249,8 @@ fn composer_bar( .ghost() .rounded(ButtonRounded::None) .icon(IconName::ArrowUp) - .h(px(COMPOSER_HEIGHT)) - .w(px(COMPOSER_HEIGHT)) + .h(px(COMPOSER_MIN_HEIGHT)) + .w(px(COMPOSER_MIN_HEIGHT)) .text_color(primary) .border_1() .border_color(border_strong) From 03072f6af939defa8db24e5c59765d623f51f26c Mon Sep 17 00:00:00 2001 From: Bambang Tri Rahmat Doni Date: Tue, 1 Sep 2026 11:13:13 +0700 Subject: [PATCH 3/6] chore(shell): document Shift+Enter newline shortcut in composer hint. Update the workspace composer helper text so users know how to insert a newline before sending. --- src/app/shell/main_workspace_panel.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/shell/main_workspace_panel.rs b/src/app/shell/main_workspace_panel.rs index 4aa42c9..d6a2f44 100644 --- a/src/app/shell/main_workspace_panel.rs +++ b/src/app/shell/main_workspace_panel.rs @@ -262,7 +262,7 @@ fn composer_bar( .font_family(mono) .text_size(px(TypeRole::MonoSm.size())) .text_color(muted) - .child("Enter to send · ⌘K for commands"), + .child("Enter to send · Shift+Enter for newline · ⌘K for commands"), ) } From 50aac3b36ed7672c16f1bd6e09f085b3b61ee15f Mon Sep 17 00:00:00 2001 From: Bambang Tri Rahmat Doni Date: Tue, 1 Sep 2026 11:51:21 +0700 Subject: [PATCH 4/6] feat(shell): wire workspace composer submit on Enter and send click. Subscribe to PressEnter, clear non-empty trimmed input on submit, and route the send button through the same handler so Enter-to-send matches the helper text. --- src/app/shell/main_workspace_panel.rs | 85 +++++++++++++++++++-------- 1 file changed, 62 insertions(+), 23 deletions(-) diff --git a/src/app/shell/main_workspace_panel.rs b/src/app/shell/main_workspace_panel.rs index d6a2f44..ef40f20 100644 --- a/src/app/shell/main_workspace_panel.rs +++ b/src/app/shell/main_workspace_panel.rs @@ -2,14 +2,15 @@ use gpui::{ App, AppContext, Context, EventEmitter, FocusHandle, Focusable, InteractiveElement, - IntoElement, ParentElement, Render, SharedString, Styled, Window, div, px, relative, + IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window, div, px, + relative, }; use gpui_component::{ IconName, Sizable, button::{Button, ButtonRounded, ButtonVariants as _}, dock::{Panel, PanelEvent}, h_flex, - input::{Input, InputState}, + input::{Input, InputEvent, InputState}, v_flex, }; @@ -29,6 +30,7 @@ pub struct MainWorkspacePanel { focus_handle: FocusHandle, theme: WorkspaceTheme, input: gpui::Entity, + _input_subscription: Subscription, } const COMPOSER_MIN_ROWS: usize = 1; @@ -42,12 +44,35 @@ impl MainWorkspacePanel { .submit_on_enter(true) .placeholder(COMPOSER_PLACEHOLDER) }); + let _input_subscription = cx.subscribe_in( + &input, + window, + |this, _, event, window, cx| match event { + InputEvent::PressEnter { shift: false, .. } => { + this.submit_composer(window, cx); + } + _ => {} + }, + ); Self { focus_handle: cx.focus_handle(), theme, input, + _input_subscription, } } + + fn submit_composer(&mut self, window: &mut Window, cx: &mut Context) { + let text = self.input.read(cx).value().trim().to_string(); + if text.is_empty() { + return; + } + + self.input.update(cx, |input, cx| { + input.set_value("", window, cx); + }); + cx.notify(); + } } impl EventEmitter for MainWorkspacePanel {} @@ -84,7 +109,7 @@ impl Panel for MainWorkspacePanel { } impl Render for MainWorkspacePanel { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = self.theme.get(); let page = theme.surface(BackgroundToken::Primary); let surface = theme.surface(BackgroundToken::Secondary); @@ -126,7 +151,7 @@ impl Render for MainWorkspacePanel { )) .child(quick_actions_row(tertiary, border, primary)), ) - .child(composer_bar(&self.input, &theme, mono, pad)) + .child(composer_bar(cx, &self.input, &theme, mono, pad)) } } @@ -206,6 +231,7 @@ fn quick_actions_row( } fn composer_bar( + cx: &mut Context, input: &gpui::Entity, theme: &OpenCoreTheme, mono: SharedString, @@ -234,27 +260,39 @@ fn composer_bar( .gap(px(SpacingToken::S1.value())) .items_end() .child( - div().flex_1().min_w_0().child( - Input::new(input) - .large() - .w_full() - .text_size(px(COMPOSER_TEXT)) - .bordered(true) - .appearance(true) - .cleanable(false), - ), + div() + .flex_1() + .min_w_0() + .min_h(px(COMPOSER_MIN_HEIGHT)) + .child( + Input::new(input) + .large() + .w_full() + .text_size(px(COMPOSER_TEXT)) + .bordered(true) + .appearance(true) + .cleanable(false), + ), ) .child( - Button::new("workspace-send") - .ghost() - .rounded(ButtonRounded::None) - .icon(IconName::ArrowUp) - .h(px(COMPOSER_MIN_HEIGHT)) - .w(px(COMPOSER_MIN_HEIGHT)) - .text_color(primary) - .border_1() - .border_color(border_strong) - .bg(surface), + div() + .id("workspace-composer-send") + .debug_selector(|| "workspace-composer-send".to_string()) + .child( + Button::new("workspace-send") + .ghost() + .rounded(ButtonRounded::None) + .icon(IconName::ArrowUp) + .h(px(COMPOSER_MIN_HEIGHT)) + .w(px(COMPOSER_MIN_HEIGHT)) + .text_color(primary) + .border_1() + .border_color(border_strong) + .bg(surface) + .on_click(cx.listener(|this, _, window, cx| { + this.submit_composer(window, cx); + })), + ), ), ) .child( @@ -273,3 +311,4 @@ fn mono_family() -> SharedString { fn sans_family() -> SharedString { SharedString::from("Space Grotesk") } + From 142a03cf72bc12f8ba31328714b90d5ce97d0a89 Mon Sep 17 00:00:00 2001 From: Bambang Tri Rahmat Doni Date: Tue, 1 Sep 2026 11:52:08 +0700 Subject: [PATCH 5/6] test(shell): add workspace composer submit integration tests. Cover Enter submit, send-button click, Shift+Enter newline insertion, whitespace-only no-op, and auto-grow row limits. --- src/app/shell/main_workspace_panel.rs | 125 ++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/src/app/shell/main_workspace_panel.rs b/src/app/shell/main_workspace_panel.rs index ef40f20..aaf4266 100644 --- a/src/app/shell/main_workspace_panel.rs +++ b/src/app/shell/main_workspace_panel.rs @@ -312,3 +312,128 @@ fn sans_family() -> SharedString { SharedString::from("Space Grotesk") } +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + + use gpui::{Entity, Modifiers, TestAppContext, VisualContext, VisualTestContext}; + use gpui_component::Root; + + use super::super::workspace_theme::WorkspaceTheme; + use super::*; + + fn init_composer_test(cx: &mut TestAppContext) { + cx.update(gpui_component::init); + } + + macro_rules! mount_composer_panel { + ($cx:ident, $panel:ident) => { + let panel_cell = Rc::new(RefCell::new(None)); + let panel_cell_capture = panel_cell.clone(); + let (_, $cx) = $cx.add_window_view(|window, cx| { + let panel = + cx.new(|cx| MainWorkspacePanel::new(window, WorkspaceTheme::default(), cx)); + panel_cell_capture.borrow_mut().replace(panel.clone()); + Root::new(panel, window, cx) + }); + let $panel = panel_cell.borrow().clone().expect("composer panel entity"); + }; + } + + fn set_composer_value( + panel: &Entity, + value: &str, + cx: &mut VisualTestContext, + ) { + cx.update_window_entity(panel, |panel, window, cx| { + panel.input.update(cx, |input, cx| { + input.set_value(value, window, cx); + }); + }); + } + + fn focus_composer_at_end(panel: &Entity, cx: &mut VisualTestContext) { + cx.update_window_entity(panel, |panel, window, cx| { + panel.input.update(cx, |input, cx| { + let value = input.value(); + let line_count = value.lines().count().max(1); + let last_line = value.lines().last().unwrap_or(""); + input.set_cursor_position( + gpui_component::input::Position::new( + (line_count - 1) as u32, + last_line.chars().count() as u32, + ), + window, + cx, + ); + }); + }); + } + + fn composer_value(panel: &Entity, cx: &mut VisualTestContext) -> String { + cx.read_entity(panel, |panel, cx| panel.input.read(cx).value().to_string()) + } + + #[gpui::test] + fn composer_enter_submits_non_empty_text(cx: &mut TestAppContext) { + init_composer_test(cx); + mount_composer_panel!(cx, panel); + + set_composer_value(&panel, "hello", cx); + focus_composer_at_end(&panel, cx); + cx.simulate_keystrokes("enter"); + cx.run_until_parked(); + + assert_eq!(composer_value(&panel, cx), ""); + } + + #[gpui::test] + fn composer_submit_ignores_whitespace_only(cx: &mut TestAppContext) { + init_composer_test(cx); + mount_composer_panel!(cx, panel); + + set_composer_value(&panel, " ", cx); + cx.update_window_entity(&panel, |panel, window, cx| { + panel.submit_composer(window, cx); + }); + + assert_eq!(composer_value(&panel, cx), " "); + } + + #[gpui::test] + fn composer_send_button_submits_non_empty_text(cx: &mut TestAppContext) { + init_composer_test(cx); + mount_composer_panel!(cx, panel); + + set_composer_value(&panel, "hello", cx); + cx.run_until_parked(); + + let button_bounds = cx + .debug_bounds("workspace-composer-send") + .expect("workspace send button should be visible"); + cx.simulate_click(button_bounds.center(), Modifiers::none()); + cx.run_until_parked(); + + assert_eq!(composer_value(&panel, cx), ""); + } + + #[gpui::test] + fn composer_shift_enter_inserts_newline(cx: &mut TestAppContext) { + init_composer_test(cx); + mount_composer_panel!(cx, panel); + + set_composer_value(&panel, "line one", cx); + focus_composer_at_end(&panel, cx); + cx.simulate_keystrokes("shift-enter"); + cx.run_until_parked(); + + assert_eq!(composer_value(&panel, cx), "line one\n"); + } + + #[test] + fn composer_row_limits_match_spec() { + assert_eq!(COMPOSER_MIN_ROWS, 1); + assert_eq!(COMPOSER_MAX_ROWS, 6); + } +} From 65b5d70eedb1626466e5dcf9b0442debcf521983 Mon Sep 17 00:00:00 2001 From: Bambang Tri Rahmat Doni Date: Tue, 1 Sep 2026 13:13:38 +0700 Subject: [PATCH 6/6] style: apply rustfmt and fix clippy in workspace composer. Use if-let for the PressEnter subscription and format the composer send button chain so CI fmt/clippy checks pass. --- src/app/shell/main_workspace_panel.rs | 43 ++++++++++++--------------- 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/src/app/shell/main_workspace_panel.rs b/src/app/shell/main_workspace_panel.rs index aaf4266..f0944b7 100644 --- a/src/app/shell/main_workspace_panel.rs +++ b/src/app/shell/main_workspace_panel.rs @@ -44,16 +44,11 @@ impl MainWorkspacePanel { .submit_on_enter(true) .placeholder(COMPOSER_PLACEHOLDER) }); - let _input_subscription = cx.subscribe_in( - &input, - window, - |this, _, event, window, cx| match event { - InputEvent::PressEnter { shift: false, .. } => { - this.submit_composer(window, cx); - } - _ => {} - }, - ); + let _input_subscription = cx.subscribe_in(&input, window, |this, _, event, window, cx| { + if let InputEvent::PressEnter { shift: false, .. } = event { + this.submit_composer(window, cx); + } + }); Self { focus_handle: cx.focus_handle(), theme, @@ -279,20 +274,20 @@ fn composer_bar( .id("workspace-composer-send") .debug_selector(|| "workspace-composer-send".to_string()) .child( - Button::new("workspace-send") - .ghost() - .rounded(ButtonRounded::None) - .icon(IconName::ArrowUp) - .h(px(COMPOSER_MIN_HEIGHT)) - .w(px(COMPOSER_MIN_HEIGHT)) - .text_color(primary) - .border_1() - .border_color(border_strong) - .bg(surface) - .on_click(cx.listener(|this, _, window, cx| { - this.submit_composer(window, cx); - })), - ), + Button::new("workspace-send") + .ghost() + .rounded(ButtonRounded::None) + .icon(IconName::ArrowUp) + .h(px(COMPOSER_MIN_HEIGHT)) + .w(px(COMPOSER_MIN_HEIGHT)) + .text_color(primary) + .border_1() + .border_color(border_strong) + .bg(surface) + .on_click(cx.listener(|this, _, window, cx| { + this.submit_composer(window, cx); + })), + ), ), ) .child(