SLOPSHOPPER

status-panel

Shows the status line's figures (location, model, context, rate limits) in a side pane

paneguardcommandtoastpromptprocess
★ 7v0.1.0no licenseupdated 2026-10-22LizeLing/status-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · status-panel
│ ┃ Status ✕ › fix the failing auth test and add an audit log call │ ┃ 상태 Git 달력 하네스 뉴스 파일 문서 │ ┃ ━━━━ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ /work/app ⏺ Update(src/auth.ts) │ ┃ claude-opus-5-5 · v2.1.289 ⎿ Added 2 lines, removed 1 line │ ┃ 30m · 프롬프트 1 · $0.42 ⏺ Bash(bun test) │ ┃ 세션 preview-session ⎿ 3 pass, 1 fail │ ┃ │ ┃ ▍사용량 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Current session │ ┃ ██████████████ ✻ Worked for 42s · done 4:20 PM │ ┃ Resets 9:53am (UTC) │ ┃ › /status-panel │ ┃ ▍컨텍스트 ⎿ status-panel: Status pane opened. │ ┃ ██████████████████████ │ ┃ 51% 남음 (102k) │ ┃ │ ┃ ┏━┓ ┏━┓ • ┏━╸ ╺━┓ │ ┃ ┃ ┃ ┣━┫ ┗━┓ ╺━┫ │ ┃ ┗━┛ ┗━┛ • ╺━┛ ╺━┛ │ ┃ 10월 9일 목요일 │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Status
상태 Git 달력 하네스 뉴스 파일 문서 ━━━━ /work/app claude-opus-5-5 · v2.1.289 30m · 프롬프트 1 · $0.42 세션 preview-session ▍사용량 Current session ██████████████ 31% used Resets 9:53am (UTC) ▍컨텍스트 ██████████████████████ 49% used 51% 남음 (102k) ┏━┓ ┏━┓ • ┏━╸ ╺━┓ ┃ ┃ ┣━┫ ┗━┓ ╺━┫ ┗━┛ ┗━┛ • ╺━┛ ╺━┛ 10월 9일 목요일
README

status-panel

Claude Code 세션 옆에 상태 패널을 띄우는 모드입니다. 상태 줄에 나오는 정보(계정 · 위치 · 모델 · 컨텍스트 · 사용량 한도)를 패널 하나에 모아 보여 주고, 탭을 바꿔 가며 Git 상태, 달력, 하네스(~/.claude 와 프로젝트의 설정), 뉴스, 프로젝트 파일, Claude Code 한국어 문서를 확인할 수 있습니다.

대화 옆에 붙은 status-panel 의 상태 탭

설치

터미널에서 연 Claude Code 세션의 프롬프트에 다음 명령을 입력합니다.

/plugin install status-panel --marketplace LizeLing/status-panel

마켓플레이스를 추가할지 물으면 y 를 누르고, 설치 범위를 고릅니다(user 범위를 권장합니다). 이어서 나오는 설정 화면에서 [열기] 버튼이 파일을 열 앱을 정할 수 있습니다. 비워 두면 macOS 기본 앱으로 엽니다.

셸에서 설치하려면 다음 명령을 실행합니다.

claude plugin install status-panel --marketplace LizeLing/status-panel

데스크톱 앱에서는 프롬프트 입력창 옆의 + → Plugins → Add marketplace 에 LizeLing/status-panel 을 입력한 뒤, Add plugin 으로 설치합니다.

패널 열기와 조작

  • 세션이 시작되면 패널이 저절로 열립니다. 패널을 닫았다면 /status-panel 명령으로 다시 엽니다.
  • 터미널이 전체 화면 모드(설정의 "tui": "fullscreen")이고 너비가 110칸 이상이면 패널이 대화 오른쪽에 붙습니다. 그렇지 않으면 프롬프트 위에 나타납니다.
  • 전체 화면 모드에서는 탭과 버튼을 마우스로 누를 수 있습니다.
  • 키보드로 다룰 때는 ctrl+x 다음 Tab 으로 패널에 포커스를 옮깁니다. 포커스가 있는 동안 Tab 으로 버튼 사이를 이동하고, Enter 로 누르고, 화살표 키로 패널을 스크롤합니다. Esc 를 누르면 포커스가 프롬프트로 돌아가고, ctrl+x 다음 x 를 누르면 패널이 닫힙니다.
  • 탭이 패널 너비에 다 들어가지 않으면 다음 줄로 넘어가 표시됩니다.

탭

상태

<img src="docs/screenshots/status-tab.png" width="340" alt="상태 탭">

대화 옆에 늘 띄워 두는 첫 화면입니다.

  • 로그인한 계정과 요금제, 작업 폴더와 브랜치, 모델 · effort · Claude Code 버전을 보여 줍니다.
  • 세션 시간, 보낸 프롬프트 수, 비용, 세션 ID 를 보여 줍니다.
  • 사용량: 현재 세션과 이번 주의 사용량 한도를 막대로 보여 주고, 초기화되는 시각을 함께 적습니다. 남은 양에 따라 막대 색이 초록 · 노랑 · 빨강으로 바뀝니다.
  • 컨텍스트: 컨텍스트 창을 얼마나 썼는지와, /context 처럼 시스템 프롬프트 · 도구 · 에이전트 · 메모리 파일 · 스킬 같은 항목별 토큰 수를 보여 줍니다.
  • 아래쪽에는 큰 시계와 오늘 날짜가 나옵니다.

Git

<img src="docs/screenshots/git.png" width="340" alt="Git 탭">

  • 스테이징 · 수정 · 새 파일 수와 추가 · 삭제한 줄 수, 원격 브랜치보다 앞서거나 뒤처진 커밋 수를 보여 줍니다.
  • 이어서 브랜치 목록, 최근 커밋, 변경된 파일(M 수정 · ?? 새 파일 등)을 나열합니다.

달력

<img src="docs/screenshots/calendar.png" width="340" alt="달력 탭">

  • 이번 달 달력에 오늘을 표시합니다.
  • 주간 사용량 한도가 초기화되는 날을 달력에 표시하고, 그 날짜와 시각을 아래에 적습니다.

하네스

<img src="docs/screenshots/harness.png" width="340" alt="하네스 탭">

~/.claude(글로벌)와 프로젝트(로컬)에 설정된 하네스를 종류별로 봅니다.

  • 범위는 글로벌 · 로컬 · 사용됨 · 활동 이고, 종류는 CLAUDE(CLAUDE.md) · Agent(서브에이전트) · Skill · MCP · Hook 입니다.
  • 항목을 누르면 아래 「세부 내용」에 파일 경로와 내용이 나옵니다. CLAUDE.md 와 스킬은 Markdown 으로 그리고, 훅은 실행하는 명령을 셸 문법에 맞춰 줄을 나눠 보여 줍니다.
  • [열기] 를 누르면 그 파일을 설정한 앱(기본은 macOS 기본 앱)으로 엽니다.
  • 사용됨 은 이 세션에서 실제로 불러온 항목만 모아 보여 주고, 범위와 종류 앞의 ● 는 이 세션에서 쓴 항목이 있다는 표시입니다.
  • 활동 은 이 세션의 도구 호출 수와 에이전트의 상태(실행 중 · 완료 · 실패 등)를 보여 줍니다.

뉴스

<img src="docs/screenshots/news.png" width="340" alt="뉴스 탭">

  • GeekNews · 인벤 · AI타임스 · GitHub 트렌딩 · claude.dev 블로그를 골라 최신 글을 10개씩 넘겨 봅니다. 글마다 올라온 지 얼마나 지났는지 함께 적습니다.
  • GitHub 트렌딩은 저장소 이름, 오늘 받은 별 수, 설명을 두 줄로 보여 줍니다.
  • 글을 누르면 「세부 내용」에 제목과 요약이 나옵니다. [열기] 는 브라우저로 글을 열고, [세션에 넣기] 는 글 주소를 프롬프트 입력란에 넣습니다.

파일

