kasaterm 학생 칸의 답 머리에 그 학생의 얼굴과 이름을 학생 색으로 그린다 — 학생이 없는 칸은 그대로 둔다

<img src="assets/AppIcon.png" width="120" alt="kasaterm" />
Rust로 바닥부터 만든 크로스플랫폼 GPU 터미널.
셀 렌더러 · 한글 IME · PTY를 기성 라이브러리 없이 자체 crate로 구현했고,<br/> 그 위에 여러 Claude를 학생처럼 거느리는 GUI를 얹었다.
데모 · 강점 · crate · 기계를 가로지른다 · 설치 · 단축키 · 구조
<img src="assets/shot-terminal.png" width="820" alt="kasaterm — GUI 버튼·드래그로 나눈 멀티페인. 한글 커밋 로그와 색재현이 그대로." /> <sub>GUI 버튼·드래그로 나눈 멀티페인. tmux prefix 키 없이 분할하고, 한글·색·box-drawing이 자체 렌더러로 그려진다.</sub>
자체 제작 GUI 터미널이다. tmux를 prefix 키 대신 GUI 버튼·드래그·자연어로 다루는 네이티브 Rust 앱이고, 렌더러·한글 IME·PTY까지 기성 터미널 라이브러리에 기대지 않고 전부 직접 만들었다.
두 축으로 읽으면 된다:
기성 라이브러리를 붙인 게 아니라, 터미널의 핵심 부품을 바닥부터 만들었다.
| 무엇 | crate | |
|---|---|---|
| GPU 셀 렌더 | swash atlas에 글리프를 한 번 굽고 셀당 인스턴스 1개로 그린다. box-drawing은 wgpu quad, CJK·이모지 fallback 내장 | kasa-cells |
| 한글 IME | OS IME에 의존하지 않는 두벌식 입력 오토마타. 복합 종성까지 자체 조합 | kasa-ime |
| 크로스플랫폼 PTY | portable-pty + alacritty_terminal. macOS·Linux BSD PTY와 Windows ConPTY가 동일 코드 경로 | kasa-pty |
| 색재현 | shader sRGB→DisplayP3 변환 + root CAMetalLayer. 터미널 색이 디자인 의도대로 (sugarloaf/ghostty 동급) | kasa-cells |
macOS·Windows·Linux를 같은 코드로 굴린다. PTY는 portable-pty로 추상화해 Windows에서는 ConPTY, 그 외에서는 BSD PTY로 자동 분기한다 — 플랫폼별 백엔드 분기 없이 동일 경로. macOS .app, Windows .msi 번들을 빌드 스크립트로 굽는다.
워크스페이스가 곧 부품 카탈로그다. 각 crate는 kasaterm 없이도 독립적으로 쓸 수 있게 경계를 잡았다 — 특히 kasa-cells는 프레임워크 중립이라 alacritty_terminal·wezterm-term 같은 터미널 상태머신과 짝지어 다른 터미널을 만드는 데 그대로 가져다 쓸 수 있다.
| crate | 한 줄 | 독립 사용 |
|---|---|---|
kasa-cells | 프레임워크 중립 GPU 셀 렌더러 (wgpu). swash atlas·sRGB→P3·box-drawing·Nerd 폰트 번들 | 터미널/그리드 UI 제작용 |
kasa-pty | PTY + alacritty_terminal 백엔드. 크로스플랫폼(ConPTY 포함) | 헤드리스 PTY 호스트 |
kasa-ime | 두벌식 한글 입력 오토마타. OS IME 비의존 | 한글 입력이 필요한 Rust 앱 |
kasa-socket | cmux 호환 Unix-socket JSON-RPC 서버. kasaterm-cli 포함 | pane 제어 프로토콜 |
kasa-bridge | tmux control-mode(-C) 브리지. GUI 비의존, 이벤트·화면 채널만 넘긴다 | tmux를 붙이는 다른 UI |
kasa-mcp | 원격 세션·기계 명부·폰 관문·보드의 HTTP 층 | 폰·다른 기기·웹 pane 연동 |
app/kasaterm | 메인 바이너리 — winit+wgpu 윈도우, chrome UI, 입력·단축키 라우팅 | — |
엔진이 안정될수록 그 위에 쌓는 게 본 게임이다. pane마다 Claude Code를 띄우고, 각 학생(pane)이 무슨 작업을 하는지 BA GUI로 한눈에 본다.
<img src="assets/shot-arona.png" width="780" alt="kasaterm BA GUI — 여러 Claude의 작업이 학생별 채팅·작업 트리로 실시간 표시" /> <sub>왼쪽 교실에 학생(pane)들이, 가운데 각 학생의 대화·작업이, 오른쪽 Command Center에 현재 작업이 실시간으로.</sub>
다른 터미널과 다른 점:
pane에서 claude를 실행하면 그 pane에 블루 아카이브 학생 한 명이 배정된다 — 이름·테두리색·프로필이 전부 그 학생으로 맞춰지고, 창 전체에서 겹치지 않게 자동으로 고른다. /rename 미도리처럼 이름을 바꾸면 원하는 학생으로 갈아끼운다. (기본 로스터 12명: 아로나·프라나·미도리·모모이·유즈·아리스·유우카·시로코·호시노·코하루·히마리·아루.)
로스터는 갈아끼울 수 있다. 테마 팩 하나가 캐릭터 세트 하나다 — 폴더에 명부(characters.json)·그림·색 프리셋을 넣어 두면 그게 통째로 로스터가 된다. 설정 창에 zip을 떨어뜨려 가져오고, 여러 팩을 가로질러 좋아하는 캐릭터만 골라 쓰는 풀도 만든다. 캐릭터마다 모델·성격·이름을 따로 지정할 수 있고, 대화를 끊지 않고 도중에 바꿔도 말투까지 따라온다.
그리고 kasaterm은 Claude Code가 그리는 터미널 화면 자체를 렌더 단계에서 읽어, 그 위에 배정된 학생을 그린다. 로그를 파싱하거나 별도로 통합한 게 아니라 — 화면만 보고 동작한다:
로그를 읽는 게 아니라 학생이 옆에서 같이 일하는 것처럼 보인다.
<img src="assets/shot-sprite.png" width="820" alt="claude 로 대화하는 중 — 배정된 아루가 시작 배너·작업 스피너 옆·statusline 에 도트로 나타난다" /> <sub><code>claude</code> 로 대화하는 중 — 배정된 <b>아루</b>가 <b>시작 배너</b>(좌상) · <b>작업 스피너 옆 전신</b>(좌하) · <b>statusline 프로필</b>(최하단)에 동시에 나타난다. 작업 중일 땐 effort 칩을 피해 옆으로 비켜선다.</sub>
한 모노레포에 세 층이 쌓여 있고, 아래층이 위층을 떠받친다:
| 층 | 코드네임 | 역할 | 상태 |
|---|---|---|---|
| ① 엔진 | kasaterm | 터미널 — wgpu 셀 렌더 · PTY · 한글 IME · multipane | 거의 안정 |
| ② 작업환경 | kasaspace | 파일트리 · git 관리 · pane 간 에이전트 연결 | 진행 중 |
| ③ 오케스트레이션 | blueclaudearchive | 여러 Claude를 학생처럼 거느리는 하네스 GUI (아로나 모드) | 무게중심 |
pane이 한 대의 컴퓨터에 묶여 있지 않다. 노트북에서 띄운 학생을 데스크톱·서버로 옮기고, 그 화면은 원래 창에 그대로 남기고, 폰에서 같은 pane을 이어서 본다.
| 무엇 | |
|---|---|
| 이사(migrate) | 도는 claude·codex 세션을 다른 기계로 통째로 옮긴다. 대화·모델·작업 경로가 따라가고, 커밋 안 한 변경과 안 올린 커밋까지 떠서 도착지에 재현한다 |
| 거울 pane | 옮긴 뒤에도 원래 창에 그 화면이 남는다. 거울 창을 줄여도 원본 기계의 화면 크기는 안 변한다 — 글자만 작아진다 |
| 세션 소통 | 기계들이 중계소에 스스로 등록한다. 다른 기계에서 도는 세션이 내 목록에 뜨고, 메시지 한 통이면 거기까지 배달된다. 기계가 죽으면 1분 안에 목록에서 빠진다 |
| 폰 웹터미널 | 앱이 켜지면 관문에 붙어 자기 주소를 하나 받는다. 폰에서 그 주소를 열면 방·학생 목록이 나오고, 고르면 그 pane이 그대로 뜬다. 끊기면 화면이 보일 때 저절로 재접속 |
기계를 명부에 적어 두면 원격 탭에서 방별로 학생을 보고, 학생 줄을 누르면 그 화면이 포커스된 pane의 탭으로 열리고(거울), 기계 하나의 학생 전부를 방 단위로 거울로 펼치고, 「화면 보기」로 그 기계의 화면공유를 연다. Info 탭 「다른 기계」 줄이 그 기계의 학생 수·기다림·거울 수를 요약한다. 원격 pane은 몸통에 색 리본이 붙어 헤더를 안 봐도 갈린다.
최신 릴리스에서 받는다.
.dmg를 열고 kasaterm을 Applications로 드래그. 처음 한 번만 우클릭 → 열기(직접 서명한 앱이라 macOS가 한 번 확인받는다). 첫 실행에서 화면 녹화·접근 권한을 물으면 허용한다..msi 실행. SmartScreen 경고가 뜨면 「추가 정보 → 실행」.앱 안에서 자동 업데이트를 받는다(macOS는 Sparkle, Windows는 WinSparkle — 릴리스마다 서명된 appcast가 붙는다).
# 소스 받기
git clone https://github.com/2rami/kasaterm.git
cd kasaterm
# 개발 빌드
cargo run -p kasaterm
# 체감(스크롤·입력 지연) 테스트는 반드시 release — 디버그는 원래 버벅임
cargo run --release -p kasaterm
macOS .app은 scripts/build-app.sh, Windows .msi와 portable ZIP은 scripts/windows/package.ps1로 빌드한다. Windows 패키징은 완성된 MSI를 다시 추출해 앱·CLI·아로나 UI·학생 로스터·협업 훅의 누락까지 검사한다. 앱을 실행하면 pane 제어 CLI(kasaterm-cli)를 바로 쓸 수 있다.
폴더만 손으로 바꾸면 Claude Code 대화와 연결 worktree가 이전 경로를 계속 가리킨다. 먼저 kasaterm·Claude Code·Codex를 모두 정상 종료하고, 저장소의 바깥 폴더에서 이전 도구를 실행한다. 첫 명령은 바뀔 항목만 보여주는 dry-run이다.
cd /path/to/parent
./tmuxify/scripts/rename-repo-to-kasaterm.sh \
--source "$PWD/tmuxify" \
--target "$PWD/kasaterm"
# dry-run 내용을 확인한 뒤 실제 적용
./tmuxify/scripts/rename-repo-to-kasaterm.sh \
--source "$PWD/tmuxify" \
--target "$PWD/kasaterm" \
--apply
적용 시 설정 원본과 Git 연결 정보는 ~/.config/kasaterm/migrations/ 아래에 백업된다. 스크립트는 연결 worktree를 복구·검증하고, 이전 경로를 가리키는 영구 symlink는 만들지 않는다.
멀티 pane 제어·협업·긴 잡 사이클·UI 자체검증 워크플로우를 Claude Code 스킬로 묶었다:
claude plugin marketplace add 2rami/kasaterm
claude plugin install kasapane@kasaterm
설치 후 /kasapane으로 호출한다. 스킬이 쓰는 kasaterm-cli는 앱 빌드에 내장돼 있다.
macOS는 Cmd, Windows/Linux는 Ctrl+Shift를 "호스트 modifier"로 쓴다 (Ctrl+letter는 셸로 흘려보내기 위함). 폰트 zoom만 Windows/Linux에서 Ctrl 단독.
| 동작 | macOS | Windows / Linux |
|---|---|---|
| 가로 분할 (위아래로 쌓기) | Cmd + D | Ctrl + Shift + D |
| 세로 분할 (좌우로 나누기) | Cmd + Shift + D 또는 Cmd + E | Ctrl + Shift + E |
| 포커스된 pane 닫기 | Cmd + W | Ctrl + Shift + W |
| pane 포커스 순환 | Cmd + [ / Cmd + ] | Ctrl + Shift + [ / ] |
| 방향 쪽 pane으로 포커스 이동 | Cmd + Option + 방향키 | Ctrl + Shift + Alt + 방향키 |
| 두 pane 위치 맞바꾸기(swap) | Cmd + Option + Shift + 방향키 | (동일 패턴) |
| 동작 | macOS | Windows / Linux |
|---|---|---|
| 전체 UI 확대 / 축소 / 리셋 | Cmd + = / Cmd + - / Cmd + 0 | Ctrl + = / Ctrl + - / Ctrl + 0 |
| 포커스된 pane만 폰트 확대 / 축소 / 리셋 | Cmd + Shift + = / Cmd + Shift + - / Cmd + Shift + 0 | Ctrl + Alt + = / Ctrl + Alt + - / Ctrl + Alt + 0 |
pane 사이 비율 조절은 경계선(divider) 마우스 드래그, pane을 끌어 합치거나 나누는 건 drag → merge/split 존으로 한다 (키보드 단축키 없음).
| 동작 | 키 |
|---|---|
| 새 윈도우 (PTY 백엔드) | Cmd + T |
| 윈도우 1~9 전환 | Cmd + 1 ~ Cmd + 9 |
| 자동완성 suggestion 수락 | → / End / Ctrl + E |
| suggestion 단어 단위 수락 | Alt(Option) + F |
| 단어 단위 삭제 | Alt(Option) + Backspace |
워크스페이스 멤버는 강점 — 재사용 가능한 crate 표 참고. spikes/*는 iced/egui/gpui/warpui 등 채택 안 된 GUI 프레임워크 PoC다.
기본 렌더러는 cell-renderer(gpu.rs) + P3. 주요 env 토글:
| 변수 | 효과 |
|---|---|
KASATERM_P3_ROOT=0 | P3 root-layer 경로 끄고 옛 sRGB sublayer 폴백 |
KASATERM_TEXT_GAMMA / _CONTRAST / _COLOR_SAT | 텍스트 감마·대비·채도 노브 |
KASATERM_AUTOSPLIT / _MS | N초 후 자동 분할 ("vh" 등, 헤드리스 검증용) |
KASATERM_AUTOCAPTURE_MS / _PATH | N초 후 자동 스크린샷 (자체 테스트용) |
KASATERM_AUTOSEND / _MS | N초 후 키 자동 전송 (자체 테스트용) |
pane 목록·이름·분할·전송 같은 조작은 전부 kasaterm-cli가 맡는다(앱 빌드에 내장). 모델이 셸에서 그대로 부르는 편이 왕복이 적고, 도구 설명이 매 요청 실리지도 않는다 — kasaterm-cli board로 남이 뭘 하는지 보고, kasaterm-cli tell로 말을 건다. 예전의 kasaspace MCP 도구는 이것과 겹쳐 걷었고, 앱이 부팅할 때 옛 등록 항목을 AI 클라이언트 설정에서 지운다.
세 층이 같이 진화 중이다. 아래층이 안정될수록 위층을 더 단단히 떠받친다.
| 층 | 항목 | 상태 |
|---|---|---|
| ① 엔진 | wgpu 셀 렌더 · P3 색재현 | 안정 |
| ① 엔진 | 두벌식 한글 IME (OS 비의존) | 안정 |
| ① 엔진 | 크로스플랫폼 PTY (macOS · Windows · Linux) | 안정 |
| ① 엔진 | claude --resume 세션 복원 | 안정 |
| ② 작업환경 | 파일트리 · git 패널 | 진행 중 |
| ② 작업환경 | pane 간 에이전트 연결 | 안정 |
| ② 작업환경 | 기계 간 세션 이사 · 거울 pane | 진행 중 |
| ② 작업환경 | 폰 웹터미널 (관문 주소) | 진행 중 |
| ③ 오케스트레이션 | BA GUI — 작업 실시간 시각화 | 진행 중 |
| ③ 오케스트레이션 | 여러 Claude 협업 (아로나 모드) | 안정 |
| ③ 오케스트레이션 | 테마 팩 — 캐릭터 세트 교체 | 안정 |
| ③ 오케스트레이션 | claude · codex 계정 슬롯 전환 | 진행 중 |
tmux로 Claude Code 팀모드를 굴리다 시작됐다. 여러 에이전트를 한 화면에 띄워 쓰다 보니, "작업할 때만이 아니라 평소에도 에이전트끼리 소통하면 어떨까" 싶었다.
마침 불편한 게 겹쳤다. ghostty 같은 GPU 터미널은 쾌적한데 윈도우엔 마땅한 게 없었고, 터미널 안에서 여러 에이전트가 무슨 작업을 하는지는 로그를 헤집어야 보였다. 그래서 세 가지를 한 번에 풀기로 했다 — 플랫폼에 묶이지 않는 GPU 터미널, 그 위에 올린 나만의 하네스, 그리고 작업이 굴러가는 걸 한눈에 보여주는 UI.
기성 라이브러리에 기대지 않고 직접 만들고 싶었다. 디자이너로 일하다 개발에 입문한 터라, 터미널이 정보를 보여주는 방식 자체가 늘 답답했던 것도 있다. GPU 셀 렌더러(P3 색재현), 두벌식 한글 IME(OS IME 비의존), 크로스플랫폼 PTY까지 전부 자체 구현했고, 그 과정에서 깎인 부품들을 누구나 가져다 쓸 수 있는 crate로 남겼다. 결과물보다 만들면서 배운 게 더 컸다.
무료로 공개한다. 누군가에게 쓸모가 되거나, 같은 길을 걷는 사람에게 참고가 되면 충분하다.
혼자 만드는 프로젝트입니다. 쓸모가 있었다면 후원으로 응원해주세요.
hooks/register.tsx 129 lines1import type { EngineInterface, Register } from 'claude-code'
2
3type Face = { name: string; color?: string; file?: string; generation?: number }
4
5// 학생은 칸이 뜰 때 정해지지만 외형 끄기·테마 바꾸기는 그 뒤에도 바뀐다 — 턴을 열 때 다시 묻되 이 간격보다 자주는 안 묻는다.
6const REFRESH_MS = 30000
7
8// 모듈 변수는 다시 실릴 때 처음으로 돌아간다 — session.start 가 다시 묻는다.
9const known = { face: null as Face | null, at: 0 }
10
11// 얼굴은 턴마다 한 번 — 그 턴의 첫 답 블록에만. 엔진의 isFirstOfReply 는 도구 줄 뒤 글마다 참이라
12// 그대로 쓰면 한 턴에 얼굴이 몇 번씩 끼었다(2026-10-06 「위치가 이상해」). 블록은 메시지 id(requestId)로
13// 기억해 스크롤로 다시 그려져도 같은 자리에 남는다.
14const turn = { waiting: false, firsts: new Set<string>() }
15const FIRSTS_MAX = 500
16
17// 엔진이 고른 영어 낱말(Sauteing…) 대신 학생이 무엇을 하는지 한국어로.
18const DOING: Record<string, string> = {
19 requesting: '정리하는 중',
20 thinking: '생각하는 중',
21 responding: '답 쓰는 중',
22 'tool-input': '도구 준비하는 중',
23 'tool-use': '확인하는 중',
24}
25
26// 받침이 있으면 「이」, 없으면 「가」 — 학생 이름 뒤 주격 조사.
27function subject(name: string): string {
28 const last = name.charCodeAt(name.length - 1)
29 const hangul = last >= 0xac00 && last <= 0xd7a3
30 return `${name}${hangul && (last - 0xac00) % 28 !== 0 ? '이' : '가'}`
31}
32
33function took(ms: number): string {
34 const s = Math.max(0, Math.round(ms / 1000))
35 const h = Math.floor(s / 3600)
36 const m = Math.floor((s % 3600) / 60)
37 const parts = [h ? `${h}시간` : '', m ? `${m}분` : '', h || m ? (s % 60 ? `${s % 60}초` : '') : `${s}초`].filter(Boolean)
38 return `${parts.join(' ')} 걸려 끝났어요`
39}
40
41function same(a: Face | null, b: Face | null): boolean {
42 return JSON.stringify(a) === JSON.stringify(b)
43}
44
45// 앱이 정본이다 — 칸의 학생 이름으로 색·얼굴 파일·외형 끄기를 묻는다. 앱에 못 닿으면 아는 것을 그대로 둔다.
46async function ask($: EngineInterface) {
47 known.at = Date.now()
48 const name = (await $.env.get('KASATERM_CHARACTER')) ?? ''
49 const port = (await $.env.get('KASASPACE_MCP_PORT')) ?? ''
50 let face: Face | null = null
51 if (name && /^\d+$/.test(port)) {
52 try {
53 const res = await $.http.fetch(`http://127.0.0.1:${port}/claude-mod/face?name=${encodeURIComponent(name)}`)
54 if (!res.ok) return
55 const body = JSON.parse(res.text) as Partial<Face>
56 face = body.name ? { name: body.name, color: body.color ?? undefined, file: body.file ?? undefined, generation: body.generation ?? undefined } : null
57 } catch {
58 return
59 }
60 } else if (name) {
61 face = { name }
62 }
63 if (!same(face, known.face)) {
64 known.face = face
65 $.ui.invalidate('ui.render')
66 }
67}
68
69export const register: Register = on => {
70 on('session.start', async ($, e, next) => {
71 const result = await next(e)
72 await ask($)
73 return result
74 })
75
76 on('turn.start', async ($, e, next) => {
77 turn.waiting = true
78 if (Date.now() - known.at > REFRESH_MS) void ask($)
79 return next(e)
80 })
81
82 // 말하는 자리에 얼굴만 — 턴의 첫 답 왼쪽에 아바타처럼. 이름은 칸 머리·상태가 이미 말해 빼었다(2026-10-06).
83 // 엔진의 답 블록은 첫 줄에 ● 를 달아 얼굴 옆에 점이 하나 더 섰다 — 그 블록은 글을 Markdown 으로 직접 그려
84 // 점 자리를 얼굴이 맡는다. 엔진이 숨기는 부분(<context>)은 e.props.text 에 이미 없다.
85 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
86 const face = known.face
87 if (!face?.file || e.surface !== 'terminal' || !e.props.isFirstOfReply) return next(e)
88 if (turn.waiting) {
89 turn.waiting = false
90 turn.firsts.add(e.requestId)
91 if (turn.firsts.size > FIRSTS_MAX) turn.firsts.delete(turn.firsts.values().next().value as string)
92 }
93 // 턴의 첫 답은 큰 얼굴, 도구 뒤 같은 턴에 이어지는 답의 ● 는 한 줄짜리 작은 얼굴로.
94 const big = turn.firsts.has(e.requestId)
95 const { Box, Image, Markdown } = $.ui.resolve(e)
96 return (
97 <Box flexDirection="row" gap={1}>
98 <Image key="student-face" source={{ file: face.file, format: 'png', generation: face.generation }} columns={big ? 4 : 2} rows={big ? 2 : 1} alt=" " />
99 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
100 <Markdown text={e.props.text} />
101 </Box>
102 </Box>
103 )
104 })
105
106 on('ui.render', { component: 'Spinner' }, ($, e, next) => {
107 const face = known.face
108 if (!face || e.props.message !== null) return next(e)
109 return next({ ...e, props: { ...e.props, message: `${subject(face.name)} ${DOING[e.props.mode] ?? '일하는 중'}` } })
110 })
111
112 // 턴 끝 줄: 엔진의 영어 한 줄(Baked for 3s) 대신 학생 색 한국어, 앞 표지는 얼굴.
113 on('ui.render', { component: 'TurnDuration' }, ($, e, next) => {
114 const face = known.face
115 if (!face) return next(e)
116 const { Box, Image, Text } = $.ui.resolve(e)
117 return (
118 <Box flexDirection="row" gap={1}>
119 {face.file ? (
120 <Image key="student-face-end" source={{ file: face.file, format: 'png', generation: face.generation }} columns={2} rows={1} alt="◆" />
121 ) : (
122 <Text color={face.color}>◆</Text>
123 )}
124 <Text color={face.color}>{took(e.props.durationMs)}</Text>
125 </Box>
126 )
127 })
128}
129