SLOPSHOPPER

ide-mod

Claude Code 안의 IDE 창: 에이전트 보드 + 파일 트리·탭 에디터 + HWP·HWPX·PDF 뷰어(rhwp·pdf.js) + 요청 기록, 강의 자막과 한국어 문체 게이트

newpanebandguardcommandprompt
★ 6v0.8.1MITupdated 2026-10-09jkf87/ide-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ide-mod
│ ┃ IDE ✕ › fix the failing auth test and add an audit log call │ ┃ 시스템 · 메모리와 디스크를 읽는 중… │ ┃ 에이전트 ○ … · 서브 0개 작업 중 / 0개 끝남 ⏺ Read(src/auth.ts) │ ┃ ○ 메인 모델 확인 중 대기 0s · 도구 9 ⎿ Read 6 lines │ ┃ 결과 Done. I made `refresh` reject expired ⏺ Update(src/auth.ts) │ ┃ ──────────────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ t: 트리 q: 파일 트리 u: 상위 폴더 r: 새로 ⏺ Bash(bun test) │ ┃ 보낼 세션 0개 · r 새로고침 ⎿ 3 pass, 1 fail │ ┃ 열려 있는 다른 세션이 없어요. c로 핸드오프 글 │ ┃ 다른 에이전트에 붙여 넣으세요. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /ide │ ┃ ⎿ ide-mod: IDE를 열었어요: /work/app │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ▶ 강의 모드를 켰어요. Claude가 하는 일을 여기 보여 드려요 │ │ 0단계 · 0s │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ⟨Claude Code's own drawing⟩ 세션 컨텍스트 ⣿⣿⣀⣀ 49% │ Claude 5h ⣿⣀⣀⣀ 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ▶ 강의 모드를 켰어요. Claude가 하는 일을 여기 보여 드려요 │ │ 0단계 · 0s │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ⟨Claude Code's own drawing⟩ 세션 컨텍스트 ⣿⣿⣀⣀ 49% │ Claude 5h ⣿⣀⣀⣀ 31%
Pane · IDE
시스템 · 메모리와 디스크를 읽는 중… 에이전트 ○ … · 서브 0개 작업 중 / 0개 끝남 세션 preview- i ○ 메인 모델 확인 중 대기 0s · 도구 9 결과 Done. I made `refresh` reject expired claims, ad… ──────────────────────────────────────────────────────── t: 트리 q: 파일 트리 u: 상위 폴더 r: 새로고침 c: 핸드오프 보낼 세션 0개 · r 새로고침 열려 있는 다른 세션이 없어요. c로 핸드오프 글을 복사해 다른 에이전트에 붙여 넣으세요.
README

ide-mod

Claude Code 안에 IDE 창을 띄우는 모드(function-hooks 플러그인)예요. 입력창 위에는 이 세션의 컨텍스트·캐시 적중·생성 속도(tok/s)와 Claude·Codex·Antigravity 사용 한도가 도트 게이지 한 줄로 늘 떠요. 창 위쪽에는 에이전트 보드가, 아래쪽에는 파일 트리와 탭 에디터가 있어요. 탭에서 HWP·HWPX·PDF 문서를 쪽 모양 그대로 열어 보고, 왼쪽 칸을 이번 세션의 요청 기록으로 바꿔 볼 수 있어요. 입력창 위에는 강의 자막과 한국어 문체 게이트가 떠요.

ide-mod 데모: IDE 창 열기, 코드·한글(HWPX) 문서 보기, 서브에이전트 보드, 요청 기록

<sub>데모 녹화: docs/demo.tape (VHS) · 샘플 문서는 행정안전부 「2025년 주요업무 추진계획」(공공 문서)</sub>

에이전트 ● opus 5.5 1M · xhigh · 서브 1개 작업 중 / 1개 끝남   a: 보드 접기  h: 끝난 것 숨기기  s: 요약 끄기(haiku)  x: 끝난 것 지우기
● 메인  opus 5.5 1M · xhigh  작업 중 3m12s · 도구 14  로그인 버그 고쳐줘
  요약 인증 흐름을 서브에이전트에 나눠 조사하는 중
├─ ● Explore  haiku 4.5  작업 중 34s · 도구 6  인증 코드 찾기
│    지금 Grep  login
└─ ✓ general-purpose  sonnet 5.5 · medium  완료 1m02s  테스트 작성
     결과 login.test.ts에 실패 케이스 3개 추가
──────────────────────────────────────────────────────────────────────────────
t: 트리 접기  u: 상위 폴더  r: 새로고침  p: @프롬프트  c: 경로 복사  w: 탭 닫기
my-project  1-24/57   k ▲ j ▼ │ ▎app.ts ●   README.md
▾ src/                        │ src/app.ts · 1.2 KB · 1-21/88줄
    ▾ auth/                ●  │  1 import { login } from './auth'
        login.ts           ●  │  2
    app.ts                 ●  │  3 export const answer = 42
▸ tests/                      │  …
  README.md                   │

설치

Claude Code 터미널 세션에서 아래 한 줄을 입력하세요.

/plugin install ide-mod --marketplace jkf87/ide-mod

마켓플레이스를 추가할지 물으면 y를 누르고 범위(user 권장)를 고르면 돼요. 설치한 세션에서 바로 동작해요.

function hooks는 Claude Code의 early access API예요. 2.1.290에서 만들고 시험했어요. 버전이 바뀌면 API가 달라질 수 있어요.

쓰는 법

명령하는 일
/ideIDE 창을 열어요 (처음엔 작업 폴더). 새 세션은 저절로 열려요
`/ide auto on\off`세션을 시작할 때 IDE 창을 저절로 열지 정해요 (기본 켜짐)
/open <경로>폴더면 트리의 뿌리로, 파일이면 탭으로 열어요. ~, 상대 경로, @경로 모두 돼요
`/lecture [on\off]`강의 모드: Claude가 하는 일을 입력창 위에 쉬운 한국어 자막으로 보여줘요
/handoff [세션 이름] [메모]이 세션을 다른 에이전트 세션에 넘겨요. 이름을 주면 그 세션에 핸드오프 글을 바로 보내고, 없으면 글을 보여 주고 핸드오프 화면을 열어요
`/limits [line\rows\off\refresh]`입력창 위 사용 한도 띠의 모양을 바꾸거나 지금 다시 받아요. 인자 없이 치면 현재 값을 글로 보여 줘요
`/style-gate [파일\on\off]`한국어 원고의 AI티 지표를 검사해요. 켜 두면 Claude가 .md·.txt를 쓸 때마다 자동으로 검사해요

전체 화면 레이아웃(fullscreen)에서 쓰면 창이 대화 옆에 넓게 붙어서 IDE처럼 보여요. 창 크기는 직접 끌어서 바꿀 수 있어요.

사용 한도 띠 (입력창 위)

이 세션과 모든 사용 한도를 한 줄에 도트 게이지로 보여 줘요.

세션 컨텍스트 ⣿⣀⣀⣀ 18% 캐시 ⣿⣿⣿⡇ 94% 114 tok/s │ Claude 5h ⣀⣀⣀⣀ 4% 주 ⣿⣿⣿⣿ 96% 2일 Fable ⣿⣿⣀⣀ 54% │ Codex 주 ⣿⣿⣿⣀ 78% 4일 │ Gemini 5h ⣀⣀⣀⣀ 5% 주 ⡇⣀⣀⣀ 9% │ AG Claude·GPT …
  • 세션: 컨텍스트 창 사용률, 마지막 응답의 프롬프트 캐시 적중률, 출력 속도(tok/s). 메인 에이전트의 응답이 끝날 때마다 그 응답의 토큰 수로 바로 고쳐요.
  • Claude(5시간·주간·모델별 주간), Codex, Gemini·AG Claude·GPT(Antigravity의 5시간·주간).
  • 게이지는 점자 점으로 그린 도트 막대예요. 사용률 게이지는 칸마다 초록→노랑→빨강으로 물들고, 캐시 게이지는 80% 이상 초록·50% 밑 빨강이에요. 70% 넘게 쓴 창에는 리셋까지 남은 시간(2일)이 붙어요.
  • 줄이 화면보다 길면 오른쪽 묶음부터 게이지를 빼고 숫자만 남겨요.
  • Claude는 세션의 자격 증명으로 Claude Code의 /usage와 같은 사용량 응답을 1분마다 받아요(토큰은 mod에 보이지 않아요). 못 받으면 엔진이 응답마다 주는 5시간·주간 한도를 써요.
  • Codex는 Codex CLI의 앱 서버(codex app-server)에, Antigravity는 agy /usage에 5분마다 물어요. 설치돼 있지 않으면 그 묶음은 나오지 않아요.
  • 띠가 화면에 있을 때만 받아요. /limits rows로 출처마다 한 줄(막대·리셋 시각), /limits line으로 다시 한 줄, /limits off로 숨겨요.

시스템 상태 (보드 맨 위)

  • 메모리 사용량과 메모리 압력(정상·경고·위험), 스왑 사용량, 디스크 사용률과 남은 용량을 한 줄로 보여 줘요. 보드가 떠 있을 때만 10초마다 다시 읽어요.
  • 압력이 경고면 노랑, 위험이면 빨강이에요. 스왑이 램의 15%를 넘으면 노랑, 50%를 넘으면 빨강, 디스크가 90%를 넘으면 노랑, 95%를 넘으면 빨강이에요.
  • z를 누르면 메모리를 많이 쓰는 앱 6개를 보여 줘요. 앱의 도우미 프로세스는 앱 이름으로 묶어요(Google Chrome Helper → Google Chrome).
  • macOS는 sysctl·vm_stat·df·ps로, Linux는 /proc/meminfo로 읽어요. 보드를 접어도 이 줄은 남아요.

에이전트 보드 (위쪽)

  • 메인 에이전트와 서브에이전트를 누가 누구를 띄웠는지에 따라 트리로 보여줘요. 팀원이나 손자 에이전트까지 나와요.
  • 에이전트마다 모델·effort(실제로 보낸 모델 요청 기준), 역할(서브에이전트 타입, 팀원 이름), 맡은 작업, 지금 하는 일(마지막 도구 호출), 경과 시간과 도구 호출 수를 보여줘요.
  • 요약: 보드가 화면에 떠 있을 때만 20초마다 새 활동이 쌓인 에이전트를 haiku로 한 줄 요약해요. 끝난 에이전트는 답의 첫 줄을 보여줘요.
  • 요약은 계정 사용량을 써요(20초에 최대 3번, 짧은 호출). s로 끌 수 있고, 3번 연속 실패하면 스스로 꺼져요.
  • 서브에이전트가 돌 때는 상태줄에 ◆ opus 5.5 · xhigh ⇉ 서브에이전트 N개 작업 중이 떠요.
  • 단축키: z 메모리 많이 쓰는 앱, i 핸드오프, a 보드 접기/펼치기, h 끝난 것 숨기기(돌고 있는 자손은 남아요), s 요약 켜기/끄기, x 끝난 것 지우기

탐색기 (아래쪽)

  • 폴더(▸)를 누르면 그 자리에서 펼쳐지고, 파일을 누르면 오른쪽에 탭으로 열려요. 탭은 최대 8개예요. 심볼릭 링크 폴더도 펼쳐져요.
  • 코드는 문법 하이라이트와 줄 번호로 보여줘요. 마크다운은 m을 누르면 렌더 화면으로 바뀌어요.
  • 스크롤: 휠은 마우스가 올라간 쪽(트리 또는 파일)만 움직여요. 방향키는 열린 파일을 움직이고, 열린 파일이 없으면 트리를 움직여요. j/k는 트리를 반 화면씩 넘겨요.
  • Claude가 고친 파일은 트리와 탭에 ●이 붙고, 열려 있으면 다시 읽어요. 거절되거나 실패한 수정은 표시하지 않아요.
  • 폭이 70칸보다 좁으면 트리와 파일을 번갈아 보여줘요. 파일을 고르면 파일 화면으로 넘어가고, t를 누르면 트리로 돌아와요.
  • 단축키: t 트리 접기, q 파일 트리/요청 기록, l 요청 모두 보기/하나만, u 상위 폴더, r 새로고침, m 렌더/원문, v HWP 문서/그림 보기, o HWP 미리보기로 열기, b·n HWP 앞뒤 쪽, p 입력창에 @경로 넣기, c 경로 복사, w 탭 닫기

