나만의 터미널 제작기
들어가며
ADHD같은 삶을 사는 요즘입니다.
이 탭에서 claude한테 작업 지시해두고, 저 탭에서 다른 작업 지시해두고, 같은 프로젝트 안에서도 worktree를 대여섯개씩은 만들어서 병렬로 작업하면서, 또 미팅도 하고, 슬랙으로 들어오는 이슈도 확인하면서요.
이게 이렇게 작업하다보니 언제 뭐가 끝났는지도 모르고 한동안 AI를 놀리거나, 알아서 잘 돌아가고 있을 줄 알았는데 시작하고 한 3분만에 제게 의사결정을 내려달라고 한 채로 멈춰있던 걸 한 시간 뒤에 발견하는 문제들이 생겼습니다.
또 개인적으로 작업은 Claude로 하면서, Claude의 plan과 코드에 대한 리뷰를 codex에게 지시하는데, codex를 직접 켤 일은 없다보니 항상 usage limit에 걸려야 claude의 ’리뷰에 실패했다’는 말로 limit에 다다른 걸 알 수 있었습니다.
이걸 해결하기 위해 터미널인 Copad와 Terminal Multiplexer인 Comux를 작업해봤습니다.
Copad는 Todo 관리, Knowledge Base 연동, Slack 연동 등의 기능을 포함해서 터미널을 떠나지 않고 다양한 작업을 처리할 수 있도록 하는 터미널입니다. Todo에 특정 작업의 맥락을 넣어두고, 버튼만 누르면 claude가 작업을 시작한다던가 하는 작업을 해볼 수 있습니다.
Comux는 터미널 멀티플렉서로, pane split 등의 기능과 함께 좌측에는 session 목록과 agent의 상태가 나오고, 알림 센터 에서 최근 agnet들이 보낸 알림을 확인하는 작업도 가능하고, codex, claude의 상태도 하단 status bar에서 확인할 수 있는 agent와 깊게 통합된 멀티플렉서입니다.
이것들을 제작한 제작기를 작성해 봅니다.
코드는 이곳
에서 확인하실 수 있습니다.
설치는 아래 명령어들을 참고하시거나 Installation 을 확인하시면 진행해보실 수 있습니다.
curl -fsSL https://raw.githubusercontent.com/marshallku/copad/master/install.sh | bash
# 혹은 build from source를 하시려면
./scripts/install-dev.sh # Linux
./scripts/install-macos.sh # MacOS
작업 계기
개인적으로 ghostty, kitty, foot 등을 전전하다가 하나씩 아쉬움이 있어서 터미널에 제대로 정착을 못하고 있었습니다.
kitty는 배경 이미지 렌더링에 CPU를 사용했고, ghostty는 bus로 한 터미널만 제어할 수 없는 아쉬움이 있었습니다. 또한 탭을 여러개 열게되면 MacOS(밥 벌어먹고 살기 위해선 써야하니까…)에서는 aerospace와 충돌이 있어서 탭 하나 생길 때마다 window가 작아지는 문제도 있었고요.
또한 최근에 터미널에 그냥 눌러 앉아 살게 되면서 Cursor도 버리고 NeoVim을 쓰는 중인데, 두 터미널 모두 단순히 ’터미널’로서 역할에 충실해서, 제가 작업하는 데 필요한 툴들은 부족하단 점도 크게 아쉬운 부분이었습니다.
그래서 시작은, kitty와 ghostty에서 조금씩 아쉬운 부분들을 메꿀 수 있는 터미널을 만들어보자는 가벼운 마음에 해봤습니다.
Ghostty가 GPU로 렌더하고, 그게 꽤 힘든 일일 거란건 진작부터 알았지만, 사실 CPU로 렌더하는 터미널에서 성능의 아쉬움을 느껴본 적은 크게 없어서 그냥 간단하게 필요한 것만 붙이면 금방 되지 않을까 했습니다.
Custerm 제작
그렇게 Linux에서 GTK4와 VTE를 활용해 초기 터미널을 작업했습니다.
그러다 EventBus로 터미널을 하나씩만 제어할 수 있도록 하고, 특정 작업이 시작되거나 종료됐을 때 터미널에서 그 이벤트를 수신할 수 있도록 하는 툴들을 추가하했습니다.
첫 플러그인, Browser
물론 요즘은 claude in chrome같은 것들도 있지만, 이걸 만들던 당시에는 GUI로 떠있는 브라우저를 claude로 제어할 수 있는 표준이 달리 없었습니다.
그래서 터미널에 플러그인이란 개념을 넣기 시작했고, cli로 JavaScript 실행, 네트워크 디버깅 등을 할 수 있는 브라우저를 터미널에 이식했습니다.
이때부터 웹 화면 작업을 하면 Claude에게 end to end로 검증까지 하는 워크플로우를 녹일 수 있었습니다.
쉽지 않은 webkit freezing 디버깅
이 과정에서 첫 난관에 봉착했는데, 전 개인 작업에선 Arch Linux를 사용하는데 workspace를 이동하면 브라우저가 마지막 프레임에 멈춰버리는 이슈가 있었습니다.
분명히 백엔드나, 프로세스, IPC 등은 다 살아있는데 무슨 짓을 해도 repaint를 안 하더라고요.
환경 변수도 조절해보고, nvidia gpu 문제인가 싶어 랩탑에서도 테스트해보고, wayland protocol
을 살펴보면서 이런저런 시그널들을 보내보기도 하는 등 토큰도 엄청 태우면서 디버깅을 아무리 해봐도 수정이 불가능한 버그였습니다.
minibrowser에서 재현 시도도 배고ㅗ, hyprctl로 hyprctl dispatch resizewindowpixel '1 0,class:com.marshall.copad' && hyprctl dispatch -- resizewindowpixel '-1 0,class:com.marshall.copad' 이런 식으로 resize를 진행해보면 듀얼 모니터일 땐 회복이 되어서(와중에 ;로 연결하면 안되고, &&로 체인해서 호출해야 하더라고요) 이렇게 살아야 하는 건가 고민이 참 깊었습니다.
# 당시 config file에 추가한 webkit을 살리기 위한 설정들
# 아래와 같은 형태로 trigger를 정의할 수 있는 것도 copad의 특징이었습니다
[[triggers]]
name = "hyprland-webkit-cure"
action = "system.spawn"
[triggers.when]
event_kind = "window.restored"
[triggers.security]
allow_privileged = true
[triggers.params]
argv = ["sh", "-c",
"hyprctl dispatch resizewindowpixel '1 0,class:com.marshall.copad' && hyprctl dispatch -- resizewindowpixel '-1 0,class:com.marshall.copad'"]
그래도 가볍게 브라우저를 유지하고 싶어서 chromium 대신 webkit으로 브라우저를 넣어뒀는데, webkit에만 있는 버그 같아서 그냥 토큰 전부 태워서 크로미움을 이식해볼까 고민도 했고요.
Arch에선 크로미움 도입할 수 있는 간단한 라이브러리가 없고, 이식 난도도 꽤 높은 편이라 살짝 절망 중이었습니다.
그래서 끝끝내 디버깅 해서 hyprland에 이슈를 올렸는데
, 머쓱하게도 다른 gtk의 버그였습니다
.
처음으로 hyprland의 업데이트를 간절히 기다리게된 순간이었습니다.
조금만 더 힘들었어도 이 프로젝트가 엎어질 뻔 했는데, 그래도 다행이라고 생각합니다.
또 쉽지 않은 이미지 렌더링
Linux에서 배여 전환 시 stdin / stdout이 1초씩 밀리는 현상이 발생했었습니다.
알고보니 VTE는 PTY read/write 루프를 렌더링과 같은 스레드에서 진행하더라고요. MacOS에서는 스레드가 쪼개져있는데, Linux는 동기라 큰 이미지를 로드해버리면 PTY가 최대 1초 이상 멈춰버리는 현상이 있었습니다.
// background.rs:70 — 상태
load_generation: Cell<u64>, // 전환마다 bump; stale 디코드 drop
decoding: Cell<bool>, // 최대 1개 in-flight
pending: RefCell<Option<(PathBuf, bool)>>, // latest-wins 큐
mounted: RefCell<Option<(PathBuf, bool)>>, // 실패 시 롤백 타깃
// background.rs:174 — 요청: coalesce + generation bump
self.load_generation.set(self.load_generation.get() + 1);
if self.decoding.get() {
*self.pending.borrow_mut() = Some((path.to_path_buf(), from_list));
return;
}
self.spawn_decode(path.to_path_buf(), from_list);
// background.rs:202 — off-main (Send 바이트만 경계 넘음)
let generation = self.load_generation.get();
glib::spawn_future_local(async move {
let result = gtk4::gio::spawn_blocking(move || decode_image(&decode_path)).await;
layer.on_decode_complete(generation, path, from_list, decoded);
});
// background.rs:225 — 완료: stale-drop + 실패 롤백 + pending drain
if generation == self.load_generation.get() {
match decoded {
Ok(image) => { self.mount_texture(image); *self.mounted.borrow_mut() = Some((path, from_list)); }
Err(e) => { let mounted = self.mounted.borrow().clone(); // current → mounted 롤백
self.has_image.set(mounted.is_some()); *self.current.borrow_mut() = mounted; }
}
}
이건 그래도 간단하게 gio::spawn_blocking으로 다른 스레드에서 디코딩하도록 비교적 간단하게 처리할 수 있었습니다.
MacOS 포팅
그냥 리눅스만 쓰고 살 수 있으면 좋은데, 인생이 그렇게 호락호락하지 않습니다.
회사에선 맥북이 고정이니, 결국 눈물을 머금고 MacOS에도 포팅을 시작했습니다.
제 맥북은 Arch로 넘어가고 전부 처분해버린 탓에, 성능 안 좋은 M1 맥북에서 작업해야 했습니다.
이때부터 인생이 참 힘겨워지기 시작하는데, Linux에서 작성한 터미널 관련 로직을 MacOS에도 포팅해야했고, 각 플랫폼마다 조금씩 다른 동작을 커버해야 했습니다.
또 리눅스에는 VTE4라는 안정적으로 완성된 터미널 엔진이 있는데, MacOS에는 그런게 없다는 것도 큰 허들이었습니다.
물론 제가 코드를 작성하는 비율은 아주 적어졌으니 예전에 비하면 사실 놀고 먹으면서 만드는 수준이긴 하지만, 네이티브 앱을 만들다보니 테스트도 꼼꼼히 해야하고, 이 플랫폼에서 작업하다 저 플랫폼에서 이어가야하고 여러모로 쉽지 않더라고요.
최종적으로 큰 틀에선 위와 같은 구조로 Copad를 만들게 되었습니다.
copad를 cli로 제어하기 위해 coctl을 만들었고, daemon에서 각종 작업들을 실행하기 위해 copadd도 만들었습니다.
공통적인 뼈대인 copad core와 cli, daemon 등은 Rust로 만들고, UI는 Linux에선 Rust, MacOS에선 Swift로 제작했습니다.
사실 상술한 것처럼 결국 결정적 차이는 터미널 위젯의 여부라서, 한 번 정리해보자면 차이는 아래와 같습니다.
| Linux | MacOS | |
| 터미널 엔진 | VTE4 | alacritty_terminal - 파서와 그리드 상태머신밖에 존재하지 않습니다 |
| PTY | VTE 내장인 slawn_async | copad-term crate로 관리하고, FFI로 노출합니다 |
| 렌더링 | VTE가 다 해줍니다 | Metal 글리프 아틀라스로 직접 렌더링합니다 / Fallback으로 CoreText를 사용합니다 |
| shell | GTK4 | AppKit |
| 웹뷰 | WebKitGTK 6.0 | WKWebView (참 오랜만에 다시 보는 친구입니다) |
| splitting | gtk4::Paned (binary tree) | NSSplitView + EqualSplitView (N-ary SplitNode) |
| 알림 | libnotify | osascript |
| 돌아보니 쉽지 않네요. \ | ||
| 처음에는 SwiftTerm이라는 친구를 써서 쉽게쉽게 만들었는데, 아래 이유들로 SwiftTerm은 버렸습니다. |
- IME Composition이 깨집니다. Linux나 MacOS에서 글자를 쓸 때마다 한국인인 게 서글퍼지는 순간이 있는데, 조합 문자를 처리한다는 게 참 쉬운 일은 아닌가보다 하고 있습니다.
- 배경 이미지가 깔리면 커서가 안 보입니다.
- SGR 7 (
ESC[7m)이 tui에서 ’선택된 항목’을 표시하는 거의 표준인데, 배경 이미지가 있는 상태에서 이걸 제대로 렌더링을 못 합니다. - SwiftTerm은 caret을 monolithic한
drawRect로 처리해서, 이미지 위에서 caret의 가시성을 확보하는 게 힘듭니다.
그래서 SwiftTerm을 포크할지, alacritty_terminal crate를 사용하고 렌더러를 만들지, 아니면 libghostty 출시까지 대기할지, 아니면 아예 밑바닥부터 만들지를 고민하다, 결국 alacrity terminal을 택했습니다.
SwiftTerm 포크는 저런 문제 하나 생길 때마다 수정도 해야하고, rebase도 지속적으로 해야하는 부담이 있었고, libghostty를 기다리기엔 Copad 개발이 너무 재밌어서 빨리빨리 진행하고 싶었습니다. 또 아예 처음부터 시작하는 건 너무 오래 걸리지 않을까 하여, alacritty terminal은 외부 사용할 목적으로 개발된 게 아니지만 리스크를 수용하고 사용하기로 했습니다.
Metal GPU 렌더러를 만들면서도 아주 다양한 이슈들을 마주했던 걸 보면, 그래도 꽤 잘한 선택이지 않았을까 생각하고 있습니다.
Comux 제작
배경
사실 이렇게 제 니즈를 충족하는 터미널이 완성된 것 만으로도 꽤 만족감 높은 생활을 하고 있었는데, 결국 몇 문제를 마주했습니다.
먼저, knowledge base같은 툴의 활용도도 좀 떨어졌습니다.
유용할 거라고 생각하고 개발한 기능들인데, 이게 결국 편집은 neovim을 이기지 못하고, 접근성과 활용도는 cli로 만드는 게 훨씬 높더라고요.
둘째로, 전 remote 작업이 꽤 많은 편입니다.
출퇴근길에 iPad로 코딩하거나, 제 개인적인 다른 개발 기기에 ssh해서 작업할 땐 이런 툴들이 아무 소용이 없었습니다.
그래도 MacOS 버전을 업무에서는 활용할 수 있지만, 그것만으론 아무래도 ’작업을 완전 최적화했다’고 말하긴 어려움이 많았습니다.
개인적인 harness를 coctl과 copadd와 연동해두고, 그냥 데스크탑이 기동되면 Copad가 자동 실행되게 해서 마찰을 줄일 시도도 해봤지만, 그것 만으론 만족스러운 결과를 얻기가 힘들었습니다.
tmux에서의 사용 경험을 살려서 session도 만들고, 영속성도 챙겨뒀는데 결국 원격 접속때문에 tmux를 계속 쓰고있는 저를 발견할 수 있었습니다.
이런 이유들로 결국 터미널 멀티플렉서를 만들기로 결심했습니다.
UI 구성은 herdr
에서 꽤 많은 영감을 받았습니다.
그럼 왜 herdr를 안 쓰고 직접 구현했냐는 물음이 자연스레 뒤따를 수 있을 것 같은데, 여기도 몇몇 사유가 있습니다.
- 전 tmux의 status bar를 꽤 잘 활용하고 있었고, 탭 등의 시스템을 잘 활용하고 있었는데 UI나 단축키 등이 확 바뀌는 게 일단 좀 싫었습니다
- tmx 라는 툴을 만들어서 tmux에서 사용 경험을 꽤 최적화 해뒀는데, 이때의 경험을 유지하고 싶기도 했습니다
- 다음에 herdr가 만든 것들을 이미 copad에서 많이 구현해둬서, 구현 비용도 꽤 높지 않을 것 같았습니다
그래서 직접 comux를 만들었고, 결과는 굉장히 만족스러웠습니다.
tmux처럼 session을 생성하고, session 내에서 tab을 만들거나, pane을 split하는 등의 기능은 당연히 모두 내장했습니다.
사실 전 alt $num으로 tab 넘어다니는 게 훨씬 빠르고 직관적이라 생각해서 pane 쪼개는 걸 한 번도 써본 적은 없는데, 이걸 유용하게 쓸 수 있을지는 의문이긴 하지만, 대중적인 기능이니만큼 넣어봤습니다.
과정에 터미널 렌더링에 관한 꽤 많은 공부를 할 수 있었습니다.
// state.rs:236 — 소유는 정확히 한 task
pub struct State {
workspaces: Vec<Workspace>,
terminals: HashMap<TerminalId, Terminal>,
clients: HashMap<ClientId, Attach>,
next_pane: u64, next_term: u64, next_tab: u64,
}
// state.rs:455 — 유일 진입점, &mut self로 직렬
pub fn apply(&mut self, cmd: Command) -> Result<Vec<Event>, MuxError> {
match cmd {
Command::SplitPane { origin, workspace, pane, dir, if_rev } =>
self.split(origin, workspace, pane, dir, if_rev),
// ...
}
}
// state.rs:784 — stale-rev 가드 + rev bump
if let Some(expected) = if_rev && expected != cur_rev {
return Err(MuxError::StaleRev { current: cur_rev });
}
tab.rev += 1;
self.workspaces[idx].rev += 1;
mux의 가변 상태를 하나의 State가 소유하고, 진입점을 하나만 둬서 변경하도록 설계했습니다.
아무래도 여러 input을 어디서 어떻게 처리할지를 복잡하게 관리해야하다보니, 이런 형태가 제일 적합해 보이더라고요.
// proto.rs:24
pub struct WireCell {
pub x: u16, pub y: u16, pub sym: String,
pub fg: Color, pub bg: Color,
pub mods: Modifier, // ratatui Modifier bits (BOLD/DIM/REVERSED…)
#[serde(default)] pub skip: bool, // wide glyph 후반부 — 미전송 시 CJK/이모지 깨짐
}
// server.rs:636 — compose + per-client diff
let mut buf = Buffer::empty(area);
let cursor = app.render_to(&mut buf).map(|p| (p.x, p.y));
for c in clients.iter_mut() {
let changed = c.last.diff(&buf); // ratatui cell diff
if changed.is_empty() && !c.needs_full && cursor == c.last_cursor { continue; }
let cells: Vec<WireCell> = changed.iter().map(|(x, y, cell)| WireCell {
x: *x, y: *y, sym: cell.symbol().to_string(),
fg: cell.fg, bg: cell.bg, mods: cell.modifier, skip: cell.skip,
}).collect();
또 저런 서버 위에서, PTY에 있는 output을 off-screen ratatui Buffer에 렌더하고, 클라이언트별로 last-sent 버퍼와 diff를 확인해 바뀐 셀만 전송하는 것도 작업해야 했습니다.
매일 쓰던 터미널과 매일 쓰던 tmux가 뒤에서 어떻게 동작하고 있었는지 배울 수 있는 좋은 기회기도 했습니다.
특히 claude code같은 독특한 방식의 렌더링을 사용하는 툴이나, 전체 화면을 차지하는 tui들을 적합하게 dff를 계산해서 화면에 올바르게 출력하는 데 생각보다 많은 노력이 들더라고요.
그러고 tmux에 없어서 아쉽지만 tmx로 좀 갈증을 채웠던 기능을 훨씬 직관적으로 만들었습니다.
좌측에 session 목록과 agent 목록이 있어서, tmx에선 ctrl b + g 입력해야 다이얼로그로 볼 수 있던 어떤 agent가 무슨 일을 하고 있는지를 그냥 한 눈에 훨씬 직관적으로 확인할 수 있게 되었습니다.
추가로 하단에는 claude와 codex의 usage를 직관적으로 확인할 수 있게도 해뒀습니다.
물론 codex한테 작업 리뷰받는 건 제가 따로 제어하는 부분이 아니긴 하지만, 사용량 모니터링 하면서 좀 더 경각심을 갖고 사용할 수 있지 않을까 합니다.
추가로, agent가 세션을 종료하거나 할 때 알림을 쌓아두게 하고, 단축어로 알람들을 확인할 수 있도록 했습니다.
내가 지금 뭘 해야하는지를 놓치지 않고 확인할 수 있긴 한데, context switching이 꽤 빠르다고 자부하고 살고 있었는데도 이게 대여섯개씩 에이전트 돌려두고 알림이 쌓이기 시작하니 감당하기가 좀 힘들긴 합니다.
추가로 tmx에서 제공하던 다이얼로그 형태의 UI도 내장해서 넣어뒀습니다.
모바일 앱 제작
이건 orca에서 앱을 제공하는 걸 보고 따라서 시작해봤는데, 사실 Termius로 접속해서 comux 띄우는 것 이상의 가치를 지닐 수 있을지가 살짝 걱정이긴 합니다.
그나마 장점이라면 push notification 정도가 아닐까 싶긴 한데, 활용성은 조금 애매하지 않나 싶네요.
그래도 애플한테 매년 친구비(개발자 계정 등록 비용)를 내기 시작한 기념으로 앱들도 좀 찍어내보고 있습니다.
맺으며
이상 약 4개월간 제 workflow를 최적화하기위한 터미널을 만드는 여정을 간략하게 정리해봤습니다.
요즘 AI때문에 사고하는 시간이 줄어들어 슬퍼질 때가 종종 있는데, 고작 4개월만에 멀티 플랫폼을 지원하는 터미널(심지어 GPU로 렌더링하는), 터미널 멀티플렉서 등을 전부 만들 수 있는 세상이 온 걸 보면 또 밀더로 느끼는 도파민은 훨씬 다양해지지 않았나 싶습니다.
초기부터 오픈소스로 여러 사람이 쓰도록 하는 걸 고려해서 작업을 했었는데, 구조를 짜다보니 comux도 coctl에 조금 의존하는 부분(agent 사용량)이 있어, 한 세트로 릴리즈를 하거나, comux만 따로 설치할 수 있도록 하거나 하는 옵션들을 고민 중입니다.
orca, herdr, cmux같은 툴을 사용해보진 않았지만, tmux를 꽤 오랫동안 사용하던 유저로서 이제 완전 갈아탈 수 있는 툴을 만든 것 같아서 꽤 설레는 마음입니다.
제가 매일 일하면서 항상 쓰는 툴이라, 당장도 쓸 수는 있는 버전이라고 생각해 1.0.0 버전을 릴리즈해봤습니다.
아예 아무것도 없는 환경에서 clean install을 하면서 comux만 따로 떼거나 하는 작업들도 좀 진행해서, 앞으로 훨씬 안정된 툴을 만들어가 보려 합니다.
댓글을 불러오는 중...