The right panel's button opens T3 Code's surface picker: Subagents (S), Browser (B) and Terminal (T). The terminal can sit in the right panel instead of the drawer. The subagents button leaves the top bar; the right panel's button lights up while subagents work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 6 changed files with 275 additions and 78 deletions.
| @@ -104,11 +104,14 @@ Gruvbox, Rosé Pine, Solarized and Dracula. | ||
| 104 | 104 | Sublime Text, Kate or the file manager, whichever are installed), the branch with a |
| 105 | 105 | switcher, **Commit** (asks the agent to commit, or commit and push), the size of the |
| 106 | 106 | uncommitted changes, and the thread's cost. |
| 107 | - - A subagents panel (Ctrl+Alt+S, or the robot in the top bar), in the project panel's | |
| 107 | + - A right panel, as T3 Code has it: its button in the top bar (Ctrl+Alt+B) opens "Open a | |
| 108 | + surface", which lists the subagents, the browser and a terminal, keyed S, B and T. A | |
| 109 | + terminal there holds the thread's terminals instead of the drawer under the conversation. | |
| 110 | + - A subagents panel (Ctrl+Alt+S, or S in the right panel), in the project panel's | |
| 108 | 111 | place: every subagent the thread started, the working ones first with what each is |
| 109 | 112 | doing right now (its latest tool call and progress summary), how long it has run, its |
| 110 | 113 | kind, tool uses and tokens, then the finished and failed ones. Every working one has a Stop button. Click one to jump to its call in the conversation. While the |
| 111 | - panel is closed, a light on the robot says subagents are working. It covers Claude's | |
| 114 | + panel is closed, a light on the right panel's button says subagents are working. It covers Claude's | |
| 112 | 115 | Task and Agent calls; Pi's [`subagent` extension](https://github.com/earendil-works/pi/tree/main/packages/coding-agent/examples/extensions/subagent) |
| 113 | 116 | (a row per task, single, parallel or chained); and jcode's swarm, whose agents keep |
| 114 | 117 | working in sessions of their own after the turn ends: the app checks on them every few |
| @@ -274,7 +277,7 @@ The shortcuts follow T3 Code's default keymap. | ||
| 274 | 277 | | Ctrl+D / Ctrl+Shift+D | Split the terminal side by side / stacked (terminal focused) | |
| 275 | 278 | | Ctrl+N / Ctrl+W | New terminal / close the terminal (terminal focused) | |
| 276 | 279 | | Ctrl+Shift+C / Ctrl+Shift+V | Copy / paste in the terminal | |
| 277 | - | Ctrl+Alt+B | Show or hide the right panel (the browser) | | |
| 280 | + | Ctrl+Alt+B | Show or hide the right panel: Open a surface | | |
| 278 | 281 | | Ctrl+Shift+B | Show or hide the thread details (project panel) | |
| 279 | 282 | | Ctrl+Alt+S | Show or hide the subagents panel | |
| 280 | 283 | | Ctrl+O | Add a project folder | |
| @@ -53,6 +53,7 @@ mod palette; | ||
| 53 | 53 | mod remote; |
| 54 | 54 | mod sidebar; |
| 55 | 55 | mod subagents; |
| 56 | + mod surfaces; | |
| 56 | 57 | mod terminal_drawer; |
| 57 | 58 | |
| 58 | 59 | use actions::UndoEntry; |
| @@ -219,10 +220,11 @@ pub struct Workspace { | ||
| 219 | 220 | search: Entity<InputState>, |
| 220 | 221 | sidebar_open: bool, |
| 221 | 222 | panel_open: bool, |
| 222 | - /// The browser takes the project panel's place while open. | |
| 223 | - browser_open: bool, | |
| 224 | - /// The subagents panel, in the project panel's place. | |
| 225 | - subagents_open: bool, | |
| 223 | + /// What the right panel shows, in the project panel's place: a surface | |
| 224 | + /// (subagents, browser, terminal) or the list to pick one from. | |
| 225 | + right_panel: Option<surfaces::Surface>, | |
| 226 | + /// The surface picker's focus, for its keys. | |
| 227 | + surface_focus: FocusHandle, | |
| 226 | 228 | settled_open: bool, |
| 227 | 229 | snoozed_open: bool, |
| 228 | 230 | working_open: bool, |
| @@ -376,8 +378,8 @@ impl Workspace { | ||
| 376 | 378 | search, |
| 377 | 379 | sidebar_open: true, |
| 378 | 380 | panel_open: true, |
| 379 | - browser_open: false, | |
| 380 | - subagents_open: false, | |
| 381 | + right_panel: None, | |
| 382 | + surface_focus: cx.focus_handle(), | |
| 381 | 383 | settled_open: false, |
| 382 | 384 | snoozed_open: false, |
| 383 | 385 | working_open: false, |
| @@ -604,7 +606,7 @@ impl Workspace { | ||
| 604 | 606 | } |
| 605 | 607 | // Working threads show how long they've worked. |
| 606 | 608 | let working = self.projects.iter().flat_map(|project| &project.sessions).any(|session| session.read(cx).is_working()); |
| 607 | - if working && (self.sidebar_open || self.subagents_open) { | |
| 609 | + if working && (self.sidebar_open || self.surface_open(surfaces::Surface::Subagents)) { | |
| 608 | 610 | cx.notify(); |
| 609 | 611 | } |
| 610 | 612 | // Repaint about once a minute so thread ages stay current. |
| @@ -1761,10 +1763,9 @@ impl Workspace { | ||
| 1761 | 1763 | } |
| 1762 | 1764 | |
| 1763 | 1765 | fn toggle_project_panel(&mut self, cx: &mut Context<Self>) { |
| 1764 | - // With the browser in its place, the project panel comes back. | |
| 1765 | - if self.browser_open || self.subagents_open { | |
| 1766 | - self.browser_open = false; | |
| 1767 | - self.subagents_open = false; | |
| 1766 | + // With a surface in its place, the project panel comes back. | |
| 1767 | + if self.right_panel.is_some() { | |
| 1768 | + self.right_panel = None; | |
| 1768 | 1769 | self.panel_open = true; |
| 1769 | 1770 | } else { |
| 1770 | 1771 | self.panel_open = !self.panel_open; |
| @@ -1775,7 +1776,7 @@ impl Workspace { | ||
| 1775 | 1776 | /// The open thread's browser panel while it shows, made on first use. |
| 1776 | 1777 | /// The others let go of their streams. |
| 1777 | 1778 | fn shown_browser(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Option<Entity<BrowserPanel>> { |
| 1778 | - let session = self.active.clone().filter(|_| self.browser_open && !self.settings_open); | |
| 1779 | + let session = self.active.clone().filter(|_| self.surface_open(surfaces::Surface::Browser) && !self.settings_open); | |
| 1779 | 1780 | let panel = session.map(|session| { |
| 1780 | 1781 | let id = session.entity_id(); |
| 1781 | 1782 | if let Some(panel) = self.browsers.get(&id) { |
| @@ -1785,7 +1786,7 @@ impl Workspace { | ||
| 1785 | 1786 | let panel = cx.new(|cx| BrowserPanel::new(&thread_id, window, cx)); |
| 1786 | 1787 | let subscription = cx.subscribe(&panel, |this, _, event: &BrowserPanelEvent, cx| match event { |
| 1787 | 1788 | BrowserPanelEvent::Close => { |
| 1788 | - this.browser_open = false; | |
| 1789 | + this.right_panel = None; | |
| 1789 | 1790 | cx.notify(); |
| 1790 | 1791 | } |
| 1791 | 1792 | }); |
| @@ -2082,14 +2083,16 @@ impl Render for Workspace { | ||
| 2082 | 2083 | cx.set_global(directory); |
| 2083 | 2084 | let browser = self.shown_browser(window, cx); |
| 2084 | 2085 | let p = metal::palette(cx); |
| 2085 | - let subagents_visible = self.subagents_open && browser.is_none(); | |
| 2086 | + let subagents_visible = self.surface_open(surfaces::Surface::Subagents); | |
| 2087 | + let picker_visible = self.surface_open(surfaces::Surface::Picker) && !self.settings_open; | |
| 2088 | + let docked_terminal = | |
| 2089 | + if self.surface_open(surfaces::Surface::Terminal) { self.render_terminal_drawer(true, window, cx) } else { None }; | |
| 2086 | 2090 | let panel_visible = self.panel_open |
| 2087 | - && !subagents_visible | |
| 2088 | - && browser.is_none() | |
| 2091 | + && self.right_panel.is_none() | |
| 2089 | 2092 | && self.active_project().is_some() |
| 2090 | 2093 | && window.viewport_size().width >= px(PANEL_MIN_WINDOW); |
| 2091 | 2094 | // The browser gives way before the conversation gets too narrow. |
| 2092 | - let chat_min = if browser.is_some() { px(360.) } else { px(0.) }; | |
| 2095 | + let chat_min = if browser.is_some() || docked_terminal.is_some() { px(360.) } else { px(0.) }; | |
| 2093 | 2096 | |
| 2094 | 2097 | h_flex() |
| 2095 | 2098 | .key_context("Workspace") |
| @@ -2134,10 +2137,7 @@ impl Render for Workspace { | ||
| 2134 | 2137 | } |
| 2135 | 2138 | })) |
| 2136 | 2139 | .on_action(cx.listener(|this, _: &OpenUsage, window, cx| this.open_settings_page("Usage", window, cx))) |
| 2137 | - .on_action(cx.listener(|this, _: &ToggleRightPanel, _, cx| { | |
| 2138 | - this.browser_open = !this.browser_open; | |
| 2139 | - cx.notify(); | |
| 2140 | - })) | |
| 2140 | + .on_action(cx.listener(|this, _: &ToggleRightPanel, window, cx| this.toggle_right_panel(window, cx))) | |
| 2141 | 2141 | .on_action(cx.listener(|this, _: &SelectTheme, window, cx| { |
| 2142 | 2142 | this.open_palette(palette::PaletteView::Themes, window, cx) |
| 2143 | 2143 | })) |
| @@ -2182,10 +2182,11 @@ impl Render for Workspace { | ||
| 2182 | 2182 | cx.notify(); |
| 2183 | 2183 | })) |
| 2184 | 2184 | .on_action(cx.listener(|this, _: &ToggleProjectPanel, _, cx| this.toggle_project_panel(cx))) |
| 2185 | - .on_action(cx.listener(|this, _: &ToggleSubagents, _, cx| this.toggle_subagents(cx))) | |
| 2186 | - .on_action(cx.listener(|this, _: &ToggleBrowser, _, cx| { | |
| 2187 | - this.browser_open = !this.browser_open; | |
| 2188 | - cx.notify(); | |
| 2185 | + .on_action(cx.listener(|this, _: &ToggleSubagents, window, cx| { | |
| 2186 | + this.toggle_surface(surfaces::Surface::Subagents, window, cx) | |
| 2187 | + })) | |
| 2188 | + .on_action(cx.listener(|this, _: &ToggleBrowser, window, cx| { | |
| 2189 | + this.toggle_surface(surfaces::Surface::Browser, window, cx) | |
| 2189 | 2190 | })) |
| 2190 | 2191 | .on_action(cx.listener(|this, _: &FocusSearch, window, cx| { |
| 2191 | 2192 | this.settings_open = false; |
| @@ -2230,10 +2231,12 @@ impl Render for Workspace { | ||
| 2230 | 2231 | Some(chat) => this.child(chat), |
| 2231 | 2232 | None => this.child(self.render_empty_sheet(cx)), |
| 2232 | 2233 | })) |
| 2233 | - .children(self.render_terminal_drawer(window, cx)), | |
| 2234 | + .children(self.render_terminal_drawer(false, window, cx)), | |
| 2234 | 2235 | ) |
| 2235 | 2236 | .when(panel_visible, |this| this.child(self.render_panel(cx))) |
| 2236 | 2237 | .when(subagents_visible, |this| this.child(self.render_subagents_panel(cx))) |
| 2238 | + .when(picker_visible, |this| this.child(self.render_surface_picker(cx))) | |
| 2239 | + .children(docked_terminal) | |
| 2237 | 2240 | .when_some(browser, |this, browser| this.child(browser)), |
| 2238 | 2241 | ), |
| 2239 | 2242 | ), |
| @@ -9,6 +9,7 @@ use gpui_kit::prelude::FluentBuilder as _; | ||
| 9 | 9 | use gpui_kit::*; |
| 10 | 10 | |
| 11 | 11 | use super::Workspace; |
| 12 | + use super::surfaces::Surface; | |
| 12 | 13 | use crate::Dismiss; |
| 13 | 14 | use crate::ui::browser::BrowserWatch; |
| 14 | 15 | use crate::ui::icons::Lucide; |
| @@ -236,10 +237,12 @@ impl Workspace { | ||
| 236 | 237 | /// drawer, and the right panel (the browser), plus the subagents panel. |
| 237 | 238 | fn render_layout_controls(&self, panel_visible: bool, cx: &mut Context<Self>) -> impl IntoElement { |
| 238 | 239 | let p = metal::palette(cx); |
| 239 | - let subagents_shown = self.subagents_open && !self.browser_open; | |
| 240 | - let subagents_working = !subagents_shown && self.working_subagents(cx) > 0; | |
| 241 | - let live = !self.browser_open | |
| 240 | + // While the panel shows something else, its light says subagents are | |
| 241 | + // working, or the agent is using the browser. | |
| 242 | + let subagents_working = !self.surface_open(Surface::Subagents) && self.working_subagents(cx) > 0; | |
| 243 | + let browser_live = !self.surface_open(Surface::Browser) | |
| 242 | 244 | && self.active.as_ref().is_some_and(|session| BrowserWatch::is_live(&session.read(cx).id, cx)); |
| 245 | + let live = subagents_working || browser_live; | |
| 243 | 246 | h_flex() |
| 244 | 247 | .flex_none() |
| 245 | 248 | .gap(px(4.)) |
| @@ -252,28 +255,12 @@ impl Workspace { | ||
| 252 | 255 | .tooltip("Toggle thread details (Ctrl+Shift+B)") |
| 253 | 256 | .on_click(cx.listener(|this, _, _, cx| this.toggle_project_panel(cx))), |
| 254 | 257 | ) |
| 255 | - .child( | |
| 256 | - div() | |
| 257 | - .relative() | |
| 258 | - .child( | |
| 259 | - MetalButton::new("toggle-subagents") | |
| 260 | - .ghost() | |
| 261 | - .mini() | |
| 262 | - .icon(Lucide::Bot) | |
| 263 | - .selected(subagents_shown) | |
| 264 | - .tooltip("Toggle subagents (Ctrl+Alt+S)") | |
| 265 | - .on_click(cx.listener(|this, _, _, cx| this.toggle_subagents(cx))), | |
| 266 | - ) | |
| 267 | - .when(subagents_working, |this| { | |
| 268 | - this.child(div().absolute().top(px(1.)).right(px(1.)).child(metal::led("subagents-live", Led::Live, true, p))) | |
| 269 | - }), | |
| 270 | - ) | |
| 271 | 258 | .child(if self.terminal_available(cx) { |
| 272 | 259 | MetalButton::new("toggle-terminal-drawer") |
| 273 | 260 | .ghost() |
| 274 | 261 | .mini() |
| 275 | 262 | .icon(IconName::PanelBottom) |
| 276 | - .selected(self.terminal_open()) | |
| 263 | + .selected(self.terminal_open() || self.surface_open(Surface::Terminal)) | |
| 277 | 264 | .tooltip("Toggle terminal drawer (Ctrl+J)") |
| 278 | 265 | .on_click(cx.listener(|this, _, window, cx| this.toggle_terminal(window, cx))) |
| 279 | 266 | .into_any_element() |
| @@ -293,15 +280,14 @@ impl Workspace { | ||
| 293 | 280 | .ghost() |
| 294 | 281 | .mini() |
| 295 | 282 | .icon(IconName::PanelRight) |
| 296 | - .selected(self.browser_open) | |
| 283 | + .selected(self.right_panel.is_some()) | |
| 297 | 284 | .tooltip("Toggle right panel (Ctrl+Alt+B)") |
| 298 | - .on_click(cx.listener(|this, _, _, cx| { | |
| 299 | - this.browser_open = !this.browser_open; | |
| 300 | - cx.notify(); | |
| 301 | - })), | |
| 285 | + .on_click(cx.listener(|this, _, window, cx| this.toggle_right_panel(window, cx))), | |
| 302 | 286 | ) |
| 303 | 287 | .when(live, |this| { |
| 304 | - this.child(div().absolute().top(px(1.)).right(px(1.)).child(metal::led("browser-live", Led::Live, false, p))) | |
| 288 | + this.child( | |
| 289 | + div().absolute().top(px(1.)).right(px(1.)).child(metal::led("right-panel-live", Led::Live, subagents_working, p)), | |
| 290 | + ) | |
| 305 | 291 | }), |
| 306 | 292 | ) |
| 307 | 293 | } |
| @@ -92,17 +92,6 @@ impl Workspace { | ||
| 92 | 92 | }) |
| 93 | 93 | } |
| 94 | 94 | |
| 95 | - pub(super) fn toggle_subagents(&mut self, cx: &mut Context<Self>) { | |
| 96 | - // With the browser in its place, the subagents come back. | |
| 97 | - if self.browser_open { | |
| 98 | - self.browser_open = false; | |
| 99 | - self.subagents_open = true; | |
| 100 | - } else { | |
| 101 | - self.subagents_open = !self.subagents_open; | |
| 102 | - } | |
| 103 | - cx.notify(); | |
| 104 | - } | |
| 105 | - | |
| 106 | 95 | /// Opens a subagent's thread, if it isn't open, and shows its call; or |
| 107 | 96 | /// for a task delegated to the app, the thread it runs in. |
| 108 | 97 | fn reveal_subagent( |
| @@ -156,7 +145,7 @@ impl Workspace { | ||
| 156 | 145 | .icon(IconName::Close) |
| 157 | 146 | .tooltip("Close subagents") |
| 158 | 147 | .on_click(cx.listener(|this, _, _, cx| { |
| 159 | - this.subagents_open = false; | |
| 148 | + this.right_panel = None; | |
| 160 | 149 | cx.notify(); |
| 161 | 150 | })), |
| 162 | 151 | ); |
| @@ -0,0 +1,160 @@ | ||
| 1 | + //! The right panel, as T3 Code has it: it shows one surface at a time (the | |
| 2 | + //! subagents, the browser or a terminal), and opens on "Open a surface", a | |
| 3 | + //! short list to pick one from, each with a key of its own. | |
| 4 | + | |
| 5 | + use gpui_kit::component::{Icon, IconName, h_flex, v_flex}; | |
| 6 | + use gpui_kit::prelude::FluentBuilder as _; | |
| 7 | + use gpui_kit::*; | |
| 8 | + | |
| 9 | + use super::Workspace; | |
| 10 | + use crate::ui::icons::Lucide; | |
| 11 | + use crate::ui::metal::{self, MetalStyled as _}; | |
| 12 | + | |
| 13 | + /// What the right panel shows. | |
| 14 | + #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 15 | + pub(super) enum Surface { | |
| 16 | + /// "Open a surface": the list of the others. | |
| 17 | + Picker, | |
| 18 | + Subagents, | |
| 19 | + Browser, | |
| 20 | + Terminal, | |
| 21 | + } | |
| 22 | + | |
| 23 | + /// The surfaces the picker lists: each with its icon, name and key. | |
| 24 | + const SURFACES: [(Surface, &str, &str); 3] = | |
| 25 | + [(Surface::Subagents, "Subagents", "S"), (Surface::Browser, "Browser", "B"), (Surface::Terminal, "Terminal", "T")]; | |
| 26 | + | |
| 27 | + /// How wide a terminal in the right panel is. | |
| 28 | + pub(super) const TERMINAL_PANEL_WIDTH: f32 = 520.; | |
| 29 | + | |
| 30 | + impl Workspace { | |
| 31 | + pub(super) fn surface_open(&self, surface: Surface) -> bool { | |
| 32 | + self.right_panel == Some(surface) | |
| 33 | + } | |
| 34 | + | |
| 35 | + /// The right panel's button: opens "Open a surface", or closes the panel. | |
| 36 | + pub(super) fn toggle_right_panel(&mut self, window: &mut Window, cx: &mut Context<Self>) { | |
| 37 | + if self.right_panel.is_some() { | |
| 38 | + self.right_panel = None; | |
| 39 | + } else { | |
| 40 | + self.right_panel = Some(Surface::Picker); | |
| 41 | + self.surface_focus.focus(window, cx); | |
| 42 | + } | |
| 43 | + cx.notify(); | |
| 44 | + } | |
| 45 | + | |
| 46 | + /// Shows a surface in the right panel, or closes the panel if it's | |
| 47 | + /// showing it already. | |
| 48 | + pub(super) fn toggle_surface(&mut self, surface: Surface, window: &mut Window, cx: &mut Context<Self>) { | |
| 49 | + if self.right_panel == Some(surface) { | |
| 50 | + self.right_panel = None; | |
| 51 | + cx.notify(); | |
| 52 | + return; | |
| 53 | + } | |
| 54 | + self.open_surface(surface, window, cx); | |
| 55 | + } | |
| 56 | + | |
| 57 | + pub(super) fn open_surface(&mut self, surface: Surface, window: &mut Window, cx: &mut Context<Self>) { | |
| 58 | + if surface == Surface::Terminal { | |
| 59 | + if !self.terminal_available(cx) { | |
| 60 | + return; | |
| 61 | + } | |
| 62 | + // The terminals move over from the drawer, if it's open. | |
| 63 | + self.dock_terminal(window, cx); | |
| 64 | + } | |
| 65 | + self.right_panel = Some(surface); | |
| 66 | + if surface == Surface::Picker { | |
| 67 | + self.surface_focus.focus(window, cx); | |
| 68 | + } | |
| 69 | + cx.notify(); | |
| 70 | + } | |
| 71 | + | |
| 72 | + /// The picker's keys: S, B and T open their surfaces; Escape closes it. | |
| 73 | + fn surface_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) { | |
| 74 | + let keystroke = &event.keystroke; | |
| 75 | + if keystroke.modifiers.modified() { | |
| 76 | + return; | |
| 77 | + } | |
| 78 | + if keystroke.key == "escape" { | |
| 79 | + self.right_panel = None; | |
| 80 | + cx.notify(); | |
| 81 | + return; | |
| 82 | + } | |
| 83 | + if let Some((surface, _, _)) = SURFACES.iter().find(|(_, _, key)| key.eq_ignore_ascii_case(&keystroke.key)) { | |
| 84 | + cx.stop_propagation(); | |
| 85 | + self.open_surface(*surface, window, cx); | |
| 86 | + } | |
| 87 | + } | |
| 88 | + | |
| 89 | + pub(super) fn render_surface_picker(&self, cx: &mut Context<Self>) -> impl IntoElement { | |
| 90 | + let p = metal::palette(cx); | |
| 91 | + let terminal = self.terminal_available(cx); | |
| 92 | + let rows = SURFACES.iter().map(|(surface, name, key)| { | |
| 93 | + let surface = *surface; | |
| 94 | + let enabled = surface != Surface::Terminal || terminal; | |
| 95 | + let icon = match surface { | |
| 96 | + Surface::Subagents => Icon::new(Lucide::Bot), | |
| 97 | + Surface::Browser => Icon::new(IconName::Globe), | |
| 98 | + _ => Icon::new(Lucide::SquareTerminal), | |
| 99 | + }; | |
| 100 | + h_flex() | |
| 101 | + .id(SharedString::from(format!("surface-{name}"))) | |
| 102 | + .h(px(31.)) | |
| 103 | + .px(px(10.)) | |
| 104 | + .gap(px(10.)) | |
| 105 | + .rounded(px(8.)) | |
| 106 | + .text_size(px(13.5)) | |
| 107 | + .text_color(if enabled { p.ink } else { p.ink3 }) | |
| 108 | + .when(enabled, |this| { | |
| 109 | + this.cursor_pointer() | |
| 110 | + .hover(|style| style.bg(p.row_hover)) | |
| 111 | + .on_click(cx.listener(move |this, _, window, cx| this.open_surface(surface, window, cx))) | |
| 112 | + }) | |
| 113 | + .child(icon.size(px(16.)).text_color(if enabled { p.ink2 } else { p.ink3 })) | |
| 114 | + .child(div().flex_1().child(*name)) | |
| 115 | + .child( | |
| 116 | + div() | |
| 117 | + .flex_none() | |
| 118 | + .min_w(px(18.)) | |
| 119 | + .h(px(18.)) | |
| 120 | + .px(px(5.)) | |
| 121 | + .flex() | |
| 122 | + .items_center() | |
| 123 | + .justify_center() | |
| 124 | + .rounded(px(4.)) | |
| 125 | + .bg(p.row_hover) | |
| 126 | + .font_family(metal::MONO) | |
| 127 | + .text_size(px(10.5)) | |
| 128 | + .text_color(p.ink3) | |
| 129 | + .child(*key), | |
| 130 | + ) | |
| 131 | + }); | |
| 132 | + v_flex() | |
| 133 | + .id("surface-picker") | |
| 134 | + .key_context("SurfacePicker") | |
| 135 | + .track_focus(&self.surface_focus) | |
| 136 | + .on_key_down(cx.listener(Self::surface_key)) | |
| 137 | + .w(px(super::PANEL_WIDTH)) | |
| 138 | + .flex_none() | |
| 139 | + .h_full() | |
| 140 | + .p(px(12.)) | |
| 141 | + .pl(px(0.)) | |
| 142 | + .child( | |
| 143 | + v_flex() | |
| 144 | + .flex_1() | |
| 145 | + .justify_center() | |
| 146 | + .px(px(16.)) | |
| 147 | + .rounded(px(18.)) | |
| 148 | + .raised(p) | |
| 149 | + .child( | |
| 150 | + div() | |
| 151 | + .pb(px(14.)) | |
| 152 | + .text_center() | |
| 153 | + .text_size(px(14.)) | |
| 154 | + .font_weight(FontWeight::MEDIUM) | |
| 155 | + .child("Open a surface"), | |
| 156 | + ) | |
| 157 | + .child(v_flex().gap(px(2.)).children(rows)), | |
| 158 | + ) | |
| 159 | + } | |
| 160 | + } | |
| @@ -13,6 +13,7 @@ use gpui_kit::component::{Icon, h_flex, tooltip::Tooltip, v_flex}; | ||
| 13 | 13 | use gpui_kit::prelude::FluentBuilder as _; |
| 14 | 14 | use gpui_kit::*; |
| 15 | 15 | |
| 16 | + use super::surfaces::{Surface, TERMINAL_PANEL_WIDTH}; | |
| 16 | 17 | use super::{Workspace, actions}; |
| 17 | 18 | use crate::ui::icons::Lucide; |
| 18 | 19 | use crate::ui::metal::{self, MetalButton, Palette}; |
| @@ -174,6 +175,13 @@ impl Workspace { | ||
| 174 | 175 | if self.settings_open { |
| 175 | 176 | return; |
| 176 | 177 | } |
| 178 | + // In the right panel, it hides from there. | |
| 179 | + if self.surface_open(Surface::Terminal) { | |
| 180 | + self.right_panel = None; | |
| 181 | + self.focus_active_composer(window, cx); | |
| 182 | + cx.notify(); | |
| 183 | + return; | |
| 184 | + } | |
| 177 | 185 | let Some(session) = self.active.as_ref().map(|session| session.entity_id()) else { return }; |
| 178 | 186 | if self.terminal_open() { |
| 179 | 187 | if let Some(drawer) = self.terminals.get_mut(&session) { |
| @@ -196,10 +204,29 @@ impl Workspace { | ||
| 196 | 204 | cx.notify(); |
| 197 | 205 | } |
| 198 | 206 | |
| 207 | + /// Shows the open thread's terminals in the right panel instead of the | |
| 208 | + /// drawer, starting one if it has none. | |
| 209 | + pub(super) fn dock_terminal(&mut self, window: &mut Window, cx: &mut Context<Self>) { | |
| 210 | + let Some(session) = self.active.as_ref().map(|session| session.entity_id()) else { return }; | |
| 211 | + let drawer = self.terminals.entry(session).or_default(); | |
| 212 | + drawer.open = false; | |
| 213 | + let empty = drawer.layout.is_empty(); | |
| 214 | + self.right_panel = Some(Surface::Terminal); | |
| 215 | + if empty { | |
| 216 | + self.new_terminal(window, cx); | |
| 217 | + } else { | |
| 218 | + self.focus_terminal(window, cx); | |
| 219 | + } | |
| 220 | + } | |
| 221 | + | |
| 199 | 222 | /// T3's terminal.new: a terminal in a group of its own. |
| 200 | 223 | pub(super) fn new_terminal(&mut self, window: &mut Window, cx: &mut Context<Self>) { |
| 201 | 224 | let Some(session) = self.active.as_ref().map(|session| session.entity_id()) else { return }; |
| 202 | - let Some(drawer) = self.terminals.get_mut(&session).filter(|drawer| drawer.open) else { return }; | |
| 225 | + let docked = self.surface_open(Surface::Terminal); | |
| 226 | + if docked { | |
| 227 | + self.terminals.entry(session).or_default(); | |
| 228 | + } | |
| 229 | + let Some(drawer) = self.terminals.get_mut(&session).filter(|drawer| drawer.open || docked) else { return }; | |
| 203 | 230 | let id = drawer.layout.new_terminal(); |
| 204 | 231 | self.start_terminal(session, id, window, cx); |
| 205 | 232 | } |
| @@ -207,7 +234,8 @@ impl Workspace { | ||
| 207 | 234 | /// T3's terminal.split and terminal.splitVertical. |
| 208 | 235 | pub(super) fn split_terminal(&mut self, vertical: bool, window: &mut Window, cx: &mut Context<Self>) { |
| 209 | 236 | let Some(session) = self.active.as_ref().map(|session| session.entity_id()) else { return }; |
| 210 | - let Some(drawer) = self.terminals.get_mut(&session).filter(|drawer| drawer.open) else { return }; | |
| 237 | + let docked = self.surface_open(Surface::Terminal); | |
| 238 | + let Some(drawer) = self.terminals.get_mut(&session).filter(|drawer| drawer.open || docked) else { return }; | |
| 211 | 239 | let Some(id) = drawer.layout.split(vertical) else { return }; |
| 212 | 240 | self.start_terminal(session, id, window, cx); |
| 213 | 241 | } |
| @@ -284,6 +312,9 @@ impl Workspace { | ||
| 284 | 312 | let empty = drawer.layout.is_empty(); |
| 285 | 313 | if empty { |
| 286 | 314 | self.terminals.remove(&session); |
| 315 | + if self.surface_open(Surface::Terminal) && self.active.as_ref().is_some_and(|active| active.entity_id() == session) { | |
| 316 | + self.right_panel = None; | |
| 317 | + } | |
| 287 | 318 | } |
| 288 | 319 | if self.active.as_ref().is_some_and(|active| active.entity_id() == session) { |
| 289 | 320 | let refocus = move |this: &mut Self, window: &mut Window, cx: &mut Context<Self>| { |
| @@ -328,9 +359,19 @@ impl Workspace { | ||
| 328 | 359 | } |
| 329 | 360 | |
| 330 | 361 | /// The open thread's drawer, when it shows. |
| 331 | - pub(super) fn render_terminal_drawer(&self, window: &mut Window, cx: &mut Context<Self>) -> Option<AnyElement> { | |
| 362 | + /// The open thread's terminals: in the drawer under the conversation, | |
| 363 | + /// or `docked` in the right panel. | |
| 364 | + pub(super) fn render_terminal_drawer(&self, docked: bool, window: &mut Window, cx: &mut Context<Self>) -> Option<AnyElement> { | |
| 332 | 365 | let session = self.active.as_ref()?.entity_id(); |
| 333 | - let drawer = self.terminals.get(&session).filter(|drawer| drawer.open)?; | |
| 366 | + let no_terminals = TerminalDrawer::default(); | |
| 367 | + let drawer = match self.terminals.get(&session) { | |
| 368 | + Some(drawer) if docked || drawer.open => drawer, | |
| 369 | + None if docked => &no_terminals, | |
| 370 | + _ => return None, | |
| 371 | + }; | |
| 372 | + if !docked && self.surface_open(Surface::Terminal) { | |
| 373 | + return None; | |
| 374 | + } | |
| 334 | 375 | let p = metal::palette(cx); |
| 335 | 376 | let height = clamp_height(self.terminal_height, window.viewport_size().height.as_f32()); |
| 336 | 377 | let layout = &drawer.layout; |
| @@ -351,11 +392,26 @@ impl Workspace { | ||
| 351 | 392 | cx.notify(); |
| 352 | 393 | }), |
| 353 | 394 | ); |
| 354 | - let base = v_flex().relative().flex_none().w_full().h(px(height)).overflow_hidden().child(metal::rule(p)); | |
| 395 | + let base = v_flex().relative().overflow_hidden().map(|this| { | |
| 396 | + if docked { | |
| 397 | + this.size_full().rounded(px(18.)).border_1().border_color(p.rule) | |
| 398 | + } else { | |
| 399 | + this.flex_none().w_full().h(px(height)).child(metal::rule(p)) | |
| 400 | + } | |
| 401 | + }); | |
| 402 | + // Docked, it fills the panel and has no edge to drag. | |
| 403 | + let handle = (!docked).then_some(handle); | |
| 404 | + let finish = move |body: AnyElement| -> AnyElement { | |
| 405 | + if docked { | |
| 406 | + div().w(px(TERMINAL_PANEL_WIDTH)).flex_none().h_full().p(px(12.)).pl(px(0.)).child(body).into_any_element() | |
| 407 | + } else { | |
| 408 | + body | |
| 409 | + } | |
| 410 | + }; | |
| 355 | 411 | |
| 356 | 412 | if layout.is_empty() { |
| 357 | - return Some( | |
| 358 | - base.child(handle) | |
| 413 | + return Some(finish( | |
| 414 | + base.children(handle) | |
| 359 | 415 | .child( |
| 360 | 416 | v_flex() |
| 361 | 417 | .flex_1() |
| @@ -376,7 +432,7 @@ impl Workspace { | ||
| 376 | 432 | ), |
| 377 | 433 | ) |
| 378 | 434 | .into_any_element(), |
| 379 | - ); | |
| 435 | + )); | |
| 380 | 436 | } |
| 381 | 437 | |
| 382 | 438 | let active = layout.active; |
| @@ -423,8 +479,8 @@ impl Workspace { | ||
| 423 | 479 | .child(div().flex_1().min_w_0().h_full().child(grid)) |
| 424 | 480 | .when(has_sidebar, |this| this.child(self.render_terminal_sidebar(drawer, at_limit, cx))); |
| 425 | 481 | |
| 426 | - Some( | |
| 427 | - base.child(handle) | |
| 482 | + Some(finish( | |
| 483 | + base.children(handle) | |
| 428 | 484 | .child(body) |
| 429 | 485 | .when(!has_sidebar, |this| { |
| 430 | 486 | this.child( |
| @@ -450,7 +506,7 @@ impl Workspace { | ||
| 450 | 506 | ) |
| 451 | 507 | }) |
| 452 | 508 | .into_any_element(), |
| 453 | - ) | |
| 509 | + )) | |
| 454 | 510 | } |
| 455 | 511 | |
| 456 | 512 | /// The list of groups beside the terminals once there's more than one. |