HWP·HWPX·PDF 뷰어 (rhwp·pdf.js 엔진)

  • 트리에서 .hwp·.hwpx·.pdf를 누르면 탭으로 열려요. HWP 엔진은 rhwp(Rust→WASM, MIT, vendor/rhwp), PDF 엔진은 pdf.js(Apache-2.0, vendor/pdfjs)예요. 둘 다 같은 문서 보기 렌더러(bin/grid.mjs)를 써요.
  • PDF는 pdf.js가 꺼낸 글자 좌표와 선(획·얇은 막대)으로 문서 보기를 만들고, 그림 보기는 poppler의 pdftoppm으로 만들어요(brew install poppler). o는 PDF 원본을 미리보기로 바로 열어요.
  • 문서 보기(기본): rhwp가 그린 쪽을 창 너비에 맞춘 글자 격자로 다시 그려요. 가운데 정렬 제목, 번호 상자, 표 테두리(┌┬┐├┼┤└┴┘), 굵은 글씨, 글자색, 가운뎃점을 쪽 모양 그대로 보여주고, 칸을 넘는 글은 …로 줄여요. 그래픽이 없는 터미널(macOS 터미널 등)에서도 HWP 모양으로 보여요. 쪽 끝까지 내리면 다음 쪽으로 넘어가요.
  • 그림 보기(v): rhwp가 그린 실제 쪽 그림이에요. 터미널은 PNG(kitty·Ghostty 같은 그래픽 터미널), 데스크톱 앱은 SVG로 그려요.
  • 미리보기로 열기(o): 지금 쪽을 그림으로 만들어 macOS 미리보기로 열어요. 한컴에서 보는 모양 그대로 확인할 때 써요.
  • b/n으로 앞뒤 쪽을 넘기고, 치환이 안 된 {{키}} 칸은 빨갛게 표시하고 개수를 위에 보여줘요.
  • 엔진은 플러그인의 bin/rhwp-view.mjs를 Node.js로 실행해요. 그림 변환은 rsvg-convert → resvg → macOS qlmanage+sips 순서로 있는 것을 써요.

핸드오프 (i)

  • 보드 맨 위에 이 세션의 ID(앞 8자리)가 떠요. i를 누르면 탐색기가 핸드오프 화면으로 바뀌어요.
  • 왼쪽에는 지금 열려 있는 다른 Claude 세션(데스크톱 앱·터미널)이 나와요. 세션을 누르면 오른쪽 핸드오프 글을 그 세션에 메시지로 보내요. r로 목록을 새로 받아요.
  • 핸드오프 글에는 세션 ID, 작업 폴더, 대화 기록 파일 경로, 이어서 여는 명령(claude --resume <ID> --fork-session), 사용자가 보낸 요청(최근 20개, 시각과 함께), 에이전트가 마지막에 하던 일이 들어가요. 받은 쪽은 대화 기록을 읽고 이어서 일해요.
  • c 핸드오프 글 복사(Codex 같은 다른 에이전트에 붙여 넣을 때), y 세션 ID만 복사, e 이어서 열기 명령 복사.
  • 세션 목록은 Claude Code의 ListAgents 도구로 받고, 보내기는 $.session.send를 써요.

요청 기록 (q)

  • 이번 세션에서 사람이 보낸 요청(입력창·원격 조종·SDK)이 번호·시각·상태(● 진행 중, ✓ 끝남, ■ 중단)와 함께 왼쪽 칸에 쌓여요. 백그라운드 작업 알림이나 다른 세션이 보낸 메시지는 넣지 않아요. 최근 요청이 맨 위예요.
  • 모두 보기(기본): 오른쪽 칸에 이번 세션 요청 전부를 전문 그대로 이어서 보여 줘요. 요청마다 시각·상태·걸린 시간과 Claude 답의 첫 줄이 붙어요. 내가 무슨 말을 했는지 한눈에 훑어볼 때 써요. c로 전부 복사해요.
  • 요청을 누르면 그 요청 하나만 펼쳐요. p로 입력창에 다시 넣고, c로 복사해요. l로 모두 보기와 하나만 보기를 오가요.
  • 세션별로 저장돼서 모드를 다시 불러오거나 세션을 이어 열어도 남아요. 최근 40개 세션까지 보관해요.

입력창 위: 강의 자막과 문체 게이트

  • 강의 자막(/lecture): "파일을 읽고 있어요 · login.ts", "터미널에서: 테스트 실행"처럼 지금 하는 일과 방금 한 일, 단계 수, 경과 시간, 일하는 서브에이전트 수를 테두리 상자에 보여줘요. 모델 호출 없이 도구 호출만으로 만들어요.
  • 문체 게이트(/style-gate): noslop-ko의 결정적 패턴 16개(대조 구문, 접속어 뒤 쉼표, 맺음 상투어, 과장 어휘, 번역투 등)를 세서 통과·경고·중단을 판정해요. 경고 이상이면 입력창 위에 뜨고, g로 예문을 펼치고 d로 닫아요. 한글이 50자 넘는 원고만 검사하고 코드 블록은 빼요.
  • 다른 모드가 입력창 위에 그리는 띠(예: usage-band)는 그 아래에 그대로 둬요.

패널에 포커스가 있을 때(클릭하거나 ctrl+x tab) 단축키가 먹어요. Esc를 누르면 입력창으로 돌아가요.

한계

  • 보기 전용이라 편집은 Claude에게 시키거나 실제 에디터에서 하세요.
  • 이미지: PNG만, kitty·Ghostty처럼 그래픽 프로토콜을 지원하는 터미널에서만 보여요. HWP 페이지 그림도 같아요.
  • HWP: Node.js가 있어야 해요. 데스크톱 앱은 쪽 SVG를 줄여서(글꼴 목록을 스타일 한 줄로, 좌표 소수 한 자리) 그리는데, 그래도 128K자를 넘는 쪽은 문서 보기를 써요.
  • 큰 파일·폴더: 4 MB가 넘는 파일은 열지 않아요. 트리는 3,000줄까지만 펼쳐요.
  • 렌더된 마크다운은 문단 단위로 스크롤돼요.
  • 바깥 수정: Claude 밖(내 에디터)에서 고친 파일은 r을 누르거나 다음에 다시 그려질 때 반영돼요.
  • UI 언어: 화면 문구는 한국어예요.

개발

claude plugin validate .     # 매니페스트·훅 모듈 검사
claude plugin test .         # tests/*.test.tsx
claude --plugin-dir .        # 이 폴더를 그대로 로드해서 써 보기

타입 정의(.claude-plugin/types/)와 tsconfig.json은 Claude Code가 이 폴더를 한 번 로드할 때 깔아 줘요. 그 뒤에 npx tsc -p .로 타입 검사를 할 수 있어요. 둘 다 .gitignore에 들어 있어요.

구조는 hooks/register.tsx 파일 하나예요. 엔진이 $를 같은 파일 안의 함수로만 따라가서, 보드와 탐색기를 한 파일에 나눠 적었어요.

License

MIT. 함께 들어 있는 rhwp(vendor/rhwp)는 Edward Kim의 MIT 라이선스, pdf.js(vendor/pdfjs)는 Mozilla의 Apache-2.0 라이선스예요.