<img src="docs/screenshots/files.png" width="340" alt="파일 탭">

  • 프로젝트의 파일을 트리로 보여 줍니다. 폴더를 누르면 펼치거나 접습니다.
  • 에이전트가 이 세션에서 만들고(C) · 읽고(R) · 고치고(U) · 지운(D) 파일에 표시를 붙입니다. Bash 명령으로 바뀐 파일도 명령 전후를 비교해 찾아냅니다.
  • 전체 와 손댄 것만 으로 목록을 거를 수 있고, 파일을 누르면 그 파일에 무슨 일이 언제 있었는지 「세부 내용」에 나옵니다.

문서

<img src="docs/screenshots/docs-page.png" width="340" alt="문서 탭">

Claude Code 한국어 문서를 패널 안에서 읽습니다.

  • 위쪽에는 사이트의 섹션(시작하기 · Claude Code로 만들기 · 플러그인 · 관리 · 구성 · 레퍼런스 · Agent SDK · 새로운 기능 · 리소스)이 탭으로 나오고, 고른 섹션의 페이지가 그룹 제목 아래에 12줄씩 나옵니다.
  • 페이지를 누르면 아래에 제목과 한 줄 설명이 나오고, 본문을 Markdown 으로 그립니다. 긴 페이지는 ## 제목마다, 그리고 약 2400자마다 나눠 「◂ 이전 · 다음 ▸」로 넘깁니다.
  • 목차 를 누르면 본문 자리에 페이지의 제목 목록이 나오고, 제목을 누르면 그 부분으로 바로 넘어갑니다.
  • 본문에 있는 다른 문서 링크를 누르면 브라우저 대신 패널 안에서 그 문서로 넘어갑니다.
  • [열기] 는 브라우저로 페이지를 열고, [세션에 넣기] 는 페이지 주소를 프롬프트 입력란에 넣습니다. 변경 로그처럼 사이트 밖에 있는 문서는 [열기] 로 봅니다.
  • 본문이 패널보다 길면 패널에 포커스를 옮긴 뒤 화살표 키나 휠로 스크롤합니다.

설정

항목설명
[열기]로 여는 앱(openWith)하네스 탭의 [열기] 가 파일을 여는 앱 이름(예: Visual Studio Code). 비워 두면 macOS 기본 앱으로 엽니다.

읽는 정보와 네트워크

패널이 읽고 실행하는 것은 다음과 같습니다. 어떤 정보도 외부로 보내지 않습니다.

  • 명령: claude auth status(계정), git -C <작업 폴더> …(Git 탭), find(파일 목록과 Bash 명령 전후 비교), open([열기]).
  • 파일: ~/.claude 와 프로젝트의 CLAUDE.md · 에이전트 · 스킬 · 설정 파일(하네스 탭). MCP 서버는 실행 명령과 URL(쿼리 문자열 제외)만 표시하고, 환경 변수와 헤더는 이름만 표시합니다.
  • 기록: 패널이 보이는 동안 ~/.claude/status-panel/<세션 id> 에 마지막으로 확인한 시각을 기록합니다. 상태 줄 스크립트가 이 값을 읽어, 패널이 보일 때 같은 정보를 두 번 표시하지 않도록 할 수 있습니다.
  • 네트워크: 뉴스 탭은 탭을 열 때 각 사이트의 RSS 와 github.com/trending 페이지를 읽습니다. 문서 탭은 탭을 열 때 code.claude.com/docs/_llms/ko.txt 의 목록을, 페이지를 고를 때 그 페이지의 Markdown 을 읽습니다. 두 탭 모두 10분 동안은 다시 읽지 않으며, 「새로고침」을 누르면 바로 다시 읽습니다.

참고

  • 화면의 문구는 한국어입니다.
  • [열기] 버튼은 macOS 의 open 명령을 사용하므로 다른 OS 에서는 동작하지 않습니다.
  • README 의 스크린숏은 예시 계정(you@example.com)과 예시 프로젝트(~/projects/todo-app)로 찍은 화면입니다. 뉴스 · 문서 탭의 내용은 찍은 날(2026-10-22)의 사이트 내용이고, 사용량 막대는 찍을 때의 실제 값입니다.
