可以。你的需求其实不需要一开始做成完整的 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。
执行:
myterm new
完整调用链:
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)
所以:
myterm new
实际上相当于:
create session
+
attach session
例如:
myterm attach abc
调用:
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:
myterm ls
得到:
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
可以设计:
myterm ls
输出:
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
以及:
Ctrl+g d
Detach。
功能:
MVP
│
┌────────────┼────────────┐
│ │ │
Session PTY IPC
│ │ │
create spawn socket
list read/write request
attach resize response
detach
kill
这已经可以很好地替代你现在在 VS Code 中使用 Zellij 的持久 Session需求。
我建议最终命令叫:
myterm
例如:
myterm
如果没有 session:
No sessions.
Create one with:
myterm new
创建:
myterm new
或者:
myterm new analysis
查看:
myterm ls
进入:
myterm attach analysis
删除:
myterm kill analysis
重命名:
myterm rename analysis gwas
自动进入最近 Session:
myterm attach --last
这个以后非常好用。
你甚至可以让:
myterm
自动:
如果没有 server
↓
启动 server
如果没有 session
↓
创建 default
如果有 session
↓
attach 最近 session
于是:
myterm
就是:
一个持久化终端入口。
这比 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
这会简单很多。
甚至可以做到:
myterm
直接进入最近 Session。
然后:
myterm ls
查看:
● analysis detached
● gwas detached
● r-analysis attached
再:
myterm attach analysis
恢复。
这套设计的核心只有一句话:
Server 持有 PTY 和进程,Terminal Client 只是可随时替换的观察/输入端。
如果按照这个方向实现,我建议第一版甚至可以控制在 约 2,000~4,000 行 Rust,而不是一开始就进入 Zellij 那种几十万行级别的 terminal UI / layout / multiplexer 复杂度。