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

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

터미널에서 연 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="상태 탭">
대화 옆에 늘 띄워 두는 첫 화면입니다.
/context 처럼 시스템 프롬프트 · 도구 · 에이전트 · 메모리 파일 · 스킬 같은 항목별 토큰 수를 보여 줍니다.<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(글로벌)와 프로젝트(로컬)에 설정된 하네스를 종류별로 봅니다.
[열기] 를 누르면 그 파일을 설정한 앱(기본은 macOS 기본 앱)으로 엽니다.● 는 이 세션에서 쓴 항목이 있다는 표시입니다.<img src="docs/screenshots/news.png" width="340" alt="뉴스 탭">
[열기] 는 브라우저로 글을 열고, [세션에 넣기] 는 글 주소를 프롬프트 입력란에 넣습니다.<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 한국어 문서를 패널 안에서 읽습니다.
## 제목마다, 그리고 약 2400자마다 나눠 「◂ 이전 · 다음 ▸」로 넘깁니다.목차 를 누르면 본문 자리에 페이지의 제목 목록이 나오고, 제목을 누르면 그 부분으로 바로 넘어갑니다.[열기] 는 브라우저로 페이지를 열고, [세션에 넣기] 는 페이지 주소를 프롬프트 입력란에 넣습니다. 변경 로그처럼 사이트 밖에 있는 문서는 [열기] 로 봅니다.| 항목 | 설명 |
|---|---|
[열기]로 여는 앱(openWith) | 하네스 탭의 [열기] 가 파일을 여는 앱 이름(예: Visual Studio Code). 비워 두면 macOS 기본 앱으로 엽니다. |
패널이 읽고 실행하는 것은 다음과 같습니다. 어떤 정보도 외부로 보내지 않습니다.
claude auth status(계정), git -C <작업 폴더> …(Git 탭), find(파일 목록과 Bash 명령 전후 비교), open([열기]).~/.claude 와 프로젝트의 CLAUDE.md · 에이전트 · 스킬 · 설정 파일(하네스 탭). MCP 서버는 실행 명령과 URL(쿼리 문자열 제외)만 표시하고, 환경 변수와 헤더는 이름만 표시합니다.~/.claude/status-panel/<세션 id> 에 마지막으로 확인한 시각을 기록합니다. 상태 줄 스크립트가 이 값을 읽어, 패널이 보일 때 같은 정보를 두 번 표시하지 않도록 할 수 있습니다.code.claude.com/docs/_llms/ko.txt 의 목록을, 페이지를 고를 때 그 페이지의 Markdown 을 읽습니다. 두 탭 모두 10분 동안은 다시 읽지 않으며, 「새로고침」을 누르면 바로 다시 읽습니다.[열기] 버튼은 macOS 의 open 명령을 사용하므로 다른 OS 에서는 동작하지 않습니다.you@example.com)과 예시 프로젝트(~/projects/todo-app)로 찍은 화면입니다. 뉴스 · 문서 탭의 내용은 찍은 날(2026-10-22)의 사이트 내용이고, 사용량 막대는 찍을 때의 실제 값입니다.hooks/register.tsx 3209 lines1import { 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}
1200types/index.d.ts 163 lines1export 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