feat(terminal): add integrated terminal panel with ⌘+J toggle

- 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
This commit is contained in:
Taylor Ho
2026-05-21 20:08:56 -07:00
parent 441b6c4b1a
commit 40ebf3598c
17 changed files with 1327 additions and 243 deletions

View File

@@ -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",

View File

@@ -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)

View File

@@ -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"

View File

@@ -73,5 +73,6 @@ earshot = "1.0"
rubato = "2.0"
audioadapter-buffers = "3.0"
tempfile = "3"
portable-pty = "0.8"
[dev-dependencies]

View File

@@ -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<Keys>,
@@ -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<Mutex<crate::prevent_sleep::PreventSleepState>>,
/// 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(),
}
}

View File

@@ -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::*;

View File

@@ -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<crate::terminal::TerminalOpenOutput, String> {
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<bool, String> {
state.terminal_registry.close_session(&input.session_id)
}

View File

@@ -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");

View File

@@ -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<Mutex<TerminalSessions>>,
}
#[derive(Default)]
struct TerminalSessions {
by_session_id: HashMap<String, TerminalSession>,
/// Maps channel_id → session_id for single-instance-per-channel semantics.
session_id_by_channel: HashMap<String, String>,
}
struct TerminalSession {
session_id: String,
channel_id: String,
#[allow(dead_code)]
master: Box<dyn MasterPty + Send>,
writer: Box<dyn Write + Send>,
killer: Box<dyn ChildKiller + Send + Sync>,
buffer: Arc<Mutex<Vec<u8>>>,
}
#[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<String>,
}
#[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<TerminalOpenOutput, String> {
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<TerminalSession> = 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<bool, String> {
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<Mutex<TerminalSessions>>,
channel_id: &str,
size: PtySize,
) -> Result<TerminalSession, String> {
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<dyn Read + Send>,
buffer: Arc<Mutex<Vec<u8>>>,
) {
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<Mutex<TerminalSessions>>, 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<Mutex<Vec<u8>>>) -> Result<Option<String>, 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<std::path::PathBuf> = 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::path::PathBuf> = 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);
}
}

View File

