SLOPSHOPPER

statusline

모델·effort·문맥·경로·브랜치가 바뀌는 그 순간 상태줄을 다시 그린다 — kasaterm 칸은 kasaterm 상태줄을 바로 덧그리고, kasaterm 밖에서는 같은 줄을 프롬프트 아래에 그린다

newspinnerguardprocessnetworktimer
A shopper browsing a rack in a slop shop
README

<img src="assets/AppIcon.png" width="120" alt="kasaterm" />

kasaterm

Rust로 바닥부터 만든 크로스플랫폼 GPU 터미널.

셀 렌더러 · 한글 IME · PTY를 기성 라이브러리 없이 자체 crate로 구현했고,<br/> 그 위에 여러 Claude를 학생처럼 거느리는 GUI를 얹었다.

데모 · 강점 · crate · 기계를 가로지른다 · 설치 · 단축키 · 구조

Release License Platform Built with Rust Renderer GitHub stars GitHub Sponsors


데모

<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까지 기성 터미널 라이브러리에 기대지 않고 전부 직접 만들었다.

두 축으로 읽으면 된다:

  • 아래 — 터미널 엔진. wgpu 셀 렌더러, 두벌식 한글 IME, 크로스플랫폼 PTY를 각각 독립 crate로 깎았다. 터미널을 만들려는 사람이 부품만 가져다 쓸 수 있게 설계했다.
  • 위 — AI 오케스트레이션. 그 엔진 위에서, pane마다 도는 Claude의 작업이 BA GUI(아로나 모드)로 실시간으로 보인다. 로그를 읽는 게 아니라 작업을 지켜본다.
  • 밖 — 기계를 가로지른다. pane이 이 컴퓨터에 묶여 있지 않다. 도는 세션을 다른 기계로 이사시키고, 그 화면을 원래 창에 거울로 두고, 폰 브라우저로 같은 pane을 이어서 본다.

강점 — 전부 자체 구현했다

기성 라이브러리를 붙인 게 아니라, 터미널의 핵심 부품을 바닥부터 만들었다.

무엇crate
GPU 셀 렌더swash atlas에 글리프를 한 번 굽고 셀당 인스턴스 1개로 그린다. box-drawing은 wgpu quad, CJK·이모지 fallback 내장kasa-cells
한글 IMEOS IME에 의존하지 않는 두벌식 입력 오토마타. 복합 종성까지 자체 조합kasa-ime
크로스플랫폼 PTYportable-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

워크스페이스가 곧 부품 카탈로그다. 각 crate는 kasaterm 없이도 독립적으로 쓸 수 있게 경계를 잡았다 — 특히 kasa-cells는 프레임워크 중립이라 alacritty_terminal·wezterm-term 같은 터미널 상태머신과 짝지어 다른 터미널을 만드는 데 그대로 가져다 쓸 수 있다.

crate한 줄독립 사용
kasa-cells프레임워크 중립 GPU 셀 렌더러 (wgpu). swash atlas·sRGB→P3·box-drawing·Nerd 폰트 번들터미널/그리드 UI 제작용
kasa-ptyPTY + alacritty_terminal 백엔드. 크로스플랫폼(ConPTY 포함)헤드리스 PTY 호스트
kasa-ime두벌식 한글 입력 오토마타. OS IME 비의존한글 입력이 필요한 Rust 앱
kasa-socketcmux 호환 Unix-socket JSON-RPC 서버. kasaterm-cli 포함pane 제어 프로토콜
kasa-bridgetmux control-mode(-C) 브리지. GUI 비의존, 이벤트·화면 채널만 넘긴다tmux를 붙이는 다른 UI
kasa-mcp원격 세션·기계 명부·폰 관문·보드의 HTTP 층폰·다른 기기·웹 pane 연동
app/kasaterm메인 바이너리 — winit+wgpu 윈도우, chrome UI, 입력·단축키 라우팅—

그 위 — 여러 Claude를 거느린다