Source 2 files
hooks/register.tsx 3209 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type {
5  Account,
6  Activity,
7  Article,
8  Breakdown,
9  Call,
10  Detail,
11  DocIndex,
12  DocPart,
13  DocSection,
14  DocText,
15  DocView,
16  Feed,
17  FileOp,
18  Files,
19  FileView,
20  Git,
21  Harness,
22  HarnessItem,
23  HarnessScope,
24  Hook,
25  Info,
26  Limit,
27  NewsView,
28  Used,
29} from '../types'
30
31const PANE = 'status-panel'
32const TITLE = 'Status'
33const COLUMNS = 41
34const BEAT_MS = 5000
35const BRIGHT_MAGENTA = '#e58cf5'
36const BRIGHT_BLUE = '#7ab8ff'
37
38const info = atom({ plugin: 'status-panel', key: 'info' } as const, null)
39const EMPTY: Activity = { calls: [], counts: {}, total: 0 }
40const activity = atom({ plugin: 'status-panel', key: 'activity' } as const, EMPTY)
41const tab = atom({ plugin: 'status-panel', key: 'tab' } as const, 'tab1')
42const scope = atom({ plugin: 'status-panel', key: 'scope' } as const, 'global')
43const kind = atom({ plugin: 'status-panel', key: 'kind' } as const, 'memory')
44const NO_USE: Used = { memory: {}, agents: {}, skills: {}, mcp: {}, hooks: {} }
45const used = atom({ plugin: 'status-panel', key: 'used' } as const, NO_USE)
46const page = atom({ plugin: 'status-panel', key: 'page' } as const, 0)
47const detail = atom({ plugin: 'status-panel', key: 'detail' } as const, null)
48
49const DOC_CHARS = 60000
50const PREVIEW_CHARS = 300
51const DETAIL_ROWS = 24
52const HOOK_ROWS = 16
53const INDENT_MAX = 8
54const ALL = '*'
55const LIST_ROWS = 10
56
57type Tab = { id: string; label: string }
58
59const HARNESS_TAB = 'tab6'
60const FILES_TAB = 'tab7'
61const NEWS_TAB = 'tab8'
62const DOCS_TAB = 'tab10'
63
64// Status, git, the calendar, the harness (what the user's `~/.claude` and the
65// project set up, and what the session did with it), news from a few feeds,
66// the project's files with what the agent did to each, and Claude Code's
67// documentation in Korean.
68const TABS: readonly Tab[] = [
69  { id: 'tab1', label: '상태' },
70  { id: 'tab2', label: 'Git' },
71  { id: 'tab5', label: '달력' },
72  { id: HARNESS_TAB, label: '하네스' },
73  { id: NEWS_TAB, label: '뉴스' },
74  { id: FILES_TAB, label: '파일' },
75  { id: DOCS_TAB, label: '문서' },
76]
77
78// Inside the harness tab: a row of scopes, then a row of kinds. After the two
79// scopes come what this session used (of either scope or of neither) and its
80// activity (tool calls and agents), which has no kinds.
81const SCOPES: readonly (Tab & { id: keyof Harness | 'used' | 'activity' })[] = [
82  { id: 'global', label: '글로벌' },
83  { id: 'project', label: '로컬' },
84  { id: 'used', label: '사용됨' },
85  { id: 'activity', label: '활동' },
86]
87
88const SECTIONS: readonly (Tab & { id: keyof HarnessScope; title: string })[] = [
89  { id: 'memory', label: 'CLAUDE', title: 'CLAUDE.md' },
90  { id: 'agents', label: 'Agent', title: 'SubAgent' },
91  { id: 'skills', label: 'Skill', title: 'Skills' },
92  { id: 'mcp', label: 'MCP', title: 'MCP' },
93  { id: 'hooks', label: 'Hook', title: 'Hook' },
94]
95
96const colorForLeft = (left: number) =>
97  left > 50 ? 'green' : left > 20 ? 'yellow' : 'red'
98
99// Cells a text takes on a terminal: Hangul and other wide forms take two.
100const cells = (text: string) =>
101  [...text].reduce((sum, char) => sum + ((char.codePointAt(0) ?? 0) >= 0x1100 ? 2 : 1), 0)
102
103// Cuts a text to the room it has, keeping its end (a path) or its start.
104const fit = (text: string, room: number, keep: 'start' | 'end') => {
105  if (cells(text) <= room) {
106    return text
107  }
108
109  const chars = keep === 'start' ? [...text] : [...text].reverse()
110  const kept: string[] = []
111  let used = 1
112
113  for (const char of chars) {
114    used += cells(char)
115
116    if (used > room) {
117      break
118    }
119
120    kept.push(char)
121  }
122
123  return keep === 'start' ? `${kept.join('')}…` : `…${kept.reverse().join('')}`
124}
125
126const two = (n: number) => String(n).padStart(2, '0')
127
128const resetLine = (limit: Limit | undefined, now: number) => {
129  if (limit?.resetsAt === undefined) {
130    return null
131  }
132
133  const at = new Date(limit.resetsAt)
134  const left = Math.floor((at.getTime() - now) / 1000)
135  const when = `${two(at.getMonth() + 1)}/${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`
136
137  if (left <= 0) {
138    return { text: `초기화 ${when}`, color: 'blue', at, eta: '' }
139  }
140
141  const days = Math.floor(left / 86400)
142  const hours = Math.floor((left % 86400) / 3600)
143  const eta = days > 0 ? `${days}d ${hours}h` : `${hours}h`
144  const color = left > 172800 ? 'blue' : left > 43200 ? 'yellow' : 'red'
145
146  return { text: `초기화 ${when} (${eta} 후)`, color, at, eta }
147}
148
149// Seven-segment digits in box-drawing strokes, three cells by three rows.
150const DIGITS: Record<string, readonly [string, string, string]> = {
151  '0': ['┏━┓', '┃ ┃', '┗━┛'],
152  '1': ['  ╻', '  ┃', '  ╹'],
153  '2': ['╺━┓', '┏━┛', '┗━╸'],
154  '3': ['╺━┓', '╺━┫', '╺━┛'],
155  '4': ['╻ ╻', '┗━┫', '  ╹'],
156  '5': ['┏━╸', '┗━┓', '╺━┛'],
157  '6': ['┏━╸', '┣━┓', '┗━┛'],
158  '7': ['╺━┓', '  ┃', '  ╹'],
159  '8': ['┏━┓', '┣━┫', '┗━┛'],
160  '9': ['┏━┓', '┗━┫', '╺━┛'],
161}
162
163// Two digits side by side, as the three rows that draw them.
164const bigDigits = (pair: string) =>
165  [0, 1, 2].map(line => [...pair].map(digit => DIGITS[digit]?.[line] ?? '   ').join(' '))
166
167const COLON = ['•', ' ', '•'] as const
168const FACE_CELLS = 17
169
170const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
171
172// A rate-limit window's name as /usage prints it; a window it does not know
173// (a per-model week, a gateway's spend limit) is named from its kind.
174const limitName = (kind: string) =>
175  kind === 'five_hour'
176    ? 'Current session'
177    : kind === 'seven_day'
178      ? 'Current week (all models)'
179      : kind.startsWith('seven_day_')
180        ? `Current week (${kind.slice('seven_day_'.length).replaceAll('_', ' ')})`
181        : kind.replaceAll('_', ' ')
182
183const timeZone = () => {
184  try {
185    return ` (${Intl.DateTimeFormat().resolvedOptions().timeZone})`
186  } catch {
187    return ''
188  }
189}
190
191// `Resets 12am (Asia/Seoul)` today, `Resets Oct 13 at 8am (Asia/Seoul)` after.
192const resetsLabel = (resetsAt: string, now: number) => {
193  const at = new Date(resetsAt)
194  const today = new Date(now)
195  const hour = `${at.getHours() % 12 || 12}${at.getMinutes() === 0 ? '' : `:${two(at.getMinutes())}`}${at.getHours() < 12 ? 'am' : 'pm'}`
196  const isSameDay =
197    at.getFullYear() === today.getFullYear() && at.getMonth() === today.getMonth() && at.getDate() === today.getDate()
198
199  return isSameDay
200    ? `Resets ${hour}${timeZone()}`
201    : `Resets ${MONTHS[at.getMonth()] ?? ''} ${at.getDate()} at ${hour}${timeZone()}`
202}
203
204const WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토']
205
206const dayLabel = (at: Date) =>
207  `${two(at.getMonth() + 1)}/${two(at.getDate())} (${WEEKDAYS[at.getDay()] ?? ''})`
208
209type Day = { day: number; isToday: boolean; isReset: boolean; isNextMonth: boolean }
210
211// This month in weeks from Sunday; a reset in the next month adds that
212// month's days up to the end of the reset's week, so it is always on the grid.
213const calendarWeeks = (today: Date, reset: Date | null) => {
214  const year = today.getFullYear()
215  const month = today.getMonth()
216  const lead = new Date(year, month, 1).getDay()
217  const length = new Date(year, month + 1, 0).getDate()
218  const next = new Date(year, month + 1, 1)
219  const isResetNext =
220    reset !== null && reset.getFullYear() === next.getFullYear() && reset.getMonth() === next.getMonth()
221  const isResetHere = reset !== null && reset.getFullYear() === year && reset.getMonth() === month
222  const cells: (Day | null)[] = Array.from({ length: lead }, () => null)
223
224  for (let day = 1; day <= length; day += 1) {
225    cells.push({
226      day,
227      isToday: day === today.getDate(),
228      isReset: isResetHere && day === reset.getDate(),
229      isNextMonth: false,
230    })
231  }
232
233  if (isResetNext) {
234    const last = reset.getDate() + (6 - reset.getDay())
235
236    for (let day = 1; day <= last; day += 1) {
237      cells.push({ day, isToday: false, isReset: day === reset.getDate(), isNextMonth: true })
238    }
239  }
240
241  while (cells.length % 7 !== 0) {
242    cells.push(null)
243  }
244
245  return Array.from({ length: cells.length / 7 }, (_, week) => cells.slice(week * 7, week * 7 + 7))
246}
247
248const effortColor = (effort: string) =>
249  ({ low: 'gray', medium: 'green', high: 'yellow', xhigh: BRIGHT_MAGENTA, max: 'red' })[effort] ?? 'cyan'
250
251let effort = ''
252let breakdown: Breakdown | null = null
253let git: Git | null = null
254let branch = ''
255let home = ''
256let account: Account | null = null
257let harness: Harness | null = null
258
259const readHome = async ($: EngineInterface) => {
260  if (home === '') {
261    home = (await $.env.get('HOME')) ?? ''
262  }
263
264  return home
265}
266
267// The signed-in account, as `claude auth status` reports it.
268const readAccount = async ($: EngineInterface): Promise<Account | null> => {
269  try {
270    const ran = await $.process.run(['claude', 'auth', 'status'], { timeoutMs: 10000 })
271    const raw = JSON.parse(ran.stdout) as { loggedIn?: boolean; email?: string; subscriptionType?: string }
272
273    if (raw.loggedIn !== true) {
274      return { error: '로그인되어 있지 않습니다' }
275    }
276
277    return { email: raw.email ?? '(알 수 없음)', plan: raw.subscriptionType }
278  } catch {
279    return account
280  }
281}
282
283const accountLine = (one: Account) =>
284  'error' in one ? one.error : `${one.email}${one.plan === undefined ? '' : ` (${one.plan})`}`
285
286const readBranch = async ($: EngineInterface, cwd: string) => {
287  try {
288    if ((await $.session.repo()) === null) {
289      return ''
290    }
291
292    const head = await $.process.run(['git', '-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], { timeoutMs: 3000 })
293    const name = head.exitCode === 0 ? head.stdout.trim() : ''
294
295    if (name !== 'HEAD') {
296      return name
297    }
298
299    const short = await $.process.run(['git', '-C', cwd, 'rev-parse', '--short', 'HEAD'], { timeoutMs: 3000 })
300
301    return short.exitCode === 0 ? short.stdout.trim() : ''
302  } catch {
303    return ''
304  }
305}
306
307// `git status --porcelain=v2 --branch` and `git diff --numstat HEAD`, as counts.
308const parseGit = (status: string, numstat: string): Git => {
309  const found: Git = {
310    staged: 0,
311    unstaged: 0,
312    untracked: 0,
313    added: 0,
314    deleted: 0,
315    ahead: 0,
316    behind: 0,
317    files: [],
318    branches: [],
319    commits: [],
320  }
321
322  for (const line of status.split('\n')) {
323    const ab = /^# branch\.ab \+(\d+) -(\d+)/.exec(line)
324
325    if (ab !== null) {
326      found.ahead = Number(ab[1])
327      found.behind = Number(ab[2])
328    } else if (line.startsWith('? ')) {
329      found.untracked += 1
330      found.files.push({ code: '??', path: line.slice(2) })
331    } else if (/^[12u] /.test(line)) {
332      const code = line.slice(2, 4)
333      const fields = line.startsWith('2 ') ? 9 : line.startsWith('u ') ? 10 : 8
334      const path = (line.split(' ').slice(fields).join(' ').split('\t')[0] ?? '').trim()
335
336      found.staged += code[0] === '.' ? 0 : 1
337      found.unstaged += code[1] === '.' ? 0 : 1
338      found.files.push({ code: code.replaceAll('.', ' '), path })
339    }
340  }
341
342  for (const line of numstat.split('\n')) {
343    const [added, deleted] = line.split('\t')
344
345    found.added += Number(added) || 0
346    found.deleted += Number(deleted) || 0
347  }
348
349  return found
350}
351
352const readGit = async ($: EngineInterface, cwd: string): Promise<Git | null> => {
353  if ((await $.session.repo()) === null) {
354    return null
355  }
356
357  const status = await $.process.run(['git', '-C', cwd, 'status', '--porcelain=v2', '--branch'], { timeoutMs: 5000 })
358
359  if (status.exitCode !== 0) {
360    return null
361  }
362
363  const numstat = await $.process.run(['git', '-C', cwd, 'diff', '--numstat', 'HEAD'], { timeoutMs: 5000 })
364
365  const found = parseGit(status.stdout, numstat.exitCode === 0 ? numstat.stdout : '')
366  // Local branches, the most recently committed first, and the last three commits.
367  const branches = await $.process.run(
368    ['git', '-C', cwd, 'branch', '--sort=-committerdate', '--format=%(HEAD)%09%(refname:short)'],
369    { timeoutMs: 5000 },
370  )
371  const log = await $.process.run(['git', '-C', cwd, 'log', '-3', '--format=%h%x09%s'], { timeoutMs: 5000 })
372
373  if (branches.exitCode === 0) {
374    found.branches = branches.stdout
375      .split('\n')
376      .filter(line => line.includes('\t'))
377      .map(line => ({ name: line.slice(line.indexOf('\t') + 1).trim(), isCurrent: line.startsWith('*') }))
378  }
379
380  if (log.exitCode === 0) {
381    found.commits = log.stdout
382      .split('\n')
383      .filter(line => line.includes('\t'))
384      .map(line => ({ hash: line.slice(0, line.indexOf('\t')), subject: line.slice(line.indexOf('\t') + 1) }))
385  }
386
387  return found
388}
389
390const EMPTY_SCOPE: HarnessScope = { memory: [], agents: [], skills: [], mcp: [], hooks: [] }
391
392const isItem = (one: HarnessItem | null): one is HarnessItem => one !== null
393
394// The `.md` files right under a directory, by name; none when it is missing.
395// A symbolic link is listed as `other`, and a linked file counts as one.
396const markdownIn = async ($: EngineInterface, dir: string) =>
397  (await $.fs.list(dir).catch(() => []))
398    .filter(one => (one.kind === 'file' || one.isLink) && one.name.endsWith('.md'))
399    .map(one => one.name)
400    .sort()
401
402// A value of a file's leading `---` block.
403const frontmatter = (text: string, key: string) =>
404  new RegExp(`^${key}:\\s*(.+)$`, 'm').exec(/^---\n([\s\S]*?)\n---/.exec(text)?.[1] ?? '')?.[1]?.trim()
405
406const IMPORTED = '불러온 파일'
407const IMPORT_DEPTH = 3
408const IMPORTS_PER_FILE = 20
409
410type Named = { name: string; path: string; group?: string }
411
412type Loaded = Named & { note: string; text: string }
413
414// Those of some files that exist, each with its line and character counts
415// and its text.
416const loadFiles = async ($: EngineInterface, files: Named[]) => {
417  const found = await Promise.all(
418    files.map(async (one): Promise<Loaded | null> => {
419      const text = await $.fs.read(one.path).catch(() => null)
420
421      return text === null ? null : { ...one, note: `${text.split('\n').length}줄 ${tokens(text.length)}자`, text }
422    }),
423  )
424
425  return found.filter((one): one is Loaded => one !== null)
426}
427
428// The `.md` files of a folder, to be listed as a section named after it.
429const folderFiles = async ($: EngineInterface, dir: string, group: string): Promise<Named[]> =>
430  (await markdownIn($, dir)).map(name => ({ name, path: `${dir}/${name}`, group }))
431
432// A path with its `.` and `..` segments worked out.
433const normal = (path: string) =>
434  path
435    .split('/')
436    .reduce<string[]>(
437      (kept, part) => (part === '.' ? kept : part === '..' && kept.length > 1 ? kept.slice(0, -1) : [...kept, part]),
438      [],
439    )
440    .join('/')
441
442// The files a text names with `@path`, outside its code, as absolute paths;
443// one that names no file is found out when it is read.
444const importsOf = (text: string, dir: string) =>
445  [...text.replace(/```[\s\S]*?```/g, '').replace(/`[^`\n]*`/g, '').matchAll(/(?:^|\s)@([^\s]+)/g)]
446    .map(found => (found[1] ?? '').replace(/[),;:'"]+$/, '').replace(/\.$/, ''))
447    .filter(name => name !== '')
448    .slice(0, IMPORTS_PER_FILE)
449    .map(name => normal(name.startsWith('~/') ? `${home}${name.slice(1)}` : name.startsWith('/') ? name : `${dir}/${name}`))
450
451// A long path with its middle folders left out: `~/.config/…/guide.md`.
452const brief = (path: string) => {
453  const parts = short(path).split('/')
454
455  return parts.length > 4 ? [...parts.slice(0, 2), '…', ...parts.slice(-1)].join('/') : parts.join('/')
456}
457
458// The files some instruction files import, and those these import in turn.
459const readImports = async ($: EngineInterface, from: Loaded[]) => {
460  const seen = new Set(from.map(one => one.path))
461  const found: Loaded[] = []
462  let next = from
463
464  for (let depth = 0; depth < IMPORT_DEPTH && next.length > 0; depth += 1) {
465    const paths = [
466      ...new Set(next.flatMap(one => importsOf(one.text, one.path.slice(0, one.path.lastIndexOf('/'))))),
467    ].filter(path => !seen.has(path))
468
469    for (const path of paths) {
470      seen.add(path)
471    }
472
473    next = await loadFiles($, paths.map(path => ({ name: brief(path), path, group: IMPORTED })))
474    found.push(...next)
475  }
476
477  return found
478}
479
480// The instruction files that exist, each with its line count: the scope's
481// own, those of its folders a section apiece, and those all of them import.
482const readMemory = async ($: EngineInterface, files: Named[], folders: Named[][]): Promise<HarnessItem[]> => {
483  const own = await loadFiles($, [...files, ...folders.flat()])
484
485  return [...own, ...(await readImports($, own))].map(one => ({
486    name: one.name,
487    note: one.note,
488    path: one.path,
489    group: one.group,
490  }))
491}
492
493// The agent files of a `.claude` folder, each with the model it names.
494const readAgents = async ($: EngineInterface, dir: string): Promise<HarnessItem[]> =>
495  Promise.all(
496    (await markdownIn($, `${dir}/agents`)).map(async file => {
497      const text = await $.fs.read(`${dir}/agents/${file}`).catch(() => '')
498
499      return {
500        name: frontmatter(text, 'name') ?? file.slice(0, -3),
501        note: frontmatter(text, 'model'),
502        path: `${dir}/agents/${file}`,
503      }
504    }),
505  )
506
507// The skill folders (those holding a SKILL.md) and the command files.
508const readSkills = async ($: EngineInterface, dir: string): Promise<HarnessItem[]> => {
509  const folders = (await $.fs.list(`${dir}/skills`).catch(() => [])).filter(one => one.kind === 'dir' || one.isLink)
510  const skills = await Promise.all(
511    folders.map(async (one): Promise<HarnessItem | null> => {
512      const path = `${dir}/skills/${one.name}/SKILL.md`
513
514      return (await $.fs.exists(path).catch(() => false)) ? { name: one.name, path } : null
515    }),
516  )
517  const commands = (await markdownIn($, `${dir}/commands`)).map(file => ({
518    name: `/${file.slice(0, -3)}`,
519    note: '명령',
520    path: `${dir}/commands/${file}`,
521  }))
522
523  return [...skills.filter(isItem).sort((one, other) => one.name.localeCompare(other.name)), ...commands]
524}
525
526type Servers = Record<
527  string,
528  {
529    type?: string
530    command?: string
531    args?: string[]
532    url?: string
533    env?: Record<string, string>
534    headers?: Record<string, string>
535  }
536>
537
538// A server's details: how it is started or reached, and the names (never the
539// values) of the variables and headers it is given.
540const serverItems = (found: Servers | undefined, path: string): HarnessItem[] =>
541  Object.entries(found ?? {}).map(([name, one]) => {
542    const env = Object.keys(one.env ?? {})
543    const headers = Object.keys(one.headers ?? {})
544    const lines = [
545      ...(one.command === undefined ? [] : [[one.command, ...(one.args ?? [])].join(' ')]),
546      ...(one.url === undefined ? [] : [one.url.split('?')[0] ?? one.url]),
547      ...(env.length === 0 ? [] : [`env: ${env.join(', ')}`]),
548      ...(headers.length === 0 ? [] : [`headers: ${headers.join(', ')}`]),
549    ]
550
551    return {
552      name,
553      note: one.type ?? (one.command !== undefined ? 'stdio' : one.url !== undefined ? 'http' : undefined),
554      lines: lines.length === 0 ? ['세부 정보가 없습니다.'] : lines,
555      path,
556    }
557  })
558
559type HookGroups = Record<
560  string,
561  { matcher?: string; hooks?: { type?: string; command?: string; prompt?: string; url?: string }[] }[]
562>
563
564// The hooks of some settings sources by event: how many, and for each its
565// matcher and what it runs; an event's file is the first source's that has it.
566const hookItems = (sources: { settings: unknown; path: string }[]): HarnessItem[] => {
567  const found: Record<string, Hook[]> = {}
568  const files: Record<string, string> = {}
569
570  for (const source of sources) {
571    for (const [event, groups] of Object.entries((source.settings as { hooks?: HookGroups }).hooks ?? {})) {
572      files[event] ??= source.path
573
574      for (const group of groups) {
575        for (const one of group.hooks ?? []) {
576          found[event] = [
577            ...(found[event] ?? []),
578            {
579              matcher: group.matcher === undefined || group.matcher === '' ? ALL : group.matcher,
580              type: one.type ?? 'command',
581              command: (one.command ?? one.prompt ?? one.url ?? '').trim(),
582            },
583          ]
584        }
585      }
586    }
587  }
588
589  return Object.entries(found).map(([name, hooks]) => ({ name, note: `${hooks.length}개`, hooks, path: files[name] }))
590}
591
592// Numbers the commands that hooks of two events or more run alike, in the
593// order they come, and marks each hook running one.
594const markShared = (found: Harness): Harness => {
595  const events: Record<string, Set<string>> = {}
596
597  for (const item of [...found.global.hooks, ...found.project.hooks]) {
598    for (const one of item.hooks ?? []) {
599      events[one.command] = (events[one.command] ?? new Set()).add(item.name)
600    }
601  }
602
603  const shared = Object.keys(events).filter(command => command !== '' && (events[command]?.size ?? 0) > 1)
604  const marked = (items: HarnessItem[]) =>
605    items.map(item => ({
606      ...item,
607      hooks: item.hooks?.map(one =>
608        shared.includes(one.command)
609          ? { ...one, shared: { index: shared.indexOf(one.command) + 1, events: events[one.command]?.size ?? 0 } }
610          : one,
611      ),
612    }))
613
614  return {
615    global: { ...found.global, hooks: marked(found.global.hooks) },
616    project: { ...found.project, hooks: marked(found.project.hooks) },
617  }
618}
619
620// A shell command in the pieces a line is made of: words (a quoted stretch
621// kept whole), and `;`, `;;` and a line's end apiece.
622const shellTokens = (command: string) => {
623  const tokens: string[] = []
624  let word = ''
625  let quote = ''
626
627  const end = () => {
628    if (word !== '') {
629      tokens.push(word)
630      word = ''
631    }
632  }
633
634  for (let at = 0; at < command.length; at += 1) {
635    const char = command[at] ?? ''
636
637    if (quote !== '') {
638      word += char
639      quote = char === quote ? '' : quote
640    } else if (char === '\\') {
641      word += char + (command[at + 1] ?? '')
642      at += 1
643    } else if (char === "'" || char === '"') {
644      word += char
645      quote = char
646    } else if (char === ';') {
647      end()
648      tokens.push(command[at + 1] === ';' ? ';;' : ';')
649      at += command[at + 1] === ';' ? 1 : 0
650    } else if (char === '\n') {
651      end()
652      tokens.push(';')
653    } else if (char === ' ' || char === '\t') {
654      end()
655    } else {
656      word += char
657    }
658  }
659
660  end()
661
662  return tokens
663}
664
665// A shell command as lines to read: one statement a line, what an `if`, a
666// loop, a `case` and its branches, and braces hold set in. Only how it is
667// shown; the command is not changed, and a script it cannot follow is still
668// all there, laid out less well.
669const shellLines = (command: string) => {
670  const tokens = shellTokens(command)
671  const lines: { depth: number; text: string }[] = []
672  const cases: number[] = []
673  let line: string[] = []
674  let depth = 0
675  let isPattern = false
676
677  const flush = () => {
678    if (line.length > 0) {
679      lines.push({ depth, text: line.join(' ') })
680      line = []
681    }
682  }
683
684  tokens.forEach((token, at) => {
685    const next = tokens[at + 1]
686
687    if (token === ';') {
688      // `; then` and `; do` stay on the line they close.
689      if ((next === 'then' || next === 'do') && line.length > 0) {
690        line[line.length - 1] += ';'
691      } else {
692        flush()
693      }
694    } else if (token === ';;') {
695      flush()
696      depth = (cases[cases.length - 1] ?? depth - 2) + 1
697      isPattern = true
698    } else if (line.length === 0 && (token === 'fi' || token === 'done' || token === '}')) {
699      depth = Math.max(0, depth - 1)
700      line.push(token)
701    } else if (line.length === 0 && token === 'esac') {
702      depth = Math.max(0, cases.pop() ?? depth - 1)
703      isPattern = false
704      line.push(token)
705    } else if (line.length === 0 && (token === 'else' || token === 'elif')) {
706      depth = Math.max(0, depth - 1)
707      line.push(token)
708
709      if (token === 'else') {
710        flush()
711        depth += 1
712      }
713    } else {
714      line.push(token)
715
716      if (token === 'then' || token === 'do' || (token === '{' && line.length === 1)) {
717        flush()
718        depth += 1
719      } else if (token === 'in' && line[0] === 'case') {
720        flush()
721        cases.push(depth)
722        depth += 1
723        isPattern = true
724      } else if (isPattern && line.length === 1 && token.endsWith(')')) {
725        flush()
726        depth += 1
727        isPattern = false
728      }
729    }
730  })
731
732  flush()
733
734  return lines
735}
736
737const CIRCLED = '①②③④⑤⑥⑦⑧⑨'
738
739// A text in rows of at most `room` cells, broken between its words; a word
740// longer than a row is cut where the row ends.
741const wrapWords = (text: string, room: number) => {
742  const rows: string[] = []
743  let line = ''
744
745  for (const word of text.split(' ')) {
746    if (line !== '' && cells(line) + 1 + cells(word) <= room) {
747      line = `${line} ${word}`
748    } else {
749      if (line !== '') {
750        rows.push(line)
751      }
752
753      const pieces = wrap(word, room)
754      rows.push(...pieces.slice(0, -1))
755      line = pieces[pieces.length - 1] ?? ''
756    }
757  }
758
759  return [...rows, line]
760}
761
762const short = (path: string) => (home !== '' && path.startsWith(home) ? `~${path.slice(home.length)}` : path)
763
764const FILES_MAX = 20000
765const FILE_ROWS = 20
766const OPS_MAX = 20
767const SCAN_MAX = 300
768const INDENT_FILES = 12
769
770// Folders a walk of a project that is no git repository stays out of.
771const PRUNED = ['.git', 'node_modules', '.build', 'dist', '.venv', '__pycache__', 'DerivedData']
772const OPS = ['C', 'R', 'U', 'D'] as const
773const OP_COLORS: Record<string, string> = { C: 'green', R: BRIGHT_BLUE, U: 'yellow', D: 'red' }
774const NO_VIEW: FileView = { filter: 'all', open: [], picked: '', page: 0 }
775
776const files = atom({ plugin: 'status-panel', key: 'files' } as const, null)
777const touched = atom({ plugin: 'status-panel', key: 'touched' } as const, {})
778const fileView = atom({ plugin: 'status-panel', key: 'fileView' } as const, NO_VIEW)
779
780// `find` over a project's files, the pruned folders left out; `more` narrows it.
781const findArgs = (root: string, more: string[]) => [
782  'find',
783  root,
784  '-type',
785  'd',
786  '(',
787  ...PRUNED.flatMap((name, at) => (at === 0 ? ['-name', name] : ['-o', '-name', name])),
788  ')',
789  '-prune',
790  '-o',
791  '-type',
792  'f',
793  '!',
794  '-name',
795  '.DS_Store',
796  ...more,
797  '-print',
798]
799
800// The lines of a listing that are under the root, as paths from it.
801const under = (root: string, listing: string) =>
802  listing
803    .split('\n')
804    .filter(line => line.startsWith(`${root}/`))
805    .map(line => line.slice(root.length + 1))
806
807// Reads the project's files: those git knows and does not ignore where the
808// project is a repository, else a walk of its folders.
809const readProject = async ($: EngineInterface): Promise<Files | null> => {
810  const root = await $.session.root()
811  let paths: string[] | null = null
812  let isCut = false
813
814  if ((await $.session.repo().catch(() => null)) !== null) {
815    const listed = await $.process
816      .run(['git', '-C', root, 'ls-files', '-z', '--cached', '--others', '--exclude-standard'], { timeoutMs: 10000 })
817      .catch(() => null)
818
819    if (listed !== null && listed.exitCode === 0) {
820      // The index still lists a file removed from the folder.
821      const gone = await $.process
822        .run(['git', '-C', root, 'ls-files', '-z', '--deleted'], { timeoutMs: 10000 })
823        .catch(() => null)
824      const removed = new Set((gone !== null && gone.exitCode === 0 ? gone.stdout : '').split('\0'))
825      paths = listed.stdout.split('\0').filter(path => path !== '' && !removed.has(path))
826      isCut = listed.isStdoutTruncated
827    }
828  }
829
830  if (paths === null) {
831    const found = await $.process.run(findArgs(root, []), { timeoutMs: 15000 })
832    paths = under(root, found.stdout)
833    isCut = found.isStdoutTruncated
834  }
835
836  const sorted = [...new Set(paths)].sort()
837  const next: Files = { root, paths: sorted.slice(0, FILES_MAX), isPartial: isCut || sorted.length > FILES_MAX }
838  await update($, files, () => next)
839
840  return next
841}
842
843// Records what was done to some files, the last few a file kept.
844const noteOps = ($: EngineInterface, ops: [string, FileOp][]) =>
845  update($, touched, now => {
846    const next = { ...now }
847
848    for (const [path, op] of ops) {
849      next[path] = [...(next[path] ?? []), op].slice(-OPS_MAX)
850    }
851
852    return next
853  }).catch(() => undefined)
854
855// The file a tool call reads or writes, as the call names it.
856const fileOf = (e: unknown) => {
857  const one = e as { tool?: string; file_path?: unknown; notebook_path?: unknown }
858  const path = one.tool === 'NotebookEdit' ? one.notebook_path : one.file_path
859
860  return ['Read', 'Write', 'Edit', 'NotebookEdit'].includes(one.tool ?? '') && typeof path === 'string' ? path : null
861}
862
863// A path as one from the project's root; none when it is outside it.
864const within = (root: string, path: string) => {
865  const whole = normal(path.startsWith('/') ? path : `${root}/${path}`)
866
867  return root !== '' && whole.startsWith(`${root}/`) ? whole.slice(root.length + 1) : null
868}
869
870// A time as `find -newermt` reads it, in the local zone.
871const findTime = (ms: number) => {
872  const at = new Date(ms)
873
874  return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}:${two(at.getSeconds())}`
875}
876
877let scans: Promise<unknown> = Promise.resolve()
878
879// What a shell command did to the project's files cannot be read off it, so
880// the files are compared across it: one there only after was made, one there
881// only before removed, and one written since it began changed. Another
882// program writing meanwhile is counted too.
883const scan = async ($: EngineInterface, since: number, tool: string) => {
884  const before = await read($, files)
885  const root = await $.session.root()
886  const written = await $.process
887    .run(findArgs(root, ['-newermt', findTime(since - 1000)]), { timeoutMs: 15000 })
888    .catch(() => null)
889  const after = await readProject($).catch(() => null)
890
891  if (before === null || after === null || before.root !== after.root) {
892    return
893  }
894
895  const was = new Set(before.paths)
896  const is = new Set(after.paths)
897  const at = await $.clock.now()
898  const made = after.paths.filter(path => !was.has(path))
899  const removed = before.paths.filter(path => !is.has(path))
900  const changed = under(root, written?.stdout ?? '').filter(path => was.has(path) && is.has(path))
901  const ops: [string, FileOp][] = [
902    ...made.map((path): [string, FileOp] => [path, { op: 'C', at, tool }]),
903    ...removed.map((path): [string, FileOp] => [path, { op: 'D', at, tool }]),
904    ...changed.map((path): [string, FileOp] => [path, { op: 'U', at, tool }]),
905  ]
906  await noteOps($, ops.slice(0, SCAN_MAX))
907}
908
909type FileRow = { path: string; name: string; depth: number; isDir: boolean; isOpen: boolean; ops: string[] }
910
911const opsOf = (found: FileOp[] | undefined) => OPS.filter(op => (found ?? []).some(one => one.op === op))
912
913// The files tab's rows: folders before files, a folder's own under it when
914// it is open, each with what was done to it (a folder: to anything in it).
915// The touched view shows those files alone, every folder open.
916const fileRows = (paths: string[], marks: Record<string, FileOp[]>, view: FileView) => {
917  const marked = Object.keys(marks)
918  const isTouched = view.filter === 'touched'
919  const shown = isTouched ? marked : [...new Set([...paths, ...marked])]
920  const folders = new Map<string, { folders: Set<string>; files: string[] }>()
921  const inside = new Map<string, Set<string>>()
922  const folder = (path: string) => {
923    const found = folders.get(path) ?? { folders: new Set<string>(), files: [] }
924    folders.set(path, found)
925
926    return found
927  }
928
929  for (const path of shown) {
930    const parts = path.split('/')
931
932    parts.slice(0, -1).forEach((_, at) => {
933      folder(parts.slice(0, at).join('/')).folders.add(parts.slice(0, at + 1).join('/'))
934    })
935    folder(parts.slice(0, -1).join('/')).files.push(path)
936  }
937
938  for (const path of marked) {
939    const parts = path.split('/')
940
941    parts.slice(0, -1).forEach((_, at) => {
942      const key = parts.slice(0, at + 1).join('/')
943      const found = inside.get(key) ?? new Set<string>()
944      inside.set(key, found)
945
946      for (const op of opsOf(marks[path])) {
947        found.add(op)
948      }
949    })
950  }
951
952  const rows: FileRow[] = []
953  const walk = (path: string, depth: number) => {
954    const found = folders.get(path)
955
956    for (const each of [...(found?.folders ?? [])].sort()) {
957      const isOpen = isTouched || view.open.includes(each)
958      rows.push({
959        path: each,
960        name: `${each.slice(each.lastIndexOf('/') + 1)}/`,
961        depth,
962        isDir: true,
963        isOpen,
964        ops: OPS.filter(op => inside.get(each)?.has(op) === true),
965      })
966
967      if (isOpen) {
968        walk(each, depth + 1)
969      }
970    }
971
972    for (const each of [...(found?.files ?? [])].sort()) {
973      rows.push({
974        path: each,
975        name: each.slice(each.lastIndexOf('/') + 1),
976        depth,
977        isDir: false,
978        isOpen: false,
979        ops: opsOf(marks[each]),
980      })
981    }
982  }
983
984  walk('', 0)
985
986  return rows
987}
988
989// Counts one use of a piece of the harness, or with `once` marks it used
990// without adding to a count it already has.
991const bump = ($: EngineInterface, kind: keyof HarnessScope, key: string, once = false) =>
992  update($, used, now => ({
993    ...now,
994    [kind]: { ...now[kind], [key]: once ? Math.max(now[kind][key] ?? 0, 1) : (now[kind][key] ?? 0) + 1 },
995  })).catch(() => undefined)
996
997// What a settings hook's matcher is tried on, of a classic event's input.
998const subjectOf = (e: unknown) => {
999  const one = e as Record<string, unknown>
1000  const found = one.tool ?? one.tool_name ?? one.source ?? one.trigger ?? one.agent_type ?? one.notification_type
1001
1002  return typeof found === 'string' ? found : ''
1003}
1004
1005// Whether a settings hook's matcher takes the subject: none and `*` take all.
1006const matches = (matcher: string, subject: string) => {
1007  if (matcher === '' || matcher === '*') {
1008    return true
1009  }
1010
1011  try {
1012    return new RegExp(`^(?:${matcher})$`).test(subject)
1013  } catch {
1014    return matcher === subject
1015  }
1016}
1017
1018// How often an event fired with a subject one of the matchers takes: an
1019// estimate of the settings hooks' runs, which the engine does not report.
1020const hookRuns = (counts: Record<string, number>, event: string, matchers: string[]) =>
1021  Object.entries(counts).reduce((sum, [key, count]) => {
1022    const [name = '', subject = ''] = key.split('\t')
1023
1024    return name === event && matchers.some(one => matches(one, subject)) ? sum + count : sum
1025  }, 0)
1026
1027// A server's name as tool names spell it (`mcp__<server>__<tool>`).
1028const spelled = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, '_')
1029
1030const serverCalls = (counts: Record<string, number>, server: string) =>
1031  Object.entries(counts).reduce((sum, [key, count]) => (key.split('\t')[0] === server ? sum + count : sum), 0)
1032
1033// How often this session used an item of the lists.
1034const usesOf = (now: Used, kind: keyof HarnessScope, one: HarnessItem) =>
1035  kind === 'memory'
1036    ? (now.memory[one.path ?? ''] ?? 0)
1037    : kind === 'agents'
1038      ? (now.agents[one.name] ?? 0)
1039      : kind === 'skills'
1040        ? (now.skills[one.name.replace(/^\//, '')] ?? 0)
1041        : kind === 'mcp'
1042          ? serverCalls(now.mcp, spelled(one.name))
1043          : hookRuns(now.hooks, one.name, (one.hooks ?? []).map(each => each.matcher))
1044
1045const times = (count: number) => `${count}회`
1046
1047// What this session used of a kind, as items: those the lists hold keep their
1048// file to preview, and one they do not (a plugin's, a connector) says so.
1049const usedItems = (now: Used, kind: keyof HarnessScope, found: Harness | null): HarnessItem[] => {
1050  const known = found === null ? [] : [...found.global[kind], ...found.project[kind]]
1051
1052  if (kind === 'memory') {
1053    return Object.entries(now.memory).map(([path, count]) => ({ name: short(path), note: times(count), path }))
1054  }
1055
1056  if (kind === 'mcp') {
1057    const servers = [...new Set(Object.keys(now.mcp).map(key => key.split('\t')[0] ?? ''))]
1058
1059    return servers.map(server => ({
1060      path: known.find(one => spelled(one.name) === server)?.path,
1061      name: known.find(one => spelled(one.name) === server)?.name ?? server,
1062      note: times(serverCalls(now.mcp, server)),
1063      lines: Object.entries(now.mcp)
1064        .filter(([key]) => key.split('\t')[0] === server)
1065        .sort((one, other) => other[1] - one[1])
1066        .map(([key, count]) => `${key.split('\t')[1] ?? ''} · ${times(count)}`),
1067    }))
1068  }
1069
1070  if (kind === 'hooks') {
1071    const events = [...new Set(known.map(one => one.name))]
1072
1073    return events
1074      .map(event => {
1075        const hooks = known.filter(one => one.name === event).flatMap(one => one.hooks ?? [])
1076
1077        return {
1078          name: event,
1079          path: known.find(one => one.name === event)?.path,
1080          count: hookRuns(now.hooks, event, hooks.map(one => one.matcher)),
1081          hooks: hooks.map(one => ({ ...one, count: hookRuns(now.hooks, event, [one.matcher]) })),
1082        }
1083      })
1084      .filter(one => one.count > 0)
1085      .map(one => ({ name: one.name, note: times(one.count), hooks: one.hooks, path: one.path }))
1086  }
1087
1088  return Object.entries(now[kind]).map(([name, count]) => {
1089    const path = known.find(one => one.name.replace(/^\//, '') === name)?.path
1090
1091    return path === undefined
1092      ? { name, note: times(count), lines: ['목록에 없는 항목입니다.'] }
1093      : { name, note: times(count), path }
1094  })
1095}
1096
1097// The instruction files the engine reads as a session starts, as far as they
1098// can be told without its load events: the user's own and those above the
1099// working directory, each with what it imports.
1100const readLoaded = async ($: EngineInterface) => {
1101  const own = `${await readHome($)}/.claude/CLAUDE.md`
1102  const above = await $.fs
1103    .ancestors({ names: ['CLAUDE.md', '.claude/CLAUDE.md', 'CLAUDE.local.md'] })
1104    .catch(() => [])
1105
1106  return [
1107    ...new Set([
1108      ...((await $.fs.exists(own).catch(() => false)) ? [own] : []),
1109      ...above.flatMap(one => one.parts.map(part => part.path)),
1110    ]),
1111  ]
1112}
1113
1114// When a file was last changed; -1 when it is not there.
1115const changedAt = ($: EngineInterface, path: string) =>
1116  $.fs.stat(path).then(
1117    one => one.mtimeMs,
1118    () => -1,
1119  )
1120
1121// The fields of a text's leading `---` block, a line that carries on a value
1122// joined to it, and the markdown after the block.
1123const splitDoc = (text: string) => {
1124  const head = /^---\n([\s\S]*?)\n---[ \t]*(?:\n|$)/.exec(text)
1125  const fields: [string, string][] = []
1126
1127  for (const line of (head?.[1] ?? '').split('\n')) {
1128    const field = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line)
1129    const last = fields[fields.length - 1]
1130
1131    if (field !== null) {
1132      fields.push([field[1] ?? '', (field[2] ?? '').replace(/^[>|][+-]?$/, '')])
1133    } else if (last !== undefined && line.trim() !== '') {
1134      last[1] = `${last[1]} ${line.trim()}`.trim()
1135    }
1136  }
1137
1138  return { fields, body: text.slice(head?.[0].length ?? 0).replace(/^\n+/, '') }
1139}
1140
1141// A file as the lower half draws it: its path, its fields and its markdown,
1142// whole up to what one drawing takes.
1143const readDoc = async ($: EngineInterface, path: string) => {
1144  const at = await changedAt($, path)
1145  const text = await $.fs.read(path).catch(() => null)
1146
1147  if (text === null) {
1148    return { lines: [short(path), '파일을 읽지 못했습니다.'], file: path, at }
1149  }
1150
1151  const { fields, body } = splitDoc(text.replaceAll('\r\n', '\n'))
1152
1153  return {
1154    lines: [],
1155    file: path,
1156    at,
1157    path: short(path),
1158    fields,
1159    text: body.length > DOC_CHARS ? `${body.slice(0, DOC_CHARS)}\n\n…` : body,
1160  }
1161}
1162
1163let stamp = ''
1164
1165// The harness's folders by their entries and its files by when each was last
1166// changed: a change to any of them changes this text. `~/.claude.json` is left
1167// out, since the engine rewrites it all the time.
1168const readStamp = async ($: EngineInterface) => {
1169  const base = await readHome($)
1170  const root = await $.session.root()
1171  const folders = [
1172    ...[`${base}/.claude`, `${root}/.claude`].flatMap(dir =>
1173      ['rules', 'agents', 'skills', 'commands'].map(name => `${dir}/${name}`),
1174    ),
1175    memoryDir(base, root),
1176  ]
1177  const files = [
1178    `${base}/.claude/CLAUDE.md`,
1179    `${base}/.claude/settings.json`,
1180    `${root}/CLAUDE.md`,
1181    `${root}/.claude/CLAUDE.md`,
1182    `${root}/CLAUDE.local.md`,
1183    `${root}/.claude/settings.json`,
1184    `${root}/.claude/settings.local.json`,
1185    `${root}/.mcp.json`,
1186    ...[...(harness?.global.memory ?? []), ...(harness?.project.memory ?? [])].map(one => one.path ?? ''),
1187  ]
1188  const listed = await Promise.all(
1189    folders.map(dir =>
1190      $.fs.list(dir).then(
1191        entries => entries.map(one => `${one.name}:${one.mtimeMs}`).join(','),
1192        () => '-',
1193      ),
1194    ),
1195  )
1196  const stats = await Promise.all([...new Set(files)].filter(path => path !== '').map(path => changedAt($, path)))
1197
1198  return [...listed, ...stats].join('|')
1199}
1200
types/index.d.ts 163 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Category = { name: string; tokens: number; color: string; kind: string }
4
5export type Breakdown = { categories: Category[]; total: number; max: number }
6
7export type GitFile = { code: string; path: string }
8
9export type GitBranch = { name: string; isCurrent: boolean }
10
11export type GitCommit = { hash: string; subject: string }
12
13export type Git = {
14  staged: number
15  unstaged: number
16  untracked: number
17  added: number
18  deleted: number
19  ahead: number
20  behind: number
21  files: GitFile[]
22  branches: GitBranch[]
23  commits: GitCommit[]
24}
25
26export type Agent = { name: string; type: string; status: string }
27
28export type Call = { id: string; tool: string; state: 'run' | 'ok' | 'err' }
29
30export type Activity = { calls: Call[]; counts: Record<string, number>; total: number; lastTurnMs?: number }
31
32export type Account = { email: string; plan?: string } | { error: string }
33
34// `path` names the file a press previews; `lines` are details known already.
35// `group` names the section an item is listed under, apart from its kind's
36// own: a folder of instruction files (`rules/`), or the files they import.
37export type HarnessItem = {
38  name: string
39  note?: string
40  path?: string
41  lines?: string[]
42  group?: string
43  hooks?: Hook[]
44}
45
46// One settings hook of an event: what it is tried on, its kind, what it runs,
47// and in the list of what was used how often it ran. A command several events
48// run alike is `shared`: its number among those, and how many events run it.
49export type Hook = {
50  matcher: string
51  type: string
52  command: string
53  count?: number
54  shared?: { index: number; events: number }
55}
56
57// What the lower half shows of the item picked: plain `lines`, or for a file
58// its path, the fields of its leading `---` block, and its markdown.
59export type Detail = {
60  key: string
61  title: string
62  lines: string[]
63  path?: string
64  fields?: [string, string][]
65  text?: string
66  hooks?: Hook[]
67  // The file read and when it was last changed, to tell a later change by.
68  file?: string
69  at?: number
70}
71
72export type HarnessScope = {
73  memory: HarnessItem[]
74  agents: HarnessItem[]
75  skills: HarnessItem[]
76  mcp: HarnessItem[]
77  hooks: HarnessItem[]
78}
79
80export type Harness = { global: HarnessScope; project: HarnessScope }
81
82// How often each piece of the harness was used this session, by kind and key:
83// a file's path, an agent type, a skill's name, `server\ttool`, and
84// `event\tsubject` (what a hook's matcher is tried on).
85export type Used = Record<keyof HarnessScope, Record<string, number>>
86
87// One thing the agent did to a file: made, read, changed or removed it, when,
88// and by which tool.
89export type FileOp = { op: 'C' | 'R' | 'U' | 'D'; at: number; tool: string }
90
91// The project's files, as paths under its root; `isPartial` when there were
92// more than are kept.
93export type Files = { root: string; paths: string[]; isPartial: boolean }
94
95// How the files tab stands: all files or the touched ones, the folders
96// opened, the file picked, and the page shown.
97export type FileView = { filter: string; open: string[]; picked: string; page: number }
98
99// An article of a news feed: its title, link, summary (cut short) and when it
100// came out, as epoch milliseconds (NaN where the feed gave no date it reads).
101// A trending repository has no date: `aside` stands where the age would (the
102// stars of the day), and `meta` heads its details.
103export type Article = { title: string; link: string; summary: string; at: number; aside?: string; meta?: string }
104
105// A feed as last read: its articles, when it was read, and what went wrong
106// the last time (empty when nothing did).
107export type Feed = { articles: Article[]; readAt: number; error: string }
108
109// How the news tab stands: the source shown, its page, and the article picked
110// (by its link).
111export type NewsView = { source: string; page: number; picked: string }
112
113// A page of the Korean documentation as its index lists it: where it is under
114// code.claude.com/docs/ko, its title and the line under it.
115export type DocPage = { slug: string; title: string; about: string }
116
117// A group of pages in a section, a group under another named by both.
118export type DocGroup = { name: string; pages: DocPage[] }
119
120// A section of the documentation, as the site's tabs divide it.
121export type DocSection = { name: string; groups: DocGroup[] }
122
123// The documentation's index as last read, as a feed is.
124export type DocIndex = { sections: DocSection[]; readAt: number; error: string }
125
126// A stretch of a page's text: what is under one heading, or a piece of it
127// where that is long; `heading` is the heading it is under.
128export type DocPart = { heading: string; text: string }
129
130// A page's text as last read, in parts.
131export type DocText = { title: string; parts: DocPart[]; readAt: number; error: string }
132
133// How the docs tab stands: the section shown, the page of its list, the page
134// picked, the part of it shown, and whether its headings are listed instead.
135export type DocView = { section: number; page: number; picked: string; part: number; isToc: boolean }
136
137export type Info = {
138  cwd: string
139  branch: string
140  model: string
141  effort: string
142  version: string
143  sessionId: string
144  ctxPercentUsed?: number
145  ctxWindow: number
146  limits: Limit[]
147  breakdown: Breakdown | null
148  startedAt: number
149  turns: number
150  costUsd?: number
151  git: Git | null
152  agents: Agent[]
153  account: Account | null
154  harness: Harness | null
155  now: number
156}
157
158declare module 'claude-code' {
159  interface PluginState {
160    'status-panel': { info: Info | null; tab: string; activity: Activity; scope: string; kind: string; page: number; detail: Detail | null; used: Used; files: Files | null; touched: Record<string, FileOp[]>; fileView: FileView; news: Record<string, Feed>; newsView: NewsView; docIndex: DocIndex | null; docTexts: Record<string, DocText>; docView: DocView }
161  }
162}
163