可以。你的需求其实不需要一开始做成完整的 Zellij。如果核心目标只是:
那么可以设计成一个非常清晰的 Rust + PTY + Unix Socket + Session Manager 架构。
我建议把它定位成:
一个轻量级、单机版的 tmux/zellij session daemon
而不是一开始实现 terminal multiplexer 的完整 pane/layout 能力。
整体可以设计成:
User Terminal │ │ stdin/stdout/resize ▼ ┌─────────────────┐ │ myterm CLI │ │ │ │ new │ │ ls │ │ attach │ │ kill │ │ detach │ └────────┬────────┘ │ Unix Socket │ ▼ ┌──────────────────────────┐ │ myterm-server │ │ │ │ SessionManager │ │ │ │ │ SessionRegistry │ │ │ │ │ Session │ │ │ │ │ PTY │ │ │ │ │ Shell │ └──────────────────────────┘ │ ▼ /bin/bash / zsh /usr/bin/python /usr/bin/r ...
这里最重要的思想是:
终端窗口不是 Session。
终端窗口只是 Session 的一个 Client。
例如:
Terminal A │ └── attach ──> Session abc │ └── bash Terminal A 关闭 │ X │ └── bash 继续运行 Terminal B │ └── attach ──> Session abc │ └── bash
这就是 Zellij/tmux 最核心的机制。
我非常建议你采用:
myterm myterm-server
两个程序。
CLI 客户端。
myterm
或者:
myterm new
myterm ls
myterm attach abc
myterm kill abc
它本身不负责保存 Session。
它只负责:
Terminal ↓ myterm ↓ Unix Socket ↓ myterm-server
这是整个系统的核心。
它是一个长期运行的 daemon:
myterm-server │ ├── SessionManager │ ├── SessionRegistry │ ├── PTY Manager │ ├── Client Manager │ └── Unix Socket Server
$ myterm-server
启动以后:
~/.myterm/ server.sock
监听:
Unix Domain Socket
/tmp/myterm.sock
或者更推荐:
$XDG_RUNTIME_DIR/myterm.sock
最核心的对象是:
pub struct Session { pub id: SessionId, pub name: String, pub pid: u32, pub shell: String, pub created_at: DateTime<Utc>, pub last_attached_at: DateTime<Utc>, pub state: SessionState, pub client: Option<ClientId>, pub pty: PtyHandle, }
状态:
pub enum SessionState { Running, Attached, Detached, Dead, }
实际上 MVP 可以更加简单:
pub enum SessionState { Attached, Detached, Dead, }
整个系统的核心 API:
pub struct SessionManager { sessions: HashMap<SessionId, Arc<Session>>, }
提供:
impl SessionManager { pub async fn create( &self, name: Option<String>, shell: String, ) -> Result<SessionId>; pub async fn list( &self, ) -> Result<Vec<SessionInfo>>; pub async fn attach( &self, id: SessionId, client: Client, ) -> Result<()>; pub async fn detach( &self, id: SessionId, ) -> Result<()>; pub async fn kill( &self, id: SessionId, ) -> Result<()>; }
整个调用关系就非常清楚。
你真正需要解决的不是 Session。
而是:
如何让一个 shell 脱离当前 terminal 后继续运行。
答案就是:
PTY
myterm-server │ │ fork ▼ PTY │ ├── master │ └── slave │ ▼ /bin/bash
结构:
PTY ┌──────────────┐ │ │ │ master │ │ │ │ │ │ │ │ slave │ │ │ │ └──────┼───────┘ │ ▼ bash
Shell 不知道自己是不是在 Zellij 中。
它认为:
stdin = tty stdout = tty stderr = tty
所以:
vim top htop python R bash zsh
都可以正常运行。
你可以把 PTY 独立成:
pty/ mod.rs unix.rs
核心接口:
pub trait Pty { fn spawn( shell: &str, cols: u16, rows: u16, ) -> Result<PtyProcess>; fn read(&mut self) -> Result<Vec<u8>>; fn write(&mut self, data: &[u8]) -> Result<()>; fn resize( &mut self, cols: u16, rows: u16, ) -> Result<()>; fn kill(&mut self) -> Result<()>; }
实际实现可以基于:
portable-pty
或者直接:
nix libc
如果目标主要是 Linux,我反而建议 MVP 直接使用 Unix PTY。
执行:
完整调用链:
Terminal │ ▼ myterm │ │ connect() ▼ Unix Socket │ ▼ myterm-server │ ▼ CommandHandler │ ▼ SessionManager.create() │ ▼ PtyManager.create() │ ├── openpty() │ ├── fork() │ └── exec("/bin/bash") │ ▼ Session │ ▼ SessionRegistry.insert() │ ▼ Session ID │ ▼ myterm │ ▼ attach(session)
实际上相当于:
create session + attach session
调用:
Terminal │ │ stdin │ stdout ▼ myterm │ │ Unix Socket ▼ myterm-server │ ▼ SessionManager │ ▼ Session abc │ ▼ PTY master
此时需要建立一个:
Terminal <──> PTY
的数据转发。
用户输入:
键盘 │ ▼ Terminal │ ▼ myterm │ ▼ Unix Socket │ ▼ myterm-server │ ▼ PTY master │ ▼ bash
bash 输出:
bash │ ▼ PTY master │ ▼ myterm-server │ ▼ Unix Socket │ ▼ myterm │ ▼ Terminal
所以实际上:
Unix Socket ┌───────────────┐ │ │ ▼ │ Terminal → Client → Server → PTY → Shell Terminal ← Client ← Server ← PTY ← Shell │ │ └───────────────┘
例如用户按:
Ctrl+Space d
Ctrl+g d
触发:
Client │ ▼ detach request │ ▼ Server │ ▼ Session.detach()
但是:
绝对不能 kill shell。
只需要:
Client connection X │ ▼ PTY master │ ▼ bash
变成:
bash │ │ PTY │ ▼ server 持有
Session 继续:
Session abc │ ├── state = Detached │ └── bash PID = 12345
这是你这个工具最重要的场景。
VS Code Terminal │ ▼ myterm │ ▼ myterm-server │ ▼ bash
然后 VS Code 被关闭:
VS Code X │ ▼ myterm client X
server 收到:
Unix Socket EOF
此时:
ClientConnection::closed()
不能:
session.kill()
而应该:
session.detach()
于是:
Session abc state = Detached PTY │ ▼ bash
继续运行。
重新打开 VS Code:
得到:
ID NAME STATUS PID ------------------------------------- a81c analysis detached 12345 f29d shell attached 12380
然后:
myterm attach a81c
myterm │ ▼ Unix Socket │ ▼ Server │ ▼ SessionManager │ ▼ Session a81c │ ▼ PTY master │ ▼ bash
然后继续:
bash
例如你之前运行:
R
那么 R 进程仍然在那里。
你要求的:
查看活跃的 session
可以设计:
输出:
ID NAME STATUS PID CREATED -------------------------------------------------------- a81c analysis detached 12345 10:21 f29d shell attached 12380 10:32 7ab2 rstudio detached 12401 10:40
Server:
ListSessions │ ▼ SessionManager │ ▼ SessionRegistry │ ▼ Vec<SessionInfo> │ ▼ Unix Socket │ ▼ CLI
建议不要直接传字符串:
"attach abc"
最好从一开始设计成结构化协议。
enum Request { Create { name: Option<String>, shell: Option<String>, }, List, Attach { session_id: SessionId, }, Detach { session_id: SessionId, }, Kill { session_id: SessionId, }, Resize { session_id: SessionId, cols: u16, rows: u16, }, }
Response:
enum Response { Ok, SessionCreated { session_id: SessionId, }, Sessions { sessions: Vec<SessionInfo>, }, Error { message: String, }, }
序列化:
JSON
MVP 完全够用。
后面可以换:
MessagePack bincode postcard
这里有一个非常重要的架构点。
普通 RPC:
Request ↓ Response
适合:
ls new kill rename
但是 Attach 后:
terminal input ↕ PTY ↕ terminal output
是一个长期双向 stream。
因此 Attach 应该:
Request: Attach(session_id) Response: Attached
之后:
Raw byte stream
也就是说:
Unix Socket │ ├── Control protocol │ └── Data stream
或者 Attach 成功后,直接把这个连接升级成:
PTY stream
┌─────────────────────────────────────┐ │ Frame │ ├──────────┬──────────┬───────────────┤ │ type │ length │ payload │ │ 1 byte │ 4 bytes │ N bytes │ └──────────┴──────────┴───────────────┘
Frame:
pub enum FrameType { Request = 1, Response = 2, Input = 3, Output = 4, Resize = 5, Detach = 6, }
但是 MVP 甚至可以更简单:
control phase ↓ attach accepted ↓ raw PTY byte stream
Client attach 后,需要把当前 Terminal 设置为:
raw mode
canonical mode OFF echo OFF signals OFF
否则:
vim top R python
会出现问题。
生命周期:
Terminal │ ▼ save terminal state │ ▼ enable raw mode │ ▼ attach │ ▼ interactive │ ▼ detach │ ▼ restore terminal state
Rust 可以用:
crossterm
nix::sys::termios
终端大小变化:
VS Code Terminal │ │ SIGWINCH ▼ myterm client │ │ Resize(cols, rows) ▼ server │ ▼ PTY
ioctl(TIOCSWINSZ)
设置:
rows cols
pub struct Resize { pub cols: u16, pub rows: u16, }
我建议直接这样组织 Rust 项目:
src/ ├── main.rs │ ├── cli/ │ ├── mod.rs │ ├── commands.rs │ └── attach.rs │ ├── server/ │ ├── mod.rs │ ├── listener.rs │ └── connection.rs │ ├── session/ │ ├── mod.rs │ ├── manager.rs │ ├── registry.rs │ ├── session.rs │ └── state.rs │ ├── pty/ │ ├── mod.rs │ ├── unix.rs │ └── process.rs │ ├── terminal/ │ ├── mod.rs │ ├── raw.rs │ └── resize.rs │ ├── protocol/ │ ├── mod.rs │ ├── request.rs │ ├── response.rs │ └── frame.rs │ └── error.rs
如果考虑你之前的 Rust 项目架构,我甚至建议把 PTY 和 Session 分开:
Application │ ▼ SessionManager │ ▼ Session │ ▼ TerminalRuntime │ ▼ PTY │ ▼ Process
即:
pub trait Runtime { async fn spawn(&self, spec: ProcessSpec) -> Result<Process>; async fn kill(&self, pid: Pid) -> Result<()>; }
Linux:
UnixRuntime
以后:
LinuxRuntime MacOSRuntime WindowsRuntime
非常重要。
不要:
Session { pid }
把所有东西都绑定死。
最好:
Session │ └── Process │ └── Pty
pub struct Session { pub id: SessionId, pub name: String, pub process: ProcessHandle, pub pty: PtyHandle, pub state: SessionState, }
这样未来可以实现:
Session ├── shell ├── process ├── pane └── environment
如果以后想发展成:
┌─────────────────────────────┐ │ │ │ bash │ │ │ ├──────────────┬──────────────┤ │ │ │ │ R │ Python │ │ │ │ └──────────────┴──────────────┘
那么架构可以自然演化成:
Session │ └── Workspace │ ├── Pane │ └── PTY │ ├── Pane │ └── PTY │ └── Pane └── PTY
Session │ └── Layout │ ├── Pane A ├── Pane B └── Pane C
但现在不要实现 Pane。
最终可以做到:
Session │ ├── Metadata │ ├── Layout │ └── Panes │ ├── Pane │ ├── PTY │ └── Process │ ├── Pane │ ├── PTY │ └── Process │ └── Pane ├── PTY └── Process
而现在:
Session │ └── PTY │ └── Shell
MVP 是 Zellij 的 Session 层,不是 Zellij 的 UI 层。
这是非常好的切入点。
Tokio Runtime │ ┌───────────┴───────────┐ │ │ UnixSocketListener SessionManager │ │ │ HashMap<SessionId, │ Session> │ ClientConnection │ ┌──────┴──────┐ │ │ Request Attach │ │ ▼ ▼ CommandHandler DataStream │ │ ▼ ▼ SessionManager PTY
这里我建议:
Arc<RwLock<HashMap<SessionId, Arc<Session>>>>
但不要让所有 Session 共享一个巨大的锁。
更好的方式:
SessionRegistry │ ├── Session A → Mutex ├── Session B → Mutex └── Session C → Mutex
pub struct SessionRegistry { sessions: DashMap<SessionId, Arc<Session>>, }
RwLock<HashMap<...>>
MVP 用:
Arc<RwLock<HashMap<...>>>
完全足够。
推荐:
pub struct Session { pub id: SessionId, pub name: String, pub state: Mutex<SessionState>, pub pty: Mutex<PtyHandle>, pub attached_client: Mutex<Option<ClientId>>, pub created_at: Instant, }
状态机:
create │ ▼ Running │ attach │ ▼ Attached │ │ detach│ │kill │ │ ▼ ▼ Detached Dead │ attach │ ▼ Attached
建议明确写成:
enum SessionState { Creating, Detached, Attached, Dead, }
允许:
Creating → Detached Detached → Attached Attached → Detached Attached → Dead Detached → Dead
不允许:
Dead → Attached
这样以后代码不会越来越乱。
myterm-server
启动:
Server │ ├── create socket │ ├── load sessions │ ├── validate processes │ └── listen
但是这里有一个重要问题:
如果 server 自己死掉,Session 怎么办?
如果:
server X
PTY 和 shell 很可能也受到影响。
所以 MVP 可以接受:
Server 是 Session 生命周期的一部分。
server │ ├── session A ├── session B └── session C
server 死:
全部结束
这已经足够。
以后可以考虑:
systemd │ ▼ myterm-server
systemd --user │ ▼ myterm-server │ ├── session A ├── session B └── session C
那么:
systemctl --user restart myterm
仍然可以恢复。
不过这属于第二阶段。
第一版其实不需要数据库。
SessionManager │ └── HashMap
因为:
PTY PID Unix socket
本身都是运行态资源。
但是可以保存 metadata:
~/.local/state/myterm/ sessions/ a81c.json f29d.json
{ "id": "a81c", "name": "analysis", "shell": "/bin/bash", "created_at": "2026-09-13T10:30:00Z" }
注意:
不要把 terminal buffer 当成第一版的持久化数据。
因为 terminal buffer 其实是第二个复杂问题。
$ R > library(Seurat) > ...
你如果希望重新 attach 后看到:
之前的输出
就需要实现:
scrollback buffer
包括:
ANSI escape sequence cursor colors screen state alternate screen
这就开始进入:
terminal emulator
领域。
所以第一版:
Session persistence ≠ Terminal screen persistence
只保证:
进程继续运行,可以重新连接。
已经非常有用了。
第一版只做:
myterm new myterm ls myterm attach myterm kill myterm rename
以及:
Detach。
功能:
MVP │ ┌────────────┼────────────┐ │ │ │ Session PTY IPC │ │ │ create spawn socket list read/write request attach resize response detach kill
这已经可以很好地替代你现在在 VS Code 中使用 Zellij 的持久 Session需求。
我建议最终命令叫:
如果没有 session:
No sessions. Create one with: myterm new
创建:
myterm new analysis
查看:
进入:
myterm attach analysis
删除:
myterm kill analysis
重命名:
myterm rename analysis gwas
自动进入最近 Session:
myterm attach --last
这个以后非常好用。
你甚至可以让:
自动:
如果没有 server ↓ 启动 server 如果没有 session ↓ 创建 default 如果有 session ↓ attach 最近 session
就是:
一个持久化终端入口。
这比 Zellij 的命令体验还可以更简单。
myterm new analysis │ ▼ CLI::New │ ▼ Client::connect() │ ▼ UnixSocket │ ▼ Server::handle_connection() │ ▼ CommandHandler::Create │ ▼ SessionManager::create() │ ├── generate SessionId │ ├── PtyManager::spawn() │ ├── spawn /bin/bash │ └── SessionRegistry::insert() │ ▼ SessionCreated │ ▼ Client │ ▼ Client::attach()
myterm attach analysis │ ▼ Terminal::enable_raw_mode() │ ▼ Client::connect() │ ▼ Request::Attach │ ▼ Server │ ▼ SessionManager::attach() │ ├── find Session │ ├── check state │ ├── mark Attached │ └── bind client │ ▼ Attached │ ▼ PTY bridge │ ├───────────────┐ │ │ ▼ ▼ socket → PTY PTY → socket │ │ ▼ ▼ stdin stdout │ │ └──── Terminal ─┘
VS Code Terminal │ ▼ myterm │ ▼ Unix Socket │ X │ ▼ Server detects EOF │ ▼ ClientConnection::closed() │ ▼ SessionManager::detach() │ ▼ Session.state = Detached │ ▼ PTY + bash continues
关键:
EOF != Kill
这是整个系统必须保证的原则。
重新打开 VS Code Terminal │ ▼ myterm ls │ ▼ SessionManager │ ▼ analysis detached PID=12345 │ ▼ myterm attach analysis │ ▼ Attach │ ▼ PTY │ ▼ bash PID 12345
所以 shell 不需要重新启动。
从代码工作量来说,我会这样排序:
① Unix Socket ↓ ② PTY ↓ ③ Session Manager ↓ ④ Attach/Detach ↓ ⑤ Terminal Raw Mode ↓ ⑥ Resize ↓ ⑦ CLI ↓ ⑧ Session List
而不是:
Layout Pane Tab Mouse Plugin Status Bar Theme
这些全部先不要碰。
我会选择:
[dependencies] tokio = { version = "1", features = ["full"] } clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" crossterm = "0.29" portable-pty = "0.9" uuid = { version = "1", features = ["v4", "serde"] } thiserror = "2" tracing = "0.1" tracing-subscriber = "0.3" directories = "6"
如果只支持 Linux:
nix
也非常值得加入。
这里有一个容易踩坑的地方。
PTY 本质上是:
Unix file descriptor
而不是普通 TCP socket。
Tokio │ ├── Unix Socket │ └── blocking PTY reader
PTY reader 可以独立线程:
PTY │ ▼ PTY Reader Thread │ ▼ mpsc channel │ ▼ Session │ ▼ Unix Socket
这样第一版更加稳定。
架构:
Tokio Runtime │ ┌──────────┴─────────┐ │ │ Unix Socket SessionManager │ │ │ Session │ │ │ ┌─────┴─────┐ │ │ │ │ PTY Reader PTY Writer │ │ │ └──────────────┴───────────┘
我建议把它单独抽象出来:
pub struct PtyBridge { pub pty: PtyHandle, }
核心:
impl PtyBridge { pub async fn attach( &self, socket: UnixStream, ) -> Result<()> { ... } pub async fn detach(&self) -> Result<()> { ... } }
内部:
PTY ↕ PtyBridge ↕ Client
以后你做:
multiple clients broadcast recording logging replay
都可以从这里扩展。
myterm CLI │ ▼ ┌─────────────────┐ │ ClientRuntime │ │ │ │ Terminal │ │ RawMode │ │ Resize │ └────────┬────────┘ │ Unix Socket │ ▼ ┌─────────────────────────────────────────────────────┐ │ myterm-server │ │ │ │ ┌──────────────┐ ┌────────────────────────┐ │ │ │ IPC Server │──────▶│ CommandHandler │ │ │ └──────────────┘ └────────────┬───────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────┐ │ │ │ SessionManager │ │ │ └──────────┬──────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────┐ │ │ │ SessionRegistry │ │ │ └──────────┬──────────┘ │ │ │ │ │ ┌────────────────┼────────────┐ │ │ ▼ ▼ ▼ │ │ Session A Session B Session C │ │ │ │ │ │ ▼ ▼ ▼ │ │ PtyBridge PtyBridge PtyBridge │ │ │ │ │ │ ▼ ▼ ▼ │ │ PTY PTY PTY │ │ │ │ │ │ │ ▼ ▼ ▼ │ │ bash R zsh │ └─────────────────────────────────────────────────────┘
目标:
myterm new myterm ls myterm attach myterm kill
实现:
Unix Socket PTY SessionManager Attach Detach Resize
这一阶段就已经能解决你现在 VS Code + Zellij 的主要问题。
增加:
scrollback terminal screen state ANSI parser cursor colors alternate screen
架构变成:
PTY ↓ ANSI Parser ↓ TerminalState ↓ ScreenBuffer ↓ Client
重新 attach 时:
ScreenBuffer ↓ Client
所以能看到之前的终端画面。
再增加:
Session │ ├── Tab │ │ ├── Pane │ ├── Pane │ └── Pane │ └── Layout
最终:
myterm ├── session ├── tab ├── pane ├── layout ├── resize ├── split └── floating pane
结合你现在主要是在 VS Code / code-server Terminal + zellij 中使用,我不会把这个项目做成「又一个 Zellij」。
而会定位成:
Session-first terminal manager
也就是:
传统 Zellij │ ┌──────────┴──────────┐ │ │ Terminal UI Session │ │ ▼ ▼ Pane/Layout Process
而你的工具:
myterm │ Session-first │ ┌──────────┴──────────┐ │ │ Session Terminal │ │ ▼ ▼ PTY Client │ ▼ Shell
这会简单很多。
甚至可以做到:
直接进入最近 Session。
● analysis detached ● gwas detached ● r-analysis attached
再:
恢复。
这套设计的核心只有一句话:
Server 持有 PTY 和进程,Terminal Client 只是可随时替换的观察/输入端。
如果按照这个方向实现,我建议第一版甚至可以控制在 约 2,000~4,000 行 Rust,而不是一开始就进入 Zellij 那种几十万行级别的 terminal UI / layout / multiplexer 复杂度。
Github开源生信云平台 DEMO