Vectrum
Sign In

Diff view: hide whitespace, foldable files, line numbers, side-by-side layout; remote protocol groundwork

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

Showing 11 changed files with 1647 additions and 142 deletions.

+34−0Cargo.lockView File
@@ -1313,6 +1313,12 @@ dependencies = [
13131313 "linktime-proc-macro",
13141314 ]
13151315
1316+ [[package]]
1317+ name = "data-encoding"
1318+ version = "2.11.1"
1319+ source = "registry+https://github.com/rust-lang/crates.io-index"
1320+ checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
1321+
13161322 [[package]]
13171323 name = "data-url"
13181324 version = "0.3.2"
@@ -6082,6 +6088,17 @@ dependencies = [
60826088 "serde",
60836089 ]
60846090
6091+ [[package]]
6092+ name = "sha1"
6093+ version = "0.11.0"
6094+ source = "registry+https://github.com/rust-lang/crates.io-index"
6095+ checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
6096+ dependencies = [
6097+ "cfg-if",
6098+ "cpufeatures 0.3.1",
6099+ "digest 0.11.3",
6100+ ]
6101+
60856102 [[package]]
60866103 name = "sha1_smol"
60876104 version = "1.0.1"
@@ -7025,6 +7042,22 @@ dependencies = [
70257042 "core_maths",
70267043 ]
70277044
7045+ [[package]]
7046+ name = "tungstenite"
7047+ version = "0.30.0"
7048+ source = "registry+https://github.com/rust-lang/crates.io-index"
7049+ checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22"
7050+ dependencies = [
7051+ "bytes",
7052+ "data-encoding",
7053+ "http",
7054+ "httparse",
7055+ "log",
7056+ "rand 0.10.3",
7057+ "sha1",
7058+ "thiserror 2.0.21",
7059+ ]
7060+
70287061 [[package]]
70297062 name = "typeid"
70307063 version = "1.0.3"
@@ -7303,6 +7336,7 @@ dependencies = [
73037336 "serde",
73047337 "serde_json",
73057338 "smol",
7339+ "tungstenite",
73067340 "ureq",
73077341 ]
73087342
+1−0Cargo.tomlView File
@@ -13,6 +13,7 @@ libc = "0.2"
1313 smol = "2"
1414 base64 = "0.22"
1515 ureq = { version = "2", default-features = false, features = ["tls"] }
16+ tungstenite = { version = "0.30", default-features = false, features = ["handshake"] }
1617 chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
1718
1819 [profile.dev]
+74−13src/git.rsView File
@@ -151,6 +151,10 @@ pub enum LineKind {
151151 pub struct DiffLine {
152152 pub kind: LineKind,
153153 pub text: String,
154+ /// Where the line sits in the old and the new file. Added lines have no
155+ /// old number, removed ones no new number, hunk rows neither.
156+ pub old_line: Option<u32>,
157+ pub new_line: Option<u32>,
154158 }
155159
156160 #[derive(Clone, Debug)]
@@ -161,16 +165,42 @@ pub struct FileDiff {
161165 }
162166
163167 /// The unified diff between two snapshots, optionally for one path.
164- pub fn diff_between(cwd: &Path, base: &str, head: &str, path: Option<&str>) -> Result<Vec<FileDiff>, String> {
165- let mut args = vec!["diff", "--no-color", "--no-ext-diff", "--no-renames", "-U3", base, head];
168+ /// `ignore_whitespace` leaves out whitespace-only edits, and with them the
169+ /// files that changed nothing else.
170+ pub fn diff_between(
171+ cwd: &Path,
172+ base: &str,
173+ head: &str,
174+ path: Option<&str>,
175+ ignore_whitespace: bool,
176+ ) -> Result<Vec<FileDiff>, String> {
177+ let mut args = vec!["diff", "--no-color", "--no-ext-diff", "--no-renames", "-U3"];
178+ if ignore_whitespace {
179+ args.push("--ignore-all-space");
180+ }
181+ args.extend([base, head]);
166182 if let Some(path) = path {
167183 args.extend(["--", path]);
168184 }
169- Ok(parse_diff(&git(cwd, &args)?))
185+ let mut files = parse_diff(&git(cwd, &args)?);
186+ if ignore_whitespace {
187+ // Newer gits drop such a file; older ones keep its header, hunkless.
188+ files.retain(|file| file.binary || !file.lines.is_empty());
189+ }
190+ Ok(files)
191+ }
192+
193+ /// The first old and new line of a hunk, from `@@ -a,b +c,d @@ context`.
194+ fn parse_hunk(line: &str) -> Option<(u32, u32)> {
195+ let mut ranges = line.strip_prefix("@@ ")?.split(' ');
196+ let start = |range: Option<&str>, sign: char| range?.strip_prefix(sign)?.split(',').next()?.parse().ok();
197+ Some((start(ranges.next(), '-')?, start(ranges.next(), '+')?))
170198 }
171199
172200 fn parse_diff(text: &str) -> Vec<FileDiff> {
173201 let mut files: Vec<FileDiff> = Vec::new();
202+ // The numbers the next old and new line will have.
203+ let (mut old, mut new) = (0, 0);
174204 for line in text.lines() {
175205 if let Some(rest) = line.strip_prefix("diff --git ") {
176206 // `a/path b/path`: take the new side.
@@ -179,34 +209,65 @@ fn parse_diff(text: &str) -> Vec<FileDiff> {
179209 continue;
180210 }
181211 let Some(file) = files.last_mut() else { continue };
182- let kind = if line.starts_with("@@") {
183- LineKind::Hunk
212+ let (kind, text, old_line, new_line) = if line.starts_with("@@") {
213+ (old, new) = parse_hunk(line).unwrap_or_default();
214+ (LineKind::Hunk, line, None, None)
184215 } else if line.starts_with("Binary files") {
185216 file.binary = true;
186217 continue;
187218 } else if file.lines.is_empty() && (line.starts_with("---") || line.starts_with("+++")) {
188219 continue;
189220 } else if let Some(text) = line.strip_prefix('+') {
190- file.lines.push(DiffLine { kind: LineKind::Added, text: text.to_string() });
191- continue;
221+ new += 1;
222+ (LineKind::Added, text, None, Some(new - 1))
192223 } else if let Some(text) = line.strip_prefix('-') {
193- file.lines.push(DiffLine { kind: LineKind::Removed, text: text.to_string() });
194- continue;
224+ old += 1;
225+ (LineKind::Removed, text, Some(old - 1), None)
195226 } else if let Some(text) = line.strip_prefix(' ') {
196- file.lines.push(DiffLine { kind: LineKind::Context, text: text.to_string() });
197- continue;
227+ (old, new) = (old + 1, new + 1);
228+ (LineKind::Context, text, Some(old - 1), Some(new - 1))
198229 } else {
199230 // Headers: index, mode and new/deleted file lines.
200231 continue;
201232 };
202- file.lines.push(DiffLine { kind, text: line.to_string() });
233+ file.lines.push(DiffLine { kind, text: text.to_string(), old_line, new_line });
203234 }
204235 files
205236 }
206237
207238 #[cfg(test)]
208239 mod tests {
209- use super::{LineKind, parse_diff};
240+ use super::{LineKind, parse_diff, parse_hunk};
241+
242+ #[::core::prelude::v1::test]
243+ fn parses_hunk_headers() {
244+ assert_eq!(parse_hunk("@@ -1,2 +3,4 @@ fn a() {"), Some((1, 3)));
245+ assert_eq!(parse_hunk("@@ -0,0 +1 @@"), Some((0, 1)));
246+ assert_eq!(parse_hunk("@@ -7 +7 @@"), Some((7, 7)));
247+ assert_eq!(parse_hunk("@@@ -1,2 -1,2 +1,3 @@@"), None);
248+ }
249+
250+ #[::core::prelude::v1::test]
251+ fn numbers_lines() {
252+ let diff = "diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -10,4 +12,5 @@ fn a() {\n keep\n-gone\n+one\n+two\n keep\n@@ -40 +43 @@\n-x\n+y\n";
253+ let files = parse_diff(diff);
254+ let numbers: Vec<_> = files[0].lines.iter().map(|line| (line.old_line, line.new_line)).collect();
255+ assert_eq!(
256+ numbers,
257+ [
258+ (None, None),
259+ (Some(10), Some(12)),
260+ (Some(11), None),
261+ (None, Some(13)),
262+ (None, Some(14)),
263+ (Some(12), Some(15)),
264+ (None, None),
265+ (Some(40), None),
266+ (None, Some(43)),
267+ ]
268+ );
269+ assert_eq!(files[0].lines[0].text, "@@ -10,4 +12,5 @@ fn a() {");
270+ }
210271
211272 #[::core::prelude::v1::test]
212273 fn parses_unified_diff() {
+1−0src/main.rsView File
@@ -8,6 +8,7 @@ mod clock;
88 mod git;
99 mod http;
1010 mod models;
11+ mod remote;
1112 mod session;
1213 mod settings;
1314 mod store;
+170−0src/remote/client.rsaddedView File
@@ -0,0 +1,170 @@
1+ //! The client side: a connection to another device's app on its own thread,
2+ //! reconnecting when it drops. Messages from the host reach the workspace as
3+ //! [`ClientEvent`]s.
4+
5+ use std::{
6+ io::ErrorKind,
7+ net::{TcpStream, ToSocketAddrs},
8+ sync::{
9+ Arc,
10+ atomic::{AtomicBool, Ordering},
11+ mpsc,
12+ },
13+ thread,
14+ time::Duration,
15+ };
16+
17+ use tungstenite::{Message, WebSocket};
18+
19+ use super::{ClientMessage, PROTOCOL, ServerMessage, with_port};
20+
21+ const CONNECT_TIMEOUT: Duration = Duration::from_secs(6);
22+ const POLL: Duration = Duration::from_millis(40);
23+
24+ #[derive(Clone, Debug, PartialEq)]
25+ pub enum ConnectionStatus {
26+ Connecting,
27+ /// Connected to the host of that name.
28+ Connected(String),
29+ Failed(String),
30+ /// The host refused the key; no more retries until the settings change.
31+ Denied(String),
32+ }
33+
34+ pub enum ClientEvent {
35+ Status(ConnectionStatus),
36+ Message(ServerMessage),
37+ }
38+
39+ pub struct RemoteClient {
40+ outbox: mpsc::Sender<String>,
41+ stop: Arc<AtomicBool>,
42+ pub events: smol::channel::Receiver<ClientEvent>,
43+ }
44+
45+ impl RemoteClient {
46+ /// Connects to `address` (`host` or `host:port`) and keeps reconnecting
47+ /// until dropped.
48+ pub fn connect(address: &str, key: String, name: String) -> Self {
49+ let address = with_port(address);
50+ let stop = Arc::new(AtomicBool::new(false));
51+ let (outbox, inbox) = mpsc::channel::<String>();
52+ let (events_tx, events) = smol::channel::unbounded();
53+ {
54+ let stop = stop.clone();
55+ let _ = thread::Builder::new().name("remote-client".into()).spawn(move || {
56+ let mut backoff = Duration::from_secs(2);
57+ while !stop.load(Ordering::Relaxed) {
58+ let _ = events_tx.send_blocking(ClientEvent::Status(ConnectionStatus::Connecting));
59+ match open(&address, &key, &name) {
60+ Ok((mut socket, host)) => {
61+ backoff = Duration::from_secs(2);
62+ // Whatever was asked while offline is stale now.
63+ while inbox.try_recv().is_ok() {}
64+ let _ = events_tx.send_blocking(ClientEvent::Status(ConnectionStatus::Connected(host)));
65+ let reason = run(&mut socket, &inbox, &events_tx, &stop);
66+ let _ = socket.close(None);
67+ if stop.load(Ordering::Relaxed) {
68+ break;
69+ }
70+ let _ = events_tx.send_blocking(ClientEvent::Status(ConnectionStatus::Failed(reason)));
71+ }
72+ Err(Failure::Denied(reason)) => {
73+ let _ = events_tx.send_blocking(ClientEvent::Status(ConnectionStatus::Denied(reason)));
74+ break;
75+ }
76+ Err(Failure::Other(reason)) => {
77+ let _ = events_tx.send_blocking(ClientEvent::Status(ConnectionStatus::Failed(reason)));
78+ }
79+ }
80+ // Wait before retrying, a little longer each time.
81+ let mut waited = Duration::ZERO;
82+ while waited < backoff && !stop.load(Ordering::Relaxed) {
83+ thread::sleep(Duration::from_millis(200));
84+ waited += Duration::from_millis(200);
85+ }
86+ backoff = (backoff * 2).min(Duration::from_secs(30));
87+ }
88+ });
89+ }
90+ Self { outbox, stop, events }
91+ }
92+
93+ pub fn send(&self, message: &ClientMessage) {
94+ if let Ok(text) = serde_json::to_string(message) {
95+ let _ = self.outbox.send(text);
96+ }
97+ }
98+ }
99+
100+ impl Drop for RemoteClient {
101+ fn drop(&mut self) {
102+ self.stop.store(true, Ordering::Relaxed);
103+ }
104+ }
105+
106+ enum Failure {
107+ Denied(String),
108+ Other(String),
109+ }
110+
111+ /// Connects, says hello, and waits for the welcome.
112+ fn open(address: &str, key: &str, name: &str) -> Result<(WebSocket<TcpStream>, String), Failure> {
113+ let other = |err: &dyn std::fmt::Display| Failure::Other(err.to_string());
114+ let target = address.to_socket_addrs().map_err(|err| other(&err))?.next().ok_or_else(|| other(&"no such host"))?;
115+ let stream = TcpStream::connect_timeout(&target, CONNECT_TIMEOUT).map_err(|err| other(&err))?;
116+ let _ = stream.set_nodelay(true);
117+ stream.set_read_timeout(Some(Duration::from_secs(15))).map_err(|err| other(&err))?;
118+ let (mut socket, _) = tungstenite::client(format!("ws://{address}/"), stream).map_err(|err| other(&err))?;
119+ let hello = ClientMessage::Hello { key: key.to_string(), name: name.to_string(), protocol: PROTOCOL };
120+ socket
121+ .send(Message::text(serde_json::to_string(&hello).unwrap_or_default()))
122+ .map_err(|err| other(&err))?;
123+ loop {
124+ match socket.read().map_err(|err| other(&err))? {
125+ Message::Text(text) => {
126+ return match serde_json::from_str::<ServerMessage>(text.as_str()) {
127+ Ok(ServerMessage::Welcome { host, .. }) => {
128+ let _ = socket.get_mut().set_read_timeout(Some(POLL));
129+ Ok((socket, host))
130+ }
131+ Ok(ServerMessage::Denied { reason }) => Err(Failure::Denied(reason)),
132+ _ => Err(other(&"the host answered something unexpected")),
133+ };
134+ }
135+ Message::Close(_) => return Err(Failure::Denied("The host closed the connection.".into())),
136+ _ => {}
137+ }
138+ }
139+ }
140+
141+ /// Trades messages until the connection drops; returns why it did.
142+ fn run(
143+ socket: &mut WebSocket<TcpStream>,
144+ inbox: &mpsc::Receiver<String>,
145+ events: &smol::channel::Sender<ClientEvent>,
146+ stop: &AtomicBool,
147+ ) -> String {
148+ while !stop.load(Ordering::Relaxed) {
149+ while let Ok(text) = inbox.try_recv() {
150+ if let Err(err) = socket.send(Message::text(text)) {
151+ return err.to_string();
152+ }
153+ }
154+ match socket.read() {
155+ Ok(Message::Text(text)) => match serde_json::from_str::<ServerMessage>(text.as_str()) {
156+ Ok(message) => {
157+ if events.send_blocking(ClientEvent::Message(message)).is_err() {
158+ return "closed".into();
159+ }
160+ }
161+ Err(err) => eprintln!("vectrum-code: unreadable message from host: {err}"),
162+ },
163+ Ok(Message::Close(_)) => return "The host closed the connection.".into(),
164+ Ok(_) => {}
165+ Err(tungstenite::Error::Io(err)) if matches!(err.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => {}
166+ Err(err) => return err.to_string(),
167+ }
168+ }
169+ "closed".into()
170+ }
+252−0src/remote/mod.rsaddedView File
@@ -0,0 +1,252 @@
1+ //! Remote connections, like T3 Code's environments: one copy of the app can
2+ //! let others on the network see and drive its projects and threads. The
3+ //! host runs a small WebSocket server guarded by a key; a client connects
4+ //! with the host's address and that key, and its threads show up in the
5+ //! client's sidebar under the host's name.
6+ //!
7+ //! The protocol is JSON over WebSocket text messages. A client says
8+ //! [`ClientMessage::Hello`] with the key, gets the host's projects and
9+ //! threads in [`ServerMessage::State`], subscribes to the threads it opens,
10+ //! and receives each change as a [`ServerMessage::Thread`] carrying the
11+ //! transcript from the first item that changed.
12+
13+ pub mod client;
14+ pub mod server;
15+
16+ use std::path::PathBuf;
17+
18+ use serde::{Deserialize, Serialize};
19+
20+ use crate::git::TurnChanges;
21+ use crate::models::Provider;
22+ use crate::session::{QueuedMessage, SavedItem};
23+ use crate::worktree::PullRequest;
24+
25+ /// The port a host listens on unless told otherwise.
26+ pub const DEFAULT_PORT: u16 = 47_823;
27+ /// Bumped when the messages change incompatibly.
28+ pub const PROTOCOL: u32 = 1;
29+
30+ /// A thread as the sidebar needs it.
31+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
32+ #[serde(default)]
33+ pub struct ThreadSummary {
34+ pub id: String,
35+ pub project: PathBuf,
36+ pub title: String,
37+ pub provider: Provider,
38+ pub instance: String,
39+ pub model: Option<String>,
40+ pub permission_mode: String,
41+ pub reasoning: String,
42+ pub fast_mode: bool,
43+ /// `starting`, `idle`, `working` or `exited`.
44+ pub status: String,
45+ /// A tool call or question waits for the user.
46+ pub attention: bool,
47+ pub settled: bool,
48+ pub pinned: bool,
49+ /// Milliseconds since the epoch, like every time here.
50+ pub snoozed_until: Option<u64>,
51+ pub created: u64,
52+ pub updated: u64,
53+ pub last_message: u64,
54+ /// The thread's worktree branch, if it runs in one.
55+ pub branch: Option<String>,
56+ pub total_cost: f64,
57+ /// Whether anything was sent in it yet.
58+ pub has_items: bool,
59+ }
60+
61+ /// The rest of an open thread's state, sent with its transcript.
62+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
63+ #[serde(default)]
64+ pub struct ThreadDetail {
65+ pub cwd: PathBuf,
66+ pub active_model: Option<String>,
67+ pub thinking: bool,
68+ pub preparing: bool,
69+ /// How long the running turn has been going.
70+ pub turn_elapsed_ms: Option<u64>,
71+ pub context_tokens: Option<u64>,
72+ pub context_window: Option<u64>,
73+ pub queue: Vec<QueuedMessage>,
74+ pub limit_resets: Option<u64>,
75+ pub resume_at: Option<u64>,
76+ /// Bound for a worktree made with the first message.
77+ pub pending_worktree: bool,
78+ pub pull_request: Option<PullRequest>,
79+ pub turn_changes: Vec<(usize, TurnChanges)>,
80+ }
81+
82+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
83+ #[serde(default)]
84+ pub struct ProjectInfo {
85+ pub path: PathBuf,
86+ pub name: String,
87+ /// `None` outside a git repository.
88+ pub branch: Option<String>,
89+ pub branches: Vec<String>,
90+ pub git: bool,
91+ pub added: u64,
92+ pub removed: u64,
93+ }
94+
95+ /// A file sent along with a message.
96+ #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
97+ pub struct Upload {
98+ pub name: String,
99+ /// Base64.
100+ pub data: String,
101+ }
102+
103+ /// What a client asks of a thread on the host.
104+ #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
105+ #[serde(tag = "action", rename_all = "snake_case")]
106+ pub enum ThreadAction {
107+ Send { text: String, attachments: Vec<Upload> },
108+ Queue { text: String, attachments: Vec<Upload> },
109+ SteerQueued { ix: usize },
110+ RemoveQueued { ix: usize },
111+ Interrupt,
112+ /// `allow`, `always` or `deny`.
113+ Permission { ix: usize, decision: String },
114+ Answer { ix: usize, option: usize },
115+ SetModel { model: Option<String> },
116+ SetPermissionMode { mode: String },
117+ SetReasoning { reasoning: String },
118+ SetFastMode { on: bool },
119+ Settle,
120+ Unsettle,
121+ Pin { pinned: bool },
122+ Snooze { until: Option<u64> },
123+ Rename { title: String },
124+ CancelResume,
125+ ScheduleResume { at: u64 },
126+ Delete,
127+ }
128+
129+ #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
130+ #[serde(tag = "type", rename_all = "snake_case")]
131+ pub enum ClientMessage {
132+ Hello { key: String, name: String, protocol: u32 },
133+ Subscribe { thread: String },
134+ Unsubscribe { thread: String },
135+ Action { thread: String, action: ThreadAction },
136+ /// Starts a thread in one of the host's projects with its first message.
137+ Create {
138+ reference: u64,
139+ project: PathBuf,
140+ instance: String,
141+ provider: Provider,
142+ model: Option<String>,
143+ permission_mode: String,
144+ reasoning: String,
145+ fast_mode: bool,
146+ worktree: bool,
147+ /// The branch a worktree starts from.
148+ base: Option<String>,
149+ text: String,
150+ attachments: Vec<Upload>,
151+ },
152+ }
153+
154+ #[derive(Clone, Debug, Serialize, Deserialize)]
155+ #[serde(tag = "type", rename_all = "snake_case")]
156+ pub enum ServerMessage {
157+ Welcome { host: String, version: String, protocol: u32 },
158+ Denied { reason: String },
159+ State { projects: Vec<ProjectInfo>, threads: Vec<ThreadSummary> },
160+ /// A subscribed thread changed: its state, and its transcript from item
161+ /// `from` on (earlier items are as last sent); `len` items in all.
162+ Thread { summary: ThreadSummary, detail: ThreadDetail, from: usize, len: usize, items: Vec<SavedItem> },
163+ Created { reference: u64, thread: String },
164+ Error { message: String },
165+ }
166+
167+ /// A new random key: 24 bytes, hex.
168+ pub fn new_key() -> String {
169+ let mut bytes = [0u8; 24];
170+ let read = std::fs::File::open("/dev/urandom").and_then(|mut file| std::io::Read::read_exact(&mut file, &mut bytes));
171+ if read.is_err() {
172+ // Not as good, but never empty.
173+ let seed = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos();
174+ for (ix, byte) in bytes.iter_mut().enumerate() {
175+ *byte = (seed >> ((ix % 16) * 8)) as u8 ^ (ix as u8).wrapping_mul(151);
176+ }
177+ }
178+ bytes.iter().map(|byte| format!("{byte:02x}")).collect()
179+ }
180+
181+ /// Compares keys in time independent of where they differ.
182+ pub fn keys_match(a: &str, b: &str) -> bool {
183+ a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
184+ }
185+
186+ /// A pairing code bundles a host's address and key: `192.168.2.10:47823/<key>`.
187+ pub fn pairing_code(address: &str, key: &str) -> String {
188+ format!("{address}/{key}")
189+ }
190+
191+ /// Splits a pairing code, or an address alone, into address and key.
192+ pub fn parse_pairing(code: &str) -> (String, Option<String>) {
193+ let code = code.trim().trim_start_matches("ws://").trim_start_matches("vectrum://");
194+ match code.split_once('/') {
195+ Some((address, key)) if !key.trim().is_empty() => (address.to_string(), Some(key.trim().to_string())),
196+ Some((address, _)) => (address.to_string(), None),
197+ None => (code.to_string(), None),
198+ }
199+ }
200+
201+ /// `host` or `host:port`, with the default port filled in.
202+ pub fn with_port(address: &str) -> String {
203+ let address = address.trim();
204+ let has_port = match address.rsplit_once(':') {
205+ // An IPv6 address without brackets has colons but no port.
206+ Some((host, port)) => !host.is_empty() && port.parse::<u16>().is_ok() && (!host.contains(':') || host.ends_with(']')),
207+ None => false,
208+ };
209+ if has_port { address.to_string() } else { format!("{address}:{DEFAULT_PORT}") }
210+ }
211+
212+ /// This machine's IPv4 addresses on the network, for showing where to connect.
213+ pub fn local_addresses() -> Vec<String> {
214+ let output = std::process::Command::new("hostname").arg("-I").stderr(std::process::Stdio::null()).output();
215+ let text = output.map(|output| String::from_utf8_lossy(&output.stdout).to_string()).unwrap_or_default();
216+ text.split_whitespace().filter(|address| address.parse::<std::net::Ipv4Addr>().is_ok()).map(str::to_string).collect()
217+ }
218+
219+ #[cfg(test)]
220+ mod tests {
221+ use super::*;
222+
223+ #[::core::prelude::v1::test]
224+ fn pairing_codes() {
225+ assert_eq!(parse_pairing("192.168.2.10:47823/abc"), ("192.168.2.10:47823".into(), Some("abc".into())));
226+ assert_eq!(parse_pairing(" 10.0.0.2 "), ("10.0.0.2".into(), None));
227+ assert_eq!(with_port("10.0.0.2"), format!("10.0.0.2:{DEFAULT_PORT}"));
228+ assert_eq!(with_port("10.0.0.2:9000"), "10.0.0.2:9000");
229+ assert_eq!(with_port("box.local"), format!("box.local:{DEFAULT_PORT}"));
230+ assert_eq!(with_port("[::1]:9000"), "[::1]:9000");
231+ }
232+
233+ #[::core::prelude::v1::test]
234+ fn keys() {
235+ let key = new_key();
236+ assert_eq!(key.len(), 48);
237+ assert_ne!(key, new_key());
238+ assert!(keys_match(&key, &key.clone()));
239+ assert!(!keys_match(&key, &new_key()));
240+ assert!(!keys_match("a", "ab"));
241+ }
242+
243+ #[::core::prelude::v1::test]
244+ fn messages_round_trip() {
245+ let message = ClientMessage::Action {
246+ thread: "t1".into(),
247+ action: ThreadAction::Send { text: "hi".into(), attachments: Vec::new() },
248+ };
249+ let text = serde_json::to_string(&message).unwrap();
250+ assert_eq!(serde_json::from_str::<ClientMessage>(&text).unwrap(), message);
251+ }
252+ }
+188−0src/remote/server.rsaddedView File
@@ -0,0 +1,188 @@
1+ //! The host side: a WebSocket listener on its own threads. Each connection
2+ //! proves it has the key, then trades JSON messages with the workspace,
3+ //! which answers on the UI thread through [`ServerEvent`]s.
4+
5+ use std::{
6+ collections::HashMap,
7+ io::ErrorKind,
8+ net::{TcpListener, TcpStream},
9+ sync::{
10+ Arc, Mutex,
11+ atomic::{AtomicBool, AtomicU64, Ordering},
12+ mpsc,
13+ },
14+ thread,
15+ time::Duration,
16+ };
17+
18+ use tungstenite::{Message, WebSocket};
19+
20+ use super::{ClientMessage, PROTOCOL, ServerMessage, keys_match};
21+
22+ /// How long a connection may take to say hello.
23+ const HELLO_TIMEOUT: Duration = Duration::from_secs(10);
24+ /// How often a connection's thread looks for messages to send.
25+ const POLL: Duration = Duration::from_millis(40);
26+
27+ pub enum ServerEvent {
28+ Connected { conn: u64, name: String, address: String },
29+ Message { conn: u64, message: ClientMessage },
30+ Disconnected { conn: u64 },
31+ }
32+
33+ /// A client that's connected, for the settings to list.
34+ #[derive(Clone, Debug)]
35+ pub struct Peer {
36+ pub conn: u64,
37+ pub name: String,
38+ pub address: String,
39+ }
40+
41+ pub struct RemoteServer {
42+ pub port: u16,
43+ stop: Arc<AtomicBool>,
44+ outboxes: Arc<Mutex<HashMap<u64, mpsc::Sender<String>>>>,
45+ pub events: smol::channel::Receiver<ServerEvent>,
46+ }
47+
48+ impl RemoteServer {
49+ /// Starts listening on every interface. Connections must say `key`.
50+ pub fn start(port: u16, key: String, host: String) -> std::io::Result<Self> {
51+ let listener = TcpListener::bind(("0.0.0.0", port))?;
52+ listener.set_nonblocking(true)?;
53+ let stop = Arc::new(AtomicBool::new(false));
54+ let outboxes: Arc<Mutex<HashMap<u64, mpsc::Sender<String>>>> = Arc::default();
55+ let (events_tx, events) = smol::channel::unbounded();
56+ let next_conn = Arc::new(AtomicU64::new(1));
57+ {
58+ let stop = stop.clone();
59+ let outboxes = outboxes.clone();
60+ thread::Builder::new().name("remote-listener".into()).spawn(move || {
61+ while !stop.load(Ordering::Relaxed) {
62+ match listener.accept() {
63+ Ok((stream, address)) => {
64+ let conn = next_conn.fetch_add(1, Ordering::Relaxed);
65+ let (stop, outboxes, events, key, host) =
66+ (stop.clone(), outboxes.clone(), events_tx.clone(), key.clone(), host.clone());
67+ let _ = thread::Builder::new().name("remote-connection".into()).spawn(move || {
68+ serve(conn, stream, address.ip().to_string(), &key, &host, &stop, &outboxes, &events);
69+ outboxes.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).remove(&conn);
70+ });
71+ }
72+ Err(err) if err.kind() == ErrorKind::WouldBlock => thread::sleep(Duration::from_millis(150)),
73+ Err(_) => thread::sleep(Duration::from_millis(500)),
74+ }
75+ }
76+ })?;
77+ }
78+ Ok(Self { port, stop, outboxes, events })
79+ }
80+
81+ 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+ }
86+ }
87+
88+ pub fn broadcast(&self, message: &ServerMessage) {
89+ let Ok(text) = serde_json::to_string(message) else { return };
90+ for outbox in self.outboxes.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).values() {
91+ let _ = outbox.send(text.clone());
92+ }
93+ }
94+
95+ /// Drops a client's connection.
96+ pub fn disconnect(&self, conn: u64) {
97+ // Its thread ends once its outbox is gone.
98+ self.outboxes.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).remove(&conn);
99+ }
100+ }
101+
102+ impl Drop for RemoteServer {
103+ fn drop(&mut self) {
104+ self.stop.store(true, Ordering::Relaxed);
105+ }
106+ }
107+
108+ #[allow(clippy::too_many_arguments)]
109+ fn serve(
110+ conn: u64,
111+ stream: TcpStream,
112+ address: String,
113+ key: &str,
114+ host: &str,
115+ stop: &AtomicBool,
116+ outboxes: &Mutex<HashMap<u64, mpsc::Sender<String>>>,
117+ events: &smol::channel::Sender<ServerEvent>,
118+ ) {
119+ let _ = stream.set_nonblocking(false);
120+ let _ = stream.set_nodelay(true);
121+ let _ = stream.set_read_timeout(Some(HELLO_TIMEOUT));
122+ let Ok(mut socket) = tungstenite::accept(stream) else { return };
123+
124+ let name = match read_message(&mut socket) {
125+ Some(ClientMessage::Hello { key: offered, name, protocol }) if keys_match(&offered, key) && protocol == PROTOCOL => name,
126+ Some(ClientMessage::Hello { protocol, .. }) if protocol != PROTOCOL => {
127+ deny(&mut socket, "This app's version speaks a different protocol; update both devices.");
128+ return;
129+ }
130+ _ => {
131+ // Slow down anyone guessing keys.
132+ thread::sleep(Duration::from_secs(1));
133+ deny(&mut socket, "Wrong key.");
134+ return;
135+ }
136+ };
137+ let welcome = ServerMessage::Welcome { host: host.to_string(), version: env!("CARGO_PKG_VERSION").into(), protocol: PROTOCOL };
138+ if socket.send(Message::text(serde_json::to_string(&welcome).unwrap_or_default())).is_err() {
139+ return;
140+ }
141+ let (outbox, inbox) = mpsc::channel::<String>();
142+ outboxes.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).insert(conn, outbox);
143+ let _ = events.send_blocking(ServerEvent::Connected { conn, name, address });
144+ let _ = socket.get_mut().set_read_timeout(Some(POLL));
145+
146+ 'connection: while !stop.load(Ordering::Relaxed) {
147+ loop {
148+ match inbox.try_recv() {
149+ Ok(text) => {
150+ if socket.send(Message::text(text)).is_err() {
151+ break 'connection;
152+ }
153+ }
154+ Err(mpsc::TryRecvError::Empty) => break,
155+ Err(mpsc::TryRecvError::Disconnected) => break 'connection,
156+ }
157+ }
158+ match socket.read() {
159+ Ok(Message::Text(text)) => {
160+ if let Ok(message) = serde_json::from_str::<ClientMessage>(text.as_str()) {
161+ let _ = events.send_blocking(ServerEvent::Message { conn, message });
162+ }
163+ }
164+ Ok(Message::Close(_)) => break,
165+ Ok(_) => {}
166+ Err(tungstenite::Error::Io(err)) if matches!(err.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => {}
167+ Err(_) => break,
168+ }
169+ }
170+ let _ = socket.close(None);
171+ let _ = events.send_blocking(ServerEvent::Disconnected { conn });
172+ }
173+
174+ fn read_message(socket: &mut WebSocket<TcpStream>) -> Option<ClientMessage> {
175+ loop {
176+ match socket.read().ok()? {
177+ Message::Text(text) => return serde_json::from_str(text.as_str()).ok(),
178+ Message::Ping(_) | Message::Pong(_) => continue,
179+ _ => return None,
180+ }
181+ }
182+ }
183+
184+ fn deny(socket: &mut WebSocket<TcpStream>, reason: &str) {
185+ let denied = ServerMessage::Denied { reason: reason.into() };
186+ let _ = socket.send(Message::text(serde_json::to_string(&denied).unwrap_or_default()));
187+ let _ = socket.close(None);
188+ }
+540−57src/session.rsView File
@@ -4,6 +4,7 @@
44 use std::{
55 collections::{HashMap, HashSet, VecDeque},
66 path::{Path, PathBuf},
7+ rc::Rc,
78 sync::atomic::{AtomicU64, Ordering},
89 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
910 };
@@ -17,6 +18,7 @@ use serde_json::{Value, json};
1718 use crate::claude::{AgentProcess, Effort, LaunchConfig, PermissionMode, ProcessEvent};
1819 use crate::git::{self, TurnChanges};
1920 use crate::models::{Catalog, Provider};
21+ use crate::remote::{ClientMessage, ThreadAction, ThreadDetail, ThreadSummary, Upload, client::RemoteClient};
2022 use crate::settings::{self, Streaming};
2123 use crate::worktree::{self, PullRequest, Worktree, WorktreePlan};
2224
@@ -136,6 +138,24 @@ pub enum ChatItem {
136138 TurnEnd { duration_ms: u64, cost: Option<f64>, at: SystemTime },
137139 }
138140
141+ /// A thread that lives on another device's app, mirrored here: actions go to
142+ /// the host, and the host sends back how the thread changed.
143+ pub struct RemoteLink {
144+ /// The connection's id in the settings.
145+ pub env: String,
146+ /// The thread's id on the host; `None` until its first message creates it.
147+ pub thread: Option<String>,
148+ pub client: Rc<RemoteClient>,
149+ /// Asked the host for the transcript.
150+ pub subscribed: bool,
151+ /// Got the transcript at least once.
152+ pub loaded: bool,
153+ /// What the host last said about pending prompts, until the transcript is in.
154+ pub attention: bool,
155+ /// The request that creates the thread, while it's on its way.
156+ pub creating: Option<u64>,
157+ }
158+
139159 /// What a session tells the views around it.
140160 pub enum SessionEvent {
141161 /// A finished turn's changes on disk are known, by its `TurnEnd` index.
@@ -250,6 +270,8 @@ pub struct Session {
250270 /// The worktree to create when the first message is sent.
251271 pub pending_worktree: Option<WorktreePlan>,
252272 pub pull_request: Option<PullRequest>,
273+ /// Set for a thread mirrored from another device.
274+ pub remote: Option<RemoteLink>,
253275 pub items: Vec<ChatItem>,
254276 pub status: SessionStatus,
255277 pub thinking: bool,
@@ -367,6 +389,7 @@ impl Session {
367389 worktree: None,
368390 pending_worktree: worktree,
369391 pull_request: None,
392+ remote: None,
370393 items: Vec::new(),
371394 status: SessionStatus::Starting,
372395 thinking: false,
@@ -416,6 +439,9 @@ impl Session {
416439
417440 /// True while a tool call or question is waiting for the user.
418441 pub fn needs_attention(&self) -> bool {
442+ if let Some(remote) = self.remote.as_ref().filter(|remote| !remote.loaded) {
443+ return remote.attention;
444+ }
419445 self.items.iter().any(|item| match item {
420446 ChatItem::Tool(ToolCall { permission: Some(prompt), .. }) => {
421447 prompt.state == PermissionState::Pending
@@ -523,6 +549,10 @@ impl Session {
523549 /// path for the agent to read. The thread's first message also creates
524550 /// its worktree, if it starts in one.
525551 pub fn send_message(&mut self, text: String, attachments: Vec<PathBuf>, cx: &mut Context<Self>) {
552+ if self.remote.is_some() {
553+ self.send_remote(text, attachments, false, cx);
554+ return;
555+ }
526556 if text.trim().is_empty() && attachments.is_empty() {
527557 return;
528558 }
@@ -588,6 +618,11 @@ impl Session {
588618
589619 /// Holds a message until the running turn ends; sends it now if none runs.
590620 pub fn queue_message(&mut self, text: String, attachments: Vec<PathBuf>, cx: &mut Context<Self>) {
621+ if self.remote.is_some() {
622+ let queue = self.is_working() && self.remote.as_ref().is_some_and(|remote| remote.thread.is_some());
623+ self.send_remote(text, attachments, queue, cx);
624+ return;
625+ }
591626 if !self.is_working() {
592627 self.send_message(text, attachments, cx);
593628 return;
@@ -601,6 +636,9 @@ impl Session {
601636
602637 /// Sends a queued message now instead of after the turn.
603638 pub fn steer_queued(&mut self, ix: usize, cx: &mut Context<Self>) {
639+ if self.forward(ThreadAction::SteerQueued { ix }) {
640+ return;
641+ }
604642 if ix < self.queue.len() {
605643 let message = self.queue.remove(ix);
606644 self.send_message(message.text, message.attachments, cx);
@@ -608,6 +646,9 @@ impl Session {
608646 }
609647
610648 pub fn remove_queued(&mut self, ix: usize, cx: &mut Context<Self>) {
649+ if self.forward(ThreadAction::RemoveQueued { ix }) {
650+ return;
651+ }
611652 if ix < self.queue.len() {
612653 self.queue.remove(ix);
613654 cx.notify();
@@ -725,6 +766,7 @@ impl Session {
725766 }
726767
727768 pub fn rename(&mut self, title: String, cx: &mut Context<Self>) {
769+ self.forward(ThreadAction::Rename { title: title.trim().to_string() });
728770 let title = title.trim();
729771 if title.is_empty() {
730772 return;
@@ -737,6 +779,14 @@ impl Session {
737779 /// Picks where the (still empty) thread will run: in the project folder,
738780 /// or a worktree made with its first message.
739781 pub fn set_worktree_plan(&mut self, plan: Option<WorktreePlan>, cx: &mut Context<Self>) {
782+ if self.remote.is_some() {
783+ // The host makes the worktree; this only says whether to.
784+ if self.remote.as_ref().is_some_and(|remote| remote.thread.is_none()) {
785+ self.pending_worktree = plan;
786+ cx.notify();
787+ }
788+ return;
789+ }
740790 if !self.items.is_empty() || self.worktree.is_some() {
741791 return;
742792 }
@@ -757,11 +807,13 @@ impl Session {
757807 }
758808
759809 pub fn set_pinned(&mut self, pinned: bool, cx: &mut Context<Self>) {
810+ self.forward(ThreadAction::Pin { pinned });
760811 self.pinned = pinned;
761812 cx.notify();
762813 }
763814
764815 pub fn snooze(&mut self, until: Option<SystemTime>, cx: &mut Context<Self>) {
816+ self.forward(ThreadAction::Snooze { until: until.map(to_millis) });
765817 self.snoozed_until = until;
766818 if until.is_none() {
767819 self.updated = SystemTime::now();
@@ -780,6 +832,7 @@ impl Session {
780832
781833 /// Drops the continuation scheduled for when a usage limit resets.
782834 pub fn cancel_resume(&mut self, cx: &mut Context<Self>) {
835+ self.forward(ThreadAction::CancelResume);
783836 self.resume_at = None;
784837 cx.notify();
785838 }
@@ -798,6 +851,11 @@ impl Session {
798851 /// Files the thread under Settled and stops its process. The transcript
799852 /// stays, and the next message resumes the conversation.
800853 pub fn settle(&mut self, cx: &mut Context<Self>) {
854+ if self.forward(ThreadAction::Settle) {
855+ self.settled = true;
856+ cx.notify();
857+ return;
858+ }
801859 self.flush_text(cx);
802860 self.process.take();
803861 // Drop the event stream too, so the process exit isn't reported as a crash.
@@ -820,12 +878,16 @@ impl Session {
820878 /// Brings a settled thread back to the open list. Its process starts again
821879 /// with the next message.
822880 pub fn unsettle(&mut self, cx: &mut Context<Self>) {
881+ self.forward(ThreadAction::Unsettle);
823882 self.settled = false;
824883 self.updated = SystemTime::now();
825884 cx.notify();
826885 }
827886
828887 pub fn interrupt(&mut self, cx: &mut Context<Self>) {
888+ if self.forward(ThreadAction::Interrupt) {
889+ return;
890+ }
829891 if !self.is_working() || self.interrupt_requested || self.process.is_none() {
830892 return;
831893 }
@@ -838,6 +900,11 @@ impl Session {
838900 }
839901
840902 pub fn set_model(&mut self, model: Option<String>, cx: &mut Context<Self>) {
903+ if self.forward(ThreadAction::SetModel { model: model.clone() }) {
904+ self.model = model;
905+ cx.notify();
906+ return;
907+ }
841908 self.model = model.clone();
842909 match self.provider {
843910 Provider::Claude => self.control(
@@ -855,6 +922,11 @@ impl Session {
855922 }
856923
857924 pub fn set_permission_mode(&mut self, mode: PermissionMode, cx: &mut Context<Self>) {
925+ if self.forward(ThreadAction::SetPermissionMode { mode: mode.as_cli().into() }) {
926+ self.permission_mode = mode;
927+ cx.notify();
928+ return;
929+ }
858930 // Pi has no permission prompts to configure.
859931 if self.provider != Provider::Claude {
860932 return;
@@ -868,6 +940,11 @@ impl Session {
868940 }
869941
870942 pub fn set_reasoning(&mut self, reasoning: Reasoning, cx: &mut Context<Self>) {
943+ if self.forward(ThreadAction::SetReasoning { reasoning: reasoning.key().into() }) {
944+ self.reasoning = reasoning;
945+ cx.notify();
946+ return;
947+ }
871948 self.reasoning = reasoning;
872949 match self.provider {
873950 Provider::Claude => self.control(
@@ -890,6 +967,11 @@ impl Session {
890967 }
891968
892969 pub fn set_fast_mode(&mut self, on: bool, cx: &mut Context<Self>) {
970+ if self.forward(ThreadAction::SetFastMode { on }) {
971+ self.fast_mode = on;
972+ cx.notify();
973+ return;
974+ }
893975 if self.provider != Provider::Claude {
894976 return;
895977 }
@@ -973,6 +1055,14 @@ impl Session {
9731055 decision: PermissionDecision,
9741056 cx: &mut Context<Self>,
9751057 ) {
1058+ let decision_key = match decision {
1059+ PermissionDecision::Allow => "allow",
1060+ PermissionDecision::AllowAlways => "always",
1061+ PermissionDecision::Deny => "deny",
1062+ };
1063+ if self.forward(ThreadAction::Permission { ix, decision: decision_key.into() }) {
1064+ return;
1065+ }
9761066 let Some(process) = &self.process else { return };
9771067 let Some(ChatItem::Tool(tool)) = self.items.get_mut(ix) else { return };
9781068 let Some(prompt) = tool.permission.as_mut() else { return };
@@ -1019,6 +1109,9 @@ impl Session {
10191109
10201110 /// Answers an extension's question with one of its options.
10211111 pub fn answer_question(&mut self, ix: usize, option: usize, cx: &mut Context<Self>) {
1112+ if self.forward(ThreadAction::Answer { ix, option }) {
1113+ return;
1114+ }
10221115 let Some(process) = &self.process else { return };
10231116 let Some(ChatItem::Question(question)) = self.items.get_mut(ix) else { return };
10241117 let Some(answer) = question.options.get(option).cloned() else { return };
@@ -1910,7 +2003,7 @@ pub struct SavedThread {
19102003 pub turn_changes: Vec<(usize, TurnChanges)>,
19112004 }
19122005
1913- #[derive(Serialize, Deserialize)]
2006+ #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
19142007 #[serde(tag = "type", rename_all = "snake_case")]
19152008 pub enum SavedItem {
19162009 User { text: String, attachments: Vec<String>, at: u64 },
@@ -1920,14 +2013,26 @@ pub enum SavedItem {
19202013 name: String,
19212014 input: Value,
19222015 result: Option<String>,
1923- /// `done` or `error`; a tool still running when saved counts as failed.
2016+ /// `done`, `running` or `error`; a restored thread counts a running
2017+ /// tool as failed.
19242018 state: String,
1925- /// `allowed`, `denied` or `cancelled`, if it asked for permission.
2019+ /// `pending`, `allowed`, `denied` or `cancelled`, if it asked for
2020+ /// permission. A restored thread treats `pending` as cancelled.
19262021 permission: Option<String>,
2022+ /// The rules a pending prompt offers to remember.
2023+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
2024+ suggestions: Vec<Value>,
19272025 subagent_tool_count: usize,
19282026 },
19292027 Notice { error: bool, text: String },
1930- Question { title: String, message: Option<String>, options: Vec<String>, answer: Option<String> },
2028+ Question {
2029+ title: String,
2030+ message: Option<String>,
2031+ options: Vec<String>,
2032+ #[serde(default)]
2033+ confirm: bool,
2034+ answer: Option<String>,
2035+ },
19312036 TurnEnd { duration_ms: u64, cost: Option<f64>, at: u64 },
19322037 }
19332038
@@ -1941,9 +2046,15 @@ pub fn from_millis(millis: u64) -> SystemTime {
19412046
19422047 impl Session {
19432048 pub fn save(&self) -> SavedThread {
1944- let items = self
1945- .items
2049+ let items = self.saved_items(0);
2050+ self.save_with(items)
2051+ }
2052+
2053+ /// The transcript from item `from` on, as it's saved and sent.
2054+ pub fn saved_items(&self, from: usize) -> Vec<SavedItem> {
2055+ self.items
19462056 .iter()
2057+ .skip(from)
19472058 .map(|item| match item {
19482059 ChatItem::User { text, attachments, at } => SavedItem::User {
19492060 text: text.to_string(),
@@ -1956,15 +2067,27 @@ impl Session {
19562067 name: tool.name.clone(),
19572068 input: tool.input.clone(),
19582069 result: tool.result.clone(),
1959- state: if tool.state == ToolState::Done { "done" } else { "error" }.into(),
2070+ state: match tool.state {
2071+ ToolState::Done => "done",
2072+ ToolState::Running => "running",
2073+ ToolState::Error => "error",
2074+ }
2075+ .into(),
19602076 permission: tool.permission.as_ref().map(|prompt| {
19612077 match prompt.state {
2078+ PermissionState::Pending => "pending",
19622079 PermissionState::Allowed => "allowed",
19632080 PermissionState::Denied => "denied",
1964- PermissionState::Pending | PermissionState::Cancelled => "cancelled",
2081+ PermissionState::Cancelled => "cancelled",
19652082 }
19662083 .into()
19672084 }),
2085+ suggestions: tool
2086+ .permission
2087+ .as_ref()
2088+ .filter(|prompt| prompt.state == PermissionState::Pending)
2089+ .map(|prompt| prompt.suggestions.clone())
2090+ .unwrap_or_default(),
19682091 subagent_tool_count: tool.subagent_tool_count,
19692092 },
19702093 ChatItem::Notice(kind, text) => {
@@ -1974,13 +2097,17 @@ impl Session {
19742097 title: question.title.to_string(),
19752098 message: question.message.as_ref().map(ToString::to_string),
19762099 options: question.options.iter().map(ToString::to_string).collect(),
2100+ confirm: question.confirm,
19772101 answer: question.answer.as_ref().map(ToString::to_string),
19782102 },
19792103 ChatItem::TurnEnd { duration_ms, cost, at } => {
19802104 SavedItem::TurnEnd { duration_ms: *duration_ms, cost: *cost, at: to_millis(*at) }
19812105 }
19822106 })
1983- .collect();
2107+ .collect()
2108+ }
2109+
2110+ fn save_with(&self, items: Vec<SavedItem>) -> SavedThread {
19842111 SavedThread {
19852112 id: self.id.clone(),
19862113 instance: self.instance.clone(),
@@ -2052,54 +2179,7 @@ impl Session {
20522179 session.interrupted = saved.working;
20532180 session.turn_changes = saved.turn_changes.into_iter().collect();
20542181 for item in saved.items {
2055- let item = match item {
2056- SavedItem::User { text, attachments, at } => ChatItem::User {
2057- text: text.into(),
2058- attachments: attachments.into_iter().map(Into::into).collect(),
2059- at: from_millis(at),
2060- },
2061- SavedItem::Assistant { text } => {
2062- session.push_assistant_text(&text, cx);
2063- continue;
2064- }
2065- SavedItem::Tool { id, name, input, result, state, permission, subagent_tool_count } => {
2066- ChatItem::Tool(ToolCall {
2067- id,
2068- name,
2069- input,
2070- partial_input: String::new(),
2071- result,
2072- state: if state == "done" { ToolState::Done } else { ToolState::Error },
2073- permission: permission.map(|state| PermissionPrompt {
2074- request_id: String::new(),
2075- input: Value::Null,
2076- suggestions: Vec::new(),
2077- state: match state.as_str() {
2078- "allowed" => PermissionState::Allowed,
2079- "denied" => PermissionState::Denied,
2080- _ => PermissionState::Cancelled,
2081- },
2082- }),
2083- expanded: false,
2084- subagent_tool_count,
2085- detached: false,
2086- })
2087- }
2088- SavedItem::Notice { error, text } => {
2089- ChatItem::Notice(if error { NoticeKind::Error } else { NoticeKind::Info }, text.into())
2090- }
2091- SavedItem::Question { title, message, options, answer } => ChatItem::Question(Question {
2092- request_id: String::new(),
2093- title: title.into(),
2094- message: message.map(Into::into),
2095- options: options.into_iter().map(Into::into).collect(),
2096- confirm: false,
2097- answer: Some(answer.unwrap_or_else(|| "No answer".into()).into()),
2098- }),
2099- SavedItem::TurnEnd { duration_ms, cost, at } => {
2100- ChatItem::TurnEnd { duration_ms, cost, at: from_millis(at) }
2101- }
2102- };
2182+ let item = session.item_from_saved(item, false, cx);
21032183 session.items.push(item);
21042184 }
21052185 if session.interrupted {
@@ -2107,6 +2187,409 @@ impl Session {
21072187 }
21082188 session
21092189 }
2190+
2191+ /// Turns a saved item back into a chat item. `live` keeps pending
2192+ /// prompts and questions pending (a host's transcript); otherwise they
2193+ /// count as over (a thread restored from disk).
2194+ fn item_from_saved(&mut self, item: SavedItem, live: bool, cx: &mut Context<Self>) -> ChatItem {
2195+ match item {
2196+ SavedItem::User { text, attachments, at } => ChatItem::User {
2197+ text: text.into(),
2198+ attachments: attachments.into_iter().map(Into::into).collect(),
2199+ at: from_millis(at),
2200+ },
2201+ SavedItem::Assistant { text } => {
2202+ let view = cx.new(|cx| TextViewState::markdown(&text, cx).selectable(true));
2203+ let shown = text.len();
2204+ ChatItem::Assistant(AssistantText { text, view, shown })
2205+ }
2206+ SavedItem::Tool { id, name, input, result, state, permission, suggestions, subagent_tool_count } => {
2207+ let pending = live && permission.as_deref() == Some("pending");
2208+ ChatItem::Tool(ToolCall {
2209+ id,
2210+ name,
2211+ input: input.clone(),
2212+ partial_input: String::new(),
2213+ result,
2214+ state: match state.as_str() {
2215+ "done" => ToolState::Done,
2216+ "running" if live => ToolState::Running,
2217+ _ => ToolState::Error,
2218+ },
2219+ permission: permission.map(|state| PermissionPrompt {
2220+ request_id: String::new(),
2221+ input,
2222+ suggestions,
2223+ state: match state.as_str() {
2224+ "allowed" => PermissionState::Allowed,
2225+ "denied" => PermissionState::Denied,
2226+ _ if pending => PermissionState::Pending,
2227+ _ => PermissionState::Cancelled,
2228+ },
2229+ }),
2230+ expanded: false,
2231+ subagent_tool_count,
2232+ detached: false,
2233+ })
2234+ }
2235+ SavedItem::Notice { error, text } => {
2236+ ChatItem::Notice(if error { NoticeKind::Error } else { NoticeKind::Info }, text.into())
2237+ }
2238+ SavedItem::Question { title, message, options, confirm, answer } => ChatItem::Question(Question {
2239+ request_id: String::new(),
2240+ title: title.into(),
2241+ message: message.map(Into::into),
2242+ options: options.into_iter().map(Into::into).collect(),
2243+ confirm,
2244+ answer: match answer {
2245+ Some(answer) => Some(answer.into()),
2246+ None if live => None,
2247+ None => Some("No answer".into()),
2248+ },
2249+ }),
2250+ SavedItem::TurnEnd { duration_ms, cost, at } => ChatItem::TurnEnd { duration_ms, cost, at: from_millis(at) },
2251+ }
2252+ }
2253+ }
2254+
2255+ // ---- mirroring threads across devices -------------------------------------
2256+
2257+ fn status_key(status: SessionStatus) -> &'static str {
2258+ match status {
2259+ SessionStatus::Starting => "starting",
2260+ SessionStatus::Idle => "idle",
2261+ SessionStatus::Working => "working",
2262+ SessionStatus::Exited => "exited",
2263+ }
2264+ }
2265+
2266+ fn status_from_key(key: &str) -> SessionStatus {
2267+ match key {
2268+ "starting" => SessionStatus::Starting,
2269+ "working" => SessionStatus::Working,
2270+ "exited" => SessionStatus::Exited,
2271+ _ => SessionStatus::Idle,
2272+ }
2273+ }
2274+
2275+ /// Reads a file for sending to a host.
2276+ fn upload(path: &Path) -> Result<Upload, String> {
2277+ let size = path.metadata().map_err(|err| err.to_string())?.len();
2278+ if size > 20 * 1024 * 1024 {
2279+ return Err("it is over the 20 MB limit for sending to another device".into());
2280+ }
2281+ let bytes = std::fs::read(path).map_err(|err| err.to_string())?;
2282+ Ok(Upload { name: file_name(path), data: BASE64.encode(bytes) })
2283+ }
2284+
2285+ /// Writes files a client sent into a folder of their own, returning their paths.
2286+ pub fn store_uploads(uploads: Vec<Upload>) -> Vec<PathBuf> {
2287+ if uploads.is_empty() {
2288+ return Vec::new();
2289+ }
2290+ let Some(dir) = settings::data_dir().map(|dir| {
2291+ dir.join("uploads").join(format!("{:x}", to_millis(SystemTime::now()) ^ u64::from(std::process::id())))
2292+ }) else {
2293+ return Vec::new();
2294+ };
2295+ if std::fs::create_dir_all(&dir).is_err() {
2296+ return Vec::new();
2297+ }
2298+ uploads
2299+ .into_iter()
2300+ .filter_map(|upload| {
2301+ let name = Path::new(&upload.name).file_name()?.to_owned();
2302+ let path = dir.join(name);
2303+ let bytes = BASE64.decode(upload.data).ok()?;
2304+ std::fs::write(&path, bytes).ok()?;
2305+ Some(path)
2306+ })
2307+ .collect()
2308+ }
2309+
2310+ impl Session {
2311+ /// A thread on another device, as the host's thread list describes it.
2312+ pub fn remote(summary: &ThreadSummary, link: RemoteLink, cx: &mut Context<Self>) -> Self {
2313+ let _ = cx;
2314+ let new = NewSession {
2315+ instance: summary.instance.clone(),
2316+ provider: summary.provider,
2317+ project: summary.project.clone(),
2318+ model: summary.model.clone(),
2319+ permission_mode: PermissionMode::default(),
2320+ reasoning: Reasoning::default(),
2321+ fast_mode: false,
2322+ worktree: None,
2323+ };
2324+ let id = summary.id.clone();
2325+ let mut session = Self::empty(id, new, from_millis(summary.created));
2326+ session.remote = Some(link);
2327+ session.apply_summary(summary);
2328+ session
2329+ }
2330+
2331+ /// A new, still empty thread meant for one of a host's projects.
2332+ pub fn new_remote(new: NewSession, link: RemoteLink) -> Self {
2333+ let now = SystemTime::now();
2334+ let id = format!("remote-{:x}-{}", to_millis(now), NEXT_SESSION.load(Ordering::Relaxed));
2335+ let mut session = Self::empty(id, new, now);
2336+ session.status = SessionStatus::Idle;
2337+ session.remote = Some(link);
2338+ session
2339+ }
2340+
2341+ pub fn is_remote(&self) -> bool {
2342+ self.remote.is_some()
2343+ }
2344+
2345+ /// Whether nothing was sent in it: not even a remote thread that exists.
2346+ pub fn is_blank(&self) -> bool {
2347+ self.items.is_empty() && self.remote.as_ref().is_none_or(|remote| remote.thread.is_none() && remote.creating.is_none())
2348+ }
2349+
2350+ /// Hands an action to the host, for a mirrored thread. Returns whether
2351+ /// the thread is mirrored (then the action is the host's to carry out).
2352+ fn forward(&self, action: ThreadAction) -> bool {
2353+ let Some(remote) = &self.remote else { return false };
2354+ if let Some(thread) = &remote.thread {
2355+ remote.client.send(&ClientMessage::Action { thread: thread.clone(), action });
2356+ }
2357+ true
2358+ }
2359+
2360+ /// Sends (or queues) a message to a mirrored thread, creating the
2361+ /// thread on the host with its first message.
2362+ fn send_remote(&mut self, text: String, attachments: Vec<PathBuf>, queue: bool, cx: &mut Context<Self>) {
2363+ if text.trim().is_empty() && attachments.is_empty() {
2364+ return;
2365+ }
2366+ let mut uploads = Vec::new();
2367+ for path in &attachments {
2368+ match upload(path) {
2369+ Ok(file) => uploads.push(file),
2370+ Err(err) => self.push_notice(NoticeKind::Error, format!("Couldn't attach {}: {err}", file_name(path))),
2371+ }
2372+ }
2373+ let Some(remote) = self.remote.as_mut() else { return };
2374+ match remote.thread.clone() {
2375+ Some(thread) => {
2376+ let action = if queue {
2377+ ThreadAction::Queue { text: text.clone(), attachments: uploads }
2378+ } else {
2379+ ThreadAction::Send { text: text.clone(), attachments: uploads }
2380+ };
2381+ remote.client.send(&ClientMessage::Action { thread, action });
2382+ if queue {
2383+ self.queue.push(QueuedMessage { text, attachments });
2384+ cx.notify();
2385+ return;
2386+ }
2387+ }
2388+ None if remote.creating.is_some() => {
2389+ // The thread is on its way; this waits for it.
2390+ self.queue.push(QueuedMessage { text, attachments });
2391+ cx.notify();
2392+ return;
2393+ }
2394+ None => {
2395+ static NEXT_REFERENCE: AtomicU64 = AtomicU64::new(1);
2396+ let reference = NEXT_REFERENCE.fetch_add(1, Ordering::Relaxed);
2397+ remote.creating = Some(reference);
2398+ let plan = self.pending_worktree.clone();
2399+ remote.client.send(&ClientMessage::Create {
2400+ reference,
2401+ project: self.project.clone(),
2402+ instance: self.instance.clone(),
2403+ provider: self.provider,
2404+ model: self.model.clone(),
2405+ permission_mode: self.permission_mode.as_cli().into(),
2406+ reasoning: self.reasoning.key().into(),
2407+ fast_mode: self.fast_mode,
2408+ worktree: plan.is_some(),
2409+ base: plan.and_then(|plan| plan.base),
2410+ text: text.clone(),
2411+ attachments: uploads,
2412+ });
2413+ }
2414+ }
2415+ // Show the message right away; the host's transcript confirms it.
2416+ let now = SystemTime::now();
2417+ let names = attachments.iter().map(|path| file_name(path).into()).collect();
2418+ if self.title.as_ref() == DEFAULT_TITLE {
2419+ self.title = title_from(&text).into();
2420+ }
2421+ self.items.push(ChatItem::User { text: text.into(), attachments: names, at: now });
2422+ self.status = SessionStatus::Working;
2423+ self.updated = now;
2424+ self.last_message = now;
2425+ cx.notify();
2426+ }
2427+
2428+ /// Learns the host's id for a thread created from here, and sends what
2429+ /// was typed meanwhile.
2430+ pub fn remote_created(&mut self, thread: String, cx: &mut Context<Self>) {
2431+ let Some(remote) = self.remote.as_mut() else { return };
2432+ remote.creating = None;
2433+ remote.thread = Some(thread.clone());
2434+ remote.subscribed = true;
2435+ remote.client.send(&ClientMessage::Subscribe { thread: thread.clone() });
2436+ self.id = thread;
2437+ for message in std::mem::take(&mut self.queue) {
2438+ self.send_remote(message.text, message.attachments, true, cx);
2439+ }
2440+ cx.notify();
2441+ }
2442+
2443+ /// Asks the host for the transcript, once.
2444+ pub fn subscribe_remote(&mut self) {
2445+ let Some(remote) = self.remote.as_mut() else { return };
2446+ if let (Some(thread), false) = (&remote.thread, remote.subscribed) {
2447+ remote.client.send(&ClientMessage::Subscribe { thread: thread.clone() });
2448+ remote.subscribed = true;
2449+ }
2450+ }
2451+
2452+ pub fn apply_summary(&mut self, summary: &ThreadSummary) {
2453+ self.title = summary.title.clone().into();
2454+ self.provider = summary.provider;
2455+ self.instance = summary.instance.clone();
2456+ self.model = summary.model.clone();
2457+ self.permission_mode = PermissionMode::from_cli(&summary.permission_mode).unwrap_or_default();
2458+ self.reasoning = Reasoning::from_key(&summary.reasoning).unwrap_or_default();
2459+ self.fast_mode = summary.fast_mode;
2460+ self.status = status_from_key(&summary.status);
2461+ self.settled = summary.settled;
2462+ self.pinned = summary.pinned;
2463+ self.snoozed_until = summary.snoozed_until.map(from_millis);
2464+ self.updated = from_millis(summary.updated);
2465+ self.last_message = from_millis(summary.last_message);
2466+ self.total_cost = summary.total_cost;
2467+ self.project = summary.project.clone();
2468+ self.worktree = summary.branch.clone().map(|branch| Worktree {
2469+ root: self.cwd.clone(),
2470+ branch,
2471+ repo: summary.project.clone(),
2472+ });
2473+ if let Some(remote) = self.remote.as_mut() {
2474+ remote.attention = summary.attention;
2475+ }
2476+ }
2477+
2478+ /// Takes a host's update of a subscribed thread: its state, and its
2479+ /// transcript from item `from` on.
2480+ pub fn apply_remote(
2481+ &mut self,
2482+ summary: &ThreadSummary,
2483+ detail: ThreadDetail,
2484+ from: usize,
2485+ len: usize,
2486+ items: Vec<SavedItem>,
2487+ cx: &mut Context<Self>,
2488+ ) {
2489+ self.apply_summary(summary);
2490+ self.cwd = detail.cwd;
2491+ if let Some(worktree) = self.worktree.as_mut() {
2492+ worktree.root = self.cwd.clone();
2493+ }
2494+ self.active_model = detail.active_model;
2495+ self.thinking = detail.thinking;
2496+ self.preparing = detail.preparing;
2497+ self.turn_started = detail.turn_elapsed_ms.map(|elapsed| Instant::now() - Duration::from_millis(elapsed));
2498+ self.context_tokens = detail.context_tokens;
2499+ self.context_window = detail.context_window;
2500+ self.queue = detail.queue;
2501+ self.limit_resets = detail.limit_resets.map(from_millis);
2502+ self.resume_at = detail.resume_at.map(from_millis);
2503+ self.pull_request = detail.pull_request;
2504+ if !detail.pending_worktree {
2505+ self.pending_worktree = None;
2506+ }
2507+ self.turn_changes = detail.turn_changes.into_iter().collect();
2508+
2509+ let from = from.min(self.items.len());
2510+ for (offset, saved) in items.into_iter().enumerate() {
2511+ let ix = from + offset;
2512+ // A streaming answer grows; feed it the new text instead of
2513+ // rebuilding its view.
2514+ if let (Some(ChatItem::Assistant(answer)), SavedItem::Assistant { text }) = (self.items.get_mut(ix), &saved)
2515+ && text.starts_with(answer.text.as_str())
2516+ {
2517+ let new = text[answer.text.len()..].to_string();
2518+ answer.text.push_str(&new);
2519+ let end = answer.text.len();
2520+ answer.show(end, cx);
2521+ continue;
2522+ }
2523+ let mut item = self.item_from_saved(saved, true, cx);
2524+ // Keep tool cards open across updates.
2525+ if let (Some(ChatItem::Tool(old)), ChatItem::Tool(new)) = (self.items.get(ix), &mut item)
2526+ && old.id == new.id
2527+ {
2528+ new.expanded = old.expanded;
2529+ }
2530+ match self.items.get_mut(ix) {
2531+ Some(slot) => *slot = item,
2532+ None => self.items.push(item),
2533+ }
2534+ }
2535+ self.items.truncate(len);
2536+ if let Some(remote) = self.remote.as_mut() {
2537+ remote.loaded = true;
2538+ }
2539+ cx.notify();
2540+ }
2541+
2542+ /// The thread as a client's sidebar needs it.
2543+ pub fn summary(&self) -> ThreadSummary {
2544+ ThreadSummary {
2545+ id: self.id.clone(),
2546+ project: self.project.clone(),
2547+ title: self.title.to_string(),
2548+ provider: self.provider,
2549+ instance: self.instance.clone(),
2550+ model: self.model.clone(),
2551+ permission_mode: self.permission_mode.as_cli().into(),
2552+ reasoning: self.reasoning.key().into(),
2553+ fast_mode: self.fast_mode,
2554+ status: status_key(self.status).into(),
2555+ attention: self.needs_attention(),
2556+ settled: self.settled,
2557+ pinned: self.pinned,
2558+ snoozed_until: self.snoozed_until.filter(|_| self.is_snoozed()).map(to_millis),
2559+ created: to_millis(self.created),
2560+ updated: to_millis(self.updated),
2561+ last_message: to_millis(self.last_message),
2562+ branch: self.worktree.as_ref().map(|worktree| worktree.branch.clone()),
2563+ total_cost: self.total_cost,
2564+ has_items: !self.items.is_empty(),
2565+ }
2566+ }
2567+
2568+ /// The rest of the thread's state, for a client that has it open.
2569+ pub fn detail(&self) -> ThreadDetail {
2570+ ThreadDetail {
2571+ cwd: self.cwd.clone(),
2572+ active_model: self.active_model.clone(),
2573+ thinking: self.thinking,
2574+ preparing: self.preparing,
2575+ turn_elapsed_ms: self.turn_started.map(|start| start.elapsed().as_millis() as u64),
2576+ context_tokens: self.context_tokens,
2577+ context_window: self.context_window,
2578+ queue: self.queue.clone(),
2579+ limit_resets: self.limit_resets.map(to_millis),
2580+ resume_at: self.resume_at.map(to_millis),
2581+ pending_worktree: self.pending_worktree.is_some(),
2582+ pull_request: self.pull_request.clone(),
2583+ turn_changes: self.turn_changes.iter().map(|(end, changes)| (*end, changes.clone())).collect(),
2584+ }
2585+ }
2586+
2587+ /// Schedules (or reschedules) continuing after a usage limit.
2588+ pub fn schedule_resume(&mut self, at: SystemTime, cx: &mut Context<Self>) {
2589+ self.forward(ThreadAction::ScheduleResume { at: to_millis(at) });
2590+ self.resume_at = Some(at);
2591+ cx.notify();
2592+ }
21102593 }
21112594
21122595 impl Drop for Session {
+320−52src/ui/changes.rsView File
@@ -2,12 +2,13 @@
22 //! diff view it opens.
33
44 use std::collections::{BTreeMap, HashSet};
5+ use std::rc::Rc;
56
6- use gpui_kit::component::{Icon, IconName, h_flex, v_flex};
7+ use gpui_kit::component::{Icon, IconName, Selectable as _, h_flex, v_flex};
78 use gpui_kit::prelude::FluentBuilder as _;
89 use gpui_kit::*;
910
10- use crate::git::{FileChange, FileDiff, LineKind, TurnChanges};
11+ use crate::git::{DiffLine, FileChange, FileDiff, LineKind, TurnChanges};
1112 use crate::session::Session;
1213 use crate::ui::icons::Lucide;
1314 use crate::ui::metal::{self, MetalButton, MetalStyled as _, Palette};
@@ -250,15 +251,170 @@ pub fn render_changes(
250251 .into_any_element()
251252 }
252253
253- /// The diff view's state: which turn and file it shows, and the diff once
254- /// git has produced it.
254+ /// The diff view's state: which turn and file it shows, how, and the diff
255+ /// once git has produced it.
256+ #[derive(Default)]
255257 pub struct DiffView {
256258 pub title: SharedString,
259+ /// The turn it shows, by the index of its end in the transcript.
260+ pub end: usize,
261+ /// The one file it was opened for; `None` for the whole turn.
262+ pub path: Option<String>,
257263 pub files: Option<Result<Vec<FileDiff>, String>>,
264+ pub ignore_whitespace: bool,
265+ pub side_by_side: bool,
266+ /// Paths of the files folded down to their header.
267+ pub collapsed: HashSet<String>,
268+ /// Fold every file once the first diff arrives.
269+ pub start_collapsed: bool,
270+ }
271+
272+ impl DiffView {
273+ pub fn set_files(&mut self, files: Result<Vec<FileDiff>, String>) {
274+ if let Ok(files) = &files
275+ && std::mem::take(&mut self.start_collapsed)
276+ {
277+ self.collapsed = files.iter().map(|file| file.path.clone()).collect();
278+ }
279+ self.files = Some(files);
280+ }
281+
282+ pub fn toggle_file(&mut self, path: String) {
283+ if !self.collapsed.remove(&path) {
284+ self.collapsed.insert(path);
285+ }
286+ }
287+
288+ pub fn set_all_collapsed(&mut self, collapsed: bool) {
289+ self.collapsed = match &self.files {
290+ Some(Ok(files)) if collapsed => files.iter().map(|file| file.path.clone()).collect(),
291+ _ => HashSet::new(),
292+ };
293+ }
294+ }
295+
296+ /// What the diff view's controls ask of whoever holds the view.
297+ #[derive(Clone, Debug, PartialEq)]
298+ pub enum DiffAction {
299+ Close,
300+ ToggleWhitespace,
301+ SetSideBySide(bool),
302+ /// Fold or unfold one file, by path.
303+ ToggleFile(String),
304+ SetAllCollapsed(bool),
305+ }
306+
307+ /// One row of the side-by-side layout.
308+ #[derive(Debug)]
309+ enum SplitRow<'a> {
310+ /// A hunk header, across both columns.
311+ Hunk(&'a DiffLine),
312+ /// The old line on the left, the new one on the right; either may be
313+ /// missing where one side of a change runs longer.
314+ Pair(Option<&'a DiffLine>, Option<&'a DiffLine>),
315+ }
316+
317+ /// Pairs a file's lines up for the side-by-side layout: context on both
318+ /// sides, and each run of removed lines next to the added run that follows it.
319+ fn split_rows(lines: &[DiffLine]) -> Vec<SplitRow<'_>> {
320+ let mut rows = Vec::new();
321+ let mut ix = 0;
322+ while ix < lines.len() {
323+ let line = &lines[ix];
324+ match line.kind {
325+ LineKind::Hunk => rows.push(SplitRow::Hunk(line)),
326+ LineKind::Context => rows.push(SplitRow::Pair(Some(line), Some(line))),
327+ LineKind::Removed | LineKind::Added => {
328+ let run = |from: usize, kind: LineKind| {
329+ lines[from..].iter().take_while(|line| line.kind == kind).count()
330+ };
331+ let removed = run(ix, LineKind::Removed);
332+ let added = run(ix + removed, LineKind::Added);
333+ let (old, new) = (&lines[ix..ix + removed], &lines[ix + removed..ix + removed + added]);
334+ rows.extend((0..removed.max(added)).map(|row| SplitRow::Pair(old.get(row), new.get(row))));
335+ ix += removed + added;
336+ continue;
337+ }
338+ }
339+ ix += 1;
340+ }
341+ rows
342+ }
343+
344+ /// A line's sign, background and ink.
345+ fn line_style(kind: LineKind, p: &Palette) -> (&'static str, Option<Hsla>, Hsla) {
346+ match kind {
347+ LineKind::Added => ("+", Some(p.green_deep.opacity(0.14)), p.ink),
348+ LineKind::Removed => ("−", Some(p.red.opacity(0.12)), p.ink),
349+ LineKind::Hunk => ("", Some(p.ink.opacity(0.04)), p.ink3),
350+ LineKind::Context => (" ", None, p.ink2),
351+ }
352+ }
353+
354+ const DIFF_ROW: Pixels = px(18.);
355+
356+ /// A hunk header, spanning the whole width.
357+ fn hunk_row(line: &DiffLine, p: &Palette) -> Div {
358+ let (_, bg, color) = line_style(line.kind, p);
359+ h_flex()
360+ .h(DIFF_ROW)
361+ .px(px(12.))
362+ .when_some(bg, |this, bg| this.bg(bg))
363+ .text_color(color)
364+ .child(div().flex_1().min_w_0().truncate().child(line.text.clone()))
365+ }
366+
367+ /// A line of code behind its numbers in the gutter, clipped at the edge
368+ /// rather than wrapped.
369+ fn code_row(line: &DiffLine, numbers: &[Option<u32>], gutter: Pixels, p: &Palette) -> Div {
370+ let (sign, bg, color) = line_style(line.kind, p);
371+ h_flex()
372+ .h(DIFF_ROW)
373+ .min_w_0()
374+ .px(px(12.))
375+ .gap(px(10.))
376+ .when_some(bg, |this, bg| this.bg(bg))
377+ .text_color(color)
378+ .children(numbers.iter().map(|number| {
379+ div()
380+ .flex_none()
381+ .w(gutter)
382+ .text_right()
383+ .text_color(p.ink3.opacity(0.7))
384+ .when_some(*number, |this, number| this.child(number.to_string()))
385+ }))
386+ .child(div().w(px(10.)).flex_none().text_color(p.ink3).child(sign))
387+ .child(div().flex_1().min_w_0().truncate().child(line.text.clone()))
258388 }
259389
260- pub fn render_diff(view: &DiffView, on_close: impl Fn(&mut Window, &mut App) + 'static, cx: &App) -> AnyElement {
390+ /// One column of a side-by-side row: a line, or a blank where the other
391+ /// side runs longer.
392+ fn split_cell(line: Option<&DiffLine>, number: impl Fn(&DiffLine) -> Option<u32>, gutter: Pixels, p: &Palette) -> Div {
393+ match line {
394+ Some(line) => code_row(line, &[number(line)], gutter, p),
395+ None => div().h(DIFF_ROW).bg(p.ink.opacity(0.025)),
396+ }
397+ .flex_1()
398+ .overflow_hidden()
399+ }
400+
401+ pub fn render_diff(
402+ view: &DiffView,
403+ on_action: impl Fn(DiffAction, &mut Window, &mut App) + 'static,
404+ cx: &App,
405+ ) -> AnyElement {
261406 let p = metal::palette(cx);
407+ let on_action = Rc::new(on_action);
408+ let on_click = |action: DiffAction| {
409+ let on_action = on_action.clone();
410+ move |_: &ClickEvent, window: &mut Window, cx: &mut App| on_action(action.clone(), window, cx)
411+ };
412+ let files = match &view.files {
413+ Some(Ok(files)) if !files.is_empty() => Some(files),
414+ _ => None,
415+ };
416+ let any_open = files.is_some_and(|files| files.iter().any(|file| !view.collapsed.contains(&file.path)));
417+
262418 let header = h_flex()
263419 .flex_none()
264420 .h(px(46.))
@@ -275,6 +431,30 @@ pub fn render_diff(view: &DiffView, on_close: impl Fn(&mut Window, &mut App) + '
275431 .text_color(p.ink)
276432 .child(view.title.clone()),
277433 )
434+ .when(files.is_some(), |this| {
435+ this.child(
436+ MetalButton::new("diff-toggle-all")
437+ .ghost()
438+ .compact()
439+ .icon(if any_open { Lucide::ChevronsDownUp } else { Lucide::ChevronsUpDown })
440+ .label(if any_open { "Collapse all" } else { "Expand all" })
441+ .on_click(on_click(DiffAction::SetAllCollapsed(any_open))),
442+ )
443+ })
444+ .child(
445+ MetalButton::new("diff-whitespace")
446+ .ghost()
447+ .mini()
448+ .icon(Lucide::Space)
449+ .selected(view.ignore_whitespace)
450+ .tooltip(if view.ignore_whitespace { "Show whitespace changes" } else { "Hide whitespace changes" })
451+ .on_click(on_click(DiffAction::ToggleWhitespace)),
452+ )
453+ .child(metal::segmented("diff-layout", &[(false, "Stacked"), (true, "Side by side")], view.side_by_side, p, {
454+ let on_action = on_action.clone();
455+ move |side_by_side, window, cx| on_action(DiffAction::SetSideBySide(side_by_side), window, cx)
456+ }))
457+ .child(metal::vrule(p))
278458 .child(metal::keycap("Esc", p))
279459 .child(
280460 MetalButton::new("close-diff")
@@ -282,33 +462,52 @@ pub fn render_diff(view: &DiffView, on_close: impl Fn(&mut Window, &mut App) + '
282462 .mini()
283463 .icon(IconName::Close)
284464 .tooltip("Close diff")
285- .on_click(move |_, window, cx| on_close(window, cx)),
465+ .on_click(on_click(DiffAction::Close)),
286466 );
287467
468+ let message = |text: SharedString, color: Hsla| {
469+ div().p(px(20.)).text_size(px(13.)).text_color(color).child(text).into_any_element()
470+ };
288471 let body: AnyElement = match &view.files {
289- None => div().p(px(20.)).text_size(px(13.)).text_color(p.ink3).child("Reading the diff…").into_any_element(),
290- Some(Err(error)) => div()
291- .p(px(20.))
292- .text_size(px(13.))
293- .text_color(p.red)
294- .child(format!("Couldn't read the diff: {error}"))
295- .into_any_element(),
472+ None => message("Reading the diff…".into(), p.ink3),
473+ Some(Err(error)) => message(format!("Couldn't read the diff: {error}").into(), p.red),
474+ Some(Ok(files)) if files.is_empty() => message(
475+ if view.ignore_whitespace { "Only whitespace changed." } else { "Nothing changed." }.into(),
476+ p.ink3,
477+ ),
296478 Some(Ok(files)) => {
297479 let mut budget = MAX_DIFF_LINES;
298480 v_flex()
299481 .gap(px(14.))
300482 .p(px(16.))
301- .children(files.iter().map(|file| {
302- let shown = file.lines.len().min(budget);
483+ .children(files.iter().enumerate().map(|(ix, file)| {
484+ let open = !view.collapsed.contains(&file.path);
485+ let shown = if open { file.lines.len().min(budget) } else { 0 };
303486 budget -= shown;
304487 let hidden = file.lines.len() - shown;
488+ let lines = &file.lines[..shown];
305489 let added = file.lines.iter().filter(|line| line.kind == LineKind::Added).count() as u64;
306490 let removed = file.lines.iter().filter(|line| line.kind == LineKind::Removed).count() as u64;
491+ // Wide enough for the longest number, in Geist Mono's 12px digits.
492+ let digits = lines.iter().filter_map(|line| line.old_line.max(line.new_line)).max().unwrap_or(0);
493+ let gutter = px(digits.to_string().len() as f32 * 7.5);
307494 v_flex()
308495 .gap(px(6.))
309496 .child(
310497 h_flex()
311- .gap(px(10.))
498+ .id(("diff-file", ix))
499+ .h(px(28.))
500+ .px(px(8.))
501+ .gap(px(8.))
502+ .rounded(px(9.))
503+ .cursor_pointer()
504+ .hover(|style| style.bg(p.row_hover))
505+ .on_click(on_click(DiffAction::ToggleFile(file.path.clone())))
506+ .child(
507+ Icon::new(if open { IconName::ChevronDown } else { IconName::ChevronRight })
508+ .size(px(12.))
509+ .text_color(p.ink3),
510+ )
312511 .child(file_icon(&file.path).size(px(14.)).text_color(p.ink3))
313512 .child(
314513 div()
@@ -326,42 +525,49 @@ pub fn render_diff(view: &DiffView, on_close: impl Fn(&mut Window, &mut App) + '
326525 p,
327526 )),
328527 )
329- .child(
330- v_flex()
331- .rounded(px(12.))
332- .well(p)
333- .py(px(6.))
334- .font_family(metal::MONO)
335- .text_size(px(12.))
336- .line_height(px(18.))
337- .when(file.binary, |this| {
338- this.child(div().px(px(12.)).text_color(p.ink3).child("Binary file"))
339- })
340- .children(file.lines.iter().take(shown).map(|line| {
341- let (sign, bg, color) = match line.kind {
342- LineKind::Added => ("+", Some(p.green_deep.opacity(0.14)), p.ink),
343- LineKind::Removed => ("−", Some(p.red.opacity(0.12)), p.ink),
344- LineKind::Hunk => ("", Some(p.ink.opacity(0.04)), p.ink3),
345- LineKind::Context => (" ", None, p.ink2),
346- };
347- h_flex()
348- .px(px(12.))
349- .gap(px(10.))
350- .when_some(bg, |this, bg| this.bg(bg))
351- .text_color(color)
352- .child(div().w(px(10.)).flex_none().text_color(p.ink3).child(sign))
353- .child(div().flex_1().min_w_0().whitespace_nowrap().child(line.text.clone()))
354- }))
355- .when(hidden > 0, |this| {
356- this.child(
357- div()
358- .px(px(12.))
359- .pt(px(4.))
360- .text_color(p.ink3)
361- .child(format!("… {hidden} more lines")),
362- )
363- }),
364- )
528+ .when(open, |this| {
529+ this.child(
530+ v_flex()
531+ .rounded(px(12.))
532+ .well(p)
533+ .py(px(6.))
534+ .overflow_hidden()
535+ .font_family(metal::MONO)
536+ .text_size(px(12.))
537+ .line_height(DIFF_ROW)
538+ .when(file.binary, |this| {
539+ this.child(div().px(px(12.)).text_color(p.ink3).child("Binary file"))
540+ })
541+ .map(|this| {
542+ if view.side_by_side {
543+ this.children(split_rows(lines).into_iter().map(|row| match row {
544+ SplitRow::Hunk(line) => hunk_row(line, p),
545+ SplitRow::Pair(old, new) => h_flex()
546+ .child(
547+ split_cell(old, |line| line.old_line, gutter, p)
548+ .border_r_1()
549+ .border_color(p.rule),
550+ )
551+ .child(split_cell(new, |line| line.new_line, gutter, p)),
552+ }))
553+ } else {
554+ this.children(lines.iter().map(|line| match line.kind {
555+ LineKind::Hunk => hunk_row(line, p),
556+ _ => code_row(line, &[line.old_line, line.new_line], gutter, p),
557+ }))
558+ }
559+ })
560+ .when(hidden > 0, |this| {
561+ this.child(
562+ div()
563+ .px(px(12.))
564+ .pt(px(4.))
565+ .text_color(p.ink3)
566+ .child(format!("… {hidden} more lines")),
567+ )
568+ }),
569+ )
570+ })
365571 }))
366572 .into_any_element()
367573 }
@@ -374,3 +580,65 @@ pub fn render_diff(view: &DiffView, on_close: impl Fn(&mut Window, &mut App) + '
374580 .child(div().id("diff-scroll").flex_1().min_h_0().overflow_y_scroll().child(body))
375581 .into_any_element()
376582 }
583+
584+ #[cfg(test)]
585+ mod tests {
586+ use super::{SplitRow, split_rows};
587+ use crate::git::{DiffLine, LineKind};
588+
589+ fn line(kind: LineKind, text: &str) -> DiffLine {
590+ DiffLine { kind, text: text.to_string(), old_line: None, new_line: None }
591+ }
592+
593+ /// The rows as `(left, right)` texts; hunk headers show on both sides.
594+ fn texts<'a>(rows: &[SplitRow<'a>]) -> Vec<(Option<&'a str>, Option<&'a str>)> {
595+ rows.iter()
596+ .map(|row| match row {
597+ SplitRow::Hunk(line) => (Some(line.text.as_str()), Some(line.text.as_str())),
598+ SplitRow::Pair(old, new) => (old.map(|line| line.text.as_str()), new.map(|line| line.text.as_str())),
599+ })
600+ .collect()
601+ }
602+
603+ #[::core::prelude::v1::test]
604+ fn pairs_changes_side_by_side() {
605+ use LineKind::*;
606+ let lines = [
607+ line(Hunk, "@@"),
608+ line(Context, "a"),
609+ line(Removed, "b"),
610+ line(Removed, "c"),
611+ line(Added, "B"),
612+ line(Context, "d"),
613+ line(Removed, "e"),
614+ line(Added, "E"),
615+ line(Added, "F"),
616+ line(Added, "G"),
617+ line(Added, "H"),
618+ line(Context, "i"),
619+ line(Removed, "j"),
620+ ];
621+ assert_eq!(
622+ texts(&split_rows(&lines)),
623+ [
624+ (Some("@@"), Some("@@")),
625+ (Some("a"), Some("a")),
626+ (Some("b"), Some("B")),
627+ (Some("c"), None),
628+ (Some("d"), Some("d")),
629+ (Some("e"), Some("E")),
630+ (None, Some("F")),
631+ (None, Some("G")),
632+ (None, Some("H")),
633+ (Some("i"), Some("i")),
634+ (Some("j"), None),
635+ ]
636+ );
637+ }
638+
639+ #[::core::prelude::v1::test]
640+ fn pairs_additions_alone() {
641+ let lines = [line(LineKind::Added, "x"), line(LineKind::Removed, "y")];
642+ assert_eq!(texts(&split_rows(&lines)), [(None, Some("x")), (Some("y"), None)]);
643+ }
644+ }
+65−20src/ui/chat.rsView File
@@ -30,7 +30,7 @@ use crate::session::{
3030 SessionEvent, ToolCall, ToolState, tool_field, tool_summary,
3131 };
3232 use crate::settings::{self, FollowUp, WorkspaceMode};
33- use crate::ui::changes::{self, DiffView};
33+ use crate::ui::changes::{self, DiffAction, DiffView};
3434 use crate::ui::icons::Lucide;
3535 use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette};
3636 use crate::ui::pickers::{self, ModelPicker, ModelPickerEvent};
@@ -408,28 +408,77 @@ impl ChatView {
408408
409409 /// Shows what a finished turn changed, all files or one.
410410 fn open_diff(&mut self, end: usize, path: Option<String>, window: &mut Window, cx: &mut Context<Self>) {
411- let session = self.session.read(cx);
412- let Some(changes) = session.turn_changes(end) else { return };
413- let (base, head) = (changes.base.clone(), changes.head.clone());
414- let cwd = session.cwd.clone();
411+ let Some(changes) = self.session.read(cx).turn_changes(end) else { return };
415412 let title = match &path {
416413 Some(path) => path.clone(),
417414 None => format!("{} changed files", changes.files.len()),
418415 };
419- self.diff = Some(DiffView { title: title.into(), files: None });
420- let task = cx
421- .background_executor()
422- .spawn(async move { git::diff_between(&cwd, &base, &head, path.as_deref()) });
416+ let (ignore_whitespace, side_by_side, collapsed) = settings::get(|settings| {
417+ (settings.hide_whitespace, settings.diff_side_by_side, settings.diff_collapsed)
418+ });
419+ self.diff = Some(DiffView {
420+ title: title.into(),
421+ end,
422+ path,
423+ ignore_whitespace,
424+ side_by_side,
425+ start_collapsed: collapsed,
426+ ..Default::default()
427+ });
428+ self.load_diff(window, cx);
429+ cx.notify();
430+ }
431+
432+ /// Reads the open diff in the background, as its whitespace toggle asks.
433+ fn load_diff(&mut self, window: &mut Window, cx: &mut Context<Self>) {
434+ let Some(diff) = &self.diff else { return };
435+ let session = self.session.read(cx);
436+ let Some(changes) = session.turn_changes(diff.end) else { return };
437+ if session.is_remote() {
438+ // The snapshots live in the host's repository.
439+ let note = Err("This thread runs on another device; its diffs open there.".to_string());
440+ if let Some(diff) = self.diff.as_mut() {
441+ diff.set_files(note);
442+ }
443+ return;
444+ }
445+ let (base, head) = (changes.base.clone(), changes.head.clone());
446+ let cwd = session.cwd.clone();
447+ let (end, path, ignore_whitespace) = (diff.end, diff.path.clone(), diff.ignore_whitespace);
448+ let task = cx.background_executor().spawn({
449+ let path = path.clone();
450+ async move { git::diff_between(&cwd, &base, &head, path.as_deref(), ignore_whitespace) }
451+ });
423452 cx.spawn_in(window, async move |this, cx| {
424453 let files = task.await;
425454 let _ = this.update(cx, |this, cx| {
426- if let Some(diff) = this.diff.as_mut() {
427- diff.files = Some(files);
455+ // Unless the diff was closed, replaced or toggled meanwhile.
456+ if let Some(diff) = this.diff.as_mut()
457+ && (diff.end, &diff.path, diff.ignore_whitespace) == (end, &path, ignore_whitespace)
458+ {
459+ diff.set_files(files);
428460 cx.notify();
429461 }
430462 });
431463 })
432464 .detach();
465+ }
466+
467+ fn diff_action(&mut self, action: DiffAction, window: &mut Window, cx: &mut Context<Self>) {
468+ let Some(diff) = self.diff.as_mut() else { return };
469+ match action {
470+ DiffAction::Close => self.diff = None,
471+ DiffAction::ToggleWhitespace => {
472+ diff.ignore_whitespace = !diff.ignore_whitespace;
473+ self.load_diff(window, cx);
474+ }
475+ DiffAction::SetSideBySide(side_by_side) => {
476+ diff.side_by_side = side_by_side;
477+ settings::update(|settings| settings.diff_side_by_side = side_by_side);
478+ }
479+ DiffAction::ToggleFile(path) => diff.toggle_file(path),
480+ DiffAction::SetAllCollapsed(collapsed) => diff.set_all_collapsed(collapsed),
481+ }
433482 cx.notify();
434483 }
435484
@@ -634,8 +683,7 @@ impl ChatView {
634683 .label("Continue then")
635684 .on_click(move |_, _, cx| {
636685 session.update(cx, |session, cx| {
637- session.resume_at = Some(resets + Duration::from_secs(60));
638- cx.notify();
686+ session.schedule_resume(resets + Duration::from_secs(60), cx)
639687 })
640688 }),
641689 })
@@ -1114,13 +1162,10 @@ impl ChatView {
11141162 impl Render for ChatView {
11151163 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
11161164 let is_empty = self.session.read(cx).items.is_empty();
1117- let close_diff = {
1165+ let on_diff = {
11181166 let chat = cx.entity().downgrade();
1119- move |_: &mut Window, cx: &mut App| {
1120- let _ = chat.update(cx, |this, cx| {
1121- this.diff = None;
1122- cx.notify();
1123- });
1167+ move |action: DiffAction, window: &mut Window, cx: &mut App| {
1168+ let _ = chat.update(cx, |this, cx| this.diff_action(action, window, cx));
11241169 }
11251170 };
11261171 v_flex()
@@ -1129,7 +1174,7 @@ impl Render for ChatView {
11291174 .on_action(cx.listener(|this, _: &Interrupt, _, cx| this.escape(cx)))
11301175 .on_action(cx.listener(|this, _: &input::Escape, _, cx| this.escape(cx)))
11311176 .map(|this| match &self.diff {
1132- Some(diff) => this.child(div().flex_1().min_h_0().child(changes::render_diff(diff, close_diff, cx))),
1177+ Some(diff) => this.child(div().flex_1().min_h_0().child(changes::render_diff(diff, on_diff, cx))),
11331178 None if is_empty => this.child(self.render_start(window, cx)),
11341179 None => this.child(self.render_transcript(cx)),
11351180 })
+2−0src/ui/icons.rsView File
@@ -14,6 +14,7 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [
1414 ArchiveRestore,
1515 Bell,
1616 Brain,
17+ ChevronsDownUp,
1718 ChevronsUpDown,
1819 Code,
1920 FileCode,
@@ -46,6 +47,7 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [
4647 ShieldCheck,
4748 SquareSlash,
4849 SlidersHorizontal,
50+ Space,
4951 Sparkles,
5052 SquarePen,
5153 Star,