Source 2 files
hooks/register.tsx 2351 lines
1import { atom, read, update } from 'claude-code'
2import type { Color, EngineInterface, ModelUsage, On, Register, RenderElement, RenderInput } from 'claude-code'
3
4import type { AgentPhase, AgentRow, Caption, GateCheck, GateResult, LimitWindow, LimitsSnapshot, PeerRow, RequestItem, SessionPerf, SysStat, ViewMode } from '../types'
5
6// ════════════════ 에이전트 보드 ════════════════
7const MAIN = 'main'
8const agentsAtom = atom({ plugin: 'ide-mod', key: 'agents' } as const, {} as Record<string, AgentRow>)
9const tickAtom = atom({ plugin: 'ide-mod', key: 'tick' } as const, 0)
10const foldedAtom = atom({ plugin: 'ide-mod', key: 'isBoardFolded' } as const, false)
11const hideDoneAtom = atom({ plugin: 'ide-mod', key: 'hideDone' } as const, false)
12const recapOnAtom = atom({ plugin: 'ide-mod', key: 'isRecapOn' } as const, true)
13
14const LOG_SIZE = 12
15const MAX_AGENTS = 40
16const RECAP_EVERY_MS = 20_000
17const RECAP_TIMEOUT_MS = 15_000
18const RECAP_MODEL = 'haiku'
19const RECAP_MAX_FAILURES = 3
20// 목록에서 빠진 지 이만큼 소식이 없으면 끝난 것으로 본다
21const STALE_AFTER_MS = 30_000
22// 도구 입력에서 "무엇을 하는지" 가장 잘 말해주는 필드 순서
23const SUMMARY_KEYS = ['description', 'command', 'file_path', 'notebook_path', 'pattern', 'url', 'query', 'skill', 'subject', 'prompt', 'path']
24
25const PHASE_ICON: Record<AgentPhase, string> = { running: '●', idle: '○', done: '✓', failed: '✗' }
26const PHASE_COLOR: Record<AgentPhase, Color> = { running: 'warning', idle: 'subtle', done: 'success', failed: 'error' }
27const PHASE_LABEL: Record<AgentPhase, string> = { running: '작업 중', idle: '대기', done: '완료', failed: '실패' }
28const ROLE_COLORS: Color[] = ['suggestion', 'claude', 'permission', 'planMode', 'autoAccept', 'remember', 'ide', 'merged']
29const LIST_PHASE: Record<string, AgentPhase> = {
30  pending: 'running', running: 'running', waiting: 'idle', idle: 'idle', completed: 'done', failed: 'failed', killed: 'failed',
31}
32
33// 재로드되면 처음부터 다시 세는 값들: 화면이 읽는 값은 전부 $.state에 둔다
34let isInteractive = true
35let timersStarted = false
36let autoOpenDefault = true
37let isAutoOpenPending = false
38let isRecapping = false
39let recapFailures = 0
40let lastStatus: string | undefined
41/** 보드가 마지막으로 그려진 시각: 창이 열려 있을 때만 요약을 만든다 */
42let lastDrawnAt = 0
43
44const oneLine = (text: string, max: number) => {
45  const line = text.replace(/\s+/g, ' ').trim()
46  return line.length > max ? `${line.slice(0, max - 1)}…` : line
47}
48const firstLine = (text: string) => text.split('\n').map(l => l.replace(/^[#>*\-\s]+/, '').trim()).find(l => l !== '') ?? ''
49
50/** claude-opus-5-5[1m] → opus 5.5 1M, us.anthropic.claude-sonnet-4-5-20250929-v1:0 → sonnet 4.5 */
51export const shortModel = (model: string) => {
52  const isLong = /\[1m\]/i.test(model)
53  const bare = model
54    .replace(/\[.*?\]/g, '')
55    .replace(/^.*?(?=claude-)/, '')
56    .replace(/@.*$/, '')
57    .replace(/-v\d+(:\d+)?$/, '')
58  const m = /^(?:claude-)?([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?$/i.exec(bare)
59    ?? /^(?:claude-)(\d+)-(\d+)-([a-z]+)(?:-\d{8})?$/i.exec(bare)
60  let name = bare
61  if (m !== null && /^[a-z]/i.test(m[1] ?? '')) name = `${m[1]} ${m[2]}${m[3] ? `.${m[3]}` : ''}`
62  else if (m !== null) name = `${m[3]} ${m[1]}.${m[2]}`
63  return isLong ? `${name} 1M` : name
64}
65const toolName = (tool: string) => (tool.startsWith('mcp__') ? tool.split('__').slice(2).join('__') || tool : tool)
66
67export const describeCall = (tool: string, input: Record<string, unknown>) => {
68  if (tool === 'Agent') {
69    const type = typeof input.subagent_type === 'string' ? input.subagent_type : 'agent'
70    return `Agent → ${type}: ${oneLine(String(input.description ?? ''), 50)}`
71  }
72  const key = SUMMARY_KEYS.find(k => typeof input[k] === 'string' && input[k] !== '')
73  const detail = key === undefined ? '' : oneLine(String(input[key]), 60)
74  return detail === '' ? toolName(tool) : `${toolName(tool)}  ${detail}`
75}
76
77const formatElapsed = (ms: number) => {
78  const s = Math.max(0, Math.round(ms / 1000))
79  if (s < 60) return `${s}s`
80  const m = Math.floor(s / 60)
81  return m < 60 ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
82}
83
84const freshRow = (id: string, fields: Partial<AgentRow>): AgentRow => ({
85  id,
86  role: id === MAIN ? 'main' : 'agent',
87  task: '',
88  model: '',
89  phase: 'running',
90  isBackground: false,
91  isTeammate: false,
92  activity: '',
93  log: [],
94  toolCount: 0,
95  recap: '',
96  isRecapStale: false,
97  startedAt: Date.now(),
98  updatedAt: Date.now(),
99  ...fields,
100})
101
102/**
103 * 한 에이전트 줄을 고친다. 서브에이전트 줄은 agent.spawn만 만든다: 엔진 내부의 포크(압축·메모리)는
104 * spawn 없이 agentId를 달고 오고 끝을 알리지 않아서, 여기서 만들면 영영 "작업 중"으로 남는다.
105 */
106async function patch($: EngineInterface, id: string, fn: (row: AgentRow) => AgentRow | undefined, canCreate = id === MAIN) {
107  await update($, agentsAtom, all => {
108    const existing = all[id]
109    if (existing === undefined && !canCreate) return all
110    const changed = fn(existing ?? freshRow(id, {}))
111    if (changed === undefined || changed === existing) return all
112    const next = { ...all, [id]: changed }
113    const ids = Object.keys(next)
114    if (ids.length <= MAX_AGENTS) return next
115    // 오래전에 끝난 서브에이전트부터 정리한다
116    const drop = ids
117      .filter(k => k !== MAIN && next[k]?.phase !== 'running')
118      .sort((a, b) => (next[a]?.startedAt ?? 0) - (next[b]?.startedAt ?? 0))
119      .slice(0, ids.length - MAX_AGENTS)
120    for (const k of drop) delete next[k]
121    return next
122  })
123  await refreshStatus($)
124}
125
126/** 서브에이전트가 돌 때만 상태줄에 모델·effort와 개수를 띄운다 (메인 모델은 기본 화면에도 있다) */
127async function refreshStatus($: EngineInterface) {
128  const all = await read($, agentsAtom)
129  const running = Object.values(all).filter(r => r.id !== MAIN && r.phase === 'running').length
130  const main = all[MAIN]
131  const brain = main === undefined ? '' : `${shortModel(main.model) || '?'}${main.effort ? ` · ${main.effort}` : ''}  `
132  const text = running > 0 ? `◆ ${brain}⇉ 서브에이전트 ${running}개 작업 중` : undefined
133  if (text === lastStatus) return
134  lastStatus = text
135  $.ui.status(text)
136}
137
138/** 엔진의 에이전트 목록과 맞춰, 죽었거나 사라진 서브에이전트가 "작업 중"에 머물지 않게 한다 */
139async function reconcile($: EngineInterface) {
140  const all = await read($, agentsAtom)
141  const running = Object.values(all).filter(r => r.id !== MAIN && r.phase === 'running')
142  if (running.length === 0) return
143  const listed = new Map((await $.agent.list().catch(() => [])).map(info => [info.id, info]))
144  const now = Date.now()
145  for (const row of running) {
146    const info = listed.get(row.id)
147    const phase: AgentPhase | undefined = info !== undefined
148      ? LIST_PHASE[info.status]
149      : now - row.updatedAt > STALE_AFTER_MS ? 'done' : undefined
150    if (phase === undefined || phase === 'running') continue
151    await patch($, row.id, r => (r.phase !== 'running' ? undefined : { ...r, phase, activity: '', endedAt: r.endedAt ?? now }))
152  }
153}
154
155/** 쌓인 도구 기록을 haiku로 한 줄 요약한다. 타이머에서만, 보드가 화면에 있을 때만 부른다 */
156async function recapStale($: EngineInterface) {
157  if (isRecapping || Date.now() - lastDrawnAt > 30_000) return
158  if (!(await read($, recapOnAtom))) return
159  isRecapping = true
160  try {
161    const all = await read($, agentsAtom)
162    const due = Object.values(all).filter(r => r.phase === 'running' && r.isRecapStale && r.log.length > 0).slice(0, 3)
163    for (const row of due) {
164      const seenTools = row.toolCount
165      const prompt = [
166        '아래는 코딩 에이전트 하나의 맡은 작업과 최근 도구 호출 기록이다.',
167        '이 에이전트가 지금 무엇을 하고 있는지 한국어 한 문장(40자 이내, 마침표 없이)으로만 답하라.',
168        `역할: ${row.role}`,
169        `맡은 작업: ${row.task || '(알 수 없음)'}`,
170        '최근 도구 호출(오래된 것부터):',
171        ...row.log.map(l => `- ${l}`),
172      ].join('\n')
173      const answer = await $.model
174        .complete({ model: RECAP_MODEL, prompt, maxTokens: 120, effort: 'low', timeoutMs: RECAP_TIMEOUT_MS })
175        .catch(() => undefined)
176      if (answer === undefined || !answer.isAnswered) {
177        recapFailures += 1
178        if (recapFailures >= RECAP_MAX_FAILURES) {
179          await update($, recapOnAtom, () => false)
180          $.ui.toast(`ide-mod: ${RECAP_MODEL} 요약 호출이 ${RECAP_MAX_FAILURES}번 실패해서 요약을 껐어요 (보드에서 s로 다시 켜기)`)
181        }
182        return
183      }
184      recapFailures = 0
185      const recap = oneLine(answer.text.replace(/^["'「]|["'」.]$/g, ''), 80)
186      // 기다리는 사이 끝났거나, 지워졌거나, 새 도구 호출이 쌓였으면 덮어쓰지 않는다
187      await patch($, row.id, r => (r.phase !== 'running' || r.toolCount !== seenTools ? undefined : { ...r, recap, isRecapStale: false }), false)
188    }
189  } finally {
190    isRecapping = false
191  }
192}
193
194/** 요약·정리 타이머를 건다. 터미널(REPL)은 session.start에서, 데스크톱 앱·SDK처럼 isInteractive가 false로 오는
195 *  화면은 보드가 처음 그려질 때 건다 (-p 실행처럼 아무것도 그리지 않으면 걸리지 않는다) */
196function startAgents($: EngineInterface, interactive: boolean) {
197  isInteractive = interactive
198  if (interactive) startTimers($)
199}
200
201function startTimers($: EngineInterface) {
202  if (timersStarted) return
203  timersStarted = true
204  $.clock.every(RECAP_EVERY_MS, () => void recapStale($).catch(() => undefined))
205  $.clock.every(SYS_EVERY_MS, () => void refreshSys($).catch(() => undefined))
206  $.clock.every(30_000, () => void refreshLimits($).catch(() => undefined))
207  $.clock.every(5_000, () =>
208    void (async () => {
209      await reconcile($)
210      const all = await read($, agentsAtom)
211      if (Object.values(all).some(r => r.phase === 'running')) await update($, tickAtom, n => n + 1)
212    })().catch(() => undefined),
213  )
214}
215
216function registerAgents(on: On) {
217  on('session.end', async ($, e, next) => {
218    if (e.reason === 'clear') {
219      await update($, agentsAtom, () => ({}))
220      lastStatus = undefined
221      $.ui.status(undefined)
222    }
223    return next(e)
224  })
225
226  // 사람이 프롬프트를 보내면 메인 에이전트의 작업이 새로 정해진다 (서브에이전트는 turn.start가 없다)
227  on('turn.start', async ($, e, next) => {
228    // 사람이 보낸 요청으로 시작한 턴만 메인 작업을 바꾼다. 백그라운드 작업 알림·다른 세션 메시지로 시작한 턴은
229    // 작업 줄은 두고 "지금 하는 일"만 바꾼다 (그 글은 사람이 쓴 게 아니라서 요청 기록에도 넣지 않는다)
230    const own = personText(e.text)
231    const isPersonTurn = own !== '' && (await requestTurnStarted($, own, e.turnId).catch(() => false))
232    if (!isPersonTurn && e.text.trim() !== '') {
233      await patch($, MAIN, row => ({ ...row, role: 'main', phase: 'running', activity: '알림·메시지 처리 중', updatedAt: Date.now(), endedAt: undefined }))
234    }
235    if (isPersonTurn) {
236      await patch($, MAIN, row => ({
237        ...row,
238        role: 'main',
239        task: oneLine(own, 120),
240        phase: 'running',
241        activity: '생각 중',
242        log: [],
243        recap: '',
244        isRecapStale: false,
245        startedAt: Date.now(),
246        updatedAt: Date.now(),
247        endedAt: undefined,
248      }))
249      await setCaption($, '요청을 읽고 계획을 세우는 중이에요', true).catch(() => undefined)
250    }
251    return next(e)
252  }).catch(($, e, next) => next(e))
253
254  // 모델 요청마다 그 에이전트가 실제로 쓰는 모델과 effort가 실려 온다
255  on('turn.step', async function* ($, e, next) {
256    const effort = e.effort === undefined ? undefined : String(e.effort)
257    await patch($, e.agentId ?? MAIN, row =>
258      row.model === e.model && row.effort === effort && row.phase === 'running'
259        ? undefined
260        : { ...row, model: e.model, effort, phase: 'running', updatedAt: Date.now(), endedAt: undefined },
261    ).catch(() => undefined)
262    // 메인 응답은 첫 조각과 끝(stop) 시각을 재서 속도·캐시 적중을 띠에 올린다. 조각은 손대지 않고 그대로 넘긴다
263    if (e.agentId !== undefined) return yield* next(e)
264    const now = () => $.clock.now().catch(() => Date.now())
265    const sentAt = await now()
266    let firstAt: number | undefined
267    for await (const chunk of next(e)) {
268      if (firstAt === undefined && chunk.kind !== 'engine') firstAt = await now()
269      if (chunk.kind === 'stop' && chunk.usage !== null) {
270        const usage = chunk.usage
271        const endAt = await now()
272        void notePerf($, usage, sentAt, firstAt, endAt).catch(() => undefined)
273      }
274      yield chunk
275    }
276  })
277
278  on('agent.spawn', async ($, e, next) => {
279    const started = await next(e)
280    if (started.agentId === undefined) return started
281    const role = e.name ? `${e.subagentType} · ${e.name}` : e.fork ? `${e.subagentType} (fork)` : e.subagentType
282    await patch(
283      $,
284      started.agentId,
285      row => ({
286        ...row,
287        parentId: e.parentAgentId ?? MAIN,
288        role,
289        task: oneLine(e.description || firstLine(e.prompt), 120),
290        model: row.model || started.model,
291        isBackground: e.background,
292        isTeammate: e.isTeammate === true,
293        phase: 'running',
294        activity: '시작하는 중',
295        startedAt: Date.now(),
296        updatedAt: Date.now(),
297      }),
298      true,
299    )
300    await setCaption($, `${role} 에이전트가 일을 시작했어요 · ${oneLine(e.description, 50)}`).catch(() => undefined)
301    return started
302  }).catch(($, e, next) => next(e))
303
304  on('turn.complete', async ($, e, next) => {
305    const id = e.agentId ?? MAIN
306    const isFailed = e.reason !== 'answer'
307    await patch($, id, row => {
308      // 메인과 팀원은 턴이 끝나도 다음 지시를 기다릴 뿐이다
309      const phase: AgentPhase = id === MAIN || row.isTeammate ? 'idle' : isFailed ? 'failed' : 'done'
310      const note = e.isAborted ? '중단됨' : e.reason === 'error' ? '오류로 끝남' : e.reason === 'refusal' ? '거절로 끝남' : ''
311      return {
312        ...row,
313        phase,
314        activity: note,
315        recap: oneLine(firstLine(e.answer), 120) || row.recap,
316        isRecapStale: false,
317        updatedAt: Date.now(),
318        endedAt: Date.now(),
319      }
320    })
321    if (id === MAIN) {
322      await requestTurnEnded($, e.turnId, e.answer, e.isAborted || e.reason !== 'answer').catch(() => undefined)
323      await setCaption($, e.isAborted ? '작업을 멈췄어요' : '답을 다 썼어요. 다음 요청을 기다려요').catch(() => undefined)
324    }
325    return next(e)
326  }).catch(($, e, next) => next(e))
327}
328
329/** 도구 호출 하나를 그 에이전트의 "지금 하는 일"로 기록한다 (탐색기와 같은 tool.call 훅에서 부른다) */
330async function recordToolCall($: EngineInterface, agentId: string | undefined, tool: string, input: Record<string, unknown>) {
331  const line = describeCall(tool, input)
332  await patch($, agentId ?? MAIN, row => ({
333    ...row,
334    phase: 'running',
335    activity: line,
336    log: [...row.log, line].slice(-LOG_SIZE),
337    toolCount: row.toolCount + 1,
338    isRecapStale: true,
339    updatedAt: Date.now(),
340  }))
341}
342
343// ════════════════ 시스템 상태 (메모리·스왑·디스크) ════════════════
344// 보드 맨 위 한 줄. macOS의 sysctl·vm_stat·df·ps 출력을 한 번에 받아 읽는다 (Linux는 /proc/meminfo)
345
346const sysAtom = atom({ plugin: 'ide-mod', key: 'sys' } as const, null as SysStat | null)
347const showProcsAtom = atom({ plugin: 'ide-mod', key: 'showProcs' } as const, false)
348const SYS_EVERY_MS = 10_000
349// 명령은 절대 경로로 부른다: PATH에 /usr/sbin이 없는 환경(데스크톱 앱·SDK)에서도 sysctl을 찾게
350const SYS_SCRIPT = [
351  'echo ==mem; /usr/sbin/sysctl -n hw.memsize kern.memorystatus_vm_pressure_level vm.swapusage 2>/dev/null',
352  'echo ==vm; /usr/bin/vm_stat 2>/dev/null',
353  'echo ==linux; cat /proc/meminfo 2>/dev/null',
354  'echo ==df; /bin/df -k /System/Volumes/Data 2>/dev/null || /bin/df -k /',
355  'echo ==ps; /bin/ps -axo rss=,comm=',
356].join('; ')
357
358const GB = 1024 ** 3
359const gb = (bytes: number) => `${(bytes / GB).toFixed(bytes >= 100 * GB ? 0 : 1)}GB`
360
361/** 위 스크립트의 출력을 읽는다. 읽을 수 없으면 undefined */
362export function parseSysStat(out: string, at = Date.now()): SysStat | undefined {
363  const part = (name: string) => out.split(`==${name}\n`)[1]?.split(/\n==\w+\n/)[0] ?? ''
364  const mem = part('mem').trim().split('\n')
365  let memTotal = Number(mem[0]) || 0
366  let memUsed = 0
367  let pressure = Number(mem[1]) || 1
368  let swapUsed = 0
369  const swap = (mem[2] ?? '').match(/used = ([\d.]+)M/)
370  if (swap) swapUsed = Number(swap[1]) * 1024 ** 2
371  const vm = part('vm')
372  const pageSize = Number(vm.match(/page size of (\d+) bytes/)?.[1]) || 0
373  if (memTotal > 0 && pageSize > 0) {
374    const pages = (label: string) => Number(vm.match(new RegExp(`${label}:\\s+(\\d+)`))?.[1]) || 0
375    // 활성 상태 보기의 "사용된 메모리" = 앱 메모리 + 고정 + 압축 (근사: active + wired + compressor)
376    memUsed = (pages('Pages active') + pages('Pages wired down') + pages('Pages occupied by compressor')) * pageSize
377  } else {
378    const info = part('linux')
379    const kb = (key: string) => Number(info.match(new RegExp(`^${key}:\\s+(\\d+) kB`, 'm'))?.[1]) * 1024 || 0
380    memTotal = kb('MemTotal')
381    memUsed = memTotal - kb('MemAvailable')
382    swapUsed = kb('SwapTotal') - kb('SwapFree')
383    const ratio = memTotal > 0 ? memUsed / memTotal : 0
384    pressure = ratio > 0.95 ? 4 : ratio > 0.85 ? 2 : 1
385  }
386  const df = part('df').trim().split('\n').pop()?.trim().split(/\s+/) ?? []
387  const diskTotal = Number(df[1]) * 1024 || 0
388  const diskFree = Number(df[3]) * 1024 || 0
389  const byName = new Map<string, number>()
390  for (const line of part('ps').split('\n')) {
391    const m = line.trim().match(/^(\d+)\s+(.+)$/)
392    if (!m) continue
393    // 앱 번들 안의 도우미는 앱 이름으로 묶는다 (Google Chrome Helper (Renderer) → Google Chrome)
394    const app = m[2].match(/\/([^/]+)\.app\//)?.[1] ?? m[2].split('/').pop() ?? m[2]
395    byName.set(app, (byName.get(app) ?? 0) + Number(m[1]) * 1024)
396  }
397  const top = [...byName.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6).map(([name, bytes]) => ({ name, bytes }))
398  if (memTotal === 0 && diskTotal === 0) return undefined
399  return { at, memTotal, memUsed: Math.min(memUsed, memTotal), pressure, swapUsed, diskTotal, diskFree, top }
400}
401
402let isSysRunning = false
403let isSysRequested = false
404async function refreshSys($: EngineInterface) {
405  if (isSysRunning || Date.now() - lastDrawnAt > 30_000) return
406  isSysRunning = true
407  try {
408    const ran = await $.process.run(['/bin/sh', '-c', SYS_SCRIPT], { timeoutMs: 8_000 })
409    const stat = parseSysStat(ran.stdout)
410    if (stat !== undefined) await update($, sysAtom, () => stat)
411  } finally {
412    isSysRunning = false
413  }
414}
415
416const bar = (ratio: number, width = 8) => {
417  const filled = clamp(Math.round(ratio * width), 0, width)
418  return '█'.repeat(filled) + '░'.repeat(width - filled)
419}
420
421/** 보드 맨 위 시스템 줄 (+ z로 펼치는 메모리 많이 쓰는 앱 줄) */
422async function drawSys($: EngineInterface, e: RenderInput<'Pane'>): Promise<RenderElement[]> {
423  const { Box, Text, Button } = $.ui.resolve(e)
424  const stat = await read($, sysAtom)
425  const showProcs = await read($, showProcsAtom)
426  if (stat === null) {
427    // 처음 그릴 때 한 번 바로 읽는다 (그리기 밖에서: 타이머로 넘긴다)
428    if (!isSysRequested) {
429      isSysRequested = true
430      $.clock.after(0, () => void refreshSys($).catch(() => undefined))
431    }
432    return [<Text key="sys-wait" dimColor>시스템 · 메모리와 디스크를 읽는 중…</Text>]
433  }
434  const memRatio = stat.memTotal > 0 ? stat.memUsed / stat.memTotal : 0
435  const memColor = stat.pressure >= 4 ? 'error' : stat.pressure >= 2 ? 'warning' : undefined
436  const pressureLabel = stat.pressure >= 4 ? '위험' : stat.pressure >= 2 ? '경고' : '정상'
437  const swapColor = stat.swapUsed > stat.memTotal * 0.5 ? 'error' : stat.swapUsed > stat.memTotal * 0.15 ? 'warning' : undefined
438  const diskRatio = stat.diskTotal > 0 ? 1 - stat.diskFree / stat.diskTotal : 0
439  const diskColor = diskRatio > 0.95 ? 'error' : diskRatio > 0.9 ? 'warning' : undefined
440  const rows: RenderElement[] = [
441    <Box key="sys" flexDirection="row" columnGap={2} height={1} overflow="hidden">
442      <Text bold>시스템</Text>
443      {stat.memTotal > 0 && <Text color={memColor} wrap="truncate-end">메모리 {bar(memRatio)} {gb(stat.memUsed)}/{gb(stat.memTotal)} · 압력 {pressureLabel}</Text>}
444      {stat.swapUsed > 0 && <Text color={swapColor}>스왑 {gb(stat.swapUsed)}</Text>}
445      {stat.diskTotal > 0 && <Text color={diskColor}>디스크 {bar(diskRatio)} {Math.round(diskRatio * 100)}% · 남은 {gb(stat.diskFree)}</Text>}
446      <Button key="sys-procs" plain hotkey="z" label={showProcs ? '앱 순위 접기' : '메모리 많이 쓰는 앱'} onPress={() => void update($, showProcsAtom, v => !v)} />
447    </Box>,
448  ]
449  if (showProcs && stat.top.length > 0) {
450    rows.push(<Text key="sys-top" dimColor wrap="truncate-end">  {stat.top.map(p => `${p.name} ${gb(p.bytes)}`).join(' · ')}</Text>)
451  }
452  return rows
453}
454
455/**
456 * IDE 창 맨 위의 에이전트 보드: 한 줄이 한 행인 요소들을 돌려준다.
457 * 아래 탐색기가 남은 행을 정확히 쓰도록 maxRows를 넘기지 않는다.
458 */
459async function drawBoard($: EngineInterface, e: RenderInput<'Pane'>, width: number, maxRows: number): Promise<RenderElement[]> {
460  const { Box, Text, Button } = $.ui.resolve(e)
461  lastDrawnAt = Date.now()
462  startTimers($)
463  const all = await read($, agentsAtom)
464  const isFolded = await read($, foldedAtom)
465  const hideDone = await read($, hideDoneAtom)
466  const isRecapOn = await read($, recapOnAtom)
467  await read($, tickAtom)
468  const now = Date.now()
469
470  const subs = Object.values(all).filter(r => r.id !== MAIN)
471  const running = subs.filter(r => r.phase === 'running').length
472  const main = all[MAIN]
473  const mainBrain = main === undefined ? '' : `${shortModel(main.model) || '…'}${main.effort ? ` · ${main.effort}` : ''}`
474  const summary = `에이전트 ${main === undefined ? '대기' : `${PHASE_ICON[main.phase]} ${mainBrain}`} · 서브 ${running}개 작업 중 / ${subs.length - running}개 끝남`
475
476  const sessionId = await $.session.id().catch(() => '')
477  const header = (
478    <Box key="board-header" flexDirection="row" columnGap={2} height={1} overflow="hidden">
479      <Text bold wrap="truncate-end">{summary}</Text>
480      {sessionId !== '' && <Text dimColor wrap="truncate-end">세션 {sessionId.slice(0, 8)}</Text>}
481      <Button key="board-handoff" plain hotkey="i" label="핸드오프" onPress={() => void openHandoff($)} />
482      <Button key="board-fold" plain hotkey="a" label={isFolded ? '보드 펼치기' : '보드 접기'} onPress={() => void update($, foldedAtom, v => !v)} />
483      {!isFolded && <Button key="board-hide" plain hotkey="h" label={hideDone ? '끝난 것 보이기' : '끝난 것 숨기기'} onPress={() => void update($, hideDoneAtom, v => !v)} />}
484      {!isFolded && <Button key="board-recap" plain hotkey="s" label={isRecapOn ? `요약 끄기(${RECAP_MODEL})` : '요약 켜기'} onPress={() => { recapFailures = 0; void update($, recapOnAtom, v => !v) }} />}
485      {!isFolded && (
486        <Button
487          key="board-clear"
488          plain
489          hotkey="x"
490          label="끝난 것 지우기"
491          onPress={() => void update($, agentsAtom, list => Object.fromEntries(Object.entries(list).filter(([id, r]) => id === MAIN || r.phase === 'running')))}
492        />
493      )}
494    </Box>
495  )
496  // 시스템 줄은 접어도 남긴다 (램이 모자랄 때 늘 보이게)
497  const sys = await drawSys($, e).catch(() => [] as RenderElement[])
498  if (isFolded || maxRows <= 1 + sys.length) return [...sys, header]
499
500  // 숨길 때도 돌고 있는 에이전트와 그 조상은 남겨 트리가 끊기지 않게 한다
501  const visible = new Set<string>([MAIN])
502  for (const row of subs) {
503    if (hideDone && row.phase !== 'running') continue
504    for (let at: AgentRow | undefined = row; at !== undefined && !visible.has(at.id); at = at.parentId === undefined ? undefined : all[at.parentId]) visible.add(at.id)
505  }
506  const children = new Map<string, AgentRow[]>()
507  for (const row of subs) {
508    if (!visible.has(row.id)) continue
509    const parent = row.parentId !== undefined && visible.has(row.parentId) && all[row.parentId] !== undefined ? row.parentId : MAIN
510    children.set(parent, [...(children.get(parent) ?? []), row])
511  }
512  for (const list of children.values()) list.sort((a, b) => a.startedAt - b.startedAt)
513  const roleColor = new Map<string, Color>()
514  const colorOf = (role: string) => {
515    const base = role.split(' ')[0] ?? role
516    if (!roleColor.has(base)) roleColor.set(base, ROLE_COLORS[roleColor.size % ROLE_COLORS.length] ?? 'suggestion')
517    return roleColor.get(base) ?? 'suggestion'
518  }
519
520  const lines: RenderElement[] = []
521  const draw = (row: AgentRow, lead: string, branch: string, depth: number) => {
522    const elapsed = formatElapsed((row.endedAt ?? now) - row.startedAt)
523    const brain = `${shortModel(row.model) || '모델 확인 중'}${row.effort ? ` · ${row.effort}` : ''}`
524    const pad = `${lead}${depth === 0 ? '' : branch === '└─ ' ? '   ' : '│  '}  `
525    // 지금 하는 일·요약은 노란색(돌고 있을 때만), 끝난 결과는 흐리게
526    const isLive = row.phase === 'running'
527    const [label, body] = isLive && row.recap !== '' ? ['요약', row.recap] : isLive && row.activity !== '' ? ['지금', row.activity] : row.recap !== '' ? ['결과', row.recap] : ['', row.activity]
528    const live = isLive && body !== ''
529    lines.push(
530      <Text key={`agent:${row.id}`} wrap="truncate-end">
531        <Text dimColor>{lead}{depth === 0 ? '' : branch}</Text>
532        <Text color={PHASE_COLOR[row.phase]}>{PHASE_ICON[row.phase]} </Text>
533        <Text bold color={row.id === MAIN ? 'claude' : colorOf(row.role)}>{row.id === MAIN ? '메인' : row.role}</Text>
534        <Text>  {brain}</Text>
535        <Text dimColor>  {PHASE_LABEL[row.phase]} {elapsed}{row.isBackground ? ' · 백그라운드' : ''}{row.toolCount > 0 ? ` · 도구 ${row.toolCount}` : ''}</Text>
536        {/* 맡긴 일(메인은 사람이 입력한 프롬프트, 서브에이전트는 받은 작업 설명)은 초록색 */}
537        <Text color="success">{row.task !== '' ? `  ${oneLine(row.task, 80)}` : ''}</Text>
538      </Text>,
539    )
540    if (body !== '') {
541      lines.push(
542        <Text key={`agent-doing:${row.id}`} wrap="truncate-end">
543          <Text dimColor>{pad}{label === '' ? '' : `${label} `}</Text>
544          <Text color={live ? 'warning' : undefined} dimColor={!live}>{oneLine(body, Math.max(10, width))}</Text>
545        </Text>,
546      )
547    }
548    const kids = children.get(row.id) ?? []
549    kids.forEach((kid, i) => {
550      const nextLead = depth === 0 ? lead : `${lead}${branch === '└─ ' ? '   ' : '│  '}`
551      draw(kid, nextLead, i === kids.length - 1 ? '└─ ' : '├─ ', depth + 1)
552    })
553  }
554  if (main !== undefined) draw(main, '', '', 0)
555  else (children.get(MAIN) ?? []).forEach((kid, i, kids) => draw(kid, '', i === kids.length - 1 ? '└─ ' : '├─ ', 1))
556
557  const room = maxRows - 1 - sys.length
558  if (lines.length === 0) return [...sys, header, <Text key="board-empty" dimColor>프롬프트를 보내면 에이전트가 여기 나타나요.</Text>].slice(0, maxRows)
559  if (lines.length <= room) return [...sys, header, ...lines]
560  return [...sys, header, ...lines.slice(0, room - 1), <Text key="board-more" dimColor>… {lines.length - room + 1}줄 더 (a로 접기, h로 끝난 것 숨기기)</Text>]
561}
562
563// ════════════════ 탐색기 ════════════════
564const PANE = 'ide-mod'
565const rootAtom = atom({ plugin: 'ide-mod', key: 'root' } as const, '')
566const expandedAtom = atom({ plugin: 'ide-mod', key: 'expanded' } as const, [] as string[])
567const tabsAtom = atom({ plugin: 'ide-mod', key: 'tabs' } as const, [] as string[])
568const activeAtom = atom({ plugin: 'ide-mod', key: 'active' } as const, '')
569const treeOffsetAtom = atom({ plugin: 'ide-mod', key: 'treeOffset' } as const, 0)
570const offsetsAtom = atom({ plugin: 'ide-mod', key: 'offsets' } as const, {} as Record<string, number>)
571const modeAtom = atom({ plugin: 'ide-mod', key: 'mode' } as const, 'code' as ViewMode)
572const revAtom = atom({ plugin: 'ide-mod', key: 'rev' } as const, 0)
573const touchedAtom = atom({ plugin: 'ide-mod', key: 'touched' } as const, [] as string[])
574const treeHiddenAtom = atom({ plugin: 'ide-mod', key: 'isTreeHidden' } as const, false)
575
576const MAX_BYTES = 4 * 1024 * 1024
577const MAX_TREE_ROWS = 3000
578const MAX_TABS = 8
579const MAX_RENDERED_CHARS = 90_000
580// 이보다 좁으면 트리와 파일을 한 번에 하나만 보여준다
581const SPLIT_MIN_COLUMNS = 70
582const SKIP_NAMES = new Set(['.DS_Store'])
583const MARKDOWN = /\.(md|markdown|mdx)$/i
584const PNG = /\.png$/i
585const OTHER_IMAGE = /\.(jpe?g|gif|webp|bmp|heic|tiff?|ico)$/i
586const EDIT_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
587
588type TreeRow = { path: string; name: string; depth: number; isDir: boolean; isOpen: boolean; isLoop: boolean }
589
590// 스크롤 훅이 쓰는 마지막 배치: 그릴 때마다 다시 잰다
591const layout = {
592  /** 탐색기 위에 놓인 행 수(에이전트 보드·구분선·툴바) */
593  topRows: 0,
594  showTree: true,
595  showFile: true,
596  treeWidth: 30,
597  treeRows: 0,
598  treeView: 10,
599  active: '',
600  fileMaxOffset: 0,
601  leftMode: 'files' as 'files' | 'requests' | 'handoff',
602  requestRows: 0,
603}
604// 스크롤할 때마다 디스크를 다시 읽지 않도록: 트리는 (뿌리·펼친 폴더·세대)로, 파일은 수정 시각으로 재사용한다
605let treeEpoch = 0
606let treeCache: { key: string; rows: TreeRow[]; isCut: boolean } | undefined
607const fileCache = new Map<string, { mtimeMs: number; size: number; lines: string[] }>()
608// 그림은 터미널이 파일을 직접 읽게 하지 않고 base64로 넘긴다(그래픽 터미널마다 파일 읽기 처리가 달라 멈출 수 있음).
609// Image가 받는 최대 크기(2 MiB)를 넘으면 그리지 않는다
610const PNG_INLINE_MAX = 2 * 1024 * 1024
611const pngCache = new Map<string, { key: string; base64: string }>()
612const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
613/** base64 앞부분만 바이트로 푼다 */
614const base64Head = (text: string, count: number) => {
615  const out: number[] = []
616  let buffer = 0
617  let bits = 0
618  for (const ch of text) {
619    const v = B64.indexOf(ch)
620    if (v < 0) break
621    buffer = (buffer << 6) | v
622    bits += 6
623    if (bits >= 8) {
624      bits -= 8
625      out.push((buffer >> bits) & 0xff)
626      if (out.length >= count) break
627    }
628  }
629  return out
630}
631async function pngData($: EngineInterface, path: string, key: string) {
632  const hit = pngCache.get(path)
633  if (hit?.key === key) return hit.base64
634  const { base64 } = await $.fs.read(path, { as: 'bytes' })
635  if ((base64.length * 3) / 4 > PNG_INLINE_MAX) return undefined
636  // PNG 서명과 IHDR가 없으면 엔진이 창 전체를 거절하므로 미리 걸러낸다
637  const head = base64Head(base64, 16)
638  const isPng = head[0] === 0x89 && head[1] === 0x50 && head[2] === 0x4e && head[3] === 0x47 && String.fromCharCode(...head.slice(12, 16)) === 'IHDR'
639  if (!isPng) return undefined
640  pngCache.set(path, { key, base64 })
641  while (pngCache.size > 6) pngCache.delete(pngCache.keys().next().value as string)
642  return base64
643}
644const FILE_CACHE_SIZE = 8
645
646const baseName = (path: string) => path.replace(/\/+$/, '').split('/').pop() || path
647const parentOf = (path: string) => {
648  const cut = path.replace(/\/+$/, '').lastIndexOf('/')
649  return cut <= 0 ? '/' : path.slice(0, cut)
650}
651const joinPath = (dir: string, name: string) => (dir.endsWith('/') ? dir + name : `${dir}/${name}`)
652const isInside = (path: string, dir: string) => path === dir || path.startsWith(dir.endsWith('/') ? dir : `${dir}/`)
653export const clamp = (n: number, low: number, high: number) => Math.min(Math.max(n, low), Math.max(low, high))
654const formatSize = (bytes: number) =>
655  bytes < 1024 ? `${bytes} B`
656    : bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB`
657      : `${(bytes / 1024 / 1024).toFixed(1)} MB`
658// 그리기 요소는 탭과 줄바꿈 말고 제어 문자를 받지 않는다
659const cleanText = (text: string) =>
660  text.replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
661
662// 한글·한자·이모지는 터미널에서 두 칸, 조합용 모음·받침과 결합 부호는 0칸
663const WIDE = /[ᄀ-ᅟ☀-➿⬀-⯿⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩\u{1f300}-\u{1faff}]/u
664const ZERO = /[̀-ͯᅠ-ᇿ​-‏︀-️]/
665const cellsOf = (ch: string) => (ZERO.test(ch) ? 0 : WIDE.test(ch) ? 2 : 1)
666/** 터미널 칸 수에 맞춰 자른다 (macOS가 풀어 쓴 한글 이름은 먼저 모아 쓴다) */
667export const fit = (raw: string, columns: number) => {
668  const text = raw.normalize('NFC')
669  if ([...text].reduce((n, ch) => n + cellsOf(ch), 0) <= columns) return text
670  let used = 0
671  let out = ''
672  for (const ch of text) {
673    if (used + cellsOf(ch) > columns - 1) break
674    used += cellsOf(ch)
675    out += ch
676  }
677  return `${out}…`
678}
679
680/** 렌더 모드에서 창을 시작해도 되는 줄: 빈 줄 다음이면서 코드 울타리(```) 밖 */
681export const blockStarts = (lines: string[]) => {
682  const starts = [0]
683  let isFenced = false
684  lines.forEach((line, i) => {
685    if (/^\s*(```|~~~)/.test(line)) isFenced = !isFenced
686    if (!isFenced && i + 1 < lines.length && line.trim() === '' && lines[i + 1]?.trim() !== '') starts.push(i + 1)
687  })
688  return starts
689}
690
691/** 입력한 경로(상대, ~, 따옴표, @ 포함)를 실제 절대 경로로 바꾼다 */
692async function resolvePath($: EngineInterface, raw: string): Promise<{ path: string; isDir: boolean } | { error: string }> {
693  let target = raw.trim().replace(/^@/, '').replace(/^(['"])(.*)\1$/, '$2')
694  if (target === '') target = '.'
695  if (target === '~' || target.startsWith('~/')) {
696    const home = await $.env.get('HOME')
697    if (home === undefined) return { error: '홈 폴더(HOME)를 알 수 없어요.' }
698    target = home + target.slice(1)
699  }
700  const stat = await $.fs.stat(target, { resolve: true }).catch(() => undefined)
701  if (stat?.realPath === undefined) return { error: `파일을 찾을 수 없어요: ${raw.trim()}` }
702  return { path: stat.realPath, isDir: stat.kind === 'dir' }
703}
704
705const remember = (path: string, entry: { mtimeMs: number; size: number; lines: string[] }) => {
706  fileCache.delete(path)
707  fileCache.set(path, entry)
708  while (fileCache.size > FILE_CACHE_SIZE) {
709    const oldest = fileCache.keys().next().value
710    if (oldest === undefined) break
711    fileCache.delete(oldest)
712  }
713}
714
715/** 파일을 탭으로 열고, 트리에서 보이도록 조상 폴더를 펼친다 */
716async function openFile($: EngineInterface, raw: string) {
717  const path = (await $.fs.stat(raw, { resolve: true }).catch(() => undefined))?.realPath ?? raw
718  const root = (await read($, rootAtom)) || (await $.session.cwd())
719  if (!isInside(path, root)) {
720    await update($, rootAtom, () => parentOf(path))
721    await update($, treeOffsetAtom, () => 0)
722  } else {
723    const ancestors: string[] = []
724    for (let dir = parentOf(path); isInside(dir, root) && dir !== root; dir = parentOf(dir)) ancestors.push(dir)
725    await update($, expandedAtom, list => [...new Set([...list, ...ancestors])])
726  }
727  await update($, tabsAtom, list => {
728    if (list.includes(path)) return list
729    const next = [...list, path]
730    return next.length > MAX_TABS ? next.slice(next.length - MAX_TABS) : next
731  })
732  await update($, activeAtom, () => path)
733  // 좁은 화면에서는 파일을 고르면 파일 쪽으로 넘어간다
734  if (!(layout.showTree && layout.showFile)) await update($, treeHiddenAtom, () => true)
735}
736
737async function closeTab($: EngineInterface, path: string) {
738  const tabs = await read($, tabsAtom)
739  const at = tabs.indexOf(path)
740  const rest = tabs.filter(t => t !== path)
741  fileCache.delete(path)
742  await update($, tabsAtom, () => rest)
743  if ((await read($, activeAtom)) === path) await update($, activeAtom, () => rest[Math.min(at, rest.length - 1)] ?? '')
744}
745
746async function pressEntry($: EngineInterface, row: TreeRow) {
747  if (row.isDir) {
748    if (row.isLoop) return
749    await update($, expandedAtom, list => (list.includes(row.path) ? list.filter(p => p !== row.path) : [...list, row.path]))
750  } else {
751    await openFile($, row.path)
752  }
753}
754
755async function buildTree($: EngineInterface, root: string, expanded: Set<string>) {
756  const rows: TreeRow[] = []
757  let isCut = false
758  const walk = async (dir: string, real: string, depth: number) => {
759    const entries = await $.fs.list(dir).catch(() => [])
760    const items = await Promise.all(
761      entries
762        .filter(entry => !SKIP_NAMES.has(entry.name))
763        .map(async entry => {
764          const path = joinPath(dir, entry.name)
765          if (!entry.isLink) return { entry, path, isDir: entry.kind === 'dir', real: joinPath(real, entry.name) }
766          // 링크는 가리키는 곳을 따라가서 폴더면 폴더로 보여준다
767          const stat = await $.fs.stat(path, { resolve: true }).catch(() => undefined)
768          return { entry, path, isDir: stat?.kind === 'dir', real: stat?.realPath ?? path }
769        }),
770    )
771    items.sort((a, b) => (a.isDir === b.isDir ? a.entry.name.localeCompare(b.entry.name) : a.isDir ? -1 : 1))
772    for (const item of items) {
773      if (rows.length >= MAX_TREE_ROWS) {
774        isCut = true
775        return
776      }
777      // 자기 조상을 가리키는 링크는 펼치지 않는다 (무한 트리 방지)
778      const isLoop = item.isDir && item.entry.isLink && isInside(real, item.real)
779      const isOpen = item.isDir && !isLoop && expanded.has(item.path)
780      rows.push({ path: item.path, name: item.entry.name, depth, isDir: item.isDir, isOpen, isLoop })
781      if (isOpen) await walk(item.path, item.real, depth + 1)
782    }
783  }
784  const rootReal = (await $.fs.stat(root, { resolve: true }).catch(() => undefined))?.realPath ?? root
785  await walk(root, rootReal, 0)
786  return { rows, isCut }
787}
788
789async function treeRows($: EngineInterface, root: string, expanded: string[], rev: number) {
790  const key = JSON.stringify([root, [...expanded].sort(), rev, treeEpoch])
791  if (treeCache?.key !== key) treeCache = { key, ...(await buildTree($, root, new Set(expanded))) }
792  return treeCache
793}
794
795async function readLines($: EngineInterface, path: string, mtimeMs: number, size: number) {
796  const cached = fileCache.get(path)
797  if (cached !== undefined && cached.mtimeMs === mtimeMs && cached.size === size) return cached.lines
798  const raw = await $.fs.read(path)
799  if (raw.slice(0, 8000).includes('\u0000')) return undefined
800  const lines = cleanText(raw).split('\n')
801  if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
802  remember(path, { mtimeMs, size, lines })
803  return lines
804}
805
806/** /open, /ide가 부른다: 경로를 열고 창을 띄운다 */
807async function openIde($: EngineInterface, args: string) {
808  if (args.trim() !== '') {
809    const resolved = await resolvePath($, args)
810    if ('error' in resolved) return resolved.error
811    if (resolved.isDir) {
812      await update($, rootAtom, () => resolved.path)
813      await update($, treeOffsetAtom, () => 0)
814      await update($, treeHiddenAtom, () => false)
815    } else {
816      await openFile($, resolved.path)
817    }
818  }
819  await $.ui.open({ id: PANE, title: 'IDE', focus: true, rows: 36, columns: 150 })
820  const root = (await read($, rootAtom)) || (await $.session.cwd())
821  return `IDE를 열었어요: ${root}`
822}
823
824/** Claude가 파일을 고치면 트리에 표시하고, 열려 있는 탭은 다시 읽는다 (거절·실패한 호출은 빼고) */
825async function noteEdit($: EngineInterface, tool: string, input: Record<string, unknown>, ran: { deny?: string; isError?: true }) {
826  if (!EDIT_TOOLS.includes(tool) || ran.deny !== undefined || ran.isError === true) return
827  const touched = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : undefined
828  if (touched === undefined) return
829  const real = (await $.fs.stat(touched, { resolve: true }).catch(() => undefined))?.realPath
830  if (real === undefined) return
831  fileCache.delete(real)
832  treeEpoch += 1
833  await update($, touchedAtom, list => (list.includes(real) ? list : [...list, real].slice(-300)))
834  await update($, revAtom, n => n + 1)
835}
836
837function registerExplorerScroll(on: On) {
838  // 휠은 포인터가 있는 쪽(트리/파일)을, 키보드는 열린 파일을(없으면 트리를) 스크롤한다
839  on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e) => {
840    if (e.pointer !== undefined && e.pointer.row < layout.topRows) return {}
841    const isOverTree = layout.showTree && (!layout.showFile || layout.active === '' || (e.pointer !== undefined && e.pointer.column < layout.treeWidth))
842    if (isOverTree && layout.leftMode === 'requests') {
843      await update($, requestOffsetAtom, n => clamp(n + e.by, 0, layout.requestRows - layout.treeView))
844    } else if (isOverTree) {
845      await update($, treeOffsetAtom, n => clamp(n + e.by, 0, layout.treeRows - layout.treeView))
846    } else if (layout.active !== '' && hwpNow.path === layout.active && HWP.test(layout.active) && (await read($, hwpViewAtom)) === 'doc') {
847      // 문서 보기: 쪽 안에서 움직이다 끝에 닿으면 다음(앞) 쪽으로 넘긴다
848      const { path, page, pages } = hwpNow
849      const key = hwpOffsetKey(path, page)
850      const now = (await read($, offsetsAtom))[key] ?? 0
851      if (e.by > 0 && now >= layout.fileMaxOffset && page < pages - 1) {
852        await update($, offsetsAtom, all => ({ ...all, [hwpOffsetKey(path, page + 1)]: 0 }))
853        await update($, hwpPagesAtom, all => ({ ...all, [path]: page + 1 }))
854      } else if (e.by < 0 && now <= 0 && page > 0) {
855        await update($, offsetsAtom, all => ({ ...all, [hwpOffsetKey(path, page - 1)]: 100_000 }))
856        await update($, hwpPagesAtom, all => ({ ...all, [path]: page - 1 }))
857      } else {
858        await update($, offsetsAtom, all => ({ ...all, [key]: clamp(now + e.by, 0, layout.fileMaxOffset) }))
859      }
860    } else if (layout.active !== '') {
861      const path = layout.active
862      await update($, offsetsAtom, all => ({ ...all, [path]: clamp((all[path] ?? 0) + e.by, 0, layout.fileMaxOffset) }))
863    }
864    return {}
865  }).catch(($, e, next) => next(e))
866}
867
868/** 탐색기 부분: 툴바 한 줄 + 트리 | 탭 에디터. 정확히 rows 행을 쓴다 */
869async function drawExplorer($: EngineInterface, e: RenderInput<'Pane'>, width: number, rows: number, topRows: number): Promise<RenderElement> {
870  const { Box, Text, Button, Code, Markdown } = $.ui.resolve(e)
871  const root = (await read($, rootAtom)) || (await $.session.cwd())
872  const expanded = await read($, expandedAtom)
873  const tabs = await read($, tabsAtom)
874  const active = await read($, activeAtom)
875  const offsets = await read($, offsetsAtom)
876  const mode = await read($, modeAtom)
877  const touched = new Set(await read($, touchedAtom))
878  const isTreeHidden = await read($, treeHiddenAtom)
879  const rev = await read($, revAtom)
880  const leftMode = await read($, leftModeAtom)
881  const requests = await read($, requestsAtom)
882  const selectedN = await read($, selectedRequestAtom)
883  const hwpView = await read($, hwpViewAtom)
884  const isRequests = leftMode === 'requests'
885  const isHandoff = leftMode === 'handoff'
886  const isFiles = leftMode === 'files'
887  const peers = isHandoff ? await read($, peersAtom) : []
888  const handoff = isHandoff ? await currentHandoff($).catch(() => undefined) : undefined
889  const sent = isHandoff ? await read($, handoffSentAtom) : null
890  // 요청 기록 모드: 기본은 이번 세션 요청 전부를 오른쪽에 이어서 보여 주고, 왼쪽에서 고르면 그 요청 하나만 펼친다
891  const requestView = await read($, requestViewAtom)
892  const isAllRequests = isRequests && requestView === 'all' && requests.length > 0
893  const selected = requests.find(r => r.n === selectedN) ?? requests[requests.length - 1]
894  const rightKey = isHandoff ? 'handoff' : isRequests ? (isAllRequests ? 'requests:all' : selected === undefined ? '' : `request:${selected.n}`) : active
895  const isHwp = isFiles && HWP.test(active)
896
897  const isWide = width >= SPLIT_MIN_COLUMNS
898  const showTree = isWide ? !isTreeHidden || rightKey === '' : !(isTreeHidden && rightKey !== '')
899  const showFile = isWide ? true : !showTree
900  const treeWidth = showTree && showFile ? clamp(Math.round(width * 0.3), 24, 44) : width
901  const fileWidth = showTree && showFile ? width - treeWidth - 1 : width
902  const mainRows = Math.max(1, rows - 1)
903  const isMarkdown = isFiles && MARKDOWN.test(active)
904  layout.topRows = topRows
905  layout.leftMode = leftMode
906
907  // ── 툴바 ──
908  const toolbar = (
909    <Box key="ide-toolbar" flexDirection="row" columnGap={2} height={1} overflow="hidden">
910      <Button key="tree" plain hotkey="t" label={showTree && showFile ? '트리 접기' : '트리'} onPress={() => void update($, treeHiddenAtom, v => (rightKey === '' ? false : !v))} />
911      <Button
912        key="left-mode"
913        plain
914        hotkey="q"
915        label={isFiles ? `요청 기록 ${requests.length}` : '파일 트리'}
916        onPress={() =>
917          void (async () => {
918            await update($, leftModeAtom, m => (m === 'files' ? 'requests' : 'files'))
919            await update($, treeHiddenAtom, () => false)
920          })()
921        }
922      />
923      <Button
924        key="up"
925        plain
926        hotkey="u"
927        label="상위 폴더"
928        onPress={() =>
929          void (async () => {
930            await update($, expandedAtom, list => [...new Set([...list, root])])
931            await update($, rootAtom, () => parentOf(root))
932            await update($, treeOffsetAtom, () => 0)
933          })()
934        }
935      />
936      <Button
937        key="reload"
938        plain
939        hotkey="r"
940        label="새로고침"
941        onPress={() => {
942          if (isHandoff) {
943            void loadPeers($).catch(() => undefined)
944            return
945          }
946          fileCache.clear()
947          hwpTextCache.clear()
948          hwpPageCache.clear()
949          hwpGridCache.clear()
950          treeEpoch += 1
951          void update($, revAtom, n => n + 1)
952        }}
953      />
954      {isHwp && (
955        <Button key="hwp-view" plain hotkey="v" label={hwpView === 'image' ? '문서 보기' : '그림 보기'} onPress={() => void update($, hwpViewAtom, v => (v === 'image' ? 'doc' : 'image'))} />
956      )}
957      {isHwp && (
958        <Button key="hwp-open" plain hotkey="o" label="미리보기로 열기" onPress={() => void openHwpPreview($, active, hwpNow.path === active ? hwpNow.page : 0)} />
959      )}
960      {isHwp && (
961        <Button key="hwp-prev" plain hotkey="b" label="◀ 앞쪽" onPress={() => void update($, hwpPagesAtom, all => ({ ...all, [active]: Math.max(0, (all[active] ?? 0) - 1) }))} />
962      )}
963      {isHwp && (
964        <Button key="hwp-next" plain hotkey="n" label="뒤쪽 ▶" onPress={() => void update($, hwpPagesAtom, all => ({ ...all, [active]: (all[active] ?? 0) + 1 }))} />
965      )}
966      {isRequests && requests.length > 0 && (
967        <Button key="request-all" plain hotkey="l" label={isAllRequests ? '하나만 보기' : '모두 보기'} onPress={() => void update($, requestViewAtom, v => (v === 'all' ? 'one' : 'all'))} />
968      )}
969      {isRequests && !isAllRequests && selected !== undefined && (
970        <Button key="request-again" plain hotkey="p" label="입력창에 다시 넣기" onPress={() => void $.prompt.fill({ text: selected.text, mode: 'insert' })} />
971      )}
972      {isRequests && !isAllRequests && selected !== undefined && <Button key="request-copy" plain hotkey="c" label="복사" onPress={() => void $.ui.copy({ text: selected.text, surface: e.surface })} />}
973      {isAllRequests && <Button key="request-copy-all" plain hotkey="c" label="전부 복사" onPress={() => void $.ui.copy({ text: requestsAsText(requests), surface: e.surface })} />}
974      {isHandoff && handoff !== undefined && (
975        <Button key="handoff-copy" plain hotkey="c" label="핸드오프 글 복사" onPress={() => void $.ui.copy({ text: handoff.text, surface: e.surface })} />
976      )}
977      {isHandoff && handoff !== undefined && <Button key="handoff-id" plain hotkey="y" label="세션 ID 복사" onPress={() => void $.ui.copy({ text: handoff.info.id, surface: e.surface })} />}
978      {isHandoff && handoff !== undefined && (
979        <Button
980          key="handoff-resume"
981          plain
982          hotkey="e"
983          label="이어서 열기 명령 복사"
984          onPress={() => void $.ui.copy({ text: `cd ${shellQuote(handoff.info.root)} && claude --resume ${handoff.info.id} --fork-session`, surface: e.surface })}
985        />
986      )}
987      {isFiles && active !== '' && isMarkdown && (
988        <Button key="mode" plain hotkey="m" label={mode === 'rendered' ? '원문' : '렌더'} onPress={() => void update($, modeAtom, m => (m === 'rendered' ? 'code' : 'rendered'))} />
989      )}
990      {isFiles && active !== '' && <Button key="mention" plain hotkey="p" label="@프롬프트" onPress={() => void $.prompt.fill({ text: `@${active} `, mode: 'insert' })} />}
991      {isFiles && active !== '' && <Button key="copy" plain hotkey="c" label="경로 복사" onPress={() => void $.ui.copy({ text: active, surface: e.surface })} />}
992      {isFiles && active !== '' && <Button key="close" plain hotkey="w" label="탭 닫기" onPress={() => void closeTab($, active)} />}
993    </Box>
994  )
995
996  // ── 트리 / 요청 기록 ──
997  let treeColumn: RenderElement | false = false
998  if (showTree && isHandoff) {
999    const self = await read($, selfNameAtom)
1000    treeColumn = (
1001      <Box key="ide-peers" flexDirection="column" width={treeWidth} height={mainRows} overflow="hidden">
1002        <Text bold wrap="truncate-end">보낼 세션 {peers.length}개 · r 새로고침</Text>
1003        {self !== '' && <Text dimColor wrap="truncate-end">이 세션: {self}</Text>}
1004        {peers.length === 0 && <Text dimColor>열려 있는 다른 세션이 없어요. c로 핸드오프 글을 복사해 다른 에이전트에 붙여 넣으세요.</Text>}
1005        {peers.slice(0, Math.max(1, mainRows - 2)).map(peer => (
1006          <Box key={`peer-row:${peer.ref}`} flexDirection="row" height={1} overflow="hidden">
1007            <Button
1008              key={`peer:${peer.ref}`}
1009              plain
1010              dimColor={peer.status !== 'idle'}
1011              label={fit(`→ ${peer.name} · ${peer.status === 'busy' ? '작업 중' : peer.status === 'idle' ? '대기' : peer.status}`, treeWidth)}
1012              onPress={() => void sendHandoff($, peer)}
1013            />
1014          </Box>
1015        ))}
1016      </Box>
1017    )
1018  } else if (showTree && isRequests) {
1019    const treeView = Math.max(1, mainRows - 1)
1020    const newestFirst = [...requests].reverse()
1021    const requestOffset = clamp(await read($, requestOffsetAtom), 0, newestFirst.length - treeView)
1022    Object.assign(layout, { requestRows: newestFirst.length, treeView })
1023    const shown = newestFirst.slice(requestOffset, requestOffset + treeView)
1024    treeColumn = (
1025      <Box key="ide-requests" flexDirection="column" width={treeWidth} height={mainRows} overflow="hidden">
1026        <Text bold wrap="truncate-end">요청 기록 {requests.length}개{requests.length > treeView ? ` · ${requestOffset + 1}-${requestOffset + shown.length}` : ''}</Text>
1027        {shown.length === 0 && <Text dimColor>아직 보낸 요청이 없어요. 이 세션에서 보내는 요청이 여기 쌓여요.</Text>}
1028        {shown.map(r => {
1029          const isOn = !isAllRequests && r.n === selected?.n
1030          return (
1031            <Box key={`req-row:${r.n}`} flexDirection="row" height={1} overflow="hidden" backgroundColor={isOn ? 'promptBorder' : undefined}>
1032              <Button
1033                key={`req:${r.n}`}
1034                plain
1035                dimColor={r.status !== 'running' && !isOn}
1036                label={fit(`${r.n}. ${REQUEST_ICON[r.status]} ${clock(r.at)} ${oneLine(r.text, 200)}`, treeWidth)}
1037                onPress={() =>
1038                  void (async () => {
1039                    await update($, selectedRequestAtom, () => r.n)
1040                    await update($, requestViewAtom, () => 'one')
1041                    if (!(layout.showTree && layout.showFile)) await update($, treeHiddenAtom, () => true)
1042                  })()
1043                }
1044              />
1045            </Box>
1046          )
1047        })}
1048      </Box>
1049    )
1050  } else if (showTree) {
1051    const tree = await treeRows($, root, expanded, rev)
1052    const treeView = Math.max(1, mainRows - 1)
1053    const treeOffset = clamp(await read($, treeOffsetAtom), 0, tree.rows.length - treeView)
1054    Object.assign(layout, { treeRows: tree.rows.length, treeView })
1055    const shown = tree.rows.slice(treeOffset, treeOffset + treeView)
1056    const position = tree.rows.length > treeView ? ` ${treeOffset + 1}-${treeOffset + shown.length}/${tree.rows.length}${tree.isCut ? '+' : ''}` : ''
1057    const page = Math.max(1, Math.floor(treeView / 2))
1058
1059    treeColumn = (
1060      <Box key="ide-tree" flexDirection="column" width={treeWidth} height={mainRows} overflow="hidden">
1061        <Box flexDirection="row" height={1} overflow="hidden">
1062          <Text bold wrap="truncate-end">{fit(baseName(root) || '/', Math.max(4, treeWidth - position.length - 6))}</Text>
1063          <Text dimColor>{position} </Text>
1064          <Button key="tree-up" plain hotkey="k" label="▲" onPress={() => void update($, treeOffsetAtom, n => clamp(n - page, 0, tree.rows.length - treeView))} />
1065          <Button key="tree-down" plain hotkey="j" label="▼" onPress={() => void update($, treeOffsetAtom, n => clamp(n + page, 0, tree.rows.length - treeView))} />
1066        </Box>
1067        {shown.length === 0 && <Text dimColor>빈 폴더예요.</Text>}
1068        {shown.map(row => {
1069          const isActive = row.path === active
1070          const isTouched = row.isDir ? [...touched].some(p => isInside(p, row.path)) : touched.has(row.path)
1071          const icon = row.isDir ? (row.isLoop ? '↻ ' : row.isOpen ? '▾ ' : '▸ ') : '  '
1072          const label = fit(`${'  '.repeat(row.depth)}${icon}${row.name}${row.isDir ? '/' : ''}`, treeWidth - (isTouched ? 2 : 0))
1073          return (
1074            <Box key={`row:${row.path}`} flexDirection="row" height={1} overflow="hidden" backgroundColor={isActive ? 'promptBorder' : undefined}>
1075              <Button key={`tree:${row.path}`} plain dimColor={row.name.startsWith('.') && !isActive} label={label} onPress={() => void pressEntry($, row)} />
1076              {isTouched && <Text color="warning"> ●</Text>}
1077            </Box>
1078          )
1079        })}
1080      </Box>
1081    )
1082  }
1083
1084  // ── 파일 ──
1085  let fileColumn: RenderElement | false = false
1086  if (showFile) {
1087    const contentRows = Math.max(1, mainRows - 2)
1088    const tabRow = (
1089      <Box flexDirection="row" columnGap={1} height={1} overflow="hidden">
1090        {tabs.length === 0 && <Text dimColor>열린 파일 없음</Text>}
1091        {tabs.map(tab => (
1092          <Button
1093            key={`tab:${tab}`}
1094            plain
1095            dimColor={tab !== active}
1096            label={`${tab === active ? '▎' : ' '}${fit(baseName(tab), 24)}${touched.has(tab) ? ' ●' : ''}`}
1097            onPress={() => void update($, activeAtom, () => tab)}
1098          />
1099        ))}
1100      </Box>
1101    )
1102    const frame = (info: string, body: RenderElement) => (
1103      <Box key="ide-file" flexDirection="column" width={fileWidth} height={mainRows} overflow="hidden">
1104        {tabRow}
1105        <Text dimColor wrap="truncate-start">{info}</Text>
1106        <Box flexDirection="column" height={contentRows} overflow="hidden">{body}</Box>
1107      </Box>
1108    )
1109    Object.assign(layout, { active: rightKey, fileMaxOffset: 0 })
1110
1111    if (isHandoff) {
1112      const lines = handoff === undefined ? ['세션 정보를 읽지 못했어요.'] : handoff.text.split('\n')
1113      const offset = clamp(offsets[rightKey] ?? 0, 0, lines.length - 1)
1114      layout.fileMaxOffset = Math.max(0, lines.length - 1)
1115      const note =
1116        sent === null
1117          ? '왼쪽에서 세션을 누르면 아래 글을 그 세션에 보내요.'
1118          : sent.ok
1119            ? `${clock(sent.at)} ${sent.to}에 보냈어요.`
1120            : `${clock(sent.at)} ${sent.to}에 못 보냈어요: ${sent.reason ?? ''}`
1121      fileColumn = (
1122        <Box key="ide-file" flexDirection="column" width={fileWidth} height={mainRows} overflow="hidden">
1123          <Text bold wrap="truncate-end">핸드오프 · 세션 {handoff?.info.id ?? ''}</Text>
1124          <Text dimColor={sent === null} color={sent === null ? undefined : sent.ok ? 'success' : 'error'} wrap="truncate-end">{note}</Text>
1125          <Box flexDirection="column" height={Math.max(1, mainRows - 2)} overflow="hidden">
1126            {lines.slice(offset).map((line, i) => (
1127              <Text key={`handoff-line:${offset + i}`} color={/^\d+\. \[|^ {3}\S/.test(line) ? 'success' : line.startsWith('세션 ID') || line.startsWith('이어서 열기') ? 'warning' : undefined}>
1128                {line === '' ? ' ' : line}
1129              </Text>
1130            ))}
1131          </Box>
1132        </Box>
1133      )
1134    } else if (isRequests) {
1135      const head = <Text dimColor>요청 기록 · {requests.length}개 · {requests.filter(r => r.status === 'running').length}개 진행 중</Text>
1136      if (isAllRequests) {
1137        // 요청 하나가 머리줄 + 본문 줄들 + 답 한 줄 + 빈 줄. 스크롤은 이 논리 줄 단위로, 넘치는 줄은 창이 자른다
1138        const segments: RenderElement[] = []
1139        for (const r of [...requests].reverse()) {
1140          segments.push(
1141            <Text key={`all-head:${r.n}`} bold wrap="truncate-end">
1142              #{r.n} · {dayClock(r.at)} · {REQUEST_ICON[r.status]} {requestStatus(r)}
1143            </Text>,
1144          )
1145          r.text.split('\n').forEach((line, i) => segments.push(<Text key={`all-line:${r.n}:${i}`} color="success">{line === '' ? ' ' : line}</Text>))
1146          if (r.answer !== '') segments.push(<Text key={`all-answer:${r.n}`} dimColor wrap="truncate-end">  └ Claude: {r.answer}</Text>)
1147          segments.push(<Text key={`all-gap:${r.n}`}> </Text>)
1148        }
1149        const offset = clamp(offsets[rightKey] ?? 0, 0, segments.length - 1)
1150        layout.fileMaxOffset = Math.max(0, segments.length - 1)
1151        fileColumn = (
1152          <Box key="ide-file" flexDirection="column" width={fileWidth} height={mainRows} overflow="hidden">
1153            <Text bold wrap="truncate-end">이번 세션 요청 {requests.length}개 전부 · 최근 것이 위 · 휠로 스크롤</Text>
1154            <Box flexDirection="column" height={Math.max(1, mainRows - 1)} overflow="hidden">
1155              {segments.slice(offset)}
1156            </Box>
1157          </Box>
1158        )
1159      } else if (selected === undefined) {
1160        fileColumn = (
1161          <Box key="ide-file" flexDirection="column" width={fileWidth} height={mainRows} overflow="hidden">
1162            {head}
1163            <Text dimColor>이 세션에서 보낸 요청이 왼쪽에 쌓이고, 고르면 여기 전문이 보여요.</Text>
1164          </Box>
1165        )
1166      } else {
1167        const lines = selected.text.split('\n')
1168        const offset = clamp(offsets[rightKey] ?? 0, 0, lines.length - 1)
1169        layout.fileMaxOffset = Math.max(0, lines.length - 1)
1170        const status = requestStatus(selected)
1171        fileColumn = (
1172          <Box key="ide-file" flexDirection="column" width={fileWidth} height={mainRows} overflow="hidden">
1173            <Text bold wrap="truncate-end">요청 #{selected.n} · {clock(selected.at)} · {status}</Text>
1174            {selected.answer !== '' ? <Text dimColor wrap="truncate-end">Claude 답: {selected.answer}</Text> : <Text dimColor> </Text>}
1175            <Box flexDirection="column" height={contentRows} overflow="hidden">
1176              <Text color="success">{lines.slice(offset).join('\n')}</Text>
1177            </Box>
1178          </Box>
1179        )
1180      }
1181    } else if (active === '') {
1182      fileColumn = frame('', <Text dimColor>왼쪽 트리에서 파일을 고르세요. 휠·방향키로 스크롤, j/k로 트리 넘기기.</Text>)
1183    } else {
1184      const relative = isInside(active, root) && active !== root ? active.slice(root.length).replace(/^\//, '') : active
1185      const stat = await $.fs.stat(active).catch(() => undefined)
1186      if (stat === undefined) {
1187        fileColumn = frame(relative, <Text color="error">파일이 없어졌어요.</Text>)
1188      } else if (stat.kind !== 'file') {
1189        fileColumn = frame(relative, <Text dimColor>일반 파일이 아니라 미리볼 수 없어요.</Text>)
1190      } else if (isHwp) {
1191        const hwp = await drawHwp($, e, active, stat, fileWidth, contentRows, offsets)
1192        fileColumn = frame(hwp.info, hwp.body)
1193      } else if (PNG.test(active)) {
1194        if (e.surface === 'terminal') {
1195          const { Image } = $.ui.resolve(e)
1196          const png = await pngData($, active, `${stat.mtimeMs}|${stat.size}`).catch(() => undefined)
1197          fileColumn = frame(
1198            `${relative} · ${formatSize(stat.size)}`,
1199            png === undefined
1200              ? <Text dimColor>이 PNG는 창 안에 그리지 않아요 (2 MB 초과 또는 PNG 형식이 아님).</Text>
types/index.d.ts 199 lines
1export type ViewMode = 'code' | 'rendered'
2
3export type AgentPhase = 'running' | 'idle' | 'done' | 'failed'
4
5export type AgentRow = {
6  /** 'main' 또는 서브에이전트 id */
7  id: string
8  /** 이 에이전트를 띄운 에이전트; 메인은 없음 */
9  parentId?: string
10  /** 역할: 메인이면 'main', 아니면 서브에이전트 타입(+팀원 이름) */
11  role: string
12  /** 맡은 작업 한 줄 (Agent 호출의 description, 메인은 사용자 프롬프트) */
13  task: string
14  model: string
15  effort?: string
16  phase: AgentPhase
17  isBackground: boolean
18  isTeammate: boolean
19  /** 지금 하고 있는 일: 마지막 도구 호출 */
20  activity: string
21  /** 최근 도구 호출 몇 개; 요약의 재료 */
22  log: string[]
23  toolCount: number
24  /** 모델이 만든 한 줄 요약, 또는 끝난 에이전트의 답 첫 줄 */
25  recap: string
26  /** 마지막 요약 뒤로 일이 쌓였는지 */
27  isRecapStale: boolean
28  startedAt: number
29  updatedAt: number
30  endedAt?: number
31}
32
33/** 이번 세션에 사람이 보낸 요청 하나 */
34export type SysStat = {
35  at: number
36  memTotal: number
37  memUsed: number
38  /** 1 정상, 2 경고, 4 위험 (macOS 메모리 압력) */
39  pressure: number
40  swapUsed: number
41  diskTotal: number
42  diskFree: number
43  /** 메모리를 많이 쓰는 앱 (이름별로 합침) */
44  top: { name: string; bytes: number }[]
45}
46
47/** 사용 한도 창 하나: 쓴 비율(0~100)과 리셋 시각(ms) */
48export type LimitWindow = { label: string; pct: number; resetsAt?: number }
49
50export type LimitsSnapshot = {
51  /** Claude: 5시간·주간·모델별 주간, 컨텍스트 사용률 */
52  claude: LimitWindow[]
53  context?: number
54  codex: LimitWindow[]
55  /** Antigravity: Gemini·Claude·GPT 각각 5시간·주간 */
56  agy: { group: string; windows: LimitWindow[] }[]
57  /** 각 출처를 마지막으로 받은 시각, 받지 못한 이유 */
58  at: { claude?: number; codex?: number; agy?: number }
59  errors: { claude?: string; codex?: string; agy?: string }
60}
61
62/** 이 세션의 메인 에이전트가 받은 마지막 응답: 컨텍스트, 프롬프트 캐시 적중, 생성 속도 */
63export type SessionPerf = {
64  /** 컨텍스트 창 사용률(0~100)과 토큰 수, 창 크기 */
65  context?: number
66  contextTokens?: number
67  window?: number
68  /** 마지막 응답의 입력 중 캐시에서 읽은 비율(0~100) */
69  cacheHit?: number
70  /** 이 세션 메인 응답을 모두 더한 캐시 적중률(0~100)과 그 재료 */
71  sessionHit?: number
72  readTokens: number
73  inputTokens: number
74  /** 마지막 응답의 출력 속도(토큰/초)와 첫 조각까지 걸린 시간 */
75  tokPerSec?: number
76  firstMs?: number
77  outputTokens?: number
78  at?: number
79}
80
81export type PeerRow = {
82  name: string
83  ref: string
84  status: string
85  detail: string
86}
87
88export type RequestItem = {
89  n: number
90  text: string
91  at: number
92  status: 'running' | 'done' | 'stopped'
93  /** 이 요청을 처리한 턴 */
94  turnId?: string
95  /** Claude 답의 첫 줄 */
96  answer: string
97  endedAt?: number
98}
99
100export type GateCheck = { label: string; count: number; limit: number; isHard: boolean }
101
102/** 한국어 문체 게이트 결과 (noslop-ko grep 게이트와 같은 기준) */
103export type GateResult = {
104  path: string
105  verdict: 'pass' | 'warn' | 'stop'
106  violations: number
107  checks: GateCheck[]
108  examples: string[]
109  sentences: number
110  longRuns: number
111  at: number
112}
113
114/** 강의 모드 자막 */
115export type Caption = { text: string; prev: string; step: number; startedAt: number }
116
117declare module 'claude-code' {
118  interface PluginState {
119    'ide-mod': {
120      // ── 탐색기 ──
121      /** 트리의 뿌리 폴더(절대 경로); 빈 문자열이면 작업 폴더 */
122      root: string
123      /** 펼친 폴더들(절대 경로) */
124      expanded: string[]
125      /** 탭으로 열어 둔 파일들, 연 순서대로 */
126      tabs: string[]
127      /** 지금 보이는 탭; 빈 문자열이면 없음 */
128      active: string
129      /** 트리 창이 보여주는 첫 줄 */
130      treeOffset: number
131      /** 파일마다 보던 첫 줄 */
132      offsets: Record<string, number>
133      /** 마크다운을 렌더링할지 원문 코드로 볼지 */
134      mode: ViewMode
135      /** 새로고침 때 올리는 값; 트리·파일을 다시 읽게 한다 */
136      rev: number
137      /** 이번 세션에 Claude가 고친 파일들 */
138      touched: string[]
139      /** 트리를 접었는지 (좁은 화면에서는 파일만 보기) */
140      isTreeHidden: boolean
141
142      // ── 에이전트 보드 ──
143      agents: Record<string, AgentRow>
144      /** 경과 시간을 다시 그리게 하는 시계 */
145      tick: number
146      isBoardFolded: boolean
147      hideDone: boolean
148      isRecapOn: boolean
149
150      // ── 요청 기록 ──
151      /** 왼쪽 칸: 파일 트리 또는 요청 기록 */
152      leftMode: 'files' | 'requests' | 'handoff'
153      requests: RequestItem[]
154      /** 오른쪽에 펼친 요청 번호; 0이면 가장 최근 */
155      selectedRequest: number
156      requestOffset: number
157      /** 오른쪽: all 이번 세션 요청 전부를 이어서, one 고른 요청 하나 */
158      requestView: 'all' | 'one'
159
160      // ── 시스템 상태 ──
161      /** 메모리·스왑·디스크 (보드가 떠 있을 때 10초마다) */
162      sys: SysStat | null
163      /** 메모리를 많이 쓰는 앱 줄을 펼쳤는지 */
164      showProcs: boolean
165
166      // ── 사용 한도 띠 (입력창 위) ──
167      limits: LimitsSnapshot
168      /** full: 출처마다 한 줄, compact: 한 줄, off: 숨김 */
169      /** line 한 줄(도트 게이지), rows 출처마다 한 줄, off; 예전 값 full·compact도 읽는다 */
170      limitsLayout: string
171      perf: SessionPerf
172
173      // ── 자동으로 열기 ──
174      /** 이 세션에서 IDE 창을 저절로 한 번 열었는지 (/reload-plugins 뒤에 다시 열지 않게) */
175      autoOpened: boolean
176
177      // ── 핸드오프 ──
178      /** ListAgents로 받은 다른 세션들 (핸드오프 화면을 열 때·r로 새로 받음) */
179      peers: PeerRow[]
180      /** 이 세션이 다른 세션에 불리는 이름 */
181      selfName: string
182      /** 마지막으로 보낸 핸드오프 결과 */
183      handoffSent: { to: string; at: number; ok: boolean; reason?: string } | null
184
185      // ── HWP 뷰어 ──
186      /** doc: 쪽을 글자 격자로(기본), image: rhwp가 그린 쪽 그림 */
187      hwpView: 'doc' | 'image'
188      hwpPages: Record<string, number>
189
190      // ── 강의 모드·문체 게이트 ──
191      isLecture: boolean
192      caption: Caption
193      gate: GateResult | null
194      isGateOn: boolean
195      isGateOpen: boolean
196    }
197  }
198}
199