엔진이 안정될수록 그 위에 쌓는 게 본 게임이다. 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끼리 연동된다 — pane이 격리된 창이 아니라, 에이전트가 pane을 넘나들며 협업하는 하나의 작업 공간이다. 방이 달라도 서로 말을 걸고, 닫힌 pane에 일을 시키는 건 보내기 직전에 막힌다.
  • 창 전체가 한눈에 — 사이드바 배치도가 어느 칸에 누가 앉아 있는지, 무엇이 얼마나 도는 중인지, 손이 필요한 칸은 어디인지 그린다. 안 볼 pane은 죽이지 않고 치워 둔다.
  • 계정을 갈아끼운다 — 상태줄에 claude·codex 계정 슬롯이 모여 있고, 전환하면 도는 pane도 새 계정으로 되띄운다. 5시간·7일 한도와 무엇이 그걸 잡아먹는지가 하단바에 상시 표시된다.
  • 확장을 한 화면에서 — MCP·플러그인·에이전트·커맨드·훅을 하네스별·스코프별로 보고 거기서 바로 켜고 끈다.

claude를 켜면 화면에 학생이 산다

pane에서 claude를 실행하면 그 pane에 블루 아카이브 학생 한 명이 배정된다 — 이름·테두리색·프로필이 전부 그 학생으로 맞춰지고, 창 전체에서 겹치지 않게 자동으로 고른다. /rename 미도리처럼 이름을 바꾸면 원하는 학생으로 갈아끼운다. (기본 로스터 12명: 아로나·프라나·미도리·모모이·유즈·아리스·유우카·시로코·호시노·코하루·히마리·아루.)

로스터는 갈아끼울 수 있다. 테마 팩 하나가 캐릭터 세트 하나다 — 폴더에 명부(characters.json)·그림·색 프리셋을 넣어 두면 그게 통째로 로스터가 된다. 설정 창에 zip을 떨어뜨려 가져오고, 여러 팩을 가로질러 좋아하는 캐릭터만 골라 쓰는 풀도 만든다. 캐릭터마다 모델·성격·이름을 따로 지정할 수 있고, 대화를 끊지 않고 도중에 바꿔도 말투까지 따라온다.

그리고 kasaterm은 Claude Code가 그리는 터미널 화면 자체를 렌더 단계에서 읽어, 그 위에 배정된 학생을 그린다. 로그를 파싱하거나 별도로 통합한 게 아니라 — 화면만 보고 동작한다:

  • 시작 배너 → Claude Code의 Clawd 블록아트를 감지해 학생 도트 idle 애니메이션으로 교체
  • statusline → 모델·컨텍스트 옆에 학생 프로필(bust)을 2행으로
  • 입력창 위 → 대기 중엔 학생이 전신으로 서서 숨쉬고(idle 애니), effort 칩이 뜨면 옆으로 비켜선다
  • 작업 중 → 스피너 자리에서 학생이 걸어다닌다(walk)

로그를 읽는 게 아니라 학생이 옆에서 같이 일하는 것처럼 보인다.

<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은 몸통에 색 리본이 붙어 헤더를 안 봐도 갈린다.


설치 & 실행

받아서 쓰기

최신 릴리스에서 받는다.

  • macOS — .dmg를 열고 kasaterm을 Applications로 드래그. 처음 한 번만 우클릭 → 열기(직접 서명한 앱이라 macOS가 한 번 확인받는다). 첫 실행에서 화면 녹화·접근 권한을 물으면 허용한다.
  • Windows — .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는 만들지 않는다.

Claude Code 플러그인 (kasapane 스킬)

멀티 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 단독.

pane 조작

동작macOSWindows / Linux
가로 분할 (위아래로 쌓기)Cmd + DCtrl + Shift + D
세로 분할 (좌우로 나누기)Cmd + Shift + D 또는 Cmd + ECtrl + Shift + E
포커스된 pane 닫기Cmd + WCtrl + Shift + W
pane 포커스 순환Cmd + [ / Cmd + ]Ctrl + Shift + [ / ]
방향 쪽 pane으로 포커스 이동Cmd + Option + 방향키Ctrl + Shift + Alt + 방향키
두 pane 위치 맞바꾸기(swap)Cmd + Option + Shift + 방향키(동일 패턴)

