SLOPSHOPPER

prompt-nav

풀스크린 Claude Code 에서 내 프롬프트 사이를 오가고, 위치를 kasaterm 스크롤바에 알린다

newbandrowscommandprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-nav
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /prompt-nav ⎿ prompt-nav: prompt-nav: no prompt there ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩
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 270 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { append, freshRequest, metrics, observe, parseCommand, parseItems, promptLine, target } from './nav'
4import type { Item, Kind, Model, Op, Seen } from './nav'
5
6// 턴을 여는 줄은 누가 보냈든 프롬프트다(사람·tell·다른 세션·예약). 이것들만 아니다 — 배경 작업
7// 알림, 다른 세션의 SendMessage 전달, 관찰자 보고, UI 동작의 뒤잇기. bin/transcript-items.py 와 같은 표.
8const NOT_PROMPT = new Set(['task-notification', 'peer-send-message', 'observer', 'observer-activity', 'auto-continuation'])
9// 이동 명령 자신은 턴이 아니다 — 눈금이 되면 「앞 프롬프트」가 먼저 그 줄로 간다.
10const NAV_COMMAND = '/prompt-nav'
11
12const model: Model = { items: [], heights: new Map(), onScreen: new Map(), notPrompt: new Set(), cols: 80, top: null }
13const index = new Map<string, number>()
14const io = {
15  state: '',
16  request: '',
17  session: '',
18  done: 0,
19  seq: 0,
20  last: '',
21  pending: false,
22  armed: null as (Op & { seq: number }) | null,
23  armedAt: 0,
24  loaded: false,
25  fullscreen: false,
26  agentView: false,
27  ask: null as (Op & { seq: number; at: number }) | null,
28}
29
30// 세션이 바뀌면(/clear·/resume) 줄 차례도 잰 높이도 그 세션 것이 아니다.
31function reset() {
32  io.loaded = false
33  model.items = []
34  index.clear()
35  model.heights.clear()
36  model.onScreen.clear()
37  model.notPrompt.clear()
38  model.top = null
39}
40
41function replace(items: Item[]) {
42  model.items = items
43  index.clear()
44  items.forEach((item, i) => index.set(item.id, i))
45  io.loaded = true
46}
47
48// 기록 도우미가 모르는 줄은 그 뒤에 새로 생긴 줄이다(이번 턴의 질문·답).
49function remember(id: string, k: Kind, text: string) {
50  if (!io.loaded || io.agentView || index.has(id)) return
51  const lines = text.split('\n')
52  append(model, index, {
53    id,
54    k,
55    l: lines.length,
56    c: lines.reduce((n, line) => n + line.length, 0),
57    t: k === 'u' ? promptLine(text) : '',
58  })
59}
60
61function see(
62  $: EngineInterface,
63  id: string,
64  k: Kind,
65  text: string,
66  on: Seen | null | undefined,
67  viewport: { columns: number; isFullscreen?: boolean } | undefined,
68) {
69  if (io.agentView) return
70  if (viewport) {
71    if (viewport.columns !== model.cols) {
72      // 폭이 바뀌면 잰 높이가 모두 틀린다 — 다시 보일 때 새로 잰다.
73      model.cols = viewport.columns
74      model.heights.clear()
75    }
76    if (viewport.isFullscreen !== undefined) io.fullscreen = viewport.isFullscreen
77  }
78  remember(id, k, text)
79  if (on !== undefined) observe(model, id, on, other => index.get(other))
80  schedule($)
81}
82
83function schedule($: EngineInterface) {
84  if (io.pending || !io.state) return
85  io.pending = true
86  $.clock.after(200, () => {
87    io.pending = false
88    void flush($)
89  })
90}
91
92async function flush($: EngineInterface) {
93  const m = metrics(model)
94  const body = {
95    session: io.session,
96    armed: io.armed?.seq ?? 0,
97    done: io.done,
98    fullscreen: io.fullscreen,
99    cols: model.cols,
100    total: m.total,
101    top: m.top,
102    current: m.current,
103    prompts: m.prompts.map(p => [p.row, p.text]),
104    ask: io.ask,
105  }
106  const key = JSON.stringify(body)
107  if (key === io.last) return
108  io.last = key
109  io.seq += 1
110  await $.fs.write(io.state, JSON.stringify({ v: 1, seq: io.seq, at: await $.clock.now(), ...body }))
111}
112
113// 점프하면 화면을 떠난 줄은 다시 보고되지 않으므로 지금까지의 보고를 비우고 새 보고로만 맨
114// 윗줄을 정한다. 스크롤이 끝날 때까지 보고가 하나도 없으면(이미 그 자리) 목표를 맨 윗줄로 둔다.
115async function go($: EngineInterface, op: Op): Promise<string | undefined> {
116  if (!io.fullscreen) return ask($, op)
117  const to = target(model, op)
118  if (!to) return 'no prompt there'
119  const before = { onScreen: new Map(model.onScreen), top: model.top }
120  model.onScreen.clear()
121  model.top = null
122  const result = await $.ui.scroll({ to: { requestId: to.id }, block: to.block })
123  if (result.deny) {
124    model.onScreen = before.onScreen
125    model.top = before.top
126  } else if (model.top === null && to.block === 'start') {
127    model.top = { id: to.id, first: 0 }
128  }
129  return result.deny
130}
131
132// classic 렌더러는 대화가 터미널 스크롤백에 쌓여 엔진이 그 줄을 스크롤하지 못한다. kasaterm 칸이면
133// 상태 파일의 ask 로 맡기고, kasaterm 이 자기 스크롤백의 프롬프트 줄로 옮긴다.
134async function ask($: EngineInterface, op: Op): Promise<string | undefined> {
135  if (!io.state) return 'the classic renderer keeps the transcript in terminal scrollback'
136  io.ask = { ...op, seq: (io.ask?.seq ?? 0) + 1, at: await $.clock.now() }
137  await flush($)
138  return undefined
139}
140
141async function readRequest($: EngineInterface): Promise<(Op & { seq: number }) | null> {
142  if (!io.request) return null
143  try {
144    return freshRequest(await $.fs.read(io.request), io.done, await $.clock.now())
145  } catch {
146    return null
147  }
148}
149
150// 대화 줄 스크롤은 누름 처리에서 파일 읽기 같은 기다림보다 먼저 와야 사람의 입력으로 인정된다
151// (읽고 나서 부르면, 다른 누름이 읽던 것을 기다려도 not person-initiated). 그래서 두 번 누른다:
152// kasaterm 이 요청 파일을 쓰고 장전 화음(ctrl+x b)을 보내면 여기서 읽어 쥐고 상태 파일의 armed 로
153// 알린다. 그것을 본 kasaterm 이 앞 단추 단축키를 보내면 쥔 요청으로 곧장 스크롤한다.
154async function arm($: EngineInterface) {
155  const request = await readRequest($)
156  if (!request) return
157  io.armed = request
158  io.armedAt = Date.now()
159  await flush($)
160}
161
162async function press($: EngineInterface, fallback: Op) {
163  // 장전하고 2초 안에 안 쏜 요청은 버린다 — 사람이 나중에 같은 단축키를 눌렀을 때 실리면 안 된다.
164  const request = io.armed && Date.now() - io.armedAt < 2000 ? io.armed : null
165  io.armed = null
166  await go($, request ?? fallback)
167  if (!request) return schedule($)
168  // kasaterm 은 done 을 보고 다음 요청(끄는 중의 다음 자리)을 보낸다 — 묶어 쓰기를 기다리지 않는다.
169  io.done = request.seq
170  await flush($)
171}
172
173async function load($: EngineInterface, path: string) {
174  const script = `${$.plugin.root}/bin/transcript-items.py`
175  for (const python of ['python3', 'python']) {
176    try {
177      const run = await $.process.run([python, script, path], { timeoutMs: 20000 })
178      if (run.exitCode === 0 && !run.isStdoutTruncated) {
179        replace(parseItems(run.stdout))
180        schedule($)
181        return
182      }
183    } catch {
184      // 다음 이름으로 다시 찾는다.
185    }
186  }
187  replace([])
188  schedule($)
189}
190
191async function boot($: EngineInterface) {
192  const dir = await $.env.get('KASATERM_PROMPT_NAV_DIR')
193  const pane = await $.env.get('KASATERM_PANE_ID')
194  if (dir && pane) {
195    io.state = `${dir}/${pane}.json`
196    io.request = `${dir}/${pane}.req.json`
197  }
198  io.session = await $.session.id()
199  await $.command.register({
200    name: 'prompt-nav',
201    description: '내 프롬프트로 이동 (prev·next·first·last·번호)',
202    argumentHint: '[prev|next|first|last|N]',
203    immediate: true,
204  })
205}
206
207export const register: Register = on => {
208  on('session.start', async ($, e, next) => {
209    const result = await next(e)
210    await boot($)
211    return result
212  })
213
214  on('classic.SessionStart', async ($, e, next) => {
215    const result = await next(e)
216    io.session = e.session_id
217    // 압축 뒤에도 화면은 지난 줄을 그대로 둔다(2026-10-06 실측). 기록은 압축 경계에서 끊기니 다시
218    // 읽으면 화면에 있는 줄을 잃는다 — 가진 차례에 새 줄(경계·요약)을 그려지는 대로 붙인다.
219    if (e.source === 'compact') return result
220    reset()
221    void load($, e.transcript_path)
222    return result
223  })
224
225  on('command.run', { command: 'prompt-nav' }, async ($, e) => {
226    io.fullscreen = e.presentation.isFullscreen
227    const op = parseCommand(e.args)
228    const deny = op ? await go($, op) : 'prev · next · first · last · 번호 중 하나'
229    return deny ? { text: `prompt-nav: ${deny}` } : {}
230  })
231
232  on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
233    const prompt = !NOT_PROMPT.has(e.props.origin.kind) && !e.props.text.startsWith(NAV_COMMAND)
234    if (!prompt) model.notPrompt.add(e.requestId)
235    see($, e.requestId, prompt ? 'u' : 'n', e.props.text, e.props.onScreen, e.viewport)
236    return next(e)
237  })
238
239  on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => {
240    see($, e.requestId, 'a', e.props.text, e.props.onScreen, e.viewport)
241    return next(e)
242  })
243
244  on('ui.render', { component: 'ToolUse' }, ($, e, next) => {
245    see($, e.requestId, 't', '', e.props.onScreen, e.viewport)
246    return next(e)
247  })
248
249  // 단추는 보이지 않는다. 엔진 단축키 동작(diff 창의 파일 목록 위·아래, diff 기준 바꾸기)에 걸어
250  // 그 창이 없는 평소 화면에서 Ctrl·Option+↑↓ 와 ctrl+x b 가 누른다 — 사람의 입력이라 대화 줄을
251  // 스크롤할 수 있다.
252  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
253    io.agentView = e.props.view.agentId !== undefined
254    if (e.viewport?.isFullscreen !== undefined) io.fullscreen = e.viewport.isFullscreen
255    const below = await next(e)
256    if (e.surface !== 'terminal' || e.props.hasSurvey) return below
257    const { Box, Button } = $.ui.resolve(e)
258    return (
259      <Box flexDirection="column">
260        {below}
261        <Box display="none">
262          <Button key="prompt-nav-prev" label="prev" action="app:diffFileListUp" onPress={() => void press($, { op: 'prev' })} />
263          <Button key="prompt-nav-next" label="next" action="app:diffFileListDown" onPress={() => void press($, { op: 'next' })} />
264          <Button key="prompt-nav-arm" label="arm" action="app:cycleDiffBase" onPress={() => void arm($)} />
265        </Box>
266      </Box>
267    )
268  })
269}
270
hooks/nav.ts 233 lines
1// 대화 줄의 차례·높이에서 스크롤 위치와 프롬프트 자리를 셈한다. `$` 를 모르는 순수 셈이라
2// 시험이 그대로 부른다.
3
4// u=프롬프트, n=그려지되 프롬프트가 아닌 사람 줄(배경 작업 알림·명령 출력), a=답 글, t=도구 줄, s=압축 요약.
5export type Kind = 'u' | 'n' | 'a' | 't' | 's'
6
7export type Item = { id: string; k: Kind; l: number; c: number; t: string }
8
9export type Seen = { first: number; last: number; of: number }
10
11export type Model = {
12  items: Item[]
13  heights: Map<string, number>
14  onScreen: Map<string, Seen>
15  notPrompt: Set<string>
16  cols: number
17  // 화면 맨 윗줄이 걸린 줄과 그 줄 안의 몇째 행인가. 엔진은 스크롤 때 **가장자리** 줄만 다시
18  // 알려 주므로, 큰 점프로 화면을 벗어난 줄은 낡은 보고가 남는다 — 그것들 중 맨 위를 맨 윗줄로
19  // 치면 틀린다. 그래서 확실한 신호(위가 잘린 줄, 더 위에서 보인 줄, 내가 보낸 스크롤)로만 옮긴다.
20  top: { id: string; first: number } | null
21}
22
23export type Prompt = { id: string; row: number; text: string }
24
25export type Metrics = {
26  total: number
27  top: number | null
28  current: number
29  prompts: Prompt[]
30}
31
32export type Op =
33  | { op: 'prev' }
34  | { op: 'next' }
35  | { op: 'first' }
36  | { op: 'last' }
37  | { op: 'prompt'; index: number }
38  | { op: 'row'; row: number }
39  | { op: 'bottom' }
40
41export type Target = { id: string; block: 'start' | 'end' }
42
43// 그린 적 없는 줄의 높이 짐작. 엔진이 줄마다 위에 빈 줄 하나를 두므로 +1 이다.
44export function estimate(item: Item, cols: number): number {
45  if (item.k === 't' || item.k === 's') return 3
46  if (item.k === 'n') return 2
47  const width = Math.max(20, cols - 4)
48  return Math.max(item.l, Math.ceil(item.c / width)) + 1
49}
50
51function positions(m: Model): { pos: number[]; total: number } {
52  const pos: number[] = []
53  let total = 0
54  for (const item of m.items) {
55    pos.push(total)
56    total += m.heights.get(item.id) ?? estimate(item, m.cols)
57  }
58  return { pos, total }
59}
60
61function isPrompt(m: Model, item: Item): boolean {
62  return item.k === 'u' && !m.notPrompt.has(item.id)
63}
64
65// 보고 하나를 받아 맨 윗줄을 옮긴다. 위가 잘렸으면(first > 0) 그 줄이 곧 맨 윗줄이고, 온전히
66// 보이는 줄이 지금 맨 윗줄보다 위에 있으면 맨 윗줄이 거기까지 올라온 것이다. 맨 윗줄이던 줄이
67// 화면을 벗어나면 남은 보고 가운데 맨 위로 짐작한다.
68export function observe(m: Model, id: string, on: Seen | null, index: (id: string) => number | undefined) {
69  if (on) {
70    m.onScreen.set(id, on)
71    m.heights.set(id, on.of)
72    const at = index(id)
73    const was = m.top ? index(m.top.id) : undefined
74    if (on.first > 0 || was === undefined || (at !== undefined && at < was)) m.top = { id, first: on.first }
75    else if (m.top?.id === id) m.top = { id, first: on.first }
76    return
77  }
78  m.onScreen.delete(id)
79  if (m.top?.id !== id) return
80  m.top = null
81  let best: number | undefined
82  for (const [other, seen] of m.onScreen) {
83    const at = index(other)
84    if (at !== undefined && (best === undefined || at < best)) {
85      best = at
86      m.top = { id: other, first: seen.first }
87    }
88  }
89}
90
91// plugin·다른 세션이 보낸 줄은 엔진의 머리말을 달고 그려진다. 눈금 글은 그 뒤 본문의 첫 줄이다 —
92// bin/transcript-items.py 의 FRAMING 과 같은 규칙.
93const FRAMING = [' plugin sent a message:', 'Another Claude session sent a message:']
94
95export function promptLine(text: string): string {
96  const lines = text.trim().split('\n')
97  const head = lines[0] ?? ''
98  const body = lines.length > 1 && FRAMING.some(f => head.endsWith(f)) ? lines.slice(1).join('\n').trim() : text.trim()
99  return (body.split('\n')[0] ?? '').slice(0, 80)
100}
101
102// 기록에 없던 줄(이번 턴의 질문·답)을 그려진 차례로 붙인다. 바로 앞의 같은 글 사람 줄이 새 id 로
103// 다시 그려진 것이면 그 자리를 바꿔 낀다 — /compact 는 친 줄을 압축 뒤 명령 줄로, tell 은 대기 줄을
104// 자리표시 줄·머리말 붙은 실제 줄로 갈아 그린다(2026-10-06 실측).
105export function append(m: Model, index: Map<string, number>, item: Item) {
106  const at = m.items.length - 1
107  const last = m.items[at]
108  if (item.k === 'u' && last?.k === 'u' && last.t === item.t) {
109    index.delete(last.id)
110    m.heights.delete(last.id)
111    m.onScreen.delete(last.id)
112    if (m.top?.id === last.id) m.top = { id: item.id, first: m.top.first }
113    m.items[at] = item
114    index.set(item.id, at)
115    return
116  }
117  index.set(item.id, m.items.length)
118  m.items.push(item)
119}
120
121export function metrics(m: Model): Metrics {
122  const { pos, total } = positions(m)
123  const prompts: Prompt[] = []
124  let topRow: number | null = null
125  m.items.forEach((item, i) => {
126    if (isPrompt(m, item)) prompts.push({ id: item.id, row: pos[i] ?? 0, text: item.t })
127    if (m.top?.id === item.id) topRow = (pos[i] ?? 0) + m.top.first
128  })
129  let current = -1
130  if (topRow !== null) {
131    const top: number = topRow
132    prompts.forEach((p, i) => {
133      if (p.row <= top) current = i
134    })
135  }
136  return { total, top: topRow, current, prompts }
137}
138
139// 「앞 프롬프트」는 지금 턴의 머리가 화면 맨 위에 없으면 그 머리로, 있으면 하나 앞으로 —
140// 터미널의 표식 오가기와 같은 규칙이다.
141export function target(m: Model, op: Op): Target | null {
142  const at = metrics(m)
143  const { prompts } = at
144  const pick = (i: number): Target | null => {
145    const p = prompts[i]
146    return p ? { id: p.id, block: 'start' } : null
147  }
148  switch (op.op) {
149    case 'first':
150      return pick(0)
151    case 'last':
152      return pick(prompts.length - 1)
153    case 'prompt':
154      return pick(op.index)
155    case 'prev': {
156      if (at.top === null) return pick(prompts.length - 1)
157      const cur = prompts[at.current]
158      if (cur && cur.row < at.top) return pick(at.current)
159      return pick(at.current - 1)
160    }
161    // 마지막 프롬프트 다음은 대화 끝이다 — 터미널 스크롤백 쪽(kasaterm)도 같은 키로 맨 아래에 선다.
162    case 'next':
163      return pick(at.current + 1) ?? target(m, { op: 'bottom' })
164    case 'bottom': {
165      const last = m.items[m.items.length - 1]
166      return last ? { id: last.id, block: 'end' } : null
167    }
168    case 'row': {
169      const { pos } = positions(m)
170      let found = -1
171      pos.forEach((start, i) => {
172        if (start <= op.row) found = i
173      })
174      const item = m.items[Math.max(0, found)]
175      return item ? { id: item.id, block: 'start' } : null
176    }
177  }
178}
179
180export function parseItems(stdout: string): Item[] {
181  const raw: unknown = JSON.parse(stdout)
182  if (!Array.isArray(raw)) return []
183  const out: Item[] = []
184  for (const row of raw) {
185    if (!Array.isArray(row)) continue
186    const [id, k, l, c, t] = row as unknown[]
187    if (typeof id !== 'string' || (k !== 'u' && k !== 'n' && k !== 'a' && k !== 't' && k !== 's')) continue
188    out.push({ id, k, l: Number(l) || 1, c: Number(c) || 0, t: typeof t === 'string' ? t : '' })
189  }
190  return out
191}
192
193// kasaterm 이 남긴 요청 — 오래된 것(이미 한 번호, 5초 넘은 것)은 버린다. 사람이 같은 단축키를
194// 직접 눌렀을 때 지난 요청이 다시 실리면 안 된다.
195export function freshRequest(text: string, done: number, now: number): (Op & { seq: number }) | null {
196  let raw: unknown
197  try {
198    raw = JSON.parse(text)
199  } catch {
200    return null
201  }
202  if (typeof raw !== 'object' || raw === null) return null
203  const r = raw as Record<string, unknown>
204  const seq = Number(r.seq)
205  const at = Number(r.at)
206  if (!Number.isFinite(seq) || seq <= done || !Number.isFinite(at) || Math.abs(now - at) > 5000) return null
207  switch (r.op) {
208    case 'prev':
209    case 'next':
210    case 'first':
211    case 'last':
212    case 'bottom':
213      return { op: r.op, seq }
214    case 'prompt':
215      return Number.isInteger(r.index) ? { op: 'prompt', index: Number(r.index), seq } : null
216    case 'row':
217      return Number.isFinite(Number(r.row)) ? { op: 'row', row: Number(r.row), seq } : null
218    default:
219      return null
220  }
221}
222
223export function parseCommand(args: string): Op | null {
224  const a = args.trim().toLowerCase()
225  if (a === '' || a === 'prev' || a === 'up' || a === '이전') return { op: 'prev' }
226  if (a === 'next' || a === 'down' || a === '다음') return { op: 'next' }
227  if (a === 'first' || a === '처음') return { op: 'first' }
228  if (a === 'last' || a === '마지막') return { op: 'last' }
229  if (a === 'bottom' || a === '끝') return { op: 'bottom' }
230  const n = Number(a)
231  return Number.isInteger(n) && n >= 1 ? { op: 'prompt', index: n - 1 } : null
232}
233