mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
183
desktop/src-tauri/Cargo.lock
generated
183
desktop/src-tauri/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -73,5 +73,6 @@ earshot = "1.0"
|
||||
rubato = "2.0"
|
||||
audioadapter-buffers = "3.0"
|
||||
tempfile = "3"
|
||||
portable-pty = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
40
desktop/src-tauri/src/commands/terminal.rs
Normal file
40
desktop/src-tauri/src/commands/terminal.rs
Normal 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)
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
449
desktop/src-tauri/src/terminal.rs
Normal file
449
desktop/src-tauri/src/terminal.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
52
desktop/src/features/terminal/TerminalContext.tsx
Normal file
52
desktop/src/features/terminal/TerminalContext.tsx
Normal 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);
|
||||
}
|
||||
192
desktop/src/features/terminal/TerminalInstance.tsx
Normal file
192
desktop/src/features/terminal/TerminalInstance.tsx
Normal 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" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
131
desktop/src/features/terminal/TerminalPanel.tsx
Normal file
131
desktop/src/features/terminal/TerminalPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
desktop/src/features/terminal/index.ts
Normal file
3
desktop/src/features/terminal/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { TerminalPanel } from "./TerminalPanel";
|
||||
export { TerminalInstance } from "./TerminalInstance";
|
||||
export { TerminalProvider, useTerminal } from "./TerminalContext";
|
||||
@@ -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
27
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user