크기 / 폰트

동작macOSWindows / Linux
전체 UI 확대 / 축소 / 리셋Cmd + = / Cmd + - / Cmd + 0Ctrl + = / Ctrl + - / Ctrl + 0
포커스된 pane만 폰트 확대 / 축소 / 리셋Cmd + Shift + = / Cmd + Shift + - / Cmd + Shift + 0Ctrl + 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=0P3 root-layer 경로 끄고 옛 sRGB sublayer 폴백
KASATERM_TEXT_GAMMA / _CONTRAST / _COLOR_SAT텍스트 감마·대비·채도 노브
KASATERM_AUTOSPLIT / _MSN초 후 자동 분할 ("vh" 등, 헤드리스 검증용)
KASATERM_AUTOCAPTURE_MS / _PATHN초 후 자동 스크린샷 (자체 테스트용)
KASATERM_AUTOSEND / _MSN초 후 키 자동 전송 (자체 테스트용)

모델이 쓰는 도구

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로 남겼다. 결과물보다 만들면서 배운 게 더 컸다.

무료로 공개한다. 누군가에게 쓸모가 되거나, 같은 길을 걷는 사람에게 참고가 되면 충분하다.


후원

혼자 만드는 프로젝트입니다. 쓸모가 있었다면 후원으로 응원해주세요.

GitHub Sponsors

라이선스

MIT