@@ -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 (
<PreventSleepProvider>
<ChannelNavigationProvider channels={channels}>
<AppShellProvider
value={{
markChannelRead,
markChannelUnread,
openChannelManagement: () => {
setIsChannelManagementOpen(true);
},
getChannelReadAt,
readStateVersion,
}}
>
<HuddleProvider>
<div className="flex h-dvh flex-col overflow-hidden overscroll-none">
<SidebarProvider className="min-h-0 flex-1 overflow-hidden">
<div
aria-hidden="true"
className="fixed inset-x-0 top-0 z-20 h-10 cursor-default select-none"
data-tauri-drag-region
/>
<div className="fixed left-[80px] top-[9px] z-50 flex items-center gap-0.5">
<SidebarTrigger className="h-[22px] w-[22px] text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground" />
<Button
aria-label="Go back"
className="h-[22px] w-[22px] text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
data-testid="global-back"
disabled={!canGoBack}
onClick={goBack}
size="icon"
variant="ghost"
>
<ChevronLeft className="h-3 w-3" />
</Button>
<Button
aria-label="Go forward"
className="h-[22px] w-[22px] text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
data-testid="global-forward"
disabled={!canGoForward}
onClick={goForward}
size="icon"
variant="ghost"
>
<ChevronRight className="h-3 w-3" />
</Button>
</div>
<AppSidebar
activeWorkspace={workspacesHook.activeWorkspace}
channels={sidebarChannels}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={
channelsQuery.error instanceof Error
? channelsQuery.error.message
: undefined
}
fallbackDisplayName={identityQuery.data?.displayName}
homeBadgeCount={homeBadgeCount}
isAddWorkspaceOpen={isAddWorkspaceOpen}
isCreatingChannel={createChannelMutation.isPending}
isCreatingForum={createForumMutation.isPending}
isLoading={channelsQuery.isLoading}
isOpeningDm={openDmMutation.isPending}
isNewDmOpen={isNewDmOpen}
isPresencePending={presenceSession.isPending}
onAddWorkspace={(workspace) => {
const id = workspacesHook.addWorkspace(workspace);
workspacesHook.switchWorkspace(id);
}}
onAddWorkspaceOpenChange={setIsAddWorkspaceOpen}
onNewDmOpenChange={setIsNewDmOpen}
onOpenAddWorkspace={() => setIsAddWorkspaceOpen(true)}
onUpdateWorkspace={workspacesHook.updateWorkspace}
onRemoveWorkspace={workspacesHook.removeWorkspace}
onSwitchWorkspace={workspacesHook.switchWorkspace}
selfPresenceStatus={presenceSession.currentStatus}
workspaces={workspacesHook.workspaces}
onCreateChannel={async ({
description,
name,
visibility,
ttlSeconds,
templateId,
}) => {
const createdChannel =
await createChannelMutation.mutateAsync({
name,
description,
channelType: "stream",
visibility,
ttlSeconds,
});
await applyCanvas(templateId, createdChannel.id, name);
await goChannel(createdChannel.id);
void applyAgents(templateId, createdChannel.id);
}}
onCreateForum={async ({
description,
name,
visibility,
ttlSeconds,
templateId,
}) => {
const createdForum = await createForumMutation.mutateAsync({
name,
<TerminalProvider>
<PreventSleepProvider>
<ChannelNavigationProvider channels={channels}>
<AppShellProvider
value={{
markChannelRead,
markChannelUnread,
openChannelManagement: () => {
setIsChannelManagementOpen(true);
},
getChannelReadAt,
readStateVersion,
}}
>
<HuddleProvider>
<div className="flex h-dvh flex-col overflow-hidden overscroll-none">
<SidebarProvider className="min-h-0 flex-1 overflow-hidden">
<div
aria-hidden="true"
className="fixed inset-x-0 top-0 z-20 h-10 cursor-default select-none"
data-tauri-drag-region
/>
<div className="fixed left-[80px] top-[9px] z-50 flex items-center gap-0.5">
<SidebarTrigger className="h-[22px] w-[22px] text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground" />
<Button
aria-label="Go back"
className="h-[22px] w-[22px] text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
data-testid="global-back"
disabled={!canGoBack}
onClick={goBack}
size="icon"
variant="ghost"
>
<ChevronLeft className="h-3 w-3" />
</Button>
<Button
aria-label="Go forward"
className="h-[22px] w-[22px] text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
data-testid="global-forward"
disabled={!canGoForward}
onClick={goForward}
size="icon"
variant="ghost"
>
<ChevronRight className="h-3 w-3" />
</Button>
</div>
<AppSidebar
activeWorkspace={workspacesHook.activeWorkspace}
channels={sidebarChannels}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={
channelsQuery.error instanceof Error
? channelsQuery.error.message
: undefined
}
fallbackDisplayName={identityQuery.data?.displayName}
homeBadgeCount={homeBadgeCount}
isAddWorkspaceOpen={isAddWorkspaceOpen}
isCreatingChannel={createChannelMutation.isPending}
isCreatingForum={createForumMutation.isPending}
isLoading={channelsQuery.isLoading}
isOpeningDm={openDmMutation.isPending}
isNewDmOpen={isNewDmOpen}
isPresencePending={presenceSession.isPending}
onAddWorkspace={(workspace) => {
const id = workspacesHook.addWorkspace(workspace);
workspacesHook.switchWorkspace(id);
}}
onAddWorkspaceOpenChange={setIsAddWorkspaceOpen}
onNewDmOpenChange={setIsNewDmOpen}
onOpenAddWorkspace={() => setIsAddWorkspaceOpen(true)}
onUpdateWorkspace={workspacesHook.updateWorkspace}
onRemoveWorkspace={workspacesHook.removeWorkspace}
onSwitchWorkspace={workspacesHook.switchWorkspace}
selfPresenceStatus={presenceSession.currentStatus}
workspaces={workspacesHook.workspaces}
onCreateChannel={async ({
description,
channelType: "forum",
name,
visibility,
ttlSeconds,
});
templateId,
}) => {
const createdChannel =
await createChannelMutation.mutateAsync({
name,
description,
channelType: "stream",
visibility,
ttlSeconds,
});
await applyCanvas(templateId, createdForum.id, name);
await goChannel(createdForum.id);
void applyAgents(templateId, createdForum.id);
}}
onHideDm={handleHideDm}
onMarkChannelUnread={markChannelUnread}
onOpenBrowseChannels={handleOpenBrowseChannels}
onOpenBrowseForums={handleOpenBrowseForums}
onOpenDm={async ({ pubkeys }) => {
const directMessage = await openDmMutation.mutateAsync({
pubkeys,
});
await goChannel(directMessage.id);
}}
onOpenSearch={handleOpenSearch}
onSelectAgents={() => {
void goAgents();
}}
onSelectChannel={(channelId) => {
void goChannel(channelId);
}}
onSelectHome={() => {
void goHome();
}}
onSelectProjects={() => {
void goProjects();
}}
onSelectPulse={() => {
void goPulse();
}}
onSelectSettings={handleOpenSettings}
onSelectWorkflows={() => {
void goWorkflows();
}}
onSetPresenceStatus={(status) =>
presenceSession.setStatus(status)
}
onSetUserStatus={(text, emoji) =>
setUserStatusMutation.mutate({ text, emoji })
}
onClearUserStatus={() =>
setUserStatusMutation.mutate({ text: "", emoji: "" })
}
profile={profileQuery.data}
selfUserStatus={
deferredPubkey
? (selfStatusQuery.data?.[deferredPubkey.toLowerCase()] ??
undefined)
: undefined
}
selectedChannelId={selectedChannelId}
selectedView={selectedView}
unreadChannelIds={unreadChannelIds}
/>
await applyCanvas(templateId, createdChannel.id, name);
await goChannel(createdChannel.id);
void applyAgents(templateId, createdChannel.id);
}}
onCreateForum={async ({
description,
name,
visibility,
ttlSeconds,
templateId,
}) => {
const createdForum =
await createForumMutation.mutateAsync({
name,
description,
channelType: "forum",
visibility,
ttlSeconds,
});
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
<Outlet />
</SidebarInset>
await applyCanvas(templateId, createdForum.id, name);
await goChannel(createdForum.id);
void applyAgents(templateId, createdForum.id);
}}
onHideDm={handleHideDm}
onMarkChannelUnread={markChannelUnread}
onOpenBrowseChannels={handleOpenBrowseChannels}
onOpenBrowseForums={handleOpenBrowseForums}
onOpenDm={async ({ pubkeys }) => {
const directMessage = await openDmMutation.mutateAsync({
pubkeys,
});
await goChannel(directMessage.id);
}}
onOpenSearch={handleOpenSearch}
onSelectAgents={() => {
void goAgents();
}}
onSelectChannel={(channelId) => {
void goChannel(channelId);
}}
onSelectHome={() => {
void goHome();
}}
onSelectProjects={() => {
void goProjects();
}}
onSelectPulse={() => {
void goPulse();
}}
onSelectSettings={handleOpenSettings}
onSelectWorkflows={() => {
void goWorkflows();
}}
onSetPresenceStatus={(status) =>
presenceSession.setStatus(status)
}
onSetUserStatus={(text, emoji) =>
setUserStatusMutation.mutate({ text, emoji })
}
onClearUserStatus={() =>
setUserStatusMutation.mutate({ text: "", emoji: "" })
}
profile={profileQuery.data}
selfUserStatus={
deferredPubkey
? (selfStatusQuery.data?.[
deferredPubkey.toLowerCase()
] ?? undefined)
: undefined
}
selectedChannelId={selectedChannelId}
selectedView={selectedView}
unreadChannelIds={unreadChannelIds}
/>
<AppShellOverlays
activeChannel={activeChannel}
browseDialogType={browseDialogType}
channels={channels}
currentPubkey={identityQuery.data?.pubkey}
isChannelManagementOpen={isChannelManagementOpen}
isSearchOpen={isSearchOpen}
onBrowseChannelJoin={handleBrowseChannelJoin}
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
onChannelManagementOpenChange={setIsChannelManagementOpen}
onDeleteActiveChannel={() => {
setIsChannelManagementOpen(false);
void goHome({ replace: true });
}}
onOpenSearchResult={handleOpenSearchResult}
onSearchOpenChange={setIsSearchOpen}
onSelectChannel={(channelId) => {
void goChannel(channelId);
}}
/>
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
<Outlet />
</SidebarInset>
{settingsOpen ? (
<React.Suspense fallback={null}>
<LazySettingsScreen
currentPubkey={identityQuery.data?.pubkey}
fallbackDisplayName={identityQuery.data?.displayName}
isUpdatingDesktopNotifications={
notificationSettings.isUpdatingDesktopEnabled
}
notificationErrorMessage={
notificationSettings.errorMessage
}
notificationPermission={notificationSettings.permission}
notificationSettings={notificationSettings.settings}
onClose={handleCloseSettings}
onSectionChange={setSettingsSection}
onSetDesktopNotificationsEnabled={
notificationSettings.setDesktopEnabled
}
onSetHomeBadgeEnabled={
notificationSettings.setHomeBadgeEnabled
}
onSetMentionNotificationsEnabled={
notificationSettings.setMentionsEnabled
}
onSetNeedsActionNotificationsEnabled={
notificationSettings.setNeedsActionEnabled
}
onSetSoundEnabled={notificationSettings.setSoundEnabled}
section={settingsSection}
/>
</React.Suspense>
) : null}
</SidebarProvider>
<HuddleBar />
</div>
</HuddleProvider>
</AppShellProvider>
</ChannelNavigationProvider>
</PreventSleepProvider>
<AppShellOverlays
activeChannel={activeChannel}
browseDialogType={browseDialogType}
channels={channels}
currentPubkey={identityQuery.data?.pubkey}
isChannelManagementOpen={isChannelManagementOpen}
isSearchOpen={isSearchOpen}
onBrowseChannelJoin={handleBrowseChannelJoin}
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
onChannelManagementOpenChange={setIsChannelManagementOpen}
onDeleteActiveChannel={() => {
setIsChannelManagementOpen(false);
void goHome({ replace: true });
}}
onOpenSearchResult={handleOpenSearchResult}
onSearchOpenChange={setIsSearchOpen}
onSelectChannel={(channelId) => {
void goChannel(channelId);
}}
/>
{settingsOpen ? (
<React.Suspense fallback={null}>
<LazySettingsScreen
currentPubkey={identityQuery.data?.pubkey}
fallbackDisplayName={identityQuery.data?.displayName}
isUpdatingDesktopNotifications={
notificationSettings.isUpdatingDesktopEnabled
}
notificationErrorMessage={
notificationSettings.errorMessage
}
notificationPermission={notificationSettings.permission}
notificationSettings={notificationSettings.settings}
onClose={handleCloseSettings}
onSectionChange={setSettingsSection}
onSetDesktopNotificationsEnabled={
notificationSettings.setDesktopEnabled
}
onSetHomeBadgeEnabled={
notificationSettings.setHomeBadgeEnabled
}
onSetMentionNotificationsEnabled={
notificationSettings.setMentionsEnabled
}
onSetNeedsActionNotificationsEnabled={
notificationSettings.setNeedsActionEnabled
}
onSetSoundEnabled={notificationSettings.setSoundEnabled}
section={settingsSection}
/>
</React.Suspense>
) : null}
</SidebarProvider>
<HuddleBar />
</div>
</HuddleProvider>
</AppShellProvider>
</ChannelNavigationProvider>
</PreventSleepProvider>
</TerminalProvider>
);
}

