Vectrum
Sign In

Terminal drawer for threads on another device

Ctrl+J did nothing on a thread from a connected device, since it has no
folder here. Its terminals now run their shells on the host, in the thread's
folder there: TerminalOpen starts one, typed keys and resizes go over as
TerminalInput and TerminalResize, and the shell's output comes back as
TerminalOutput, which the client's emulator parses. A shell's exit closes the
terminal as a local one does; closing it, or the client disconnecting, hangs
up on the shell. When the connection drops, the terminal says so, and a host
too old to answer is given up on after 15 seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 1 parent 3917276commit327e705

Showing 9 changed files with 672 additions and 40 deletions.

+3−2README.mdView File
@@ -140,7 +140,7 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
140140 URL. When the agent opens a page while the panel is closed, a light on the globe says so.
141141 - A terminal drawer under the conversation, like T3 Code's (Ctrl+J, or the panel toggle in
142142 the chat header): each thread has its own terminals, which start in the thread's folder
143- (its worktree when it has one). Drag the drawer's top edge to resize it. Split a terminal
143+ (its worktree when it has one), on the device the thread runs on. Drag the drawer's top edge to resize it. Split a terminal
144144 side by side (Ctrl+D) or stacked (Ctrl+Shift+D), up to four per group, or open another
145145 (Ctrl+N while a terminal has focus); with several, a sidebar lists the groups. Close one
146146 with Ctrl+W or the trash button (after a confirmation); a terminal whose shell exits
@@ -172,7 +172,8 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
172172 - Remote connections, like T3 Code's environments: turn on **Allow remote connections** on
173173 one device, and paste its pairing code (address and key) under Settings → Connections on
174174 another. The host's projects and threads appear in the sidebar tagged with its name; open,
175- continue or start threads there and they run on the host. The first time a device connects
175+ continue or start threads there and they run on the host. Their terminal drawer (Ctrl+J)
176+ runs its shells on the host too, in the thread's folder there. The first time a device connects
176177 with settings that differ from yours, a dialog asks whether to give it your settings or
177178 leave its own as they are. The connection is a WebSocket guarded by the key but not
178179 encrypted, so use it on trusted networks or over a VPN.
+27−1src/remote/mod.rsView File
@@ -12,10 +12,14 @@
1212 //! environment settings in [`ServerMessage::Settings`], which clients can
1313 //! change with [`ClientMessage::UpdateSettings`]. The Add project palette
1414 //! browses and adds the host's folders with [`ClientMessage::ListFolders`]
15- //! and [`ClientMessage::AddProject`].
15+ //! and [`ClientMessage::AddProject`]. A client's terminal drawer runs its
16+ //! shells on the host: [`ClientMessage::TerminalOpen`] starts one in a
17+ //! thread's folder there, keys go over as [`ClientMessage::TerminalInput`],
18+ //! and the shell's output comes back as [`ServerMessage::TerminalOutput`].
1619
1720 pub mod client;
1821 pub mod server;
22+ pub mod shell;
1923
2024 use std::path::PathBuf;
2125
@@ -182,6 +186,15 @@ pub enum ClientMessage {
182186 ListFolders { path: String },
183187 /// Adds a project on the host, cloning or creating it as asked.
184188 AddProject { source: ProjectSource },
189+ /// Starts a shell on the host in a thread's folder (the project's, for
190+ /// a thread not created yet). `terminal` is the client's id for it.
191+ /// Older hosts don't answer.
192+ TerminalOpen { terminal: u64, thread: Option<String>, project: PathBuf, columns: u16, lines: u16 },
193+ /// What was typed in a terminal, base64.
194+ TerminalInput { terminal: u64, data: String },
195+ TerminalResize { terminal: u64, columns: u16, lines: u16 },
196+ /// Hangs up on a terminal's shell.
197+ TerminalClose { terminal: u64 },
185198 }
186199
187200 #[derive(Clone, Debug, Serialize, Deserialize)]
@@ -202,6 +215,10 @@ pub enum ServerMessage {
202215 Folders { path: String, folders: Vec<Folder>, truncated: bool, error: Option<String> },
203216 /// The project a client asked for is in the host's list now.
204217 ProjectAdded { path: PathBuf },
218+ /// A terminal's shell printed something, base64.
219+ TerminalOutput { terminal: u64, data: String },
220+ /// A terminal's shell exited, or with `error`, couldn't start.
221+ TerminalExited { terminal: u64, error: Option<String> },
205222 }
206223
207224 /// A new random key: 24 bytes, hex.
@@ -298,6 +315,15 @@ mod tests {
298315 };
299316 let text = serde_json::to_string(&message).unwrap();
300317 assert_eq!(serde_json::from_str::<ClientMessage>(&text).unwrap(), message);
318+ let message =
319+ ClientMessage::TerminalOpen { terminal: 3, thread: None, project: "/p".into(), columns: 80, lines: 24 };
320+ let text = serde_json::to_string(&message).unwrap();
321+ assert_eq!(serde_json::from_str::<ClientMessage>(&text).unwrap(), message);
322+ let text = serde_json::to_string(&ServerMessage::TerminalExited { terminal: 3, error: None }).unwrap();
323+ assert!(matches!(
324+ serde_json::from_str::<ServerMessage>(&text).unwrap(),
325+ ServerMessage::TerminalExited { terminal: 3, error: None }
326+ ));
301327 let values = crate::settings::Settings::default().environment_values();
302328 let text = serde_json::to_string(&ServerMessage::Settings { values: values.clone() }).unwrap();
303329 match serde_json::from_str::<ServerMessage>(&text).unwrap() {
+22−4src/remote/server.rsView File
@@ -79,10 +79,12 @@ impl RemoteServer {
7979 }
8080
8181 pub fn send(&self, conn: u64, message: &ServerMessage) {
82- let Ok(text) = serde_json::to_string(message) else { return };
83- if let Some(outbox) = self.outboxes.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).get(&conn) {
84- let _ = outbox.send(text);
85- }
82+ self.sender(conn).send(message);
83+ }
84+
85+ /// Sends to one client from any thread, for as long as it's connected.
86+ pub fn sender(&self, conn: u64) -> ConnectionSender {
87+ ConnectionSender { conn, outboxes: self.outboxes.clone() }
8688 }
8789
8890 pub fn broadcast(&self, message: &ServerMessage) {
@@ -105,6 +107,22 @@ impl Drop for RemoteServer {
105107 }
106108 }
107109
110+ /// A way to one client's connection that works off the UI thread.
111+ #[derive(Clone)]
112+ pub struct ConnectionSender {
113+ conn: u64,
114+ outboxes: Arc<Mutex<HashMap<u64, mpsc::Sender<String>>>>,
115+ }
116+
117+ impl ConnectionSender {
118+ pub fn send(&self, message: &ServerMessage) {
119+ let Ok(text) = serde_json::to_string(message) else { return };
120+ if let Some(outbox) = self.outboxes.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).get(&self.conn) {
121+ let _ = outbox.send(text);
122+ }
123+ }
124+ }
125+
108126 #[allow(clippy::too_many_arguments)]
109127 fn serve(
110128 conn: u64,
+234−0src/remote/shell.rsaddedView File
@@ -0,0 +1,234 @@
1+ //! The host's side of a client's terminal: a shell in a pseudo-terminal on
2+ //! this device, whose output goes straight to the client from its own
3+ //! thread, without waiting on the UI. The client emulates the screen.
4+
5+ use std::path::Path;
6+
7+ /// What a shell does, for the connection to pass on.
8+ pub enum ShellEvent {
9+ Output(Vec<u8>),
10+ /// The shell exited.
11+ Exited,
12+ }
13+
14+ #[cfg(unix)]
15+ pub use unix::HostShell;
16+
17+ #[cfg(unix)]
18+ mod unix {
19+ use std::fs::File;
20+ use std::io::{self, ErrorKind, Read as _, Write as _};
21+ use std::os::fd::AsRawFd as _;
22+ use std::sync::{
23+ Arc,
24+ atomic::{AtomicBool, Ordering},
25+ mpsc,
26+ };
27+ use std::thread;
28+
29+ use alacritty_terminal::event::{OnResize as _, WindowSize};
30+ use alacritty_terminal::tty;
31+
32+ use super::{Path, ShellEvent};
33+
34+ /// How long the reader waits for output before checking whether to stop.
35+ const WAIT_MS: i32 = 100;
36+ /// The most one output message carries.
37+ const CHUNK: usize = 64 * 1024;
38+
39+ enum Input {
40+ Bytes(Vec<u8>),
41+ Resize(u16, u16),
42+ }
43+
44+ /// A running shell. Dropping it hangs up on the shell.
45+ pub struct HostShell {
46+ input: mpsc::Sender<Input>,
47+ stop: Arc<AtomicBool>,
48+ }
49+
50+ impl HostShell {
51+ /// Starts the user's shell in `cwd` with a screen of `columns` by
52+ /// `lines`. Its output and exit go to `on_event`, on another thread.
53+ pub fn spawn(
54+ cwd: &Path,
55+ columns: u16,
56+ lines: u16,
57+ on_event: impl Fn(ShellEvent) + Send + 'static,
58+ ) -> io::Result<Self> {
59+ Self::spawn_with(crate::terminal::shell_options(cwd), columns, lines, on_event)
60+ }
61+
62+ pub(in crate::remote::shell) fn spawn_with(
63+ options: tty::Options,
64+ columns: u16,
65+ lines: u16,
66+ on_event: impl Fn(ShellEvent) + Send + 'static,
67+ ) -> io::Result<Self> {
68+ let mut pty = tty::new(&options, window_size(columns, lines), 0)?;
69+ let reader = pty.file().try_clone()?;
70+ let writer = pty.file().try_clone()?;
71+ let stop = Arc::new(AtomicBool::new(false));
72+ let (input, inbox) = mpsc::channel::<Input>();
73+ {
74+ let stop = stop.clone();
75+ thread::Builder::new().name("remote-shell-out".into()).spawn(move || {
76+ read_output(&reader, &stop, &on_event);
77+ if !stop.load(Ordering::Relaxed) {
78+ on_event(ShellEvent::Exited);
79+ }
80+ })?;
81+ }
82+ thread::Builder::new().name("remote-shell-in".into()).spawn(move || {
83+ // Owns the PTY: it ends, and the shell with it, once the
84+ // shell is dropped.
85+ while let Ok(input) = inbox.recv() {
86+ match input {
87+ Input::Bytes(bytes) => {
88+ if write_all(&writer, &bytes).is_err() {
89+ break;
90+ }
91+ }
92+ Input::Resize(columns, lines) => pty.on_resize(window_size(columns, lines)),
93+ }
94+ }
95+ drop(pty);
96+ })?;
97+ Ok(Self { input, stop })
98+ }
99+
100+ /// Sends bytes to the shell, as if typed.
101+ pub fn write(&self, bytes: Vec<u8>) {
102+ if !bytes.is_empty() {
103+ let _ = self.input.send(Input::Bytes(bytes));
104+ }
105+ }
106+
107+ pub fn resize(&self, columns: u16, lines: u16) {
108+ let _ = self.input.send(Input::Resize(columns, lines));
109+ }
110+ }
111+
112+ impl Drop for HostShell {
113+ fn drop(&mut self) {
114+ // The reader stops without reporting an exit; the writer's inbox
115+ // closes with this, and it hangs up.
116+ self.stop.store(true, Ordering::Relaxed);
117+ }
118+ }
119+
120+ fn window_size(columns: u16, lines: u16) -> WindowSize {
121+ WindowSize { num_lines: lines.max(1), num_cols: columns.max(2), cell_width: 8, cell_height: 17 }
122+ }
123+
124+ /// Waits until the PTY is ready for `events`, or the wait runs out.
125+ fn ready(file: &File, events: i16, timeout_ms: i32) -> io::Result<bool> {
126+ let mut poll = libc::pollfd { fd: file.as_raw_fd(), events, revents: 0 };
127+ // SAFETY: one valid pollfd for the duration of the call.
128+ let result = unsafe { libc::poll(&mut poll, 1, timeout_ms) };
129+ if result < 0 {
130+ let err = io::Error::last_os_error();
131+ return if err.kind() == ErrorKind::Interrupted { Ok(false) } else { Err(err) };
132+ }
133+ Ok(result > 0)
134+ }
135+
136+ /// Passes the shell's output on until it exits (the PTY then reads as
137+ /// closed) or the shell is dropped.
138+ fn read_output(mut file: &File, stop: &AtomicBool, on_event: &impl Fn(ShellEvent)) {
139+ let mut buf = vec![0u8; CHUNK];
140+ while !stop.load(Ordering::Relaxed) {
141+ match ready(file, libc::POLLIN, WAIT_MS) {
142+ Ok(true) => {}
143+ Ok(false) => continue,
144+ Err(_) => return,
145+ }
146+ let mut filled = 0;
147+ // Take what's there now in one message, not one per read.
148+ loop {
149+ match file.read(&mut buf[filled..]) {
150+ Ok(0) => break,
151+ Ok(n) => filled += n,
152+ Err(err) if matches!(err.kind(), ErrorKind::WouldBlock | ErrorKind::Interrupted) => break,
153+ // EIO: the shell is gone.
154+ Err(_) if filled == 0 => return,
155+ Err(_) => break,
156+ }
157+ if filled == buf.len() || !matches!(ready(file, libc::POLLIN, 0), Ok(true)) {
158+ break;
159+ }
160+ }
161+ if filled == 0 {
162+ // Readable but empty: closed.
163+ return;
164+ }
165+ on_event(ShellEvent::Output(buf[..filled].to_vec()));
166+ }
167+ }
168+
169+ /// Writes everything to the non-blocking PTY, waiting while it's full.
170+ fn write_all(mut file: &File, mut bytes: &[u8]) -> io::Result<()> {
171+ while !bytes.is_empty() {
172+ match file.write(bytes) {
173+ Ok(0) => return Err(ErrorKind::WriteZero.into()),
174+ Ok(n) => bytes = &bytes[n..],
175+ Err(err) if err.kind() == ErrorKind::WouldBlock => {
176+ ready(file, libc::POLLOUT, WAIT_MS)?;
177+ }
178+ Err(err) if err.kind() == ErrorKind::Interrupted => {}
179+ Err(err) => return Err(err),
180+ }
181+ }
182+ Ok(())
183+ }
184+ }
185+
186+ /// Elsewhere, clients' terminals don't start.
187+ #[cfg(not(unix))]
188+ pub struct HostShell;
189+
190+ #[cfg(not(unix))]
191+ impl HostShell {
192+ pub fn spawn(_: &Path, _: u16, _: u16, _: impl Fn(ShellEvent) + Send + 'static) -> std::io::Result<Self> {
193+ Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "terminals for other devices need Linux or macOS"))
194+ }
195+
196+ pub fn write(&self, _: Vec<u8>) {}
197+
198+ pub fn resize(&self, _: u16, _: u16) {}
199+ }
200+
201+ #[cfg(all(test, unix))]
202+ mod tests {
203+ use std::time::{Duration, Instant};
204+
205+ use super::{HostShell, ShellEvent};
206+
207+ #[test]
208+ fn runs_a_shell_and_reports_its_exit() {
209+ let dir = std::env::temp_dir();
210+ let (events, received) = std::sync::mpsc::channel();
211+ // A plain shell, not the user's with its startup files.
212+ let mut options = crate::terminal::shell_options(&dir);
213+ options.shell = Some(alacritty_terminal::tty::Shell::new("/bin/sh".into(), Vec::new()));
214+ let shell = HostShell::spawn_with(options, 80, 24, move |event| {
215+ let _ = events.send(event);
216+ })
217+ .unwrap();
218+ // Quoted apart, so the echoed command line doesn't match.
219+ shell.write(b"echo vectrum\"\"-ok; exit\n".to_vec());
220+ let deadline = Instant::now() + Duration::from_secs(10);
221+ let mut output = Vec::new();
222+ let mut exited = false;
223+ while Instant::now() < deadline && !exited {
224+ match received.recv_timeout(Duration::from_millis(200)) {
225+ Ok(ShellEvent::Output(bytes)) => output.extend(bytes),
226+ Ok(ShellEvent::Exited) => exited = true,
227+ Err(_) => {}
228+ }
229+ }
230+ let output = String::from_utf8_lossy(&output);
231+ assert!(output.contains("vectrum-ok"), "output: {output:?}");
232+ assert!(exited, "the shell's exit wasn't reported");
233+ }
234+ }
+103−21src/terminal.rsView File
@@ -1,11 +1,14 @@
11 //! A shell in a pseudo-terminal, emulated by `alacritty_terminal`: the PTY,
22 //! 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.
3+ //! bytes keys and pastes send. `ui::terminal` draws the grid. A terminal can
4+ //! also show a shell on another device: its output arrives over the remote
5+ //! connection and is parsed here, and what's typed goes back the same way.
46
57 use std::borrow::Cow;
68 use std::collections::HashMap;
79 use std::path::Path;
810 use std::sync::Arc;
11+ use std::time::Instant;
912
1013 use alacritty_terminal::event::{Event, EventListener, WindowSize};
1114 use alacritty_terminal::event_loop::{EventLoop, EventLoopSender, Msg};
@@ -13,6 +16,7 @@ use alacritty_terminal::grid::Dimensions;
1316 use alacritty_terminal::sync::FairMutex;
1417 use alacritty_terminal::term::{self, Term};
1518 use alacritty_terminal::tty;
19+ use alacritty_terminal::vte::ansi;
1620 use gpui_kit::Keystroke;
1721
1822 /// How many lines a terminal keeps above the screen.
@@ -53,33 +57,89 @@ impl Dimensions for GridSize {
5357 /// A running shell and its screen.
5458 pub struct Terminal {
5559 term: Arc<FairMutex<Term<Listener>>>,
56- sender: EventLoopSender,
60+ backend: Backend,
5761 size: GridSize,
5862 }
5963
64+ /// Where the shell runs.
65+ enum Backend {
66+ /// Here, in a PTY whose reader thread feeds the screen.
67+ Local(EventLoopSender),
68+ /// On another device, which sends its output to [`Terminal::feed`].
69+ Remote { link: Box<dyn Fn(RemoteRequest)>, parser: Box<ansi::Processor> },
70+ }
71+
72+ /// What a terminal on another device asks of the connection.
73+ pub enum RemoteRequest {
74+ Input(Vec<u8>),
75+ Resize { columns: u16, lines: u16 },
76+ /// The terminal is gone; hang up on the shell.
77+ Close,
78+ }
79+
80+ /// The shell, its folder and the environment it gets.
81+ // Windows has a field more.
82+ #[allow(clippy::needless_update)]
83+ pub fn shell_options(cwd: &Path) -> tty::Options {
84+ tty::Options {
85+ shell: None,
86+ working_directory: Some(cwd.to_path_buf()),
87+ drain_on_exit: false,
88+ env: HashMap::from([
89+ ("TERM".to_string(), "xterm-256color".to_string()),
90+ ("COLORTERM".to_string(), "truecolor".to_string()),
91+ ("TERM_PROGRAM".to_string(), "vectrum-code".to_string()),
92+ ]),
93+ ..Default::default()
94+ }
95+ }
96+
6097 impl Terminal {
6198 /// Starts the user's shell in `cwd`, with a screen of `size` cells of
6299 /// `cell` pixels each. Its events arrive on the returned channel.
63100 pub fn spawn(cwd: &Path, size: GridSize, cell: (u16, u16)) -> std::io::Result<(Self, smol::channel::Receiver<Event>)> {
64101 let (events, receiver) = smol::channel::unbounded();
65102 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)?;
103+ let term = Arc::new(FairMutex::new(Term::new(config(), &size, listener.clone())));
104+ let pty = tty::new(&shell_options(cwd), window_size(size, cell), 0)?;
79105 let event_loop = EventLoop::new(term.clone(), listener, pty, false, false)?;
80106 let sender = event_loop.channel();
81107 event_loop.spawn();
82- Ok((Self { term, sender, size }, receiver))
108+ Ok((Self { term, backend: Backend::Local(sender), size }, receiver))
109+ }
110+
111+ /// A screen for a shell on another device, which `link` talks to. The
112+ /// caller has asked for the shell, at `size`, already.
113+ pub fn remote(size: GridSize, link: impl Fn(RemoteRequest) + 'static) -> (Self, smol::channel::Receiver<Event>) {
114+ let (events, receiver) = smol::channel::unbounded();
115+ let term = Arc::new(FairMutex::new(Term::new(config(), &size, Listener(events))));
116+ let backend = Backend::Remote { link: Box::new(link), parser: Box::default() };
117+ (Self { term, backend, size }, receiver)
118+ }
119+
120+ /// Takes output from a shell on another device.
121+ pub fn feed(&mut self, bytes: &[u8]) {
122+ if let Backend::Remote { parser, .. } = &mut self.backend {
123+ parser.advance(&mut *self.term.lock(), bytes);
124+ }
125+ }
126+
127+ /// When a synchronized update the shell started has to be shown even
128+ /// though it hasn't ended it.
129+ pub fn sync_deadline(&self) -> Option<Instant> {
130+ match &self.backend {
131+ Backend::Remote { parser, .. } => parser.sync_timeout().sync_timeout(),
132+ Backend::Local(_) => None,
133+ }
134+ }
135+
136+ /// Shows a synchronized update that ran past its deadline.
137+ pub fn end_sync(&mut self) {
138+ if let Backend::Remote { parser, .. } = &mut self.backend
139+ && parser.sync_timeout().sync_timeout().is_some_and(|deadline| deadline <= Instant::now())
140+ {
141+ parser.stop_sync(&mut *self.term.lock());
142+ }
83143 }
84144
85145 /// Runs `f` with the screen locked.
@@ -90,8 +150,14 @@ impl Terminal {
90150 /// Sends bytes to the shell, as if typed.
91151 pub fn write(&self, bytes: impl Into<Cow<'static, [u8]>>) {
92152 let bytes = bytes.into();
93- if !bytes.is_empty() {
94- let _ = self.sender.send(Msg::Input(bytes));
153+ if bytes.is_empty() {
154+ return;
155+ }
156+ match &self.backend {
157+ Backend::Local(sender) => {
158+ let _ = sender.send(Msg::Input(bytes));
159+ }
160+ Backend::Remote { link, .. } => link(RemoteRequest::Input(bytes.into_owned())),
95161 }
96162 }
97163
@@ -112,17 +178,33 @@ impl Terminal {
112178 }
113179 self.size = size;
114180 self.term.lock().resize(size);
115- let _ = self.sender.send(Msg::Resize(window_size(size, cell)));
181+ let window = window_size(size, cell);
182+ match &self.backend {
183+ Backend::Local(sender) => {
184+ let _ = sender.send(Msg::Resize(window));
185+ }
186+ Backend::Remote { link, .. } => link(RemoteRequest::Resize { columns: window.num_cols, lines: window.num_lines }),
187+ }
116188 }
117189 }
118190
119191 impl Drop for Terminal {
120- /// Stops the reader thread, which hangs up on the shell as it goes.
192+ /// Stops the reader thread, which hangs up on the shell as it goes, or
193+ /// asks the other device to hang up.
121194 fn drop(&mut self) {
122- let _ = self.sender.send(Msg::Shutdown);
195+ match &self.backend {
196+ Backend::Local(sender) => {
197+ let _ = sender.send(Msg::Shutdown);
198+ }
199+ Backend::Remote { link, .. } => link(RemoteRequest::Close),
200+ }
123201 }
124202 }
125203
204+ fn config() -> term::Config {
205+ term::Config { scrolling_history: SCROLLBACK, ..Default::default() }
206+ }
207+
126208 fn window_size(size: GridSize, (cell_width, cell_height): (u16, u16)) -> WindowSize {
127209 WindowSize {
128210 num_lines: size.lines.min(u16::MAX as usize) as u16,
+103−5src/ui/terminal.rsView File
@@ -8,6 +8,7 @@ use std::cell::Cell;
88 use std::ops::Range;
99 use std::path::Path;
1010 use std::rc::Rc;
11+ use std::time::{Duration, Instant};
1112
1213 use alacritty_terminal::event::Event;
1314 use alacritty_terminal::grid::{Dimensions as _, Scroll};
@@ -20,7 +21,7 @@ use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenu, PopupMenuItem};
2021 use gpui_kit::prelude::FluentBuilder as _;
2122 use gpui_kit::*;
2223
23- use crate::terminal::{GridSize, Terminal, key_bytes};
24+ use crate::terminal::{GridSize, RemoteRequest, Terminal, key_bytes};
2425 use crate::ui::metal::{self, Palette};
2526 use crate::{TerminalCopy, TerminalPaste};
2627
@@ -29,6 +30,10 @@ const LINE_HEIGHT: f32 = 17.;
2930 /// The space between the drawer's edges and the grid.
3031 const PAD_X: f32 = 10.;
3132 const PAD_Y: f32 = 6.;
33+ /// The screen a shell starts with, until the drawer lays it out.
34+ pub const INITIAL_SIZE: GridSize = GridSize { columns: 80, lines: 24 };
35+ /// How long a remote shell has to say something before it's given up on.
36+ const REMOTE_ANSWER_TIMEOUT: Duration = Duration::from_secs(15);
3237
3338 /// What a terminal tells the drawer.
3439 pub enum TerminalEvent {
@@ -54,6 +59,10 @@ pub struct TerminalView {
5459 /// Wheel movement short of a whole line, kept for the next event.
5560 scroll_remainder: f32,
5661 _events: Task<()>,
62+ /// Waits out a remote shell's synchronized update.
63+ sync_timer: Option<Task<()>>,
64+ /// Gives up on a remote shell that never says anything.
65+ answer_timer: Option<Task<()>>,
5766 }
5867
5968 impl EventEmitter<TerminalEvent> for TerminalView {}
@@ -68,8 +77,43 @@ impl TerminalView {
6877 /// Starts a shell in `cwd`.
6978 pub fn new(cwd: &Path, label: impl Into<SharedString>, window: &mut Window, cx: &mut Context<Self>) -> Self {
7079 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)) {
80+ let started = Terminal::spawn(cwd, INITIAL_SIZE, cell_pixels(cell_width))
81+ .map_err(|err| SharedString::from(format!("Couldn't start the shell: {err}")));
82+ Self::with_terminal(started, label.into(), cell_width, window, cx)
83+ }
84+
85+ /// A terminal whose shell runs on another device, reached through
86+ /// `link`. The shell was asked for at [`INITIAL_SIZE`]; its output
87+ /// arrives in [`Self::receive`].
88+ pub fn remote(
89+ label: impl Into<SharedString>,
90+ link: impl Fn(RemoteRequest) + 'static,
91+ window: &mut Window,
92+ cx: &mut Context<Self>,
93+ ) -> Self {
94+ let cell_width = cell_width(window);
95+ let started = Ok(Terminal::remote(INITIAL_SIZE, link));
96+ let mut view = Self::with_terminal(started, label.into(), cell_width, window, cx);
97+ // A shell prints its prompt at once; a device too old to start one
98+ // never answers.
99+ view.answer_timer = Some(cx.spawn(async move |this, cx| {
100+ cx.background_executor().timer(REMOTE_ANSWER_TIMEOUT).await;
101+ let _ = this.update(cx, |this, cx| {
102+ this.answer_timer = None;
103+ this.stop("The other device didn't start a shell. Update Vectrum Code there, then try again.", cx);
104+ });
105+ }));
106+ view
107+ }
108+
109+ fn with_terminal(
110+ started: Result<(Terminal, smol::channel::Receiver<Event>), SharedString>,
111+ label: SharedString,
112+ cell_width: Pixels,
113+ window: &mut Window,
114+ cx: &mut Context<Self>,
115+ ) -> Self {
116+ let (terminal, error, events) = match started {
73117 Ok((terminal, events)) => {
74118 let task = cx.spawn_in(window, async move |this, cx| {
75119 while let Ok(event) = events.recv().await {
@@ -84,12 +128,12 @@ impl TerminalView {
84128 });
85129 (Some(terminal), None, task)
86130 }
87- Err(err) => (None, Some(format!("Couldn't start the shell: {err}").into()), Task::ready(())),
131+ Err(error) => (None, Some(error), Task::ready(())),
88132 };
89133 Self {
90134 terminal,
91135 error,
92- label: label.into(),
136+ label,
93137 focus_handle: cx.focus_handle(),
94138 exited: false,
95139 grid_bounds: Rc::default(),
@@ -97,9 +141,63 @@ impl TerminalView {
97141 selecting: false,
98142 scroll_remainder: 0.,
99143 _events: events,
144+ sync_timer: None,
145+ answer_timer: None,
100146 }
101147 }
102148
149+ /// Output from a shell on another device.
150+ pub fn receive(&mut self, bytes: &[u8], cx: &mut Context<Self>) {
151+ let Some(terminal) = self.terminal.as_mut() else { return };
152+ self.answer_timer = None;
153+ terminal.feed(bytes);
154+ self.arm_sync_timer(cx);
155+ cx.notify();
156+ }
157+
158+ /// A synchronized update the remote shell doesn't end in time shows
159+ /// anyway, as a local one's does.
160+ fn arm_sync_timer(&mut self, cx: &mut Context<Self>) {
161+ if self.sync_timer.is_some() {
162+ return;
163+ }
164+ let Some(deadline) = self.terminal.as_ref().and_then(Terminal::sync_deadline) else { return };
165+ self.sync_timer = Some(cx.spawn(async move |this, cx| {
166+ cx.background_executor().timer(deadline.saturating_duration_since(Instant::now())).await;
167+ let _ = this.update(cx, |this, cx| {
168+ this.sync_timer = None;
169+ if let Some(terminal) = this.terminal.as_mut() {
170+ terminal.end_sync();
171+ }
172+ // It may have been extended meanwhile.
173+ this.arm_sync_timer(cx);
174+ cx.notify();
175+ });
176+ }));
177+ }
178+
179+ /// The shell on another device exited; without an `error`, the terminal
180+ /// closes as a local one would.
181+ pub fn remote_exited(&mut self, error: Option<String>, cx: &mut Context<Self>) {
182+ match error {
183+ Some(error) => self.stop(error, cx),
184+ None if !self.exited => {
185+ self.exited = true;
186+ cx.emit(TerminalEvent::Exited);
187+ }
188+ None => {}
189+ }
190+ }
191+
192+ /// Stops showing the shell, saying why.
193+ pub fn stop(&mut self, error: impl Into<SharedString>, cx: &mut Context<Self>) {
194+ self.terminal = None;
195+ self.sync_timer = None;
196+ self.answer_timer = None;
197+ self.error = Some(error.into());
198+ cx.notify();
199+ }
200+
103201 fn handle_events(&mut self, events: Vec<Event>, cx: &mut Context<Self>) {
104202 let Some(terminal) = self.terminal.as_ref() else { return };
105203 for event in events {
+1−1src/ui/workspace/header.rsView File
@@ -265,7 +265,7 @@ impl Workspace {
265265 .on_click(cx.listener(|this, _, window, cx| this.toggle_terminal(window, cx)))
266266 .into_any_element()
267267 } else {
268- // A thread on another device has no folder here to start in.
268+ // Its folder is gone, or its device isn't connected.
269269 div()
270270 .id("terminal-drawer-unavailable")
271271 .tooltip(|window, cx| Tooltip::new("Terminal drawer is unavailable").build(window, cx))
+146−0src/ui/workspace/remote.rsView File
@@ -7,9 +7,11 @@ use std::{
77 hash::{Hash as _, Hasher as _},
88 path::PathBuf,
99 rc::Rc,
10+ sync::atomic::{AtomicU64, Ordering},
1011 time::{Duration, SystemTime},
1112 };
1213
14+ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
1315 use gpui_kit::component::{WindowExt as _, notification::Notification};
1416 use gpui_kit::*;
1517
@@ -21,14 +23,17 @@ use crate::remote::{
2123 client::{ClientEvent, ConnectionStatus, RemoteClient},
2224 new_key,
2325 server::{Peer, RemoteServer, ServerEvent},
26+ shell::{HostShell, ShellEvent},
2427 };
2528 use crate::session::{
2629 NewSession, PermissionDecision, Reasoning, RemoteLink, Session, from_millis, store_uploads,
2730 };
2831 use crate::projects;
2932 use crate::settings::{self, RemoteHost, Settings, SettingsPatch, WorkspaceMode};
33+ use crate::terminal::RemoteRequest;
3034 use crate::ui::folder_browser;
3135 use crate::ui::metal::MetalButton;
36+ use crate::ui::terminal::TerminalView;
3237
3338 /// How often changed threads are sent to subscribed clients.
3439 pub(super) const FLUSH: Duration = Duration::from_millis(150);
@@ -61,6 +66,11 @@ pub(super) struct RemoteState {
6166 /// The environment settings clients were last sent.
6267 last_settings: String,
6368 pub(super) environments: Vec<Environment>,
69+ /// Shells clients run here, by connection and the client's id for them.
70+ shells: HashMap<(u64, u64), HostShell>,
71+ /// This device's terminals whose shells run on another device, by the
72+ /// connection's id and the terminal's id there.
73+ pub(super) remote_terminals: HashMap<(String, u64), WeakEntity<TerminalView>>,
6474 _server_events: Option<Task<()>>,
6575 }
6676
@@ -104,6 +114,7 @@ impl Workspace {
104114 self.remote._server_events = None;
105115 self.remote.peers.clear();
106116 self.remote.subscriptions.clear();
117+ self.remote.shells.clear();
107118 self.remote.server_error = None;
108119 self.remote.server_config = wanted.clone();
109120 if let Some((port, key)) = wanted {
@@ -167,6 +178,7 @@ impl Workspace {
167178
168179 /// Disconnects from a device and takes its projects and threads away.
169180 fn drop_environment(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
181+ self.remote.remote_terminals.retain(|(env, _), _| env != id);
170182 self.remote.environments.retain(|env| env.host.id != id);
171183 let removed: Vec<Entity<Session>> = self
172184 .projects
@@ -352,6 +364,7 @@ impl Workspace {
352364 }
353365 self.remote.peers.retain(|peer| peer.conn != conn);
354366 self.remote.subscriptions.remove(&conn);
367+ self.remote.shells.retain(|(owner, _), _| *owner != conn);
355368 cx.notify();
356369 }
357370
@@ -418,6 +431,7 @@ impl Workspace {
418431 ServerEvent::Disconnected { conn } => {
419432 self.remote.peers.retain(|peer| peer.conn != conn);
420433 self.remote.subscriptions.remove(&conn);
434+ self.remote.shells.retain(|(owner, _), _| *owner != conn);
421435 cx.notify();
422436 }
423437 ServerEvent::Message { conn, message } => self.on_client_message(conn, message, window, cx),
@@ -524,6 +538,60 @@ impl Workspace {
524538 })
525539 .detach();
526540 }
541+ ClientMessage::TerminalOpen { terminal, thread, project, columns, lines } => {
542+ self.open_host_shell(conn, terminal, thread, project, columns, lines, cx)
543+ }
544+ ClientMessage::TerminalInput { terminal, data } => {
545+ if let (Some(shell), Ok(bytes)) = (self.remote.shells.get(&(conn, terminal)), BASE64.decode(data)) {
546+ shell.write(bytes);
547+ }
548+ }
549+ ClientMessage::TerminalResize { terminal, columns, lines } => {
550+ if let Some(shell) = self.remote.shells.get(&(conn, terminal)) {
551+ shell.resize(columns, lines);
552+ }
553+ }
554+ ClientMessage::TerminalClose { terminal } => {
555+ self.remote.shells.remove(&(conn, terminal));
556+ }
557+ }
558+ }
559+
560+ /// Starts a shell for a client's terminal, in the thread's folder here.
561+ #[allow(clippy::too_many_arguments)]
562+ fn open_host_shell(
563+ &mut self,
564+ conn: u64,
565+ terminal: u64,
566+ thread: Option<String>,
567+ project: PathBuf,
568+ columns: u16,
569+ lines: u16,
570+ cx: &mut Context<Self>,
571+ ) {
572+ let Some(server) = &self.remote.server else { return };
573+ let sender = server.sender(conn);
574+ let exited = |error: String| ServerMessage::TerminalExited { terminal, error: Some(error) };
575+ let is_project = self.projects.iter().any(|candidate| candidate.environment.is_none() && candidate.path == project);
576+ let cwd = match thread.as_deref().map(|id| self.local_thread(id, cx)) {
577+ Some(Some((_, session))) => session.read(cx).cwd.clone(),
578+ Some(None) => return sender.send(&exited("That thread is gone.".into())),
579+ None if is_project => project,
580+ None => return sender.send(&exited(format!("{} isn't a project here.", project.display()))),
581+ };
582+ if !cwd.is_dir() {
583+ return sender.send(&exited(format!("{} isn't there anymore.", cwd.display())));
584+ }
585+ let output = sender.clone();
586+ let started = HostShell::spawn(&cwd, columns, lines, move |event| match event {
587+ ShellEvent::Output(bytes) => output.send(&ServerMessage::TerminalOutput { terminal, data: BASE64.encode(bytes) }),
588+ ShellEvent::Exited => output.send(&ServerMessage::TerminalExited { terminal, error: None }),
589+ });
590+ match started {
591+ Ok(shell) => {
592+ self.remote.shells.insert((conn, terminal), shell);
593+ }
594+ Err(err) => sender.send(&exited(format!("Couldn't start the shell: {err}"))),
527595 }
528596 }
529597
@@ -642,6 +710,10 @@ impl Workspace {
642710 match event {
643711 ClientEvent::Status(status) => {
644712 let connected = matches!(status, ConnectionStatus::Connected(_));
713+ if !connected {
714+ // A shell there ends with the connection.
715+ self.stop_remote_terminals(env, "The connection to the other device dropped; this terminal's shell ended.", cx);
716+ }
645717 if let Some(environment) = self.remote.environments.iter_mut().find(|candidate| candidate.host.id == env) {
646718 environment.status = status;
647719 }
@@ -700,6 +772,80 @@ impl Workspace {
700772 self.new_thread(ix, window, cx);
701773 }
702774 ClientEvent::Message(ServerMessage::Welcome { .. } | ServerMessage::Denied { .. }) => {}
775+ ClientEvent::Message(ServerMessage::TerminalOutput { terminal, data }) => {
776+ let view = self.remote.remote_terminals.get(&(env.to_string(), terminal)).and_then(WeakEntity::upgrade);
777+ match (view, BASE64.decode(data)) {
778+ (Some(view), Ok(bytes)) => view.update(cx, |view, cx| view.receive(&bytes, cx)),
779+ (None, _) => {
780+ // A terminal closed here whose shell still talks.
781+ self.remote.remote_terminals.remove(&(env.to_string(), terminal));
782+ self.send_to_environment(env, &ClientMessage::TerminalClose { terminal });
783+ }
784+ _ => {}
785+ }
786+ }
787+ ClientEvent::Message(ServerMessage::TerminalExited { terminal, error }) => {
788+ if let Some(view) = self.remote.remote_terminals.remove(&(env.to_string(), terminal)).and_then(|view| view.upgrade()) {
789+ view.update(cx, |view, cx| view.remote_exited(error, cx));
790+ }
791+ }
792+ }
793+ }
794+
795+ fn send_to_environment(&self, env: &str, message: &ClientMessage) {
796+ if let Some(environment) = self.remote.environments.iter().find(|candidate| candidate.host.id == env) {
797+ environment.client.send(message);
798+ }
799+ }
800+
801+ /// A terminal for a thread on another device, whose shell starts there
802+ /// in the thread's folder. `None` while the device isn't connected.
803+ pub(super) fn open_remote_terminal(
804+ &mut self,
805+ session: &Entity<Session>,
806+ label: String,
807+ window: &mut Window,
808+ cx: &mut Context<Self>,
809+ ) -> Option<Entity<TerminalView>> {
810+ static NEXT_TERMINAL: AtomicU64 = AtomicU64::new(1);
811+ let (env, thread, project) = {
812+ let session = session.read(cx);
813+ let remote = session.remote.as_ref()?;
814+ (remote.env.clone(), remote.thread.clone(), session.project.clone())
815+ };
816+ let environment = self.remote.environments.iter().find(|candidate| candidate.host.id == env)?;
817+ if !matches!(environment.status, ConnectionStatus::Connected(_)) {
818+ return None;
819+ }
820+ let client = environment.client.clone();
821+ let terminal = NEXT_TERMINAL.fetch_add(1, Ordering::Relaxed);
822+ let size = crate::ui::terminal::INITIAL_SIZE;
823+ client.send(&ClientMessage::TerminalOpen {
824+ terminal,
825+ thread,
826+ project,
827+ columns: size.columns as u16,
828+ lines: size.lines as u16,
829+ });
830+ let link = move |request: RemoteRequest| {
831+ client.send(&match request {
832+ RemoteRequest::Input(bytes) => ClientMessage::TerminalInput { terminal, data: BASE64.encode(bytes) },
833+ RemoteRequest::Resize { columns, lines } => ClientMessage::TerminalResize { terminal, columns, lines },
834+ RemoteRequest::Close => ClientMessage::TerminalClose { terminal },
835+ });
836+ };
837+ let view = cx.new(|cx| TerminalView::remote(label, link, window, cx));
838+ self.remote.remote_terminals.insert((env, terminal), view.downgrade());
839+ Some(view)
840+ }
841+
842+ /// Stops a device's terminals, whose shells are out of reach now.
843+ fn stop_remote_terminals(&mut self, env: &str, reason: &str, cx: &mut Context<Self>) {
844+ let ids: Vec<(String, u64)> = self.remote.remote_terminals.keys().filter(|(owner, _)| owner == env).cloned().collect();
845+ for id in ids {
846+ if let Some(view) = self.remote.remote_terminals.remove(&id).and_then(|view| view.upgrade()) {
847+ view.update(cx, |view, cx| view.stop(reason.to_string(), cx));
848+ }
703849 }
704850 }
705851
+33−6src/ui/workspace/terminal_drawer.rsView File
@@ -1,6 +1,7 @@
11 //! T3 Code's terminal drawer (ThreadTerminalDrawer): shells under the
22 //! 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
3+ //! when it has one), on the device the thread runs on. Ctrl+J shows and
4+ //! hides it; its top edge drags. One
45 //! terminal fills it; splits lay a group out side by side or stacked; with
56 //! more than one terminal a sidebar lists the groups. A terminal whose shell
67 //! exits closes, and closing the last one closes the drawer.
@@ -15,6 +16,7 @@ use gpui_kit::*;
1516
1617 use super::surfaces::{Surface, TERMINAL_PANEL_WIDTH};
1718 use super::{Workspace, actions};
19+ use crate::remote::client::ConnectionStatus;
1820 use crate::ui::icons::Lucide;
1921 use crate::ui::metal::{self, MetalButton, Palette};
2022 use crate::ui::terminal::{TerminalEvent, TerminalView};
@@ -149,7 +151,7 @@ impl TerminalDrawer {
149151
150152 impl Workspace {
151153 /// Where the open thread's terminals start: its folder on this device.
152- /// `None` for threads on other devices.
154+ /// `None` for threads on other devices, whose shells start there.
153155 fn terminal_cwd(&self, cx: &App) -> Option<PathBuf> {
154156 let session = self.active.as_ref()?.read(cx);
155157 let project = self.active_project()?;
@@ -159,9 +161,20 @@ impl Workspace {
159161 Some(session.cwd.clone()).filter(|cwd| cwd.is_dir())
160162 }
161163
164+ /// The open thread runs on another device that's connected now, so its
165+ /// terminals' shells can start there.
166+ fn remote_terminal_available(&self, cx: &App) -> bool {
167+ let Some(session) = self.active.as_ref() else { return false };
168+ let Some(env) = session.read(cx).remote.as_ref().map(|remote| remote.env.clone()) else { return false };
169+ self.remote
170+ .environments
171+ .iter()
172+ .any(|candidate| candidate.host.id == env && matches!(candidate.status, ConnectionStatus::Connected(_)))
173+ }
174+
162175 /// Whether the open thread can have terminals.
163176 pub(super) fn terminal_available(&self, cx: &App) -> bool {
164- self.terminal_cwd(cx).is_some()
177+ self.terminal_cwd(cx).is_some() || self.remote_terminal_available(cx)
165178 }
166179
167180 /// Whether the open thread's drawer shows.
@@ -240,10 +253,24 @@ impl Workspace {
240253 self.start_terminal(session, id, window, cx);
241254 }
242255
243- /// Starts terminal `id`'s shell and focuses it.
256+ /// Starts terminal `id`'s shell, here or on the thread's device, and
257+ /// focuses it.
244258 fn start_terminal(&mut self, session: EntityId, id: u32, window: &mut Window, cx: &mut Context<Self>) {
245- let Some(cwd) = self.terminal_cwd(cx) else { return };
246- let view = cx.new(|cx| TerminalView::new(&cwd, label(id), window, cx));
259+ let view = match self.terminal_cwd(cx) {
260+ Some(cwd) => Some(cx.new(|cx| TerminalView::new(&cwd, label(id), window, cx))),
261+ None => self
262+ .active
263+ .clone()
264+ .filter(|active| active.entity_id() == session)
265+ .and_then(|active| self.open_remote_terminal(&active, label(id), window, cx)),
266+ };
267+ let Some(view) = view else {
268+ // Nothing to show in the place the layout made for it.
269+ if let Some(drawer) = self.terminals.get_mut(&session) {
270+ drawer.layout.close(id);
271+ }
272+ return;
273+ };
247274 let subscription = cx.subscribe_in(&view, window, move |this, _, event: &TerminalEvent, window, cx| match event {
248275 TerminalEvent::Exited => this.close_terminal(session, id, false, window, cx),
249276 TerminalEvent::AddToChat(text) => {