Source 2 files
hooks/register.tsx 277 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { branchOf, effortLevel, plainParts, SEPARATOR, statuslineInput } from './line'
4import type { Context, Engine, Facts } from './line'
5
6// 바깥에서 바뀌는 것(다른 칸의 git checkout, 셸 cd)을 알아채는 간격. 파일 하나와 경로 하나를 읽는 값싼 일이다.
7const POLL_MS = 150
8
9// 모듈 변수는 다시 실릴 때 처음으로 돌아간다 — session.start 가 다시 채운다.
10const io = {
11  // kasaterm: kasaterm 상태줄을 바로 덧그린다 · plain: kasaterm 밖이라 같은 줄을 직접 그린다 · off: 사람이 고른 상태줄이 있는 칸
12  mode: 'off' as 'kasaterm' | 'plain' | 'off',
13  base: '',
14  pane: '',
15  engineFile: '',
16  facts: {} as Facts,
17  cwd: '',
18  head: '',
19  headPath: '',
20  // kasaterm 밖에서 그릴 값 — 엔진 입력이 없으니 이벤트와 설정으로만 안다.
21  plain: { model: '', window: 0, percent: undefined as number | undefined, effort: undefined as string | undefined, icon: 'nerd-font' },
22  last: '',
23  running: false,
24  again: false,
25}
26
27async function headPath($: EngineInterface, cwd: string): Promise<string> {
28  let dir = cwd.replace(/\/+$/, '') || '/'
29  for (let depth = 0; depth < 64; depth++) {
30    const dot = `${dir === '/' ? '' : dir}/.git`
31    try {
32      const stat = await $.fs.stat(dot)
33      if (stat.kind === 'dir') return `${dot}/HEAD`
34      if (stat.kind === 'file') {
35        const gitdir = /^gitdir:\s*(.+)$/m.exec(await $.fs.read(dot))?.[1]?.trim()
36        if (gitdir) return `${gitdir.startsWith('/') ? gitdir : `${dir}/${gitdir}`}/HEAD`
37      }
38    } catch {
39      // 없으면 위로.
40    }
41    if (dir === '/') return ''
42    dir = dir.replace(/\/[^/]+$/, '') || '/'
43  }
44  return ''
45}
46
47async function readHead($: EngineInterface): Promise<string> {
48  if (!io.headPath) return ''
49  try {
50    return await $.fs.read(io.headPath)
51  } catch {
52    return ''
53  }
54}
55
56function changed($: EngineInterface) {
57  if (io.mode === 'kasaterm') void redraw($)
58  else if (io.mode === 'plain') $.ui.invalidate('ui.render')
59}
60
61// 경로·브랜치는 이벤트가 없는 바깥 손에도 바뀐다 — 짧게 다시 읽어 달라졌을 때만 다시 그린다.
62// 도구·명령 뒤에도 부르므로 던지지 않는다(던지면 그 도구의 결과가 깨진다).
63async function poll($: EngineInterface) {
64  try {
65    const cwd = await $.session.cwd()
66    let moved = false
67    if (cwd !== io.cwd) {
68      io.cwd = cwd
69      io.facts.cwd = { value: cwd, at: Date.now() }
70      io.headPath = await headPath($, cwd)
71      moved = true
72    }
73    const head = await readHead($)
74    if (head !== io.head) {
75      io.head = head
76      moved = true
77    }
78    if (moved) changed($)
79  } catch {
80    // 다음 박자에 다시 읽는다.
81  }
82}
83
84async function readEngine($: EngineInterface): Promise<Engine | null> {
85  try {
86    return JSON.parse(await $.fs.read(io.engineFile)) as Engine
87  } catch {
88    return null
89  }
90}
91
92// kasaterm 칸 — 엔진의 마지막 입력에 막 안 사실을 얹어 kasaterm 상태줄 명령으로 같은 줄을 짓고 앱에 보낸다.
93// 앱은 엔진이 자기 줄을 다시 그릴 때까지만 이 줄을 덧그린다. 짓는 동안 온 바뀜은 끝난 뒤 한 번 더 짓는다.
94async function redraw($: EngineInterface) {
95  if (io.running) {
96    io.again = true
97    return
98  }
99  io.running = true
100  try {
101    do {
102      io.again = false
103      const engine = await readEngine($)
104      if (!engine) return
105      const session = await $.session.id()
106      const input = statuslineInput(engine, io.facts, session)
107      if (!input) continue
108      const run = await $.process.run(['kasaterm-cli', 'statusline'], {
109        stdin: JSON.stringify(input),
110        env: { KASATERM_STATUSLINE_DRAW_ONLY: '1' },
111        timeoutMs: 3000,
112      })
113      const line = run.stdout.replace(/\n+$/, '')
114      if (run.exitCode !== 0 || !line || line === io.last) continue
115      // 짓는 사이 엔진이 새 입력으로 자기 줄을 그렸다 — 옛 입력으로 지은 줄을 보내면 앱이 그 새 줄을 다음 엔진
116      // 박자까지 덮는다. 새 입력으로 다시 짓는다.
117      if ((await readEngine($))?.at_ms !== engine.at_ms) {
118        io.again = true
119        continue
120      }
121      io.last = line
122      await $.http.fetch(`${io.base}/claude-mod/event`, {
123        method: 'POST',
124        headers: { 'content-type': 'application/json' },
125        body: JSON.stringify({ v: 1, surface: io.pane, session, events: [{ kind: 'status', line }] }),
126      })
127    } while (io.again)
128  } catch {
129    // 앱이나 명령에 못 닿으면 엔진 상태줄이 혼자 그린다.
130  } finally {
131    io.running = false
132  }
133}
134
135function learnContext($: EngineInterface, context: Context) {
136  io.facts.context = { value: { percent: context.percent, window: context.window, tokens: context.tokens }, at: Date.now() }
137  io.plain.window = context.window
138  io.plain.percent = context.percent
139  changed($)
140}
141
142function learnModel($: EngineInterface, model: string) {
143  if (!model || model === io.facts.model?.value) return
144  io.facts.model = { value: model, at: Date.now() }
145  io.plain.model = model
146  changed($)
147}
148
149function learnEffort($: EngineInterface, effort: string) {
150  if (io.facts.effort?.value === effort) return
151  io.facts.effort = { value: effort, at: Date.now() }
152  io.plain.effort = effort || undefined
153  changed($)
154}
155
156async function boot($: EngineInterface) {
157  const pane = (await $.env.get('KASATERM_PANE_ID')) ?? ''
158  const port = (await $.env.get('KASASPACE_MCP_PORT')) ?? ''
159  const settings = await $.settings.read()
160  const command = String((settings.statusLine as { command?: unknown } | undefined)?.command ?? '')
161  if (pane) {
162    // kasaterm 칸이라도 사람이 자기 상태줄을 골랐으면(「상태줄 직접 설정」) 손대지 않는다.
163    if (!command.includes('kasaterm-cli statusline') || !/^\d+$/.test(port)) return
164    const tmp = ((await $.env.get('TMPDIR')) || '/tmp').replace(/\/+$/, '')
165    io.mode = 'kasaterm'
166    io.base = `http://127.0.0.1:${port}`
167    io.pane = pane
168    io.engineFile = `${tmp}/kasaterm-statusline/${pane.replace(/^%/, '')}-engine.json`
169  } else {
170    io.mode = 'plain'
171    io.plain.effort = effortLevel((await $.env.get('CLAUDE_EFFORT')) ?? '') ?? effortLevel(settings.effortLevel)
172    try {
173      const home = (await $.env.get('HOME')) ?? ''
174      const config = JSON.parse(await $.fs.read(`${home}/.claude/statusline-config.json`)) as { icon_set?: string }
175      if (config.icon_set) io.plain.icon = config.icon_set
176    } catch {
177      // 설정이 없으면 Nerd Font 글리프.
178    }
179  }
180  io.plain.model = await $.session.model()
181  const usage = await $.session.usage()
182  io.plain.window = usage.context.window
183  io.plain.percent = usage.context.percent
184  await poll($)
185  $.clock.every(POLL_MS, () => void poll($))
186}
187
188export const register: Register = on => {
189  on('session.start', async ($, e, next) => {
190    const result = await next(e)
191    await boot($)
192    return result
193  })
194
195  on('classic.PostModelSwitch', async ($, e, next) => {
196    const result = await next(e)
197    if (io.mode !== 'off') {
198      learnModel($, e.to_model)
199      learnContext($, (await $.session.usage()).context)
200    }
201    return result
202  }).catch(($, e, next) => next(e))
203
204  // /effort·/model·/cd 는 명령이 끝나야 값이 바뀐다. /effort 는 다른 이벤트가 없어 인자로 안다.
205  on('command.run', async ($, e, next) => {
206    const result = await next(e)
207    if (io.mode === 'off') return result
208    if (e.command === 'effort') {
209      const level = effortLevel(e.args.trim().split(/\s+/)[0])
210      if (level) learnEffort($, level)
211    }
212    try {
213      learnModel($, await $.session.model())
214    } catch {
215      // 모델은 PostModelSwitch 가 다시 알린다.
216    }
217    await poll($)
218    return result
219  })
220
221  // 요청마다 엔진이 실제로 쓴 effort — 모델 고르기 창에서 바꾼 것도 첫 요청에 바로잡힌다.
222  on('turn.step', async function* ($, e, next) {
223    if (io.mode !== 'off' && e.agentId === undefined) {
224      const level = effortLevel(e.effort)
225      if (level) learnEffort($, level)
226    }
227    return yield* next(e)
228  })
229
230  on('session.measure', async ($, e, next) => {
231    if (io.mode !== 'off') learnContext($, e.context)
232    return next(e)
233  })
234
235  on('classic.CwdChanged', async ($, e, next) => {
236    const result = await next(e)
237    if (io.mode !== 'off') await poll($)
238    return result
239  }).catch(($, e, next) => next(e))
240
241  // git checkout·cd 는 대개 Bash 로 온다 — 끝나는 순간 다시 읽는다.
242  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
243    const result = await next(e)
244    if (io.mode !== 'off') await poll($)
245    return result
246  })
247
248  // kasaterm 밖: 상태줄 자리가 비어 있으니(엔진은 명령 상태줄만 그린다) 프롬프트 아래 모드 자리에 같은 줄을 그린다.
249  // 폭을 줄 전체로 잡으면 엔진이 힌트 줄 아래 제 줄로 내린다.
250  on('ui.render', { component: 'SessionMode' }, ($, e, next) => {
251    if (io.mode !== 'plain' || !io.plain.model) return next(e)
252    const { Box, Text } = $.ui.resolve(e)
253    const parts = plainParts({ ...io.plain, branch: branchOf(io.head), cwd: io.cwd }, io.plain.icon)
254    const columns = e.viewport?.columns ?? 80
255    return (
256      <Box flexDirection="row" width={Math.max(20, columns - 4)}>
257        {parts.flatMap((part, index) => [
258          ...(index > 0
259            ? [
260                <Text key={`sep-${index}`} color={SEPARATOR.color} dimColor>
261                  {` ${SEPARATOR.text} `}
262                </Text>,
263              ]
264            : []),
265          ...part.map((seg, at) => (
266            <Text key={`seg-${index}-${at}`} color={seg.color} bold={seg.bold} dimColor={seg.dim}>
267              {seg.text}
268            </Text>
269          )),
270        ])}
271        <Box flexGrow={1} />
272        <Text dimColor>{e.props.modes.join(' & ')}</Text>
273      </Box>
274    )
275  })
276}
277
hooks/line.ts 117 lines
1export const LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
2
3export type Context = { percent?: number; window: number; tokens?: number }
4export type Fact<T> = { value: T; at: number }
5/** 엔진에 묻기 전에 이벤트로 먼저 안 값. 시각은 안 때(ms)다. effort 의 빈 글자는 「effort 없음」이다. */
6export type Facts = { model?: Fact<string>; effort?: Fact<string>; cwd?: Fact<string>; context?: Fact<Context> }
7
8/** kasaterm 상태줄 명령이 마지막으로 받은 엔진 입력(`<칸>-engine.json`). */
9export type Engine = {
10  at_ms?: number
11  session_id?: string
12  cwd?: string
13  model?: { id?: string; display_name?: string }
14  effort?: { level?: string }
15  context_window?: { used_percentage?: number; context_window_size?: number; total_input_tokens?: number }
16}
17
18export type Seg = { text: string; color?: string; bold?: boolean; dim?: boolean }
19
20/** 엔진이 상태줄에 주는 표시명(「Opus 5.5」)을 모델 id 로 짓는다 — 모델을 막 바꿔 엔진 입력에 아직 없을 때만 쓴다. */
21export function displayName(id: string): string {
22  const bare = id.replace(/\[[^\]]*\]$/, '')
23  const [vendor, family = '', ...rest] = bare.split('-')
24  const version = rest.filter(p => /^\d{1,2}$/.test(p))
25  if (vendor !== 'claude' || !/^[a-z]+$/.test(family) || version.length === 0) return bare
26  return `${family.charAt(0).toUpperCase()}${family.slice(1)} ${version.join('.')}`
27}
28
29export function effortLevel(effort: unknown): string | undefined {
30  return typeof effort === 'string' && (LEVELS as readonly string[]).includes(effort) ? effort : undefined
31}
32
33/** `.git/HEAD` 의 글자 → 상태줄의 브랜치 이름. 떨어진 HEAD 는 `HEAD`(kasaterm 상태줄과 같다). */
34export function branchOf(head: string): string {
35  const text = head.trim()
36  if (!text) return ''
37  if (!text.startsWith('ref:')) return 'HEAD'
38  const name = text.slice(4).trim()
39  return name.startsWith('refs/heads/') ? name.slice('refs/heads/'.length) : name
40}
41
42/**
43 * kasaterm 상태줄 명령에 넣을 입력 — 엔진이 마지막으로 넘긴 입력 위에 그보다 늦게 안 사실만 얹는다.
44 * 엔진은 1초 남짓마다 다시 넘기므로, 이벤트로 잘못 안 값이 있어도 그 안에 엔진 값이 이긴다.
45 *
46 * 모델을 막 바꿨고 그 뒤의 effort 를 모르면 null — 엔진은 모델을 바꾸면 effort 를 그 모델의 기본으로 되돌리는데
47 * (Opus xhigh → Sonnet medium), 옛 effort 를 얹으면 틀린 줄이 엔진 줄과 번갈아 1초 남짓 보였다(2026-10-07 실측).
48 * 그때는 엔진 줄을 기다린다(엔진은 모델을 바꾼 뒤 0.1~0.2초 안에 다시 그린다).
49 */
50export function statuslineInput(engine: Engine, facts: Facts, session: string): Record<string, unknown> | null {
51  const at = engine.at_ms ?? 0
52  const fresh = <T>(fact?: Fact<T>) => (fact && fact.at > at ? fact.value : undefined)
53  const switched = fresh(facts.model) !== undefined && facts.model?.value !== engine.model?.id
54  if (switched && !(facts.effort && facts.model && facts.effort.at >= facts.model.at)) return null
55  const input: Record<string, unknown> = { session_id: session || engine.session_id, cwd: fresh(facts.cwd) ?? engine.cwd }
56  const model = fresh(facts.model)
57  input.model =
58    model === undefined
59      ? engine.model
60      : { id: model, display_name: model === engine.model?.id ? engine.model?.display_name : displayName(model) }
61  const effort = fresh(facts.effort)
62  if (effort === undefined) {
63    if (engine.effort) input.effort = engine.effort
64  } else if (effort) {
65    input.effort = { level: effort }
66  }
67  const context = fresh(facts.context)
68  input.context_window =
69    context === undefined
70      ? engine.context_window
71      : { used_percentage: context.percent ?? 0, context_window_size: context.window, total_input_tokens: context.tokens ?? 0 }
72  return input
73}
74
75// kasaterm 상태줄(`kasaterm-cli statusline`)의 색·글리프 — kasaterm 밖 claude 에서도 같은 줄로 보이게.
76const C_MODEL = '#7aa2f7'
77const C_GIT = '#73daca'
78const C_DIR = '#bb9af7'
79const C_CTX = '#ff9e64'
80const C_DANGER = '#f7768e'
81const C_SEP = '#565f89'
82const C_EFFORT: Record<string, string> = { low: '#565f89', medium: '#7aa2f7', high: '#e0af68', xhigh: '#f7768e', max: '#bb9af7' }
83type Icons = { model: string; git: string; folder: string; effort: string }
84const NERD: Icons = { model: '\uf233', git: '\ue0a0', folder: '\uf07b', effort: '\uf0e7' }
85export const ICONS: Record<string, Icons> = {
86  'nerd-font': { model: '', git: '', folder: '', effort: '' },
87  unicode: { model: '>', git: '⎇', folder: '▸', effort: '↯' },
88  plain: { model: 'M', git: 'git', folder: 'dir', effort: 'E' },
89}
90
91export function windowLabel(window: number): string {
92  if (window >= 1_000_000) return '1M'
93  return window > 0 ? `${Math.floor(window / 1000)}k` : ''
94}
95
96export type Plain = { model: string; window: number; percent?: number; branch: string; cwd: string; effort?: string }
97
98/** kasaterm 밖 claude 의 상태줄 — kasaterm 상태줄이 칸 밖에서 짓는 줄과 같은 차례·색. 구분자는 따로 넣는다. */
99export function plainParts(v: Plain, icon = 'nerd-font'): Seg[][] {
100  const ic = ICONS[icon] ?? NERD
101  const parts: Seg[][] = []
102  if (v.model) {
103    const name = displayName(v.model)
104    const win = windowLabel(v.window)
105    parts.push([{ text: `${ic.model} ${name}`, color: C_MODEL, bold: true }, ...(win ? [{ text: ` ${win}`, dim: true }] : [])])
106  }
107  if (v.branch) parts.push([{ text: `${ic.git} ${v.branch}`, color: C_GIT }])
108  const dir = v.cwd.replace(/\/+$/, '').split('/').pop() ?? ''
109  parts.push([{ text: `${ic.folder} ${dir}`, color: C_DIR }])
110  const percent = Math.round(v.percent ?? 0)
111  parts.push([{ text: `${percent}%`, color: percent >= 90 ? C_DANGER : C_CTX }])
112  if (v.effort) parts.push([{ text: `${ic.effort} ${v.effort}`, color: C_EFFORT[v.effort] ?? C_MODEL }])
113  return parts
114}
115
116export const SEPARATOR: Seg = { text: '┃', color: C_SEP, dim: true }
117