Vectrum
Sign In

Merge the T3 Code terminal drawer

Ctrl+J opens a terminal drawer under the conversation, per thread and in
its folder: splits, several terminals with a sidebar, T3's buttons, menu
and close confirmation, and terminal shortcuts that only apply while a
terminal has focus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 2 parents d597e54 d301bebcommit3d9f92e

Showing 15 changed files with 2011 additions and 15 deletions.

Merge commit — showing changes against the first parent.

+97−0Cargo.lockView File
@@ -131,6 +131,31 @@ dependencies = [
131131 "memchr",
132132 ]
133133
134+ [[package]]
135+ name = "alacritty_terminal"
136+ version = "0.26.0"
137+ source = "registry+https://github.com/rust-lang/crates.io-index"
138+ checksum = "bda177466b9524d59f1b12f0dd30b68696788e9992a7e959021c4a0ed96fcf59"
139+ dependencies = [
140+ "base64 0.22.1",
141+ "bitflags 2.13.2",
142+ "home",
143+ "libc",
144+ "log",
145+ "miow",
146+ "parking_lot",
147+ "piper",
148+ "polling",
149+ "regex-automata",
150+ "rustix",
151+ "rustix-openpty",
152+ "serde",
153+ "signal-hook",
154+ "unicode-width",
155+ "vte",
156+ "windows-sys 0.59.0",
157+ ]
158+
134159 [[package]]
135160 name = "aligned"
136161 version = "0.4.3"
@@ -621,6 +646,9 @@ name = "bitflags"
621646 version = "2.13.2"
622647 source = "registry+https://github.com/rust-lang/crates.io-index"
623648 checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
649+ dependencies = [
650+ "serde_core",
651+ ]
624652
625653 [[package]]
626654 name = "bitstream-io"
@@ -1313,6 +1341,12 @@ dependencies = [
13131341 "linktime-proc-macro",
13141342 ]
13151343
1344+ [[package]]
1345+ name = "cursor-icon"
1346+ version = "1.2.0"
1347+ source = "registry+https://github.com/rust-lang/crates.io-index"
1348+ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
1349+
13161350 [[package]]
13171351 name = "data-encoding"
13181352 version = "2.11.1"
@@ -3010,6 +3044,15 @@ dependencies = [
30103044 "digest 0.10.7",
30113045 ]
30123046
3047+ [[package]]
3048+ name = "home"
3049+ version = "0.5.12"
3050+ source = "registry+https://github.com/rust-lang/crates.io-index"
3051+ checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
3052+ dependencies = [
3053+ "windows-sys 0.61.2",
3054+ ]
3055+
30133056 [[package]]
30143057 name = "html5ever"
30153058 version = "0.27.0"
@@ -3974,6 +4017,15 @@ dependencies = [
39744017 "windows-sys 0.61.2",
39754018 ]
39764019
4020+ [[package]]
4021+ name = "miow"
4022+ version = "0.6.1"
4023+ source = "registry+https://github.com/rust-lang/crates.io-index"
4024+ checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08"
4025+ dependencies = [
4026+ "windows-sys 0.61.2",
4027+ ]
4028+
39774029 [[package]]
39784030 name = "moxcms"
39794031 version = "0.8.1"
@@ -5745,6 +5797,17 @@ dependencies = [
57455797 "windows-sys 0.61.2",
57465798 ]
57475799
5800+ [[package]]
5801+ name = "rustix-openpty"
5802+ version = "0.2.0"
5803+ source = "registry+https://github.com/rust-lang/crates.io-index"
5804+ checksum = "1de16c7c59892b870a6336f185dc10943517f1327447096bbb7bb32cd85e2393"
5805+ dependencies = [
5806+ "errno",
5807+ "libc",
5808+ "rustix",
5809+ ]
5810+
57485811 [[package]]
57495812 name = "rustls"
57505813 version = "0.23.45"
@@ -6157,6 +6220,16 @@ version = "2.0.1"
61576220 source = "registry+https://github.com/rust-lang/crates.io-index"
61586221 checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
61596222
6223+ [[package]]
6224+ name = "signal-hook"
6225+ version = "0.4.5"
6226+ source = "registry+https://github.com/rust-lang/crates.io-index"
6227+ checksum = "b29ffa27d3d59a049bb328258de100f8012848d1b08d444e08bbc02ac468435e"
6228+ dependencies = [
6229+ "libc",
6230+ "signal-hook-registry",
6231+ ]
6232+
61606233 [[package]]
61616234 name = "signal-hook-registry"
61626235 version = "1.4.8"
@@ -7328,6 +7401,7 @@ dependencies = [
73287401 name = "vectrum-code"
73297402 version = "0.1.0"
73307403 dependencies = [
7404+ "alacritty_terminal",
73317405 "anyhow",
73327406 "base64 0.22.1",
73337407 "chrono",
@@ -7367,6 +7441,20 @@ dependencies = [
73677441 "libc",
73687442 ]
73697443
7444+ [[package]]
7445+ name = "vte"
7446+ version = "0.15.0"
7447+ source = "registry+https://github.com/rust-lang/crates.io-index"
7448+ checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
7449+ dependencies = [
7450+ "arrayvec",
7451+ "bitflags 2.13.2",
7452+ "cursor-icon",
7453+ "log",
7454+ "memchr",
7455+ "serde",
7456+ ]
7457+
73707458 [[package]]
73717459 name = "waker-fn"
73727460 version = "1.2.0"
@@ -8209,6 +8297,15 @@ dependencies = [
82098297 "windows-targets 0.52.6",
82108298 ]
82118299
8300+ [[package]]
8301+ name = "windows-sys"
8302+ version = "0.59.0"
8303+ source = "registry+https://github.com/rust-lang/crates.io-index"
8304+ checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
8305+ dependencies = [
8306+ "windows-targets 0.52.6",
8307+ ]
8308+
82128309 [[package]]
82138310 name = "windows-sys"
82148311 version = "0.60.2"
+1−0Cargo.tomlView File
@@ -16,6 +16,7 @@ ureq = { version = "2", default-features = false, features = ["tls"] }
1616 tungstenite = { version = "0.30", default-features = false, features = ["handshake"] }
1717 chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
1818 image = { version = "0.25", default-features = false, features = ["jpeg"] }
19+ alacritty_terminal = "0.26"
1920
2021 [profile.dev]
2122 debug = "line-tables-only"
+20−2README.mdView File
@@ -113,6 +113,14 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
113113 its own headless Chrome session, which its agent drives with the `agent-browser` CLI
114114 while the panel shows it live. Click, scroll and type in the page yourself, or go to a
115115 URL. When the agent opens a page while the panel is closed, a light on the globe says so.
116+ - A terminal drawer under the conversation, like T3 Code's (Ctrl+J, or the panel toggle in
117+ the chat header): each thread has its own terminals, which start in the thread's folder
118+ (its worktree when it has one). Drag the drawer's top edge to resize it. Split a terminal
119+ side by side (Ctrl+D) or stacked (Ctrl+Shift+D), up to four per group, or open another
120+ (Ctrl+N while a terminal has focus); with several, a sidebar lists the groups. Close one
121+ with Ctrl+W or the trash button (after a confirmation); a terminal whose shell exits
122+ closes on its own. Right-click for Add to chat (puts the selection in the composer), Copy,
123+ Paste, Select all and Jump to latest; Ctrl+Shift+C / Ctrl+Shift+V copy and paste.
116124 - Interrupt with Esc or the stop button. Messages sent while the agent works are queued.
117125 - Crash recovery: if a process dies, the next message restarts it and resumes the session.
118126 - Threads are saved as you go and come back after a restart, settled ones included; the next
@@ -228,6 +236,10 @@ The shortcuts follow T3 Code's default keymap.
228236 | Alt+↑ | Edit the last queued message |
229237 | Ctrl+F | Find in thread |
230238 | Ctrl+B | Show or hide the sidebar |
239+ | Ctrl+J | Show or hide the terminal drawer |
240+ | Ctrl+D / Ctrl+Shift+D | Split the terminal side by side / stacked (terminal focused) |
241+ | Ctrl+N / Ctrl+W | New terminal / close the terminal (terminal focused) |
242+ | Ctrl+Shift+C / Ctrl+Shift+V | Copy / paste in the terminal |
231243 | Ctrl+Alt+B | Show or hide the right panel (the browser) |
232244 | Ctrl+Shift+B | Show or hide the thread details (project panel) |
233245 | Ctrl+O | Add a project folder |
@@ -259,6 +271,10 @@ in its `scratch` folder.
259271 - `src/models.rs`: the model catalog across providers, and favorites.
260272 - `src/git.rs`: branch and change stats, working-tree snapshots, and diffs.
261273 - `src/settings.rs`: the saved settings file, cached in memory.
274+ - `src/terminal.rs`: a shell in a pseudo-terminal, emulated by `alacritty_terminal`, and the
275+ bytes keys and pastes send to it.
276+ - `src/ui/terminal.rs`: one terminal's view: the grid in the mono font, cursor, selection,
277+ scrollback and its right-click menu.
262278 - `src/ui/chat.rs`: start screen, transcript (with turn folding), tool cards, permission bar
263279 and composer.
264280 - `src/ui/pickers.rs`: the model, reasoning and access pickers.
@@ -272,7 +288,8 @@ in its `scratch` folder.
272288 - `src/ui/workspace.rs`: window root: thread sidebar, top bar and project panel, plus the
273289 git and editor helpers behind it. Its submodules hold the sidebar (`sidebar.rs`, and the
274290 legacy layout in `sidebar/legacy.rs`), the thread menus, snoozing, confirmations and undo
275- (`actions.rs`), the chat header (`header.rs`) and the command palette (`palette.rs`).
291+ (`actions.rs`), the chat header (`header.rs`), the command palette (`palette.rs`) and the
292+ terminal drawer (`terminal_drawer.rs`).
276293 - `src/ui/icons.rs`: the extra Lucide icons the app embeds.
277294 - `src/ui/metal.rs`: the MetalUI layer. Colorway tokens and shadow recipes copied from
278295 MetalUI's CSS, the theme mapping for the stock widgets, and the primitives (caps, wells,
@@ -286,6 +303,7 @@ in its `scratch` folder.
286303
287304 There is no UI for Pi extensions that ask for free text, code blocks have no syntax
288305 highlighting, and T3 Code's project
289- scripts and terminal drawer aren't here yet. jcode models can't write thread titles (every
306+ scripts aren't here yet. The terminal drawer doesn't report mouse clicks to full-screen
307+ programs (only the wheel), open links, or show T3's popup after a selection. jcode models can't write thread titles (every
290308 jcode run is saved as a session), and jcode's side panel, applets and background-task
291309 progress aren't shown.
+6−0assets/licenses.tsvView File
@@ -9,6 +9,7 @@ adler2 2.0.1 0BSD OR MIT OR Apache-2.0 https://github.com/oyvindln/adler2
99 aes 0.8.4 MIT OR Apache-2.0 https://github.com/RustCrypto/block-ciphers
1010 ahash 0.8.12 MIT OR Apache-2.0 https://github.com/tkaitchuck/ahash
1111 aho-corasick 1.1.5 Unlicense OR MIT https://github.com/BurntSushi/aho-corasick
12+ alacritty_terminal 0.26.0 Apache-2.0 https://github.com/alacritty/alacritty
1213 aligned 0.4.3 MIT OR Apache-2.0 https://github.com/rust-embedded-community/aligned
1314 aligned-vec 0.6.4 MIT https://github.com/sarah-ek/aligned-vec/
1415 allocator-api2 0.2.21 MIT OR Apache-2.0 https://github.com/zakarumych/allocator-api2
@@ -101,6 +102,7 @@ crunchy 0.2.4 MIT https://github.com/eira-fransham/crunchy
101102 crypto-common 0.1.7 MIT OR Apache-2.0 https://github.com/RustCrypto/traits
102103 crypto-common 0.2.2 MIT OR Apache-2.0 https://github.com/RustCrypto/traits
103104 ctor 1.0.13 Apache-2.0 OR MIT https://github.com/mmastrac/linktime
105+ cursor-icon 1.2.0 MIT OR Apache-2.0 OR Zlib https://github.com/rust-windowing/cursor-icon
104106 data-encoding 2.11.1 MIT https://github.com/ia0/data-encoding
105107 data-url 0.3.2 MIT OR Apache-2.0 https://github.com/servo/rust-url
106108 derive_more 2.1.1 MIT https://github.com/JelteF/derive_more
@@ -219,6 +221,7 @@ hex 0.4.3 MIT OR Apache-2.0 https://github.com/KokaKiwi/rust-hex
219221 hexf-parse 0.2.1 CC0-1.0 https://github.com/lifthrasiir/hexf
220222 hkdf 0.12.4 MIT OR Apache-2.0 https://github.com/RustCrypto/KDFs/
221223 hmac 0.12.1 MIT OR Apache-2.0 https://github.com/RustCrypto/MACs
224+ home 0.5.12 MIT OR Apache-2.0 https://github.com/rust-lang/cargo
222225 html5ever 0.27.0 MIT OR Apache-2.0 https://github.com/servo/html5ever
223226 http 1.5.0 MIT OR Apache-2.0 https://github.com/hyperium/http
224227 http-body 1.1.0 MIT https://github.com/hyperium/http-body
@@ -404,6 +407,7 @@ rustc-hash 1.1.0 Apache-2.0 OR MIT https://github.com/rust-lang-nursery/rustc-ha
404407 rustc-hash 2.1.3 Apache-2.0 OR MIT https://github.com/rust-lang/rustc-hash
405408 rustc_version 0.4.1 MIT OR Apache-2.0 https://github.com/djc/rustc-version-rs
406409 rustix 1.1.5 Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT https://github.com/bytecodealliance/rustix
410+ rustix-openpty 0.2.0 Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT https://github.com/sunfishcode/rustix-openpty
407411 rustls 0.23.45 Apache-2.0 OR ISC OR MIT https://github.com/rustls/rustls
408412 rustls-pki-types 1.15.1 MIT OR Apache-2.0 https://github.com/rustls/pki-types
409413 rustls-webpki 0.103.15 ISC https://github.com/rustls/webpki
@@ -437,6 +441,7 @@ sha2 0.11.0 MIT OR Apache-2.0 https://github.com/RustCrypto/hashes
437441 sharded-slab 0.1.7 MIT https://github.com/hawkw/sharded-slab
438442 shellexpand 3.1.2 MIT OR Apache-2.0 https://gitlab.com/ijackson/rust-shellexpand
439443 shlex 2.0.1 MIT OR Apache-2.0 https://github.com/comex/rust-shlex
444+ signal-hook 0.4.5 MIT OR Apache-2.0 https://github.com/vorner/signal-hook
440445 signal-hook-registry 1.4.8 MIT OR Apache-2.0 https://github.com/vorner/signal-hook
441446 simd-adler32 0.3.10 MIT https://github.com/mcountryman/simd-adler32
442447 simd_helpers 0.1.0 MIT https://github.com/lu-zero/simd_helpers
@@ -536,6 +541,7 @@ value-bag 1.14.1 Apache-2.0 OR MIT https://github.com/sval-rs/value-bag
536541 value-bag-serde1 1.14.1 Apache-2.0 OR MIT
537542 value-bag-sval2 1.14.1 Apache-2.0 OR MIT
538543 version_check 0.9.5 MIT OR Apache-2.0 https://github.com/SergioBenitez/version_check
544+ vte 0.15.0 Apache-2.0 OR MIT https://github.com/alacritty/vte
539545 waker-fn 1.2.0 Apache-2.0 OR MIT https://github.com/smol-rs/waker-fn
540546 walkdir 2.5.0 Unlicense OR MIT https://github.com/BurntSushi/walkdir
541547 wasm-bindgen 0.2.129 MIT OR Apache-2.0 https://github.com/wasm-bindgen/wasm-bindgen
+28−0src/main.rsView File
@@ -13,6 +13,7 @@ mod remote;
1313 mod session;
1414 mod settings;
1515 mod store;
16+ mod terminal;
1617 mod textgen;
1718 mod ui;
1819 mod usage;
@@ -67,6 +68,14 @@ gpui_kit::actions!(
6768 ComposerPreviousWorktree,
6869 ComposerStash,
6970 FindInThread,
71+ // T3 Code's terminal drawer commands.
72+ ToggleTerminal,
73+ SplitTerminal,
74+ SplitTerminalVertical,
75+ NewTerminal,
76+ CloseTerminal,
77+ TerminalCopy,
78+ TerminalPaste,
7079 // Keys for lists with a text field: the palette, sidebar search, the
7180 // project filter, and inline renames.
7281 SelectPrevious,
@@ -146,7 +155,26 @@ fn main() {
146155 KeyBinding::new("down", SelectNext, Some("ScopePicker > Input")),
147156 KeyBinding::new("escape", Dismiss, Some("ScopePicker > Input")),
148157 KeyBinding::new("escape", Dismiss, Some("InlineRename > Input")),
158+ // The terminal drawer. A focused terminal's keys come last so
159+ // they win over the app's (Ctrl+N is a new terminal there).
160+ KeyBinding::new("ctrl-j", ToggleTerminal, None),
161+ KeyBinding::new("ctrl-d", SplitTerminal, Some("Terminal")),
162+ KeyBinding::new("ctrl-shift-d", SplitTerminalVertical, Some("Terminal")),
163+ KeyBinding::new("ctrl-n", NewTerminal, Some("Terminal")),
164+ KeyBinding::new("ctrl-w", CloseTerminal, Some("Terminal")),
165+ KeyBinding::new("ctrl-shift-c", TerminalCopy, Some("Terminal")),
166+ KeyBinding::new("ctrl-shift-v", TerminalPaste, Some("Terminal")),
167+ KeyBinding::new("shift-insert", TerminalPaste, Some("Terminal")),
149168 ]);
169+ // Keys T3 Code keeps from the app while a terminal has focus
170+ // (its "!terminalFocus"), so they reach the shell.
171+ cx.bind_keys(
172+ [
173+ "ctrl-k", "ctrl-u", "ctrl-z", "ctrl-shift-o", "ctrl-shift-n", "ctrl-alt-n", "ctrl-shift-s",
174+ "ctrl-shift-p", "ctrl-alt-a", "ctrl-alt-shift-a", "tab", "shift-tab",
175+ ]
176+ .map(|key| KeyBinding::new(key, NoAction, Some("Terminal"))),
177+ );
150178 cx.on_action(quit);
151179 cx.on_window_closed(|cx, _| cx.quit()).detach();
152180
+2−0src/store.rsView File
@@ -34,6 +34,8 @@ pub struct AppState {
3434 pub collapsed_projects: Vec<PathBuf>,
3535 /// The thread sidebar's width as last dragged; `None` is the default.
3636 pub sidebar_width: Option<f32>,
37+ /// The terminal drawer's height as last dragged; `None` is the default.
38+ pub terminal_height: Option<f32>,
3739 }
3840
3941 fn threads_dir() -> Option<PathBuf> {
+323−0src/terminal.rsaddedView File
@@ -0,0 +1,323 @@
1+ //! A shell in a pseudo-terminal, emulated by `alacritty_terminal`: the PTY,
2+ //! the reader thread that parses the shell's output into a grid, and the
3+ //! bytes keys and pastes send. `ui::terminal` draws the grid.
4+
5+ use std::borrow::Cow;
6+ use std::collections::HashMap;
7+ use std::path::Path;
8+ use std::sync::Arc;
9+
10+ use alacritty_terminal::event::{Event, EventListener, WindowSize};
11+ use alacritty_terminal::event_loop::{EventLoop, EventLoopSender, Msg};
12+ use alacritty_terminal::grid::Dimensions;
13+ use alacritty_terminal::sync::FairMutex;
14+ use alacritty_terminal::term::{self, Term};
15+ use alacritty_terminal::tty;
16+ use gpui_kit::Keystroke;
17+
18+ /// How many lines a terminal keeps above the screen.
19+ const SCROLLBACK: usize = 10_000;
20+
21+ /// Hands the emulator's events to the view, which drains them on the main
22+ /// thread.
23+ #[derive(Clone)]
24+ pub struct Listener(smol::channel::Sender<Event>);
25+
26+ impl EventListener for Listener {
27+ fn send_event(&self, event: Event) {
28+ let _ = self.0.try_send(event);
29+ }
30+ }
31+
32+ /// A grid size, as the emulator measures one.
33+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
34+ pub struct GridSize {
35+ pub columns: usize,
36+ pub lines: usize,
37+ }
38+
39+ impl Dimensions for GridSize {
40+ fn total_lines(&self) -> usize {
41+ self.lines
42+ }
43+
44+ fn screen_lines(&self) -> usize {
45+ self.lines
46+ }
47+
48+ fn columns(&self) -> usize {
49+ self.columns
50+ }
51+ }
52+
53+ /// A running shell and its screen.
54+ pub struct Terminal {
55+ term: Arc<FairMutex<Term<Listener>>>,
56+ sender: EventLoopSender,
57+ size: GridSize,
58+ }
59+
60+ impl Terminal {
61+ /// Starts the user's shell in `cwd`, with a screen of `size` cells of
62+ /// `cell` pixels each. Its events arrive on the returned channel.
63+ pub fn spawn(cwd: &Path, size: GridSize, cell: (u16, u16)) -> std::io::Result<(Self, smol::channel::Receiver<Event>)> {
64+ let (events, receiver) = smol::channel::unbounded();
65+ let listener = Listener(events);
66+ let config = term::Config { scrolling_history: SCROLLBACK, ..Default::default() };
67+ let term = Arc::new(FairMutex::new(Term::new(config, &size, listener.clone())));
68+ let options = tty::Options {
69+ shell: None,
70+ working_directory: Some(cwd.to_path_buf()),
71+ drain_on_exit: false,
72+ env: HashMap::from([
73+ ("TERM".to_string(), "xterm-256color".to_string()),
74+ ("COLORTERM".to_string(), "truecolor".to_string()),
75+ ("TERM_PROGRAM".to_string(), "vectrum-code".to_string()),
76+ ]),
77+ };
78+ let pty = tty::new(&options, window_size(size, cell), 0)?;
79+ let event_loop = EventLoop::new(term.clone(), listener, pty, false, false)?;
80+ let sender = event_loop.channel();
81+ event_loop.spawn();
82+ Ok((Self { term, sender, size }, receiver))
83+ }
84+
85+ /// Runs `f` with the screen locked.
86+ pub fn with_term<R>(&self, f: impl FnOnce(&mut Term<Listener>) -> R) -> R {
87+ f(&mut self.term.lock())
88+ }
89+
90+ /// Sends bytes to the shell, as if typed.
91+ pub fn write(&self, bytes: impl Into<Cow<'static, [u8]>>) {
92+ let bytes = bytes.into();
93+ if !bytes.is_empty() {
94+ let _ = self.sender.send(Msg::Input(bytes));
95+ }
96+ }
97+
98+ /// Pastes text, bracketed when the program asked for that.
99+ pub fn paste(&self, text: &str) {
100+ let bracketed = self.with_term(|screen| screen.mode().contains(term::TermMode::BRACKETED_PASTE));
101+ self.write(paste_bytes(text, bracketed));
102+ }
103+
104+ pub fn size(&self) -> GridSize {
105+ self.size
106+ }
107+
108+ /// Fits the screen, and the shell's idea of it, to a new size.
109+ pub fn resize(&mut self, size: GridSize, cell: (u16, u16)) {
110+ if size == self.size || size.columns == 0 || size.lines == 0 {
111+ return;
112+ }
113+ self.size = size;
114+ self.term.lock().resize(size);
115+ let _ = self.sender.send(Msg::Resize(window_size(size, cell)));
116+ }
117+ }
118+
119+ impl Drop for Terminal {
120+ /// Stops the reader thread, which hangs up on the shell as it goes.
121+ fn drop(&mut self) {
122+ let _ = self.sender.send(Msg::Shutdown);
123+ }
124+ }
125+
126+ fn window_size(size: GridSize, (cell_width, cell_height): (u16, u16)) -> WindowSize {
127+ WindowSize {
128+ num_lines: size.lines.min(u16::MAX as usize) as u16,
129+ num_cols: size.columns.min(u16::MAX as usize) as u16,
130+ cell_width,
131+ cell_height,
132+ }
133+ }
134+
135+ /// What a paste sends: line breaks as Enter, and inside bracketed paste
136+ /// markers when the program asked for them (with any markers in the text
137+ /// taken out, so a paste can't end itself early).
138+ pub fn paste_bytes(text: &str, bracketed: bool) -> Vec<u8> {
139+ if bracketed {
140+ let clean = text.replace('\x1b', "");
141+ let mut bytes = b"\x1b[200~".to_vec();
142+ bytes.extend_from_slice(clean.as_bytes());
143+ bytes.extend_from_slice(b"\x1b[201~");
144+ bytes
145+ } else {
146+ text.replace("\r\n", "\r").replace('\n', "\r").into_bytes()
147+ }
148+ }
149+
150+ /// What a key sends to the shell, as xterm encodes it. Plain typing (with
151+ /// at most Shift held) isn't encoded here: it arrives as text through the
152+ /// view's input handler, so input methods work. `app_cursor` is the
153+ /// program's cursor key mode (DECCKM).
154+ pub fn key_bytes(keystroke: &Keystroke, app_cursor: bool) -> Option<Vec<u8>> {
155+ let modifiers = &keystroke.modifiers;
156+ if modifiers.platform || modifiers.function {
157+ return None;
158+ }
159+ let (ctrl, alt, shift) = (modifiers.control, modifiers.alt, modifiers.shift);
160+ // xterm's modifier parameter: 1 + Shift + 2·Alt + 4·Ctrl.
161+ let param = 1 + u8::from(shift) + 2 * u8::from(alt) + 4 * u8::from(ctrl);
162+ let escaped = |bytes: &[u8]| {
163+ let mut out = Vec::with_capacity(bytes.len() + 1);
164+ if alt {
165+ out.push(0x1b);
166+ }
167+ out.extend_from_slice(bytes);
168+ out
169+ };
170+ // Cursor keys: `ESC [ A`, `ESC O A` in application mode, `ESC [ 1 ; 5 A`
171+ // with modifiers.
172+ let cursor = |letter: char| {
173+ Some(match param {
174+ 1 if app_cursor => format!("\x1bO{letter}").into_bytes(),
175+ 1 => format!("\x1b[{letter}").into_bytes(),
176+ _ => format!("\x1b[1;{param}{letter}").into_bytes(),
177+ })
178+ };
179+ // Editing and function keys: `ESC [ 3 ~`, `ESC [ 3 ; 5 ~`.
180+ let tilde = |code: u8| {
181+ Some(match param {
182+ 1 => format!("\x1b[{code}~").into_bytes(),
183+ _ => format!("\x1b[{code};{param}~").into_bytes(),
184+ })
185+ };
186+ let key = keystroke.key.as_str();
187+ match key {
188+ "enter" => return Some(escaped(b"\r")),
189+ "tab" if shift => return Some(b"\x1b[Z".to_vec()),
190+ "tab" => return Some(escaped(b"\t")),
191+ "escape" => return Some(escaped(b"\x1b")),
192+ "backspace" if ctrl => return Some(escaped(b"\x08")),
193+ "backspace" => return Some(escaped(b"\x7f")),
194+ "up" => return cursor('A'),
195+ "down" => return cursor('B'),
196+ "right" => return cursor('C'),
197+ "left" => return cursor('D'),
198+ "home" => return cursor('H'),
199+ "end" => return cursor('F'),
200+ "insert" => return tilde(2),
201+ "delete" => return tilde(3),
202+ "pageup" => return tilde(5),
203+ "pagedown" => return tilde(6),
204+ "f1" | "f2" | "f3" | "f4" => {
205+ let letter = (b'P' + key[1..].parse::<u8>().ok()? - 1) as char;
206+ return Some(match param {
207+ 1 => format!("\x1bO{letter}").into_bytes(),
208+ _ => format!("\x1b[1;{param}{letter}").into_bytes(),
209+ });
210+ }
211+ _ => {}
212+ }
213+ if let Some(number) = key.strip_prefix('f').and_then(|n| n.parse::<u8>().ok()) {
214+ let code = match number {
215+ 5 => 15,
216+ 6 => 17,
217+ 7 => 18,
218+ 8 => 19,
219+ 9 => 20,
220+ 10 => 21,
221+ 11 => 23,
222+ 12 => 24,
223+ _ => return None,
224+ };
225+ return tilde(code);
226+ }
227+ let character = match key {
228+ "space" => ' ',
229+ _ => {
230+ let mut chars = key.chars();
231+ let first = chars.next()?;
232+ if chars.next().is_some() {
233+ return None;
234+ }
235+ first
236+ }
237+ };
238+ if ctrl {
239+ let control = match character.to_ascii_lowercase() {
240+ c @ 'a'..='z' => c as u8 - b'a' + 1,
241+ ' ' | '@' | '2' => 0,
242+ '[' | '3' => 0x1b,
243+ '\\' | '4' => 0x1c,
244+ ']' | '5' => 0x1d,
245+ '^' | '6' => 0x1e,
246+ '_' | '-' | '/' | '7' => 0x1f,
247+ '?' | '8' => 0x7f,
248+ _ => return None,
249+ };
250+ return Some(escaped(&[control]));
251+ }
252+ if alt {
253+ let text = keystroke.key_char.clone().unwrap_or_else(|| {
254+ if shift { character.to_uppercase().collect() } else { character.to_string() }
255+ });
256+ return Some(escaped(text.as_bytes()));
257+ }
258+ None
259+ }
260+
261+ #[cfg(test)]
262+ mod tests {
263+ use super::{key_bytes, paste_bytes};
264+ use gpui_kit::Keystroke;
265+
266+ fn bytes(key: &str) -> Option<Vec<u8>> {
267+ key_bytes(&Keystroke::parse(key).unwrap(), false)
268+ }
269+
270+ #[test]
271+ fn plain_typing_is_left_to_the_input_handler() {
272+ assert_eq!(bytes("a"), None);
273+ assert_eq!(bytes("shift-a"), None);
274+ assert_eq!(bytes("space"), None);
275+ assert_eq!(bytes("super-c"), None);
276+ }
277+
278+ #[test]
279+ fn named_keys() {
280+ assert_eq!(bytes("enter"), Some(b"\r".to_vec()));
281+ assert_eq!(bytes("backspace"), Some(b"\x7f".to_vec()));
282+ assert_eq!(bytes("ctrl-backspace"), Some(b"\x08".to_vec()));
283+ assert_eq!(bytes("tab"), Some(b"\t".to_vec()));
284+ assert_eq!(bytes("shift-tab"), Some(b"\x1b[Z".to_vec()));
285+ assert_eq!(bytes("escape"), Some(b"\x1b".to_vec()));
286+ assert_eq!(bytes("delete"), Some(b"\x1b[3~".to_vec()));
287+ assert_eq!(bytes("pageup"), Some(b"\x1b[5~".to_vec()));
288+ assert_eq!(bytes("f1"), Some(b"\x1bOP".to_vec()));
289+ assert_eq!(bytes("f5"), Some(b"\x1b[15~".to_vec()));
290+ assert_eq!(bytes("f12"), Some(b"\x1b[24~".to_vec()));
291+ }
292+
293+ #[test]
294+ fn cursor_keys_follow_the_cursor_mode() {
295+ assert_eq!(bytes("up"), Some(b"\x1b[A".to_vec()));
296+ assert_eq!(key_bytes(&Keystroke::parse("up").unwrap(), true), Some(b"\x1bOA".to_vec()));
297+ assert_eq!(bytes("ctrl-left"), Some(b"\x1b[1;5D".to_vec()));
298+ assert_eq!(bytes("shift-end"), Some(b"\x1b[1;2F".to_vec()));
299+ assert_eq!(bytes("alt-delete"), Some(b"\x1b[3;3~".to_vec()));
300+ }
301+
302+ #[test]
303+ fn control_combinations() {
304+ assert_eq!(bytes("ctrl-c"), Some(vec![3]));
305+ assert_eq!(bytes("ctrl-shift-a"), Some(vec![1]));
306+ assert_eq!(bytes("ctrl-space"), Some(vec![0]));
307+ assert_eq!(bytes("ctrl-["), Some(vec![0x1b]));
308+ assert_eq!(bytes("ctrl-/"), Some(vec![0x1f]));
309+ assert_eq!(bytes("ctrl-alt-x"), Some(vec![0x1b, 0x18]));
310+ }
311+
312+ #[test]
313+ fn alt_prefixes_escape() {
314+ assert_eq!(bytes("alt-b"), Some(b"\x1bb".to_vec()));
315+ assert_eq!(bytes("alt-enter"), Some(b"\x1b\r".to_vec()));
316+ }
317+
318+ #[test]
319+ fn pastes() {
320+ assert_eq!(paste_bytes("a\nb\r\nc", false), b"a\rb\rc".to_vec());
321+ assert_eq!(paste_bytes("x\x1b[201~y", true), b"\x1b[200~x[201~y\x1b[201~".to_vec());
322+ }
323+ }
+12−0src/ui/chat.rsView File
@@ -378,6 +378,18 @@ impl ChatView {
378378 self.composer.update(cx, |composer, cx| composer.focus(window, cx));
379379 }
380380
381+ /// Puts text into the composer at its cursor, on a line of its own, and
382+ /// focuses it: the terminal's Add to chat.
383+ pub fn insert_into_composer(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
384+ self.composer.update(cx, |composer, cx| {
385+ let value = composer.value();
386+ let text = if value.is_empty() || value.ends_with('\n') { text.to_string() } else { format!("\n{text}") };
387+ composer.insert(text, window, cx);
388+ composer.focus(window, cx);
389+ });
390+ cx.notify();
391+ }
392+
381393 /// Carries what the user typed over from another thread, when this one
382394 /// replaces it (a provider or project switch before the first message).
383395 pub fn take_draft_from(&mut self, other: &Entity<ChatView>, window: &mut Window, cx: &mut Context<Self>) {
+4−0src/ui/icons.rsView File
@@ -112,6 +112,10 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [
112112 ListFilter,
113113 CloudUpload,
114114 CloudDownload,
115+ Square,
116+ SquareSplitHorizontal,
117+ SquareSplitVertical,
118+ SquareTerminal,
115119 ]);
116120
117121 /// The app's asset source: the extra icons, then gpui-component's defaults.
+1−0src/ui/mod.rsView File
@@ -8,6 +8,7 @@ pub mod markdown_input;
88 pub mod metal;
99 pub mod pickers;
1010 pub mod settings;
11+ pub mod terminal;
1112 pub mod themes;
1213 pub mod usage;
1314 pub mod workspace;
+16−1src/ui/settings/keybindings.rsView File
@@ -52,6 +52,17 @@ const COMMANDS: &[Command] = &[
5252 label: "Pull Request: Copy Link or Thread ID",
5353 actions: &["vectrum_code::CopyThreadReference"],
5454 },
55+ Command { id: "terminal.toggle", label: "Terminal: Toggle", actions: &["vectrum_code::ToggleTerminal"] },
56+ Command { id: "terminal.split", label: "Terminal: Split", actions: &["vectrum_code::SplitTerminal"] },
57+ Command {
58+ id: "terminal.splitVertical",
59+ label: "Terminal: Split Vertical",
60+ actions: &["vectrum_code::SplitTerminalVertical"],
61+ },
62+ Command { id: "terminal.new", label: "Terminal: New", actions: &["vectrum_code::NewTerminal"] },
63+ Command { id: "terminal.close", label: "Terminal: Close", actions: &["vectrum_code::CloseTerminal"] },
64+ Command { id: "terminal.copy", label: "Terminal: Copy", actions: &["vectrum_code::TerminalCopy"] },
65+ Command { id: "terminal.paste", label: "Terminal: Paste", actions: &["vectrum_code::TerminalPaste"] },
5566 Command { id: "modelPicker.toggle", label: "Model Picker: Toggle", actions: &["vectrum_code::ToggleModelPicker"] },
5667 Command { id: "composer.effort", label: "Composer: Effort", actions: &["vectrum_code::ComposerEffort"] },
5768 Command { id: "composer.mode", label: "Composer: Mode", actions: &["vectrum_code::ComposerMode"] },
@@ -130,7 +141,11 @@ fn title_case(action: &str) -> String {
130141
131142 /// What a binding's context means, in T3 Code's when-clause words.
132143 fn when_text(context: &str) -> String {
133- context.replace("ChatView", "composerFocus").replace("SettingsView", "settingsOpen").replace("Workspace", "always")
144+ context
145+ .replace("ChatView", "composerFocus")
146+ .replace("SettingsView", "settingsOpen")
147+ .replace("Terminal", "terminalFocus")
148+ .replace("Workspace", "always")
134149 }
135150
136151 /// One row on the page.
+753−0src/ui/terminal.rsaddedView File
@@ -0,0 +1,753 @@
1+ //! One terminal of the drawer: the shell's screen drawn cell by cell in the
2+ //! mono font, its cursor and selection, scrollback on the wheel, and T3
3+ //! Code's right-click menu (Add to chat, Copy, Paste, Select all, Jump to
4+ //! latest). Keys go to the shell as xterm sends them; typed text comes
5+ //! through an input handler, so input methods work.
6+
7+ use std::cell::Cell;
8+ use std::ops::Range;
9+ use std::path::Path;
10+ use std::rc::Rc;
11+
12+ use alacritty_terminal::event::Event;
13+ use alacritty_terminal::grid::{Dimensions as _, Scroll};
14+ use alacritty_terminal::index::{Column, Line, Point as GridPoint, Side};
15+ use alacritty_terminal::selection::{Selection, SelectionType};
16+ use alacritty_terminal::term::cell::Flags;
17+ use alacritty_terminal::term::TermMode;
18+ use alacritty_terminal::vte::ansi::{Color as AnsiColor, CursorShape, NamedColor, Rgb};
19+ use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenu, PopupMenuItem};
20+ use gpui_kit::prelude::FluentBuilder as _;
21+ use gpui_kit::*;
22+
23+ use crate::terminal::{GridSize, Terminal, key_bytes};
24+ use crate::ui::metal::{self, Palette};
25+ use crate::{TerminalCopy, TerminalPaste};
26+
27+ const FONT_SIZE: f32 = 12.;
28+ const LINE_HEIGHT: f32 = 17.;
29+ /// The space between the drawer's edges and the grid.
30+ const PAD_X: f32 = 10.;
31+ const PAD_Y: f32 = 6.;
32+
33+ /// What a terminal tells the drawer.
34+ pub enum TerminalEvent {
35+ /// The shell exited, so the terminal closes.
36+ Exited,
37+ /// "Add to chat": the selection, formatted for the composer.
38+ AddToChat(String),
39+ }
40+
41+ pub struct TerminalView {
42+ terminal: Option<Terminal>,
43+ /// Why the shell didn't start.
44+ error: Option<SharedString>,
45+ /// "Terminal 2", for what Add to chat says the text is from.
46+ label: SharedString,
47+ focus_handle: FocusHandle,
48+ exited: bool,
49+ /// Where the grid was last laid out, for turning the pointer into cells.
50+ grid_bounds: Rc<Cell<Bounds<Pixels>>>,
51+ cell_width: Pixels,
52+ /// A left-button drag is extending the selection.
53+ selecting: bool,
54+ /// Wheel movement short of a whole line, kept for the next event.
55+ scroll_remainder: f32,
56+ _events: Task<()>,
57+ }
58+
59+ impl EventEmitter<TerminalEvent> for TerminalView {}
60+
61+ impl Focusable for TerminalView {
62+ fn focus_handle(&self, _: &App) -> FocusHandle {
63+ self.focus_handle.clone()
64+ }
65+ }
66+
67+ impl TerminalView {
68+ /// Starts a shell in `cwd`.
69+ pub fn new(cwd: &Path, label: impl Into<SharedString>, window: &mut Window, cx: &mut Context<Self>) -> Self {
70+ let cell_width = cell_width(window);
71+ let size = GridSize { columns: 80, lines: 24 };
72+ let (terminal, error, events) = match Terminal::spawn(cwd, size, cell_pixels(cell_width)) {
73+ Ok((terminal, events)) => {
74+ let task = cx.spawn_in(window, async move |this, cx| {
75+ while let Ok(event) = events.recv().await {
76+ let mut batch = vec![event];
77+ while let Ok(event) = events.try_recv() {
78+ batch.push(event);
79+ }
80+ if this.update_in(cx, |this, _, cx| this.handle_events(batch, cx)).is_err() {
81+ break;
82+ }
83+ }
84+ });
85+ (Some(terminal), None, task)
86+ }
87+ Err(err) => (None, Some(format!("Couldn't start the shell: {err}").into()), Task::ready(())),
88+ };
89+ Self {
90+ terminal,
91+ error,
92+ label: label.into(),
93+ focus_handle: cx.focus_handle(),
94+ exited: false,
95+ grid_bounds: Rc::default(),
96+ cell_width,
97+ selecting: false,
98+ scroll_remainder: 0.,
99+ _events: events,
100+ }
101+ }
102+
103+ fn handle_events(&mut self, events: Vec<Event>, cx: &mut Context<Self>) {
104+ let Some(terminal) = self.terminal.as_ref() else { return };
105+ for event in events {
106+ match event {
107+ Event::PtyWrite(text) => terminal.write(text.into_bytes()),
108+ Event::ClipboardStore(_, text) => cx.write_to_clipboard(ClipboardItem::new_string(text)),
109+ Event::ClipboardLoad(_, format) => {
110+ let text = cx.read_from_clipboard().and_then(|item| item.text()).unwrap_or_default();
111+ terminal.write(format(&text).into_bytes());
112+ }
113+ Event::ColorRequest(index, format) => {
114+ let color = Rgba::from(ansi_index(index, metal::palette(cx)));
115+ let channel = |value: f32| (value * 255.).round() as u8;
116+ let rgb = Rgb { r: channel(color.r), g: channel(color.g), b: channel(color.b) };
117+ terminal.write(format(rgb).into_bytes());
118+ }
119+ Event::TextAreaSizeRequest(format) => {
120+ let size = terminal.size();
121+ let (cell_width, cell_height) = cell_pixels(self.cell_width);
122+ terminal.write(
123+ format(alacritty_terminal::event::WindowSize {
124+ num_lines: size.lines as u16,
125+ num_cols: size.columns as u16,
126+ cell_width,
127+ cell_height,
128+ })
129+ .into_bytes(),
130+ );
131+ }
132+ Event::Exit | Event::ChildExit(_) => {
133+ if !self.exited {
134+ self.exited = true;
135+ cx.emit(TerminalEvent::Exited);
136+ }
137+ }
138+ Event::Wakeup
139+ | Event::MouseCursorDirty
140+ | Event::CursorBlinkingChange
141+ | Event::Title(_)
142+ | Event::ResetTitle
143+ | Event::Bell => {}
144+ }
145+ }
146+ cx.notify();
147+ }
148+
149+ /// Sends what the user typed, back at the bottom of the scrollback.
150+ fn send(&mut self, bytes: Vec<u8>, cx: &mut Context<Self>) {
151+ let Some(terminal) = self.terminal.as_ref() else { return };
152+ terminal.with_term(|term| {
153+ term.selection = None;
154+ term.scroll_display(Scroll::Bottom);
155+ });
156+ terminal.write(bytes);
157+ cx.notify();
158+ }
159+
160+ fn key_down(&mut self, event: &KeyDownEvent, _: &mut Window, cx: &mut Context<Self>) {
161+ let Some(terminal) = self.terminal.as_ref() else { return };
162+ let app_cursor = terminal.with_term(|term| term.mode().contains(TermMode::APP_CURSOR));
163+ if let Some(bytes) = key_bytes(&event.keystroke, app_cursor) {
164+ cx.stop_propagation();
165+ self.send(bytes, cx);
166+ }
167+ }
168+
169+ pub fn has_selection(&self) -> bool {
170+ self.selected_text().is_some_and(|text| !text.is_empty())
171+ }
172+
173+ fn selected_text(&self) -> Option<String> {
174+ self.terminal.as_ref()?.with_term(|term| term.selection_to_string())
175+ }
176+
177+ pub fn copy(&mut self, cx: &mut Context<Self>) {
178+ if let Some(text) = self.selected_text().filter(|text| !text.is_empty()) {
179+ cx.write_to_clipboard(ClipboardItem::new_string(text));
180+ }
181+ }
182+
183+ pub fn paste(&mut self, cx: &mut Context<Self>) {
184+ let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else { return };
185+ let Some(terminal) = self.terminal.as_ref() else { return };
186+ terminal.with_term(|term| term.scroll_display(Scroll::Bottom));
187+ terminal.paste(&text);
188+ cx.notify();
189+ }
190+
191+ pub fn select_all(&mut self, cx: &mut Context<Self>) {
192+ let Some(terminal) = self.terminal.as_ref() else { return };
193+ terminal.with_term(|term| {
194+ let start = GridPoint::new(term.topmost_line(), Column(0));
195+ let end = GridPoint::new(term.bottommost_line(), term.last_column());
196+ let mut selection = Selection::new(SelectionType::Simple, start, Side::Left);
197+ selection.update(end, Side::Right);
198+ term.selection = Some(selection);
199+ });
200+ cx.notify();
201+ }
202+
203+ /// T3's "Jump to latest".
204+ pub fn scroll_to_bottom(&mut self, cx: &mut Context<Self>) {
205+ if let Some(terminal) = self.terminal.as_ref() {
206+ terminal.with_term(|term| term.scroll_display(Scroll::Bottom));
207+ }
208+ cx.notify();
209+ }
210+
211+ /// Hands the selection to the composer, as T3's Add to chat does: the
212+ /// terminal and lines it's from, then the text.
213+ fn add_selection_to_chat(&mut self, cx: &mut Context<Self>) {
214+ let Some(terminal) = self.terminal.as_ref() else { return };
215+ let selection = terminal.with_term(|term| {
216+ let range = term.selection.as_ref()?.to_range(term)?;
217+ let text = term.selection_to_string()?;
218+ // Lines count from the top of the scrollback, from 1.
219+ let history = term.history_size() as i32;
220+ let start = (range.start.line.0 + history + 1).max(1) as usize;
221+ let end = ((range.end.line.0 + history + 1).max(1) as usize).max(start);
222+ term.selection = None;
223+ Some((text, start, end))
224+ });
225+ let Some((text, start, end)) = selection else { return };
226+ let text = text.replace("\r\n", "\n");
227+ let text = text.trim_matches('\n');
228+ if text.is_empty() {
229+ return;
230+ }
231+ cx.emit(TerminalEvent::AddToChat(context_block(&self.label, start, end, text)));
232+ cx.notify();
233+ }
234+
235+ /// The cell under a point in the window, and which half of it.
236+ fn grid_point(&self, position: Point<Pixels>) -> Option<(GridPoint, Side)> {
237+ let terminal = self.terminal.as_ref()?;
238+ let bounds = self.grid_bounds.get();
239+ let size = terminal.size();
240+ let x = (position.x - bounds.origin.x).as_f32().max(0.);
241+ let y = (position.y - bounds.origin.y).as_f32().max(0.);
242+ let cell = self.cell_width.as_f32();
243+ let column = ((x / cell) as usize).min(size.columns.saturating_sub(1));
244+ let row = ((y / LINE_HEIGHT) as usize).min(size.lines.saturating_sub(1));
245+ let side = if x - column as f32 * cell > cell / 2. { Side::Right } else { Side::Left };
246+ let offset = terminal.with_term(|term| term.grid().display_offset()) as i32;
247+ Some((GridPoint::new(Line(row as i32 - offset), Column(column)), side))
248+ }
249+
250+ fn mouse_down(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
251+ self.focus_handle.focus(window, cx);
252+ let Some((point, side)) = self.grid_point(event.position) else { return };
253+ let kind = match event.click_count {
254+ 2 => SelectionType::Semantic,
255+ 3.. => SelectionType::Lines,
256+ _ => SelectionType::Simple,
257+ };
258+ if let Some(terminal) = self.terminal.as_ref() {
259+ terminal.with_term(|term| term.selection = Some(Selection::new(kind, point, side)));
260+ }
261+ self.selecting = true;
262+ cx.notify();
263+ }
264+
265+ fn mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context<Self>) {
266+ if !self.selecting || event.pressed_button != Some(MouseButton::Left) {
267+ return;
268+ }
269+ let Some((point, side)) = self.grid_point(event.position) else { return };
270+ if let Some(terminal) = self.terminal.as_ref() {
271+ terminal.with_term(|term| {
272+ if let Some(selection) = term.selection.as_mut() {
273+ selection.update(point, side);
274+ }
275+ });
276+ }
277+ cx.notify();
278+ }
279+
280+ fn mouse_up(&mut self, _: &MouseUpEvent, _: &mut Window, cx: &mut Context<Self>) {
281+ self.selecting = false;
282+ if let Some(terminal) = self.terminal.as_ref() {
283+ terminal.with_term(|term| {
284+ if term.selection.as_ref().is_some_and(|selection| selection.is_empty()) {
285+ term.selection = None;
286+ }
287+ });
288+ }
289+ cx.notify();
290+ }
291+
292+ /// The wheel scrolls the scrollback; in a full-screen program it sends
293+ /// what that program asked for: mouse wheel reports, or arrow keys.
294+ fn scroll_wheel(&mut self, event: &ScrollWheelEvent, _: &mut Window, cx: &mut Context<Self>) {
295+ let Some(terminal) = self.terminal.as_ref() else { return };
296+ let delta = event.delta.pixel_delta(px(LINE_HEIGHT)).y.as_f32() + self.scroll_remainder;
297+ let lines = (delta / LINE_HEIGHT) as i32;
298+ self.scroll_remainder = delta - lines as f32 * LINE_HEIGHT;
299+ if lines == 0 {
300+ return;
301+ }
302+ let mode = terminal.with_term(|term| *term.mode());
303+ if mode.intersects(TermMode::MOUSE_MODE) && mode.contains(TermMode::SGR_MOUSE) {
304+ let Some((point, _)) = self.grid_point(event.position) else { return };
305+ let offset = terminal.with_term(|term| term.grid().display_offset()) as i32;
306+ let (column, row) = (point.column.0 + 1, point.line.0 + offset + 1);
307+ let button = if lines > 0 { 64 } else { 65 };
308+ let report = format!("\x1b[<{button};{column};{row}M").repeat(lines.unsigned_abs() as usize);
309+ terminal.write(report.into_bytes());
310+ } else if mode.contains(TermMode::ALT_SCREEN | TermMode::ALTERNATE_SCROLL) {
311+ let arrow: &[u8] = match (lines > 0, mode.contains(TermMode::APP_CURSOR)) {
312+ (true, true) => b"\x1bOA",
313+ (true, false) => b"\x1b[A",
314+ (false, true) => b"\x1bOB",
315+ (false, false) => b"\x1b[B",
316+ };
317+ terminal.write(arrow.repeat(lines.unsigned_abs() as usize));
318+ } else {
319+ terminal.with_term(|term| term.scroll_display(Scroll::Delta(lines)));
320+ cx.notify();
321+ }
322+ cx.stop_propagation();
323+ }
324+
325+ /// Fits the shell's screen to the space the drawer gives it.
326+ fn fit(&mut self, bounds: Bounds<Pixels>, cx: &mut Context<Self>) {
327+ let Some(terminal) = self.terminal.as_mut() else { return };
328+ let size = GridSize {
329+ columns: ((bounds.size.width.as_f32() / self.cell_width.as_f32()) as usize).max(2),
330+ lines: ((bounds.size.height.as_f32() / LINE_HEIGHT) as usize).max(1),
331+ };
332+ if size != terminal.size() {
333+ terminal.resize(size, cell_pixels(self.cell_width));
334+ cx.notify();
335+ }
336+ }
337+
338+ fn context_menu(view: WeakEntity<Self>, menu: PopupMenu, cx: &App) -> PopupMenu {
339+ let selected = view.upgrade().is_some_and(|view| view.read(cx).has_selection());
340+ let item = |label: &'static str, disabled: bool, run: fn(&mut Self, &mut Context<Self>)| {
341+ let view = view.clone();
342+ PopupMenuItem::new(label).disabled(disabled).on_click(move |_, window, cx| {
343+ let _ = view.update(cx, |this, cx| {
344+ run(this, cx);
345+ this.focus_handle.focus(window, cx);
346+ });
347+ })
348+ };
349+ menu.item(item("Add to chat", !selected, Self::add_selection_to_chat))
350+ .item(item("Copy", !selected, Self::copy))
351+ .item(item("Paste", false, Self::paste))
352+ .item(item("Select all", false, Self::select_all))
353+ .item(item("Jump to latest", false, Self::scroll_to_bottom))
354+ }
355+ }
356+
357+ /// What Add to chat puts in the composer.
358+ fn context_block(label: &str, start: usize, end: usize, text: &str) -> String {
359+ let range = if start == end { format!("line {start}") } else { format!("lines {start}-{end}") };
360+ format!("{label} {range}:\n```\n{text}\n```\n")
361+ }
362+
363+ /// The width of one cell of the mono font.
364+ fn cell_width(window: &mut Window) -> Pixels {
365+ let text_system = window.text_system();
366+ let font_id = text_system.resolve_font(&font(metal::MONO));
367+ text_system.advance(font_id, px(FONT_SIZE), 'm').map(|size| size.width).unwrap_or(px(FONT_SIZE * 0.6))
368+ }
369+
370+ fn cell_pixels(cell_width: Pixels) -> (u16, u16) {
371+ (cell_width.as_f32().round() as u16, LINE_HEIGHT as u16)
372+ }
373+
374+ /// The 16 ANSI colors, tuned to sit on the theme's sheet: the dark set
375+ /// borrows the palette's inks, the light set is deep enough to read on bone.
376+ fn ansi16(index: usize, p: &Palette) -> Hsla {
377+ const DARK: [&str; 16] = [
378+ "#4a4a4f", "#ff7a66", "#5fcb93", "#e7c98a", "#8fb0ff", "#ce76dc", "#25c2c3", "#d6d6d8", //
379+ "#77777c", "#ff9a8a", "#7fdcaa", "#f5d58f", "#aac4ff", "#e19aec", "#5fd7d8", "#f2f2f0",
380+ ];
381+ const LIGHT: [&str; 16] = [
382+ "#1b1b1d", "#c4372c", "#1d8152", "#9a6700", "#2f5fcf", "#9a3fae", "#0e7f80", "#6c6c6f", //
383+ "#57575b", "#d9473b", "#26995f", "#b07a00", "#4a74e0", "#b052c4", "#139596", "#8e8e93",
384+ ];
385+ metal::hex(if p.dark { DARK[index] } else { LIGHT[index] })
386+ }
387+
388+ /// A 256-color index: the 16 ANSI colors, the 6×6×6 cube, then the grays.
389+ fn ansi_index(index: usize, p: &Palette) -> Hsla {
390+ match index {
391+ 0..16 => ansi16(index, p),
392+ 16..232 => {
393+ let index = index - 16;
394+ let level = |value: usize| if value == 0 { 0. } else { (55 + value * 40) as f32 / 255. };
395+ Rgba { r: level(index / 36), g: level(index / 6 % 6), b: level(index % 6), a: 1. }.into()
396+ }
397+ 232..256 => {
398+ let gray = (8 + (index - 232) * 10) as f32 / 255.;
399+ Rgba { r: gray, g: gray, b: gray, a: 1. }.into()
400+ }
401+ 256 => p.ink,
402+ _ => p.s,
403+ }
404+ }
405+
406+ fn from_rgb(rgb: Rgb) -> Hsla {
407+ Rgba { r: rgb.r as f32 / 255., g: rgb.g as f32 / 255., b: rgb.b as f32 / 255., a: 1. }.into()
408+ }
409+
410+ /// The colors the screen is drawn with.
411+ struct Colors {
412+ foreground: Hsla,
413+ background: Hsla,
414+ cursor: Hsla,
415+ selection: Hsla,
416+ }
417+
418+ impl Colors {
419+ fn new(p: &Palette) -> Self {
420+ Self { foreground: p.ink, background: p.s, cursor: p.ink, selection: p.blue_ink.opacity(0.3) }
421+ }
422+
423+ /// A cell color; `None` is the default background, left unpainted.
424+ fn resolve(&self, color: AnsiColor, overrides: &alacritty_terminal::term::color::Colors, p: &Palette) -> Option<Hsla> {
425+ let index = match color {
426+ AnsiColor::Spec(rgb) => return Some(from_rgb(rgb)),
427+ AnsiColor::Indexed(index) => index as usize,
428+ AnsiColor::Named(NamedColor::Background) => return None,
429+ AnsiColor::Named(NamedColor::Foreground | NamedColor::BrightForeground) => return Some(self.foreground),
430+ AnsiColor::Named(NamedColor::DimForeground) => return Some(self.foreground.opacity(0.7)),
431+ AnsiColor::Named(NamedColor::Cursor) => return Some(self.cursor),
432+ AnsiColor::Named(named) => {
433+ let named = named as usize;
434+ // The dim colors follow the 16, from black up.
435+ if named >= NamedColor::DimBlack as usize {
436+ return Some(ansi16(named - NamedColor::DimBlack as usize, p).opacity(0.7));
437+ }
438+ named
439+ }
440+ };
441+ Some(overrides[index].map_or_else(|| ansi_index(index, p), from_rgb))
442+ }
443+ }
444+
445+ /// A stretch of a row drawn in one style.
446+ struct Run {
447+ column: usize,
448+ /// How many cells it covers.
449+ cells: usize,
450+ /// Whether the next cell may join it: a wide glyph ends its run, so what
451+ /// follows lines up with the grid whatever width the font gives it.
452+ open: bool,
453+ text: String,
454+ color: Hsla,
455+ bold: bool,
456+ italic: bool,
457+ underline: bool,
458+ strike: bool,
459+ }
460+
461+ /// A stretch of a row painted in one background color.
462+ struct Fill {
463+ column: usize,
464+ width: usize,
465+ color: Hsla,
466+ }
467+
468+ #[derive(Default)]
469+ struct Row {
470+ runs: Vec<Run>,
471+ fills: Vec<Fill>,
472+ }
473+
474+ /// The visible screen as rows of styled runs, with the cursor's place and
475+ /// shape when it shows.
476+ fn layout_screen(terminal: &Terminal, focused: bool, p: &Palette) -> (Vec<Row>, Option<(usize, usize, CursorShape)>) {
477+ let colors = Colors::new(p);
478+ terminal.with_term(|term| {
479+ let content = term.renderable_content();
480+ let lines = term.screen_lines();
481+ let offset = content.display_offset as i32;
482+ let mut rows: Vec<Row> = (0..lines).map(|_| Row::default()).collect();
483+ let cursor_row = content.cursor.point.line.0 + offset;
484+ let cursor = (content.cursor.shape != CursorShape::Hidden && (0..lines as i32).contains(&cursor_row))
485+ .then(|| (cursor_row as usize, content.cursor.point.column.0, content.cursor.shape));
486+ let block_cursor = cursor.filter(|_| focused).filter(|(_, _, shape)| *shape == CursorShape::Block);
487+ let selection = content.selection;
488+ let overrides = content.colors;
489+ for indexed in content.display_iter {
490+ let row_ix = indexed.point.line.0 + offset;
491+ if row_ix < 0 || row_ix as usize >= lines {
492+ continue;
493+ }
494+ let cell = indexed.cell;
495+ let flags = cell.flags;
496+ if flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) {
497+ continue;
498+ }
499+ let column = indexed.point.column.0;
500+ let mut foreground = colors.resolve(cell.fg, overrides, p).unwrap_or(colors.foreground);
501+ let mut background = colors.resolve(cell.bg, overrides, p);
502+ if flags.contains(Flags::BOLD) {
503+ // Bold ANSI colors brighten, as most terminals draw them.
504+ if let AnsiColor::Named(named) = cell.fg {
505+ if (named as usize) < 8 {
506+ foreground = ansi16(named as usize + 8, p);
507+ }
508+ }
509+ }
510+ if flags.contains(Flags::DIM) {
511+ foreground = foreground.opacity(0.7);
512+ }
513+ if flags.contains(Flags::INVERSE) {
514+ let back = background.unwrap_or(colors.background);
515+ background = Some(foreground);
516+ foreground = back;
517+ }
518+ if selection.is_some_and(|range| range.contains(indexed.point)) {
519+ background = Some(colors.selection);
520+ }
521+ if block_cursor.is_some_and(|(row, col, _)| row == row_ix as usize && col == column) {
522+ foreground = background.unwrap_or(colors.background);
523+ background = Some(colors.cursor);
524+ }
525+ let width = if flags.contains(Flags::WIDE_CHAR) { 2 } else { 1 };
526+ let row = &mut rows[row_ix as usize];
527+ if let Some(color) = background {
528+ match row.fills.last_mut() {
529+ Some(fill) if fill.color == color && fill.column + fill.width == column => fill.width += width,
530+ _ => row.fills.push(Fill { column, width, color }),
531+ }
532+ }
533+ let mut c = if flags.contains(Flags::HIDDEN) { ' ' } else { cell.c };
534+ if c == '\t' {
535+ c = ' ';
536+ }
537+ let bold = flags.contains(Flags::BOLD);
538+ let italic = flags.contains(Flags::ITALIC);
539+ let underline = flags.intersects(Flags::ALL_UNDERLINES);
540+ let strike = flags.contains(Flags::STRIKEOUT);
541+ let continues = row.runs.last().is_some_and(|run| {
542+ run.open
543+ && run.color == foreground
544+ && run.bold == bold
545+ && run.italic == italic
546+ && run.underline == underline
547+ && run.strike == strike
548+ && run.column + run.cells == column
549+ });
550+ let mut text = String::from(c);
551+ if let Some(zerowidth) = cell.zerowidth() {
552+ text.extend(zerowidth);
553+ }
554+ match row.runs.last_mut() {
555+ Some(run) if continues && width == 1 => {
556+ run.text.push_str(&text);
557+ run.cells += 1;
558+ }
559+ _ => row.runs.push(Run {
560+ column,
561+ cells: width,
562+ open: width == 1,
563+ text,
564+ color: foreground,
565+ bold,
566+ italic,
567+ underline,
568+ strike,
569+ }),
570+ }
571+ }
572+ for row in &mut rows {
573+ // Trailing blanks draw nothing.
574+ if let Some(run) = row.runs.last_mut().filter(|run| !run.underline && !run.strike) {
575+ let trimmed = run.text.trim_end_matches(' ').len();
576+ run.text.truncate(trimmed);
577+ }
578+ row.runs.retain(|run| !run.text.is_empty());
579+ }
580+ (rows, cursor.filter(|_| !focused || block_cursor.is_none()))
581+ })
582+ }
583+
584+ impl EntityInputHandler for TerminalView {
585+ fn text_for_range(&mut self, _: Range<usize>, _: &mut Option<Range<usize>>, _: &mut Window, _: &mut Context<Self>) -> Option<String> {
586+ None
587+ }
588+
589+ fn selected_text_range(&mut self, _: bool, _: &mut Window, _: &mut Context<Self>) -> Option<UTF16Selection> {
590+ Some(UTF16Selection { range: 0..0, reversed: false })
591+ }
592+
593+ fn marked_text_range(&self, _: &mut Window, _: &mut Context<Self>) -> Option<Range<usize>> {
594+ None
595+ }
596+
597+ fn unmark_text(&mut self, _: &mut Window, _: &mut Context<Self>) {}
598+
599+ /// Typed text, and what an input method commits.
600+ fn replace_text_in_range(&mut self, _: Option<Range<usize>>, text: &str, _: &mut Window, cx: &mut Context<Self>) {
601+ if !text.is_empty() {
602+ self.send(text.as_bytes().to_vec(), cx);
603+ }
604+ }
605+
606+ fn replace_and_mark_text_in_range(
607+ &mut self,
608+ _: Option<Range<usize>>,
609+ _: &str,
610+ _: Option<Range<usize>>,
611+ _: &mut Window,
612+ _: &mut Context<Self>,
613+ ) {
614+ }
615+
616+ /// Input method popups open at the cursor.
617+ fn bounds_for_range(&mut self, _: Range<usize>, _: Bounds<Pixels>, _: &mut Window, _: &mut Context<Self>) -> Option<Bounds<Pixels>> {
618+ let terminal = self.terminal.as_ref()?;
619+ let (line, column) = terminal.with_term(|term| {
620+ let point = term.grid().cursor.point;
621+ (point.line.0 + term.grid().display_offset() as i32, point.column.0)
622+ });
623+ let origin = self.grid_bounds.get().origin;
624+ Some(Bounds::new(
625+ point(origin.x + self.cell_width * column as f32, origin.y + px(LINE_HEIGHT * line as f32)),
626+ size(self.cell_width, px(LINE_HEIGHT)),
627+ ))
628+ }
629+
630+ fn character_index_for_point(&mut self, _: Point<Pixels>, _: &mut Window, _: &mut Context<Self>) -> Option<usize> {
631+ None
632+ }
633+ }
634+
635+ impl Render for TerminalView {
636+ fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
637+ let p = metal::palette(cx);
638+ let focused = self.focus_handle.is_focused(window);
639+ let cell = self.cell_width;
640+ let line = px(LINE_HEIGHT);
641+ let view = cx.entity();
642+ let weak = cx.weak_entity();
643+ let grid_bounds = self.grid_bounds.clone();
644+ let focus_handle = self.focus_handle.clone();
645+
646+ let screen = self.terminal.as_ref().map(|terminal| layout_screen(terminal, focused, p));
647+ let grid = screen.map(|(rows, cursor)| {
648+ let colors = Colors::new(p);
649+ let cursor = cursor.map(|(row, column, shape)| {
650+ let base = div().absolute().left(cell * column as f32).top(line * row as f32);
651+ match (focused, shape) {
652+ (true, CursorShape::Beam) => base.w(px(2.)).h(line).bg(colors.cursor),
653+ (true, CursorShape::Underline) => base.mt(line - px(2.)).w(cell).h(px(2.)).bg(colors.cursor),
654+ _ => base.w(cell).h(line).border_1().border_color(colors.cursor.opacity(0.7)),
655+ }
656+ });
657+ div()
658+ .relative()
659+ .size_full()
660+ .children(rows.into_iter().enumerate().map(|(ix, row)| {
661+ div()
662+ .absolute()
663+ .left_0()
664+ .right_0()
665+ .top(line * ix as f32)
666+ .h(line)
667+ .children(row.fills.into_iter().map(|fill| {
668+ div()
669+ .absolute()
670+ .top_0()
671+ .h(line)
672+ .left(cell * fill.column as f32)
673+ .w(cell * fill.width as f32)
674+ .bg(fill.color)
675+ }))
676+ .children(row.runs.into_iter().map(|run| {
677+ div()
678+ .absolute()
679+ .top_0()
680+ .left(cell * run.column as f32)
681+ .whitespace_nowrap()
682+ .text_color(run.color)
683+ .when(run.bold, |this| this.font_weight(FontWeight::BOLD))
684+ .when(run.italic, |this| this.italic())
685+ .when(run.underline, |this| this.underline())
686+ .when(run.strike, |this| this.line_through())
687+ .child(run.text)
688+ }))
689+ }))
690+ .children(cursor)
691+ });
692+
693+ div()
694+ .id("terminal")
695+ .key_context("Terminal")
696+ .track_focus(&self.focus_handle)
697+ .size_full()
698+ .overflow_hidden()
699+ .px(px(PAD_X))
700+ .py(px(PAD_Y))
701+ .cursor_text()
702+ .on_key_down(cx.listener(Self::key_down))
703+ .on_action(cx.listener(|this, _: &TerminalCopy, _, cx| this.copy(cx)))
704+ .on_action(cx.listener(|this, _: &TerminalPaste, _, cx| this.paste(cx)))
705+ .on_mouse_down(MouseButton::Left, cx.listener(Self::mouse_down))
706+ .on_mouse_move(cx.listener(Self::mouse_move))
707+ .on_mouse_up(MouseButton::Left, cx.listener(Self::mouse_up))
708+ .on_mouse_up_out(MouseButton::Left, cx.listener(Self::mouse_up))
709+ .on_scroll_wheel(cx.listener(Self::scroll_wheel))
710+ .child(
711+ // The text style stays in here, out of the right-click menu's way.
712+ div()
713+ .relative()
714+ .size_full()
715+ .font_family(metal::MONO)
716+ .text_size(px(FONT_SIZE))
717+ .line_height(line)
718+ .text_color(p.ink)
719+ .child(
720+ canvas(
721+ move |bounds, _, cx| {
722+ grid_bounds.set(bounds);
723+ view.update(cx, |this, cx| this.fit(bounds, cx));
724+ },
725+ move |bounds, _, window, cx| {
726+ if let Some(view) = weak.upgrade() {
727+ window.handle_input(&focus_handle, ElementInputHandler::new(bounds, view), cx);
728+ }
729+ },
730+ )
731+ .absolute()
732+ .size_full(),
733+ )
734+ .children(grid)
735+ .when_some(self.error.clone(), |this, error| this.child(div().text_color(p.red).child(error))),
736+ )
737+ .context_menu({
738+ let view = cx.weak_entity();
739+ move |menu, _, cx| Self::context_menu(view.clone(), menu, cx)
740+ })
741+ }
742+ }
743+
744+ #[cfg(test)]
745+ mod tests {
746+ use super::context_block;
747+
748+ #[test]
749+ fn add_to_chat_names_the_lines() {
750+ assert_eq!(context_block("Terminal 1", 3, 3, "ls"), "Terminal 1 line 3:\n```\nls\n```\n");
751+ assert_eq!(context_block("Terminal 2", 4, 6, "a\nb"), "Terminal 2 lines 4-6:\n```\na\nb\n```\n");
752+ }
753+ }
+39−6src/ui/workspace.rsView File
@@ -40,8 +40,9 @@ use crate::ui::settings::{SettingsEvent, SettingsView};
4040 use crate::worktree::{self, Worktree, WorktreePlan};
4141 use crate::{
4242 CopyThreadReference, CycleAppearance, FocusSearch, NewThread, NewThreadLocal, NewThreadWithoutProject, NextThread,
43- OpenInEditor, OpenProject, OpenSettings, OpenUsage, PinThread, PreviousThread, SelectTheme, SettleThread, ToggleBrowser,
44- ToggleCommandPalette, ToggleProjectPanel, ToggleRightPanel, ToggleSidebar, UndoThreadAction,
43+ CloseTerminal, NewTerminal, OpenInEditor, OpenProject, OpenSettings, OpenUsage, PinThread, PreviousThread, SelectTheme,
44+ SettleThread, SplitTerminal, SplitTerminalVertical, ToggleBrowser, ToggleCommandPalette, ToggleProjectPanel,
45+ ToggleRightPanel, ToggleSidebar, ToggleTerminal, UndoThreadAction,
4546 };
4647
4748 mod actions;
@@ -50,10 +51,12 @@ mod header;
5051 mod palette;
5152 mod remote;
5253 mod sidebar;
54+ mod terminal_drawer;
5355
5456 use actions::UndoEntry;
5557 use palette::CommandPalette;
5658 use remote::RemoteState;
59+ use terminal_drawer::TerminalDrawer;
5760
5861 pub(crate) const SIDEBAR_WIDTH: f32 = 280.;
5962 /// The narrowest the thread sidebar drags to, and how much room it leaves
@@ -250,6 +253,12 @@ pub struct Workspace {
250253 /// The thread sidebar's width, and whether its edge is being dragged.
251254 sidebar_width: f32,
252255 resizing_sidebar: bool,
256+ /// Each thread's terminal drawer, made the first time it's opened.
257+ terminals: HashMap<EntityId, TerminalDrawer>,
258+ /// The terminal drawer's height, and where a drag of its edge started
259+ /// (the pointer's y and the height then).
260+ terminal_height: f32,
261+ terminal_drag: Option<(Pixels, f32)>,
253262 editors: Rc<Vec<EditorApp>>,
254263 editor: usize,
255264 hostname: SharedString,
@@ -383,6 +392,9 @@ impl Workspace {
383392 title_click: Task::ready(()),
384393 sidebar_width: SIDEBAR_WIDTH,
385394 resizing_sidebar: false,
395+ terminals: HashMap::new(),
396+ terminal_height: terminal_drawer::DEFAULT_HEIGHT,
397+ terminal_drag: None,
386398 editors: Rc::new(editors),
387399 editor,
388400 hostname: hostname().into(),
@@ -441,6 +453,7 @@ impl Workspace {
441453 self.settled_open = state.settled_expanded;
442454 self.collapsed_projects = state.collapsed_projects.iter().cloned().collect();
443455 self.sidebar_width = state.sidebar_width.unwrap_or(SIDEBAR_WIDTH).max(SIDEBAR_MIN_WIDTH);
456+ self.terminal_height = state.terminal_height.unwrap_or(terminal_drawer::DEFAULT_HEIGHT);
444457 for project in &state.projects {
445458 let name = Some(project.name.as_str()).filter(|name| !name.is_empty());
446459 let ix = self.ensure_project(project.path.clone(), name);
@@ -496,6 +509,7 @@ impl Workspace {
496509 paths
497510 },
498511 sidebar_width: Some(self.sidebar_width.round()).filter(|width| *width != SIDEBAR_WIDTH),
512+ terminal_height: Some(self.terminal_height.round()).filter(|height| *height != terminal_drawer::DEFAULT_HEIGHT),
499513 }
500514 }
501515
@@ -1503,6 +1517,7 @@ impl Workspace {
15031517 let id = session.entity_id();
15041518 self.chats.remove(&id);
15051519 self.browsers.remove(&id);
1520+ self.drop_terminals(id);
15061521 self.observers.remove(&id);
15071522 self.activity.remove(&id);
15081523 self.dirty.remove(&id);
@@ -2104,6 +2119,11 @@ impl Render for Workspace {
21042119 this.open_palette(palette::PaletteView::Themes, window, cx)
21052120 }))
21062121 .on_action(cx.listener(|this, _: &CycleAppearance, window, cx| this.cycle_appearance(window, cx)))
2122+ .on_action(cx.listener(|this, _: &ToggleTerminal, window, cx| this.toggle_terminal(window, cx)))
2123+ .on_action(cx.listener(|this, _: &SplitTerminal, window, cx| this.split_terminal(false, window, cx)))
2124+ .on_action(cx.listener(|this, _: &SplitTerminalVertical, window, cx| this.split_terminal(true, window, cx)))
2125+ .on_action(cx.listener(|this, _: &NewTerminal, window, cx| this.new_terminal(window, cx)))
2126+ .on_action(cx.listener(|this, _: &CloseTerminal, window, cx| this.request_close_terminal(None, window, cx)))
21072127 .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| this.jump_with_key(event, window, cx)))
21082128 // Dragging the sidebar's edge.
21092129 .when(self.resizing_sidebar, |this| {
@@ -2121,6 +2141,12 @@ impl Render for Workspace {
21212141 }),
21222142 )
21232143 })
2144+ // Dragging the terminal drawer's top edge.
2145+ .when(self.terminal_drag.is_some(), |this| {
2146+ this.cursor_row_resize()
2147+ .on_mouse_move(cx.listener(Self::drag_terminal_edge))
2148+ .on_mouse_up(MouseButton::Left, cx.listener(|this, _, _, cx| this.end_terminal_drag(cx)))
2149+ })
21242150 .on_modifiers_changed(cx.listener(|this, event: &ModifiersChangedEvent, window, cx| {
21252151 this.modifiers_changed(event, window, cx)
21262152 }))
@@ -2171,10 +2197,17 @@ impl Render for Workspace {
21712197 .flex_1()
21722198 .min_h_0()
21732199 .items_start()
2174- .child(div().flex_1().min_w(chat_min).h_full().map(|this| match chat {
2175- Some(chat) => this.child(chat),
2176- None => this.child(self.render_empty_sheet(cx)),
2177- }))
2200+ .child(
2201+ v_flex()
2202+ .flex_1()
2203+ .min_w(chat_min)
2204+ .h_full()
2205+ .child(div().flex_1().min_h_0().w_full().overflow_hidden().map(|this| match chat {
2206+ Some(chat) => this.child(chat),
2207+ None => this.child(self.render_empty_sheet(cx)),
2208+ }))
2209+ .children(self.render_terminal_drawer(window, cx)),
2210+ )
21782211 .when(panel_visible, |this| this.child(self.render_panel(cx)))
21792212 .when_some(browser, |this, browser| this.child(browser)),
21802213 ),
+18−6src/ui/workspace/header.rsView File
@@ -210,7 +210,7 @@ impl Workspace {
210210 }
211211
212212 /// T3's panel toggles: thread details (our project panel), the terminal
213- /// drawer (which the app doesn't have), and the right panel (the browser).
213+ /// drawer, and the right panel (the browser).
214214 fn render_layout_controls(&self, panel_visible: bool, cx: &mut Context<Self>) -> impl IntoElement {
215215 let p = metal::palette(cx);
216216 let live = !self.browser_open
@@ -227,11 +227,23 @@ impl Workspace {
227227 .tooltip("Toggle thread details (Ctrl+Shift+B)")
228228 .on_click(cx.listener(|this, _, _, cx| this.toggle_project_panel(cx))),
229229 )
230- .child(
231- div().id("terminal-drawer-unavailable").tooltip(|window, cx| Tooltip::new("Terminal drawer is unavailable").build(window, cx)).child(
232- MetalButton::new("toggle-terminal-drawer").ghost().mini().icon(IconName::PanelBottom).disabled(true),
233- ),
234- )
230+ .child(if self.terminal_available(cx) {
231+ MetalButton::new("toggle-terminal-drawer")
232+ .ghost()
233+ .mini()
234+ .icon(IconName::PanelBottom)
235+ .selected(self.terminal_open())
236+ .tooltip("Toggle terminal drawer (Ctrl+J)")
237+ .on_click(cx.listener(|this, _, window, cx| this.toggle_terminal(window, cx)))
238+ .into_any_element()
239+ } else {
240+ // A thread on another device has no folder here to start in.
241+ div()
242+ .id("terminal-drawer-unavailable")
243+ .tooltip(|window, cx| Tooltip::new("Terminal drawer is unavailable").build(window, cx))
244+ .child(MetalButton::new("toggle-terminal-drawer").ghost().mini().icon(IconName::PanelBottom).disabled(true))
245+ .into_any_element()
246+ })
235247 .child(
236248 div()
237249 .relative()
+691−0src/ui/workspace/terminal_drawer.rsaddedView File
@@ -0,0 +1,691 @@
1+ //! T3 Code's terminal drawer (ThreadTerminalDrawer): shells under the
2+ //! conversation, per thread, started in the thread's folder (its worktree
3+ //! when it has one). Ctrl+J shows and hides it; its top edge drags. One
4+ //! terminal fills it; splits lay a group out side by side or stacked; with
5+ //! more than one terminal a sidebar lists the groups. A terminal whose shell
6+ //! exits closes, and closing the last one closes the drawer.
7+
8+ use std::collections::HashMap;
9+ use std::path::PathBuf;
10+ use std::time::Duration;
11+
12+ use gpui_kit::component::{Icon, h_flex, tooltip::Tooltip, v_flex};
13+ use gpui_kit::prelude::FluentBuilder as _;
14+ use gpui_kit::*;
15+
16+ use super::{Workspace, actions};
17+ use crate::ui::icons::Lucide;
18+ use crate::ui::metal::{self, MetalButton, Palette};
19+ use crate::ui::terminal::{TerminalEvent, TerminalView};
20+
21+ /// T3's DEFAULT_THREAD_TERMINAL_HEIGHT.
22+ pub(super) const DEFAULT_HEIGHT: f32 = 280.;
23+ const MIN_HEIGHT: f32 = 180.;
24+ /// The drawer takes at most this much of the window's height.
25+ const MAX_HEIGHT_RATIO: f32 = 0.75;
26+ /// T3's MAX_TERMINALS_PER_GROUP.
27+ const MAX_TERMINALS_PER_GROUP: usize = 4;
28+ /// The grab area along the drawer's top edge.
29+ const HANDLE_HEIGHT: f32 = 6.;
30+ /// The terminal sidebar's width (T3's `w-36`).
31+ const SIDEBAR_WIDTH: f32 = 144.;
32+ /// A little longer than a confirmation dialog takes to close.
33+ const CONFIRM_FADE: Duration = Duration::from_millis(320);
34+
35+ /// The drawer's height, kept between its minimum and three quarters of
36+ /// the window.
37+ pub(super) fn clamp_height(height: f32, window_height: f32) -> f32 {
38+ let max = MIN_HEIGHT.max((window_height * MAX_HEIGHT_RATIO).floor());
39+ let height = if height.is_finite() { height } else { DEFAULT_HEIGHT };
40+ height.round().clamp(MIN_HEIGHT, max)
41+ }
42+
43+ /// `Terminal 3`, as T3 labels `term-3`.
44+ pub(super) fn label(id: u32) -> String {
45+ format!("Terminal {id}")
46+ }
47+
48+ /// Terminals sharing the drawer at once.
49+ #[derive(Clone, Debug, PartialEq, Eq)]
50+ pub(super) struct TerminalGroup {
51+ pub terminals: Vec<u32>,
52+ /// Stacked, rather than side by side.
53+ pub vertical: bool,
54+ }
55+
56+ /// A thread's terminals and their groups, as T3's terminal UI store keeps
57+ /// them. Terminals are numbered from 1, reusing the lowest free number.
58+ #[derive(Clone, Debug, Default, PartialEq, Eq)]
59+ pub(super) struct TerminalLayout {
60+ /// In the order they were opened.
61+ pub terminals: Vec<u32>,
62+ pub groups: Vec<TerminalGroup>,
63+ pub active: Option<u32>,
64+ }
65+
66+ impl TerminalLayout {
67+ /// T3's nextTerminalId: the lowest number not in use.
68+ fn next_id(&self) -> u32 {
69+ (1..).find(|id| !self.terminals.contains(id)).unwrap_or(1)
70+ }
71+
72+ pub fn is_empty(&self) -> bool {
73+ self.terminals.is_empty()
74+ }
75+
76+ /// The group showing: the active terminal's.
77+ pub fn active_group(&self) -> Option<&TerminalGroup> {
78+ let active = self.active?;
79+ self.groups.iter().find(|group| group.terminals.contains(&active))
80+ }
81+
82+ /// Whether the showing group can't take another split.
83+ pub fn at_split_limit(&self) -> bool {
84+ self.active_group().is_some_and(|group| group.terminals.len() >= MAX_TERMINALS_PER_GROUP)
85+ }
86+
87+ /// Opens a terminal in a group of its own, and returns its number.
88+ pub fn new_terminal(&mut self) -> u32 {
89+ let id = self.next_id();
90+ self.terminals.push(id);
91+ self.groups.push(TerminalGroup { terminals: vec![id], vertical: false });
92+ self.active = Some(id);
93+ id
94+ }
95+
96+ /// Opens a terminal next to the active one, turning its group the way
97+ /// asked. `None` when the group is full.
98+ pub fn split(&mut self, vertical: bool) -> Option<u32> {
99+ let Some(active) = self.active else { return Some(self.new_terminal()) };
100+ if self.at_split_limit() {
101+ return None;
102+ }
103+ let id = self.next_id();
104+ let group = self.groups.iter_mut().find(|group| group.terminals.contains(&active))?;
105+ let anchor =
106+ group.terminals.iter().position(|terminal| *terminal == active).map_or(group.terminals.len(), |ix| ix + 1);
107+ group.terminals.insert(anchor, id);
108+ group.vertical = vertical;
109+ self.terminals.push(id);
110+ self.active = Some(id);
111+ Some(id)
112+ }
113+
114+ pub fn set_active(&mut self, id: u32) {
115+ if self.terminals.contains(&id) {
116+ self.active = Some(id);
117+ }
118+ }
119+
120+ /// Takes a terminal out; the one after it in opening order (or the new
121+ /// last one) becomes active if it was.
122+ pub fn close(&mut self, id: u32) {
123+ let Some(ix) = self.terminals.iter().position(|terminal| *terminal == id) else { return };
124+ self.terminals.remove(ix);
125+ for group in &mut self.groups {
126+ group.terminals.retain(|terminal| *terminal != id);
127+ }
128+ self.groups.retain(|group| !group.terminals.is_empty());
129+ if self.active == Some(id) {
130+ self.active = self.terminals.get(ix.min(self.terminals.len().saturating_sub(1))).copied();
131+ }
132+ }
133+ }
134+
135+ /// One thread's drawer.
136+ #[derive(Default)]
137+ pub(super) struct TerminalDrawer {
138+ pub open: bool,
139+ pub layout: TerminalLayout,
140+ views: HashMap<u32, (Entity<TerminalView>, Subscription)>,
141+ }
142+
143+ impl TerminalDrawer {
144+ fn view(&self, id: u32) -> Option<&Entity<TerminalView>> {
145+ self.views.get(&id).map(|(view, _)| view)
146+ }
147+ }
148+
149+ impl Workspace {
150+ /// Where the open thread's terminals start: its folder on this device.
151+ /// `None` for threads on other devices.
152+ fn terminal_cwd(&self, cx: &App) -> Option<PathBuf> {
153+ let session = self.active.as_ref()?.read(cx);
154+ let project = self.active_project()?;
155+ if session.is_remote() || project.environment.is_some() {
156+ return None;
157+ }
158+ Some(session.cwd.clone()).filter(|cwd| cwd.is_dir())
159+ }
160+
161+ /// Whether the open thread can have terminals.
162+ pub(super) fn terminal_available(&self, cx: &App) -> bool {
163+ self.terminal_cwd(cx).is_some()
164+ }
165+
166+ /// Whether the open thread's drawer shows.
167+ pub(super) fn terminal_open(&self) -> bool {
168+ self.active.as_ref().and_then(|session| self.terminals.get(&session.entity_id())).is_some_and(|drawer| drawer.open)
169+ }
170+
171+ /// T3's terminal.toggle: shows the drawer (with a first terminal if it
172+ /// has none) and focuses it, or hides it and goes back to the composer.
173+ pub(super) fn toggle_terminal(&mut self, window: &mut Window, cx: &mut Context<Self>) {
174+ if self.settings_open {
175+ return;
176+ }
177+ let Some(session) = self.active.as_ref().map(|session| session.entity_id()) else { return };
178+ if self.terminal_open() {
179+ if let Some(drawer) = self.terminals.get_mut(&session) {
180+ drawer.open = false;
181+ }
182+ self.focus_active_composer(window, cx);
183+ cx.notify();
184+ return;
185+ }
186+ if !self.terminal_available(cx) {
187+ return;
188+ }
189+ let drawer = self.terminals.entry(session).or_default();
190+ drawer.open = true;
191+ if drawer.layout.is_empty() {
192+ self.new_terminal(window, cx);
193+ } else {
194+ self.focus_terminal(window, cx);
195+ }
196+ cx.notify();
197+ }
198+
199+ /// T3's terminal.new: a terminal in a group of its own.
200+ pub(super) fn new_terminal(&mut self, window: &mut Window, cx: &mut Context<Self>) {
201+ 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 };
203+ let id = drawer.layout.new_terminal();
204+ self.start_terminal(session, id, window, cx);
205+ }
206+
207+ /// T3's terminal.split and terminal.splitVertical.
208+ pub(super) fn split_terminal(&mut self, vertical: bool, window: &mut Window, cx: &mut Context<Self>) {
209+ 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 };
211+ let Some(id) = drawer.layout.split(vertical) else { return };
212+ self.start_terminal(session, id, window, cx);
213+ }
214+
215+ /// Starts terminal `id`'s shell and focuses it.
216+ fn start_terminal(&mut self, session: EntityId, id: u32, window: &mut Window, cx: &mut Context<Self>) {
217+ let Some(cwd) = self.terminal_cwd(cx) else { return };
218+ let view = cx.new(|cx| TerminalView::new(&cwd, label(id), window, cx));
219+ let subscription = cx.subscribe_in(&view, window, move |this, _, event: &TerminalEvent, window, cx| match event {
220+ TerminalEvent::Exited => this.close_terminal(session, id, false, window, cx),
221+ TerminalEvent::AddToChat(text) => {
222+ if let Some(chat) = this.chats.get(&session).cloned() {
223+ chat.update(cx, |chat, cx| chat.insert_into_composer(text, window, cx));
224+ }
225+ }
226+ });
227+ if let Some(drawer) = self.terminals.get_mut(&session) {
228+ drawer.views.insert(id, (view, subscription));
229+ }
230+ self.focus_terminal(window, cx);
231+ cx.notify();
232+ }
233+
234+ /// Hands focus to the open thread's active terminal.
235+ fn focus_terminal(&self, window: &mut Window, cx: &mut App) {
236+ let Some(session) = self.active.as_ref() else { return };
237+ let Some(drawer) = self.terminals.get(&session.entity_id()) else { return };
238+ if let Some(view) = drawer.layout.active.and_then(|id| drawer.view(id)) {
239+ view.read(cx).focus_handle(cx).focus(window, cx);
240+ }
241+ }
242+
243+ fn focus_active_composer(&self, window: &mut Window, cx: &mut App) {
244+ if let Some(chat) = self.active.as_ref().and_then(|session| self.chats.get(&session.entity_id())).cloned() {
245+ chat.update(cx, |chat, cx| chat.focus_composer(window, cx));
246+ }
247+ }
248+
249+ /// The open thread's active terminal.
250+ fn active_terminal(&self) -> Option<u32> {
251+ let session = self.active.as_ref()?;
252+ self.terminals.get(&session.entity_id())?.layout.active
253+ }
254+
255+ pub(super) fn set_active_terminal(&mut self, id: u32, window: &mut Window, cx: &mut Context<Self>) {
256+ let Some(session) = self.active.as_ref().map(|session| session.entity_id()) else { return };
257+ if let Some(drawer) = self.terminals.get_mut(&session) {
258+ drawer.layout.set_active(id);
259+ }
260+ self.focus_terminal(window, cx);
261+ cx.notify();
262+ }
263+
264+ /// T3's terminal.close: asks first, as its confirmTerminalClose does.
265+ pub(super) fn request_close_terminal(&mut self, id: Option<u32>, window: &mut Window, cx: &mut Context<Self>) {
266+ let Some(session) = self.active.as_ref().map(|session| session.entity_id()) else { return };
267+ let Some(drawer) = self.terminals.get(&session).filter(|drawer| drawer.open) else { return };
268+ let Some(id) = id.or(drawer.layout.active) else { return };
269+ let message = format!("Close terminal \"{}\"?\nThis stops the running process and clears its history.", label(id));
270+ let workspace = cx.weak_entity();
271+ window.defer(cx, move |window, cx| {
272+ actions::confirm(&message, true, window, cx, move |window, cx| {
273+ let _ = workspace.update(cx, |this, cx| this.close_terminal(session, id, true, window, cx));
274+ })
275+ });
276+ }
277+
278+ /// Stops a terminal's shell and takes it out of the drawer, which closes
279+ /// with its last terminal.
280+ fn close_terminal(&mut self, session: EntityId, id: u32, confirmed: bool, window: &mut Window, cx: &mut Context<Self>) {
281+ let Some(drawer) = self.terminals.get_mut(&session) else { return };
282+ drawer.views.remove(&id);
283+ drawer.layout.close(id);
284+ let empty = drawer.layout.is_empty();
285+ if empty {
286+ self.terminals.remove(&session);
287+ }
288+ if self.active.as_ref().is_some_and(|active| active.entity_id() == session) {
289+ let refocus = move |this: &mut Self, window: &mut Window, cx: &mut Context<Self>| {
290+ if this.active.as_ref().is_some_and(|active| active.entity_id() == session) {
291+ if empty {
292+ this.focus_active_composer(window, cx);
293+ } else {
294+ this.focus_terminal(window, cx);
295+ }
296+ }
297+ };
298+ refocus(self, window, cx);
299+ if confirmed {
300+ // The confirmation hands focus back to the closed terminal as
301+ // its dialog fades out; take it once that's done.
302+ cx.spawn_in(window, async move |this, cx| {
303+ cx.background_executor().timer(CONFIRM_FADE).await;
304+ let _ = this.update_in(cx, |this, window, cx| refocus(this, window, cx));
305+ })
306+ .detach();
307+ }
308+ }
309+ cx.notify();
310+ }
311+
312+ /// Forgets a thread's terminals, stopping their shells.
313+ pub(super) fn drop_terminals(&mut self, session: EntityId) {
314+ self.terminals.remove(&session);
315+ }
316+
317+ /// Dragging the drawer's top edge.
318+ pub(super) fn drag_terminal_edge(&mut self, event: &MouseMoveEvent, window: &mut Window, cx: &mut Context<Self>) {
319+ let Some((start_y, start_height)) = self.terminal_drag else { return };
320+ let height = start_height + (start_y - event.position.y).as_f32();
321+ self.terminal_height = clamp_height(height, window.viewport_size().height.as_f32());
322+ cx.notify();
323+ }
324+
325+ pub(super) fn end_terminal_drag(&mut self, cx: &mut Context<Self>) {
326+ self.terminal_drag = None;
327+ cx.notify();
328+ }
329+
330+ /// 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> {
332+ let session = self.active.as_ref()?.entity_id();
333+ let drawer = self.terminals.get(&session).filter(|drawer| drawer.open)?;
334+ let p = metal::palette(cx);
335+ let height = clamp_height(self.terminal_height, window.viewport_size().height.as_f32());
336+ let layout = &drawer.layout;
337+
338+ let handle = div()
339+ .id("terminal-drawer-edge")
340+ .absolute()
341+ .top_0()
342+ .left_0()
343+ .right_0()
344+ .h(px(HANDLE_HEIGHT))
345+ .cursor_row_resize()
346+ .on_mouse_down(
347+ MouseButton::Left,
348+ cx.listener(move |this, event: &MouseDownEvent, _, cx| {
349+ cx.stop_propagation();
350+ this.terminal_drag = Some((event.position.y, height));
351+ cx.notify();
352+ }),
353+ );
354+ let base = v_flex().relative().flex_none().w_full().h(px(height)).overflow_hidden().child(metal::rule(p));
355+
356+ if layout.is_empty() {
357+ return Some(
358+ base.child(handle)
359+ .child(
360+ v_flex()
361+ .flex_1()
362+ .min_h_0()
363+ .items_center()
364+ .justify_center()
365+ .gap(px(12.))
366+ .px(px(16.))
367+ .py(px(24.))
368+ .text_size(px(14.))
369+ .text_color(p.ink3)
370+ .child("No terminal sessions for this thread yet.")
371+ .child(
372+ MetalButton::new("terminal-empty-new")
373+ .compact()
374+ .label(NEW_TERMINAL_LABEL)
375+ .on_click(cx.listener(|this, _, window, cx| this.new_terminal(window, cx))),
376+ ),
377+ )
378+ .into_any_element(),
379+ );
380+ }
381+
382+ let active = layout.active;
383+ let visible: Vec<u32> = layout.active_group().map(|group| group.terminals.clone()).unwrap_or_default();
384+ let vertical = layout.active_group().is_some_and(|group| group.vertical);
385+ let has_sidebar = layout.terminals.len() > 1;
386+ let at_limit = layout.at_split_limit();
387+
388+ let panes = visible.iter().enumerate().filter_map(|(ix, id)| {
389+ let view = drawer.view(*id)?.clone();
390+ let id = *id;
391+ let border = if Some(id) == active { p.rule } else { p.rule.opacity(0.7) };
392+ Some(
393+ div()
394+ .id(("terminal-pane", id as usize))
395+ .flex_1()
396+ .min_w_0()
397+ .min_h_0()
398+ .when(ix > 0 && vertical, |this| this.border_t_1().border_color(border))
399+ .when(ix > 0 && !vertical, |this| this.border_l_1().border_color(border))
400+ .on_mouse_down(
401+ MouseButton::Left,
402+ cx.listener(move |this, _, window, cx| {
403+ if this.active_terminal() != Some(id) {
404+ this.set_active_terminal(id, window, cx);
405+ }
406+ }),
407+ )
408+ .child(view),
409+ )
410+ });
411+ let grid = if vertical {
412+ v_flex().size_full().children(panes).into_any_element()
413+ } else {
414+ h_flex().size_full().items_stretch().children(panes).into_any_element()
415+ };
416+
417+ let body = h_flex()
418+ .flex_1()
419+ .min_h_0()
420+ .w_full()
421+ .items_stretch()
422+ .when(has_sidebar, |this| this.gap(px(6.)))
423+ .child(div().flex_1().min_w_0().h_full().child(grid))
424+ .when(has_sidebar, |this| this.child(self.render_terminal_sidebar(drawer, at_limit, cx)));
425+
426+ Some(
427+ base.child(handle)
428+ .child(body)
429+ .when(!has_sidebar, |this| {
430+ this.child(
431+ div()
432+ .absolute()
433+ .top(px(8.))
434+ .right(px(8.))
435+ .child(
436+ h_flex()
437+ .items_center()
438+ .rounded(px(8.))
439+ .border_1()
440+ .border_color(p.rule)
441+ .bg(p.menu_bg)
442+ .shadow(p.raise_sm.clone())
443+ .children(terminal_actions("terminal-actions", at_limit, cx).into_iter().enumerate().flat_map(
444+ |(ix, button)| {
445+ let separator = (ix > 0).then(|| metal::vrule(p).into_any_element());
446+ separator.into_iter().chain(Some(button))
447+ },
448+ )),
449+ ),
450+ )
451+ })
452+ .into_any_element(),
453+ )
454+ }
455+
456+ /// The list of groups beside the terminals once there's more than one.
457+ fn render_terminal_sidebar(&self, drawer: &TerminalDrawer, at_limit: bool, cx: &mut Context<Self>) -> impl IntoElement {
458+ let p = metal::palette(cx);
459+ let layout = &drawer.layout;
460+ let active = layout.active;
461+ let show_group_headers = layout.groups.len() > 1 || layout.groups.iter().any(|group| group.terminals.len() > 1);
462+ v_flex()
463+ .flex_none()
464+ .w(px(SIDEBAR_WIDTH))
465+ .h_full()
466+ .border_1()
467+ .border_color(p.rule)
468+ .bg(p.ink.opacity(0.02))
469+ .child(
470+ h_flex()
471+ .flex_none()
472+ .h(px(22.))
473+ .justify_end()
474+ .border_b_1()
475+ .border_color(p.rule)
476+ .children(terminal_actions("terminal-sidebar-actions", at_limit, cx)),
477+ )
478+ .child(
479+ v_flex()
480+ .id("terminal-groups")
481+ .flex_1()
482+ .min_h_0()
483+ .overflow_y_scroll()
484+ .p(px(4.))
485+ .children(layout.groups.iter().enumerate().map(|(group_ix, group)| {
486+ let rows: Vec<AnyElement> = group
487+ .terminals
488+ .iter()
489+ .map(|id| terminal_row(*id, active == Some(*id), p, cx).into_any_element())
490+ .collect();
491+ let group_active = active.is_some_and(|id| group.terminals.contains(&id));
492+ let focus_id = if group_active { active } else { group.terminals.first().copied() };
493+ let split = group.terminals.len() > 1;
494+ let (icon, name) = match (split, group.vertical) {
495+ (false, _) => (Lucide::Square, "Single"),
496+ (true, true) => (Lucide::SquareSplitVertical, "Stacked"),
497+ (true, false) => (Lucide::SquareSplitHorizontal, "Side by side"),
498+ };
499+ v_flex()
500+ .pb(px(2.))
501+ .when(show_group_headers, |this| {
502+ this.child(
503+ h_flex()
504+ .id(("terminal-group", group_ix))
505+ .h(px(22.))
506+ .gap(px(4.))
507+ .px(px(6.))
508+ .rounded(px(4.))
509+ .cursor_pointer()
510+ .text_size(px(11.))
511+ .map(|this| {
512+ if group_active {
513+ this.bg(p.row_hover).text_color(p.ink)
514+ } else {
515+ this.text_color(p.ink3).hover(|style| style.bg(p.row_hover).text_color(p.ink))
516+ }
517+ })
518+ .on_click(cx.listener(move |this, _, window, cx| {
519+ if let Some(id) = focus_id {
520+ this.set_active_terminal(id, window, cx);
521+ }
522+ }))
523+ .child(Icon::new(icon).size(px(12.)).flex_none())
524+ .child(div().flex_1().min_w_0().truncate().child(name))
525+ .child(div().text_size(px(10.)).text_color(p.ink3.opacity(0.7)).child(group.terminals.len().to_string())),
526+ )
527+ })
528+ .child(v_flex().gap(px(2.)).children(rows))
529+ })),
530+ )
531+ }
532+ }
533+
534+ /// T3's newTerminalActionLabel, with the shortcut a focused terminal takes.
535+ const NEW_TERMINAL_LABEL: &str = "New Terminal (Ctrl+N)";
536+
537+ /// The split, new and close buttons, as the floating group and the sidebar
538+ /// header show them.
539+ fn terminal_actions(id: &'static str, at_limit: bool, cx: &mut Context<Workspace>) -> Vec<AnyElement> {
540+ let split_label = |direction: &str, shortcut: &str| -> SharedString {
541+ if at_limit {
542+ format!("Split Terminal {direction} (max {MAX_TERMINALS_PER_GROUP} per group)").into()
543+ } else {
544+ format!("Split Terminal {direction} ({shortcut})").into()
545+ }
546+ };
547+ let button = |name: &'static str, icon: Lucide, tooltip: SharedString| {
548+ MetalButton::new(SharedString::from(format!("{id}-{name}")))
549+ .ghost()
550+ .mini()
551+ .icon(icon)
552+ .rounded(px(6.))
553+ .tooltip(tooltip)
554+ };
555+ vec![
556+ button("split", Lucide::SquareSplitHorizontal, split_label("Horizontally", "Ctrl+D"))
557+ .disabled(at_limit)
558+ .on_click(cx.listener(|this, _, window, cx| this.split_terminal(false, window, cx)))
559+ .into_any_element(),
560+ button("split-vertical", Lucide::SquareSplitVertical, split_label("Vertically", "Ctrl+Shift+D"))
561+ .disabled(at_limit)
562+ .on_click(cx.listener(|this, _, window, cx| this.split_terminal(true, window, cx)))
563+ .into_any_element(),
564+ button("new", Lucide::Plus, NEW_TERMINAL_LABEL.into())
565+ .on_click(cx.listener(|this, _, window, cx| this.new_terminal(window, cx)))
566+ .into_any_element(),
567+ button("close", Lucide::Trash, "Close Terminal (Ctrl+W)".into())
568+ .on_click(cx.listener(|this, _, window, cx| this.request_close_terminal(None, window, cx)))
569+ .into_any_element(),
570+ ]
571+ }
572+
573+ /// A terminal in the sidebar: its icon, which turns into a close button on
574+ /// hover, and its label.
575+ fn terminal_row(id: u32, active: bool, p: &Palette, cx: &mut Context<Workspace>) -> impl IntoElement {
576+ let name = label(id);
577+ let close = SharedString::from(format!("Close {name}{}", if active { " (Ctrl+W)" } else { "" }));
578+ let group = SharedString::from(format!("terminal-row-{id}"));
579+ h_flex()
580+ .id(("terminal-row", id as usize))
581+ .group(group.clone())
582+ .h(px(24.))
583+ .gap(px(2.))
584+ .pl(px(6.))
585+ .pr(px(8.))
586+ .rounded(px(6.))
587+ .cursor_pointer()
588+ .text_size(px(12.))
589+ .map(|this| {
590+ if active {
591+ this.bg(metal::vertical(p.row_on)).text_color(p.ink)
592+ } else {
593+ this.text_color(p.ink3).hover(|style| style.bg(p.row_hover).text_color(p.ink))
594+ }
595+ })
596+ .on_click(cx.listener(move |this, _, window, cx| this.set_active_terminal(id, window, cx)))
597+ .child(
598+ div()
599+ .id(("terminal-row-close", id as usize))
600+ .relative()
601+ .flex_none()
602+ .size(px(16.))
603+ .flex()
604+ .items_center()
605+ .justify_center()
606+ .rounded(px(4.))
607+ .tooltip(move |window, cx| Tooltip::new(close.clone()).build(window, cx))
608+ .on_click(cx.listener(move |this, _, window, cx| {
609+ cx.stop_propagation();
610+ this.request_close_terminal(Some(id), window, cx);
611+ }))
612+ .child(
613+ div()
614+ .group_hover(group.clone(), |style| style.invisible())
615+ .child(Icon::new(Lucide::SquareTerminal).size(px(12.))),
616+ )
617+ .child(
618+ div()
619+ .absolute()
620+ .inset_0()
621+ .flex()
622+ .items_center()
623+ .justify_center()
624+ .invisible()
625+ .group_hover(group, |style| style.visible())
626+ .child(Icon::new(Lucide::X).size(px(12.))),
627+ ),
628+ )
629+ .child(div().flex_1().min_w_0().truncate().child(name))
630+ }
631+
632+ #[cfg(test)]
633+ mod tests {
634+ use super::{MAX_TERMINALS_PER_GROUP, TerminalLayout, clamp_height, label};
635+
636+ #[test]
637+ fn labels_number_from_one_and_reuse_gaps() {
638+ let mut layout = TerminalLayout::default();
639+ assert_eq!(layout.new_terminal(), 1);
640+ assert_eq!(layout.new_terminal(), 2);
641+ assert_eq!(layout.new_terminal(), 3);
642+ layout.close(2);
643+ assert_eq!(layout.new_terminal(), 2);
644+ assert_eq!(label(2), "Terminal 2");
645+ assert_eq!(layout.terminals, vec![1, 3, 2]);
646+ }
647+
648+ #[test]
649+ fn splits_join_the_active_group() {
650+ let mut layout = TerminalLayout::default();
651+ layout.new_terminal();
652+ assert_eq!(layout.split(false), Some(2));
653+ assert_eq!(layout.groups.len(), 1);
654+ assert_eq!(layout.groups[0].terminals, vec![1, 2]);
655+ layout.set_active(1);
656+ assert_eq!(layout.split(true), Some(3));
657+ // After the terminal it split from, and the group turns stacked.
658+ assert_eq!(layout.groups[0].terminals, vec![1, 3, 2]);
659+ assert!(layout.groups[0].vertical);
660+ assert_eq!(layout.split(false), Some(4));
661+ assert_eq!(layout.groups[0].terminals.len(), MAX_TERMINALS_PER_GROUP);
662+ assert_eq!(layout.split(false), None);
663+ assert!(layout.at_split_limit());
664+ }
665+
666+ #[test]
667+ fn closing_moves_on_and_empties() {
668+ let mut layout = TerminalLayout::default();
669+ layout.new_terminal();
670+ layout.new_terminal();
671+ layout.new_terminal();
672+ layout.set_active(2);
673+ layout.close(2);
674+ assert_eq!(layout.active, Some(3));
675+ assert_eq!(layout.groups.len(), 2);
676+ layout.close(3);
677+ assert_eq!(layout.active, Some(1));
678+ layout.close(1);
679+ assert!(layout.is_empty());
680+ assert_eq!(layout.active, None);
681+ }
682+
683+ #[test]
684+ fn heights_clamp_to_the_window() {
685+ assert_eq!(clamp_height(100., 900.), 180.);
686+ assert_eq!(clamp_height(300.4, 900.), 300.);
687+ assert_eq!(clamp_height(800., 900.), 675.);
688+ assert_eq!(clamp_height(400., 200.), 180.);
689+ assert_eq!(clamp_height(f32::NAN, 900.), 280.);
690+ }
691+ }