View File

@@ -10,6 +10,7 @@ import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping"
import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel";
import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar";
import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel";
import { TerminalPanel, useTerminal } from "@/features/terminal";
import {
BotActivityComposerAction,
type BotActivityAgent,
@@ -179,6 +180,7 @@ export const ChannelPane = React.memo(function ChannelPane({
const timelineScrollRef = React.useRef<HTMLDivElement>(null);
const composerWrapperRef = React.useRef<HTMLDivElement>(null);
useComposerHeightPadding(timelineScrollRef, composerWrapperRef);
const { isOpen: isTerminalOpen, close: closeTerminal } = useTerminal();
React.useEffect(() => {
if (typeof window === "undefined") {
@@ -439,6 +441,13 @@ export const ChannelPane = React.memo(function ChannelPane({
</div>
</div>
)}
{activeChannel && isTerminalOpen ? (
<TerminalPanel
channelId={activeChannel.id}
isOpen={isTerminalOpen}
onClose={closeTerminal}
/>
) : null}
</div>
{threadHeadMessage ? (

View File

@@ -0,0 +1,52 @@
import * as React from "react";
type TerminalContextValue = {
isOpen: boolean;
toggle: () => void;
close: () => void;
};
const TerminalContext = React.createContext<TerminalContextValue>({
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 (
<TerminalContext.Provider value={value}>
{children}
</TerminalContext.Provider>
);
}
export function useTerminal(): TerminalContextValue {
return React.useContext(TerminalContext);
}

View File

@@ -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<HTMLDivElement>(null);
const termRef = React.useRef<Terminal | null>(null);
const fitAddonRef = React.useRef<FitAddon | null>(null);
const sessionIdRef = React.useRef<string | null>(null);
const unlistenDataRef = React.useRef<UnlistenFn | null>(null);
const unlistenExitRef = React.useRef<UnlistenFn | null>(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<TerminalOpenOutput>("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<TerminalDataPayload>("terminal:data", (event) => {
if (event.payload.sessionId === sessionIdRef.current) {
term.write(event.payload.data);
}
}).then((unlisten) => {
unlistenDataRef.current = unlisten;
});
// Listen for PTY exit.
listen<TerminalExitPayload>("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 (
<div
ref={containerRef}
className="h-full w-full"
style={{ display: isVisible ? "block" : "none" }}
/>
);
}

View File

@@ -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<HTMLButtonElement>) => {
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 (
<div
className="relative flex flex-col border-t border-border/60"
style={{ height: `${heightPx}px` }}
>
{/* Resize handle */}
<button
type="button"
className="group absolute inset-x-0 -top-1 z-20 flex h-3 cursor-row-resize items-center justify-center"
onPointerDown={handleResizeStart}
aria-label="Resize terminal"
>
<GripHorizontal className="h-3 w-3 text-muted-foreground/40 transition-colors group-hover:text-muted-foreground" />
</button>
{/* Header bar */}
<div className="flex h-8 shrink-0 items-center justify-between border-b border-border/40 bg-[#1a1b26] px-3">
<span className="text-xs font-medium text-[#7aa2f7]">Terminal</span>
<div className="flex items-center gap-1">
<button
type="button"
className="rounded p-0.5 text-[#565f89] hover:text-[#c0caf5] transition-colors"
onClick={onClose}
aria-label="Minimize terminal"
>
<Minus className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="rounded p-0.5 text-[#565f89] hover:text-[#c0caf5] transition-colors"
onClick={onClose}
aria-label="Close terminal"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
{/* Terminal content */}
<div className="min-h-0 flex-1 bg-[#1a1b26]">
<TerminalInstance channelId={channelId} isVisible={isOpen} />
</div>
</div>
);
}

View File

@@ -0,0 +1,3 @@
export { TerminalPanel } from "./TerminalPanel";
export { TerminalInstance } from "./TerminalInstance";
export { TerminalProvider, useTerminal } from "./TerminalContext";

View File

@@ -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
{

27
pnpm-lock.yaml generated
View File

@@ -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