From 40ebf3598cc37d07bd429804b3efe224757e1a74 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 21 May 2026 20:08:56 -0700 Subject: [PATCH] =?UTF-8?q?feat(terminal):=20add=20integrated=20terminal?= =?UTF-8?q?=20panel=20with=20=E2=8C=98+J=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PTY backend: portable-pty session registry scoped per channel (max 20) - Frontend: xterm.js terminal with FitAddon, WebLinksAddon, resize observer - Panel: draggable resize (150-600px), high-contrast Tokyo Night theme - Shortcut: ⌘+J / Ctrl+J toggle via TerminalContext + custom DOM event - Integration: TerminalPanel renders below composer in ChannelPane - Tauri commands: terminal_open_session, terminal_write, terminal_resize, terminal_close_session registered in invoke_handler - Shell: resolves user's $SHELL, scrubs Hermit env, sets TERM=xterm-256color - Deps: @xterm/xterm, @xterm/addon-fit, @xterm/addon-web-links --- desktop/package.json | 3 + desktop/scripts/check-file-sizes.mjs | 4 +- desktop/src-tauri/Cargo.lock | 183 ++++++- desktop/src-tauri/Cargo.toml | 1 + desktop/src-tauri/src/app_state.rs | 4 + desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/commands/terminal.rs | 40 ++ desktop/src-tauri/src/lib.rs | 5 + desktop/src-tauri/src/terminal.rs | 449 +++++++++++++++++ desktop/src/app/AppShell.tsx | 457 +++++++++--------- .../src/features/channels/ui/ChannelPane.tsx | 9 + .../src/features/terminal/TerminalContext.tsx | 52 ++ .../features/terminal/TerminalInstance.tsx | 192 ++++++++ .../src/features/terminal/TerminalPanel.tsx | 131 +++++ desktop/src/features/terminal/index.ts | 3 + desktop/src/shared/lib/keyboard-shortcuts.ts | 8 + pnpm-lock.yaml | 27 +- 17 files changed, 1327 insertions(+), 243 deletions(-) create mode 100644 desktop/src-tauri/src/commands/terminal.rs create mode 100644 desktop/src-tauri/src/terminal.rs create mode 100644 desktop/src/features/terminal/TerminalContext.tsx create mode 100644 desktop/src/features/terminal/TerminalInstance.tsx create mode 100644 desktop/src/features/terminal/TerminalPanel.tsx create mode 100644 desktop/src/features/terminal/index.ts diff --git a/desktop/package.json b/desktop/package.json index faea7fb769..aea1e808d3 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -49,6 +49,9 @@ "@tiptap/pm": "^3.22.3", "@tiptap/react": "^3.22.3", "@tiptap/starter-kit": "^3.22.3", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "emoji-mart": "^5.6.0", diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index dc154f0ca8..195bdc79cf 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -33,10 +33,10 @@ const overrides = new Map([ ["src-tauri/src/managed_agents/personas.rs", 900], // built-in persona system prompts (Solo + Kit + Scout) + persona pack import/uninstall/list + uninstall safety check ["src-tauri/src/managed_agents/teams.rs", 580], // built-in team registry (Kit & Scout) + merge_teams + validate_team_deletion + JSON export/import + tests ["src-tauri/src/managed_agents/persona_card.rs", 970], // PNG/ZIP/MD persona card codec + pack-zip detection + nested root finder + provider/model/namePool fields + 27 unit tests - ["src/app/AppShell.tsx", 815], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + projects view + memory-leak safeguards + home-badge state lifted here so it consumes the same NIP-RS read-state as the sidebar (single ReadStateManager) + ["src/app/AppShell.tsx", 825], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + projects view + memory-leak safeguards + home-badge state lifted here so it consumes the same NIP-RS read-state as the sidebar (single ReadStateManager) + terminal toggle shortcut ["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation ["src/features/channels/ui/ChannelManagementSheet.tsx", 800], - ["src/features/channels/ui/ChannelPane.tsx", 520], // composer/timeline/sidebar orchestration + anchored agent activity footers + ["src/features/channels/ui/ChannelPane.tsx", 530], // composer/timeline/sidebar orchestration + anchored agent activity footers + terminal panel integration ["src/features/channels/ui/ChannelScreen.tsx", 550], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context + agent typing classification ["src/features/notifications/hooks.ts", 535], // notification settings + feed notification lifecycle + profile batch resolution + truncated-pubkey guard + badge state ["src/features/home/ui/HomeView.tsx", 505], // inbox/feed orchestration + thread context + reply/delete flow + NIP-RS read-state projection wiring (useHomeInboxReadState) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index b38a19b136..ec08b75591 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -271,7 +271,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84790c55b5704b0d35130bf16a4ce22a8e70eb0ea773522557524d9a4852663d" dependencies = [ - "nix", + "nix 0.30.1", "rand 0.9.4", ] @@ -1337,7 +1337,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1410,6 +1410,12 @@ dependencies = [ "tendril 0.5.0", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -1478,7 +1484,7 @@ dependencies = [ "rustc_version", "toml 0.9.12+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -1547,7 +1553,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1598,10 +1604,21 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" dependencies = [ - "memoffset", + "memoffset 0.9.1", "rustc_version", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.27" @@ -2574,6 +2591,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ioctl-rs" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2969,6 +2995,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -3029,7 +3064,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3093,6 +3128,20 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.6.5", + "pin-utils", +] + [[package]] name = "nix" version = "0.30.1" @@ -3874,6 +3923,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "piper" version = "0.2.5" @@ -3961,6 +4016,27 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-pty" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.25.1", + "serial", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -4619,7 +4695,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4678,7 +4754,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5061,6 +5137,48 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serial" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" +dependencies = [ + "serial-core", + "serial-unix", + "serial-windows", +] + +[[package]] +name = "serial-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" +dependencies = [ + "libc", +] + +[[package]] +name = "serial-unix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" +dependencies = [ + "ioctl-rs", + "libc", + "serial-core", + "termios", +] + +[[package]] +name = "serial-windows" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +dependencies = [ + "libc", + "serial-core", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -5135,6 +5253,22 @@ dependencies = [ "digest 0.11.2", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "sherpa-onnx" version = "1.13.2" @@ -5210,7 +5344,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5302,6 +5436,7 @@ dependencies = [ "nostr 0.37.0", "opus", "png 0.18.1", + "portable-pty", "regex", "reqwest 0.13.3", "rodio", @@ -6161,7 +6296,7 @@ dependencies = [ "serde_with", "swift-rs", "thiserror 2.0.18", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "url", "urlpattern", "uuid", @@ -6201,7 +6336,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6225,6 +6360,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "termios" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" +dependencies = [ + "libc", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -6639,7 +6783,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6703,9 +6847,9 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ - "memoffset", + "memoffset 0.9.1", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7218,7 +7362,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7728,6 +7872,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 9cd6995822..7fbb89a646 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -73,5 +73,6 @@ earshot = "1.0" rubato = "2.0" audioadapter-buffers = "3.0" tempfile = "3" +portable-pty = "0.8" [dev-dependencies] diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 54e33553ba..cf05c43307 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -9,6 +9,7 @@ use tauri::{AppHandle, Manager}; use crate::huddle::HuddleState; use crate::managed_agents::ManagedAgentProcess; +use crate::terminal::TerminalRegistry; pub struct AppState { pub keys: Mutex, @@ -33,6 +34,8 @@ pub struct AppState { pub media_proxy_port: AtomicU16, /// IOKit power assertion state — prevents idle sleep while agents run. pub prevent_sleep: Arc>, + /// PTY terminal session registry — one session per channel. + pub terminal_registry: TerminalRegistry, } pub fn build_app_state() -> AppState { @@ -78,6 +81,7 @@ pub fn build_app_state() -> AppState { prevent_sleep: Arc::new(Mutex::new( crate::prevent_sleep::PreventSleepState::default(), )), + terminal_registry: TerminalRegistry::new(), } } diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 2bc5062f5e..b27131611b 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -18,6 +18,7 @@ mod profile; mod relay_members; mod social; mod teams; +mod terminal; mod workflows; mod workspace; @@ -40,5 +41,6 @@ pub use profile::*; pub use relay_members::*; pub use social::*; pub use teams::*; +pub use terminal::*; pub use workflows::*; pub use workspace::*; diff --git a/desktop/src-tauri/src/commands/terminal.rs b/desktop/src-tauri/src/commands/terminal.rs new file mode 100644 index 0000000000..e6bd9a735b --- /dev/null +++ b/desktop/src-tauri/src/commands/terminal.rs @@ -0,0 +1,40 @@ +use tauri::{AppHandle, State}; + +use crate::app_state::AppState; +use crate::terminal::{ + TerminalCloseInput, TerminalOpenInput, TerminalResizeInput, TerminalWriteInput, +}; + +#[tauri::command] +pub fn terminal_open_session( + app: AppHandle, + state: State<'_, AppState>, + input: TerminalOpenInput, +) -> Result { + state.terminal_registry.open_session(&app, &input) +} + +#[tauri::command] +pub fn terminal_write(state: State<'_, AppState>, input: TerminalWriteInput) -> Result<(), String> { + state + .terminal_registry + .write(&input.session_id, input.data.as_bytes()) +} + +#[tauri::command] +pub fn terminal_resize( + state: State<'_, AppState>, + input: TerminalResizeInput, +) -> Result<(), String> { + state + .terminal_registry + .resize(&input.session_id, input.cols, input.rows) +} + +#[tauri::command] +pub fn terminal_close_session( + state: State<'_, AppState>, + input: TerminalCloseInput, +) -> Result { + state.terminal_registry.close_session(&input.session_id) +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index d27a35914f..4c7f3ed8bc 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -10,6 +10,7 @@ pub mod nostr_convert; mod prevent_sleep; mod relay; mod templates; +mod terminal; mod util; use app_state::{build_app_state, resolve_persisted_identity, AppState}; @@ -584,6 +585,10 @@ pub fn run() { apply_workspace, get_active_workspace, set_prevent_sleep_active, + terminal_open_session, + terminal_write, + terminal_resize, + terminal_close_session, ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); diff --git a/desktop/src-tauri/src/terminal.rs b/desktop/src-tauri/src/terminal.rs new file mode 100644 index 0000000000..901cda6ead --- /dev/null +++ b/desktop/src-tauri/src/terminal.rs @@ -0,0 +1,449 @@ +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::sync::{Arc, Mutex}; +use std::thread; + +use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize}; +use tauri::{AppHandle, Emitter}; +use uuid::Uuid; + +const MAX_BUFFER_BYTES: usize = 256 * 1024; +const READ_CHUNK_BYTES: usize = 4096; +const DEFAULT_COLS: u16 = 80; +const DEFAULT_ROWS: u16 = 24; + +/// Maximum number of live PTY sessions. One per channel in practice, +/// but this cap prevents unbounded growth. +const MAX_LIVE_SESSIONS: usize = 20; + +/// Env vars inherited from the Tauri app's Hermit activation that should +/// be stripped from spawned shells so the user gets a clean environment. +const HERMIT_METADATA_ENV_KEYS: &[&str] = &[ + "ACTIVE_HERMIT", + "HERMIT_BIN", + "HERMIT_BIN_CHANGE", + "HERMIT_ENV", + "HERMIT_ENV_OPS", +]; + +const MACOS_APP_ENV_KEYS: &[&str] = &["__CFBundleIdentifier", "XPC_SERVICE_NAME"]; + +#[derive(Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct TerminalDataPayload { + session_id: String, + data: String, +} + +#[derive(Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct TerminalExitPayload { + session_id: String, + exit_code: i32, +} + +pub struct TerminalRegistry { + sessions: Arc>, +} + +#[derive(Default)] +struct TerminalSessions { + by_session_id: HashMap, + /// Maps channel_id → session_id for single-instance-per-channel semantics. + session_id_by_channel: HashMap, +} + +struct TerminalSession { + session_id: String, + channel_id: String, + #[allow(dead_code)] + master: Box, + writer: Box, + killer: Box, + buffer: Arc>>, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalOpenInput { + pub channel_id: String, + pub cols: u16, + pub rows: u16, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalOpenOutput { + pub session_id: String, + pub created: bool, + pub initial_data: Option, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalWriteInput { + pub session_id: String, + pub data: String, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalResizeInput { + pub session_id: String, + pub cols: u16, + pub rows: u16, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalCloseInput { + pub session_id: String, +} + +impl TerminalRegistry { + pub fn new() -> Self { + Self { + sessions: Arc::new(Mutex::new(TerminalSessions::default())), + } + } + + pub fn open_session( + &self, + app: &AppHandle, + input: &TerminalOpenInput, + ) -> Result { + let size = normalize_size(input.cols, input.rows); + let mut guard = self.sessions.lock().map_err(|e| e.to_string())?; + + // Return existing session for this channel if one exists. + if let Some(existing_id) = guard.session_id_by_channel.get(&input.channel_id).cloned() { + if let Some(session) = guard.by_session_id.get(&existing_id) { + // Resize the existing session to match the new dimensions. + session + .master + .resize(size) + .map_err(|e| format!("resize failed: {e}"))?; + let initial_data = snapshot_buffer(&session.buffer)?; + return Ok(TerminalOpenOutput { + session_id: existing_id, + created: false, + initial_data, + }); + } + // Stale mapping — remove it. + guard.session_id_by_channel.remove(&input.channel_id); + } + + // Enforce session cap via LRU eviction (by insertion order here — simple). + let mut evicted: Vec = Vec::new(); + while guard.by_session_id.len() >= MAX_LIVE_SESSIONS { + // Evict the first session we find that isn't for this channel. + let evict_id = guard + .by_session_id + .keys() + .find(|id| { + guard + .by_session_id + .get(*id) + .map(|s| s.channel_id != input.channel_id) + .unwrap_or(true) + }) + .cloned(); + let Some(evict_id) = evict_id else { + break; + }; + if let Some(session) = guard.by_session_id.remove(&evict_id) { + guard.session_id_by_channel.remove(&session.channel_id); + evicted.push(session); + } + } + drop(guard); + + // Kill evicted sessions outside the lock. + for mut session in evicted { + let _ = session.killer.kill(); + } + + // Spawn a new PTY session. + let session = spawn_session(app.clone(), self.sessions.clone(), &input.channel_id, size)?; + let session_id = session.session_id.clone(); + + let mut guard = self.sessions.lock().map_err(|e| e.to_string())?; + guard + .session_id_by_channel + .insert(input.channel_id.clone(), session_id.clone()); + guard.by_session_id.insert(session_id.clone(), session); + + Ok(TerminalOpenOutput { + session_id, + created: true, + initial_data: None, + }) + } + + pub fn write(&self, session_id: &str, data: &[u8]) -> Result<(), String> { + let mut guard = self.sessions.lock().map_err(|e| e.to_string())?; + let session = guard + .by_session_id + .get_mut(session_id) + .ok_or_else(|| format!("session not found: {session_id}"))?; + session.writer.write_all(data).map_err(|e| e.to_string())?; + session.writer.flush().map_err(|e| e.to_string())?; + Ok(()) + } + + pub fn resize(&self, session_id: &str, cols: u16, rows: u16) -> Result<(), String> { + let guard = self.sessions.lock().map_err(|e| e.to_string())?; + let session = guard + .by_session_id + .get(session_id) + .ok_or_else(|| format!("session not found: {session_id}"))?; + session + .master + .resize(normalize_size(cols, rows)) + .map_err(|e| format!("resize failed: {e}"))?; + Ok(()) + } + + pub fn close_session(&self, session_id: &str) -> Result { + let mut guard = self.sessions.lock().map_err(|e| e.to_string())?; + let Some(mut session) = guard.by_session_id.remove(session_id) else { + return Ok(false); + }; + guard.session_id_by_channel.remove(&session.channel_id); + drop(guard); + let _ = session.killer.kill(); + Ok(true) + } +} + +impl Drop for TerminalRegistry { + fn drop(&mut self) { + let sessions = match self.sessions.lock() { + Ok(mut guard) => { + guard.session_id_by_channel.clear(); + std::mem::take(&mut guard.by_session_id) + } + Err(_) => return, + }; + + for mut session in sessions.into_values() { + let _ = session.killer.kill(); + } + } +} + +fn normalize_size(cols: u16, rows: u16) -> PtySize { + PtySize { + cols: if cols == 0 { DEFAULT_COLS } else { cols }, + rows: if rows == 0 { DEFAULT_ROWS } else { rows }, + pixel_width: 0, + pixel_height: 0, + } +} + +fn resolve_shell() -> String { + if cfg!(windows) { + return "powershell.exe".to_string(); + } + + if let Ok(shell) = std::env::var("SHELL") { + let trimmed = shell.trim().to_string(); + if !trimmed.is_empty() && std::path::Path::new(&trimmed).exists() { + return trimmed; + } + } + + for candidate in ["/bin/zsh", "/bin/bash", "/bin/sh"] { + if std::path::Path::new(candidate).exists() { + return candidate.to_string(); + } + } + + "/bin/sh".to_string() +} + +fn spawn_session( + app: AppHandle, + sessions: Arc>, + channel_id: &str, + size: PtySize, +) -> Result { + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(size) + .map_err(|e| format!("failed to open pty: {e}"))?; + + let shell = resolve_shell(); + let mut cmd = CommandBuilder::new(&shell); + + // Set working directory to user's home. + if let Some(home) = dirs::home_dir() { + cmd.cwd(home); + } + + // Set terminal identification. + cmd.env("TERM", "xterm-256color"); + cmd.env("TERM_PROGRAM", "Sprout"); + + // Scrub Hermit env vars so the user gets a clean shell. + for key in HERMIT_METADATA_ENV_KEYS { + cmd.env_remove(key); + } + for key in MACOS_APP_ENV_KEYS { + cmd.env_remove(key); + } + + // Strip Hermit PATH entries. + scrub_hermit_path(&mut cmd); + + // Add interactive flag. + if !cfg!(windows) { + if shell.contains("zsh") { + cmd.args(["-il"]); + } else { + cmd.arg("-i"); + } + } + + let child = pair + .slave + .spawn_command(cmd) + .map_err(|e| format!("failed to spawn shell: {e}"))?; + drop(pair.slave); + + let session_id = Uuid::new_v4().to_string(); + let reader = pair + .master + .try_clone_reader() + .map_err(|e| format!("failed to clone reader: {e}"))?; + let writer = pair + .master + .take_writer() + .map_err(|e| format!("failed to take writer: {e}"))?; + let killer = child.clone_killer(); + let buffer = Arc::new(Mutex::new(Vec::new())); + + spawn_reader_thread(app.clone(), session_id.clone(), reader, buffer.clone()); + spawn_wait_thread(app, sessions, session_id.clone()); + + Ok(TerminalSession { + session_id, + channel_id: channel_id.to_string(), + master: pair.master, + writer, + killer, + buffer, + }) +} + +fn spawn_reader_thread( + app: AppHandle, + session_id: String, + mut reader: Box, + buffer: Arc>>, +) { + thread::spawn(move || { + let mut chunk = [0u8; READ_CHUNK_BYTES]; + loop { + match reader.read(&mut chunk) { + Ok(0) => break, + Ok(count) => { + // Append to buffer. + if let Ok(mut guard) = buffer.lock() { + guard.extend_from_slice(&chunk[..count]); + if guard.len() > MAX_BUFFER_BYTES { + let extra = guard.len() - MAX_BUFFER_BYTES; + guard.drain(0..extra); + } + } + + let payload = TerminalDataPayload { + session_id: session_id.clone(), + data: String::from_utf8_lossy(&chunk[..count]).into_owned(), + }; + if let Err(e) = app.emit("terminal:data", payload) { + eprintln!("[terminal] emit terminal:data failed: {e}"); + } + } + Err(_) => break, + } + } + }); +} + +fn spawn_wait_thread(app: AppHandle, sessions: Arc>, session_id: String) { + thread::spawn(move || { + // Wait for the child process to exit by polling the session's existence. + // The reader thread will break when the PTY closes, which happens when + // the child exits. We detect this by the reader thread ending (the PTY + // master read returns 0). For simplicity, we poll. + loop { + std::thread::sleep(std::time::Duration::from_millis(200)); + let guard = match sessions.lock() { + Ok(g) => g, + Err(_) => break, + }; + if !guard.by_session_id.contains_key(&session_id) { + break; + } + drop(guard); + } + + let payload = TerminalExitPayload { + session_id: session_id.clone(), + exit_code: 0, + }; + let _ = app.emit("terminal:exit", payload); + + // Clean up from registry. + if let Ok(mut guard) = sessions.lock() { + if let Some(session) = guard.by_session_id.remove(&session_id) { + guard.session_id_by_channel.remove(&session.channel_id); + } + } + }); +} + +fn snapshot_buffer(buffer: &Arc>>) -> Result, String> { + let guard = buffer.lock().map_err(|e| e.to_string())?; + if guard.is_empty() { + return Ok(None); + } + Ok(Some(String::from_utf8_lossy(&guard).into_owned())) +} + +fn scrub_hermit_path(cmd: &mut CommandBuilder) { + let hermit_env = cmd.get_env("HERMIT_ENV").map(|v| v.to_os_string()); + let active_hermit = cmd.get_env("ACTIVE_HERMIT").map(|v| v.to_os_string()); + + let mut roots: Vec = Vec::new(); + if let Some(val) = hermit_env { + let p = std::path::PathBuf::from(val); + if !p.as_os_str().is_empty() && !roots.contains(&p) { + roots.push(p); + } + } + if let Some(val) = active_hermit { + let p = std::path::PathBuf::from(val); + if !p.as_os_str().is_empty() && !roots.contains(&p) { + roots.push(p); + } + } + + if roots.is_empty() { + return; + } + + let Some(path_val) = cmd.get_env("PATH").map(|v| v.to_os_string()) else { + return; + }; + + let filtered: Vec = std::env::split_paths(&path_val) + .filter(|entry| !roots.iter().any(|root| entry.starts_with(root))) + .collect(); + + if let Ok(joined) = std::env::join_paths(filtered) { + cmd.env("PATH", joined); + } +} diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 837191eb93..780594b460 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -48,6 +48,7 @@ import { type SettingsSection, } from "@/features/settings/ui/SettingsPanels"; import { HuddleBar, HuddleProvider } from "@/features/huddle"; +import { TerminalProvider } from "@/features/terminal"; import { AppSidebar } from "@/features/sidebar/ui/AppSidebar"; import { useWorkspaces } from "@/features/workspaces/useWorkspaces"; import { useApplyTemplate } from "@/features/channel-templates/useApplyTemplate"; @@ -482,6 +483,12 @@ export function AppShell() { } const key = event.key.toLowerCase(); + if (key === "j" && !event.shiftKey) { + event.preventDefault(); + document.dispatchEvent(new CustomEvent("sprout:toggle-terminal")); + return; + } + if (key === "k" && !event.shiftKey) { event.preventDefault(); handleOpenSearch(); @@ -577,235 +584,239 @@ export function AppShell() { }, []); return ( - - - { - setIsChannelManagementOpen(true); - }, - getChannelReadAt, - readStateVersion, - }} - > - -
- -
)} + {activeChannel && isTerminalOpen ? ( + + ) : null} {threadHeadMessage ? ( diff --git a/desktop/src/features/terminal/TerminalContext.tsx b/desktop/src/features/terminal/TerminalContext.tsx new file mode 100644 index 0000000000..99f6aed007 --- /dev/null +++ b/desktop/src/features/terminal/TerminalContext.tsx @@ -0,0 +1,52 @@ +import * as React from "react"; + +type TerminalContextValue = { + isOpen: boolean; + toggle: () => void; + close: () => void; +}; + +const TerminalContext = React.createContext({ + isOpen: false, + toggle: () => {}, + close: () => {}, +}); + +export function TerminalProvider({ children }: { children: React.ReactNode }) { + const [isOpen, setIsOpen] = React.useState(false); + + const toggle = React.useCallback(() => { + setIsOpen((prev) => !prev); + }, []); + + const close = React.useCallback(() => { + setIsOpen(false); + }, []); + + // Listen for the global toggle event dispatched by the keyboard shortcut handler. + React.useEffect(() => { + function handleToggle() { + setIsOpen((prev) => !prev); + } + + document.addEventListener("sprout:toggle-terminal", handleToggle); + return () => { + document.removeEventListener("sprout:toggle-terminal", handleToggle); + }; + }, []); + + const value = React.useMemo( + () => ({ isOpen, toggle, close }), + [isOpen, toggle, close], + ); + + return ( + + {children} + + ); +} + +export function useTerminal(): TerminalContextValue { + return React.useContext(TerminalContext); +} diff --git a/desktop/src/features/terminal/TerminalInstance.tsx b/desktop/src/features/terminal/TerminalInstance.tsx new file mode 100644 index 0000000000..61e16c1db2 --- /dev/null +++ b/desktop/src/features/terminal/TerminalInstance.tsx @@ -0,0 +1,192 @@ +import * as React from "react"; +import { Terminal } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import { WebLinksAddon } from "@xterm/addon-web-links"; +import { invoke } from "@tauri-apps/api/core"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; + +import "@xterm/xterm/css/xterm.css"; + +type TerminalDataPayload = { + sessionId: string; + data: string; +}; + +type TerminalExitPayload = { + sessionId: string; + exitCode: number; +}; + +type TerminalOpenOutput = { + sessionId: string; + created: boolean; + initialData: string | null; +}; + +type TerminalInstanceProps = { + channelId: string; + isVisible: boolean; +}; + +/** + * A single xterm.js terminal instance backed by a native PTY session. + * Scoped to a channel — reattaches on re-render if the session already exists. + */ +export function TerminalInstance({ + channelId, + isVisible, +}: TerminalInstanceProps) { + const containerRef = React.useRef(null); + const termRef = React.useRef(null); + const fitAddonRef = React.useRef(null); + const sessionIdRef = React.useRef(null); + const unlistenDataRef = React.useRef(null); + const unlistenExitRef = React.useRef(null); + + React.useEffect(() => { + if (!containerRef.current || !isVisible) return; + + const term = new Terminal({ + cursorBlink: true, + fontSize: 13, + fontFamily: "'SF Mono', 'Fira Code', 'JetBrains Mono', Menlo, monospace", + lineHeight: 1.3, + scrollback: 10000, + allowProposedApi: true, + theme: { + background: "#1a1b26", + foreground: "#c0caf5", + cursor: "#c0caf5", + selectionBackground: "#33467c", + black: "#15161e", + red: "#f7768e", + green: "#9ece6a", + yellow: "#e0af68", + blue: "#7aa2f7", + magenta: "#bb9af7", + cyan: "#7dcfff", + white: "#a9b1d6", + brightBlack: "#414868", + brightRed: "#f7768e", + brightGreen: "#9ece6a", + brightYellow: "#e0af68", + brightBlue: "#7aa2f7", + brightMagenta: "#bb9af7", + brightCyan: "#7dcfff", + brightWhite: "#c0caf5", + }, + }); + + const fitAddon = new FitAddon(); + const webLinksAddon = new WebLinksAddon(); + + term.loadAddon(fitAddon); + term.loadAddon(webLinksAddon); + term.open(containerRef.current); + + termRef.current = term; + fitAddonRef.current = fitAddon; + + // Fit after a frame so the container has dimensions. + requestAnimationFrame(() => { + fitAddon.fit(); + }); + + // Open PTY session. + const cols = term.cols; + const rows = term.rows; + + invoke("terminal_open_session", { + input: { channelId, cols, rows }, + }).then((output) => { + sessionIdRef.current = output.sessionId; + + // Write buffered output from a reattached session. + if (output.initialData) { + term.write(output.initialData); + } + + // Listen for PTY data. + listen("terminal:data", (event) => { + if (event.payload.sessionId === sessionIdRef.current) { + term.write(event.payload.data); + } + }).then((unlisten) => { + unlistenDataRef.current = unlisten; + }); + + // Listen for PTY exit. + listen("terminal:exit", (event) => { + if (event.payload.sessionId === sessionIdRef.current) { + term.write( + `\r\n\x1b[90m[Process exited with code ${event.payload.exitCode}]\x1b[0m\r\n`, + ); + sessionIdRef.current = null; + } + }).then((unlisten) => { + unlistenExitRef.current = unlisten; + }); + + // Send keystrokes to PTY. + term.onData((data) => { + if (sessionIdRef.current) { + invoke("terminal_write", { + input: { sessionId: sessionIdRef.current, data }, + }); + } + }); + }); + + // Resize observer. + const resizeObserver = new ResizeObserver(() => { + if (fitAddonRef.current && isVisible) { + fitAddonRef.current.fit(); + if (sessionIdRef.current && termRef.current) { + invoke("terminal_resize", { + input: { + sessionId: sessionIdRef.current, + cols: termRef.current.cols, + rows: termRef.current.rows, + }, + }); + } + } + }); + resizeObserver.observe(containerRef.current); + + return () => { + resizeObserver.disconnect(); + unlistenDataRef.current?.(); + unlistenExitRef.current?.(); + term.dispose(); + termRef.current = null; + fitAddonRef.current = null; + }; + }, [channelId, isVisible]); + + // Re-fit when visibility changes. + React.useEffect(() => { + if (isVisible && fitAddonRef.current) { + requestAnimationFrame(() => { + fitAddonRef.current?.fit(); + if (sessionIdRef.current && termRef.current) { + invoke("terminal_resize", { + input: { + sessionId: sessionIdRef.current, + cols: termRef.current.cols, + rows: termRef.current.rows, + }, + }); + } + }); + } + }, [isVisible]); + + return ( +
+ ); +} diff --git a/desktop/src/features/terminal/TerminalPanel.tsx b/desktop/src/features/terminal/TerminalPanel.tsx new file mode 100644 index 0000000000..409a260cd3 --- /dev/null +++ b/desktop/src/features/terminal/TerminalPanel.tsx @@ -0,0 +1,131 @@ +import * as React from "react"; +import { X, Minus, GripHorizontal } from "lucide-react"; + +import { TerminalInstance } from "./TerminalInstance"; + +const TERMINAL_MIN_HEIGHT_PX = 150; +const TERMINAL_MAX_HEIGHT_PX = 600; +const TERMINAL_DEFAULT_HEIGHT_PX = 280; +const TERMINAL_HEIGHT_SESSION_KEY = "sprout.desktop.terminal-panel-height"; + +function clampTerminalHeight(height: number): number { + return Math.max( + TERMINAL_MIN_HEIGHT_PX, + Math.min(TERMINAL_MAX_HEIGHT_PX, height), + ); +} + +function getInitialTerminalHeight(): number { + if (typeof window === "undefined") return TERMINAL_DEFAULT_HEIGHT_PX; + try { + const raw = window.sessionStorage.getItem(TERMINAL_HEIGHT_SESSION_KEY); + if (!raw) return TERMINAL_DEFAULT_HEIGHT_PX; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed)) return TERMINAL_DEFAULT_HEIGHT_PX; + return clampTerminalHeight(parsed); + } catch { + return TERMINAL_DEFAULT_HEIGHT_PX; + } +} + +type TerminalPanelProps = { + channelId: string; + isOpen: boolean; + onClose: () => void; +}; + +export function TerminalPanel({ + channelId, + isOpen, + onClose, +}: TerminalPanelProps) { + const [heightPx, setHeightPx] = React.useState(getInitialTerminalHeight); + + // Persist height to session storage. + React.useEffect(() => { + try { + window.sessionStorage.setItem( + TERMINAL_HEIGHT_SESSION_KEY, + String(heightPx), + ); + } catch { + // Ignore. + } + }, [heightPx]); + + const handleResizeStart = React.useCallback( + (event: React.PointerEvent) => { + event.preventDefault(); + const startY = event.clientY; + const startHeight = heightPx; + const previousCursor = document.body.style.cursor; + const previousUserSelect = document.body.style.userSelect; + + document.body.style.cursor = "row-resize"; + document.body.style.userSelect = "none"; + + const handlePointerMove = (moveEvent: PointerEvent) => { + // Dragging up = negative deltaY = taller panel. + const deltaY = startY - moveEvent.clientY; + setHeightPx(clampTerminalHeight(startHeight + deltaY)); + }; + + const handlePointerUp = () => { + document.body.style.cursor = previousCursor; + document.body.style.userSelect = previousUserSelect; + window.removeEventListener("pointermove", handlePointerMove); + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp, { once: true }); + }, + [heightPx], + ); + + if (!isOpen) return null; + + return ( +
+ {/* Resize handle */} + + + {/* Header bar */} +
+ Terminal +
+ + +
+
+ + {/* Terminal content */} +
+ +
+
+ ); +} diff --git a/desktop/src/features/terminal/index.ts b/desktop/src/features/terminal/index.ts new file mode 100644 index 0000000000..afb9475022 --- /dev/null +++ b/desktop/src/features/terminal/index.ts @@ -0,0 +1,3 @@ +export { TerminalPanel } from "./TerminalPanel"; +export { TerminalInstance } from "./TerminalInstance"; +export { TerminalProvider, useTerminal } from "./TerminalContext"; diff --git a/desktop/src/shared/lib/keyboard-shortcuts.ts b/desktop/src/shared/lib/keyboard-shortcuts.ts index 0fb2f2043d..04f9a5f7cb 100644 --- a/desktop/src/shared/lib/keyboard-shortcuts.ts +++ b/desktop/src/shared/lib/keyboard-shortcuts.ts @@ -89,6 +89,14 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ keysWindows: "Ctrl+S", category: "Navigation", }, + { + id: "toggle-terminal", + label: "Toggle terminal", + description: "Open or close the terminal panel", + keys: "⌘J", + keysWindows: "Ctrl+J", + category: "Navigation", + }, // Zoom { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c96dd80a6..9e73f7d77e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,6 +108,15 @@ importers: '@tiptap/starter-kit': specifier: ^3.22.3 version: 3.22.5 + '@xterm/addon-fit': + specifier: ^0.11.0 + version: 0.11.0 + '@xterm/addon-web-links': + specifier: ^0.12.0 + version: 0.12.0 + '@xterm/xterm': + specifier: ^6.0.0 + version: 6.0.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -414,7 +423,6 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [musl] '@biomejs/cli-linux-arm64@2.4.15': resolution: {integrity: sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug==} @@ -435,7 +443,6 @@ packages: engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [glibc] '@biomejs/cli-win32-arm64@2.4.15': resolution: {integrity: sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w==} @@ -1101,7 +1108,6 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.1': resolution: {integrity: sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==} @@ -1559,6 +1565,15 @@ packages: babel-plugin-react-compiler: optional: true + '@xterm/addon-fit@0.11.0': + resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} + + '@xterm/addon-web-links@0.12.0': + resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==} + + '@xterm/xterm@6.0.0': + resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -4168,6 +4183,12 @@ snapshots: '@rolldown/pluginutils': 1.0.1 vite: 8.0.13(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0) + '@xterm/addon-fit@0.11.0': {} + + '@xterm/addon-web-links@0.12.0': {} + + '@xterm/xterm@6.0.0': {} + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1