입력창 위에 컨텍스트·사용량 현황과 주요 명령 버튼을 배치하고, 명령·스킬·단축키·대화 기록을 한곳에서 검색해 실행하는 팔레트를 제공합니다.

Claude Code를 더 빠르고 편하게 쓰는 한국어 mod 모음
Claude Code에 입력창 위 대시보드, 명령 팔레트, 세션 관리 패널을 추가합니다.
<img src="docs/images/hero-light.png" width="928" alt="Claude Code 입력창 위에 clickbar의 상태 줄과 버튼 줄, session-tracker의 세션 줄이 표시된 화면">
<sub>화면 예시입니다. 대화·폴더 이름은 가상의 예시입니다.</sub>
| mod | 한 줄 요약 | 명령 | | :- | :- | :- | | clickbar | 입력창 위에 컨텍스트·사용량 현황과 주요 명령 버튼을 표시하고, 명령·스킬·단축키·대화 기록을 한곳에서 검색해 실행하는 팔레트를 제공합니다 | /palette /idea /tips /assumptions /bubbles | | session-tracker | 실행 중인 세션의 종류와 상태를 입력창 위에 표시하고, 이 PC의 모든 대화를 폴더·날짜별로 관리합니다 | /sessions /sessions-here |
Claude Code v2.1.299 이상이 필요합니다. 터미널에서 다음 명령을 실행합니다.
claude plugin marketplace add SJvaca30/mulgogi-mods
claude plugin install clickbar@mulgogi-mods
claude plugin install session-tracker@mulgogi-mods
필요한 mod만 선택해 설치할 수 있습니다. 실행 중인 세션에는 /reload-plugins를 입력하면 바로 적용됩니다. /plugin 화면 상단에 2 mods active · clickbar, session-tracker가 표시되면 설치가 완료된 것입니다.
[!TIP] Claude Code 안에서 명령 한 줄로 설치할 수도 있습니다:
/plugin install clickbar --marketplace SJvaca30/mulgogi-mods
[compact now] 버튼이 나타납니다.[compact] [context]), 대화([resume] [sessions] [fork]), 답변([copy] [quote] [btw]), 도구([tasks] [palette]) 순서입니다. 화면이 좁으면 일부 버튼이 숨겨지며, 팔레트에서 실행할 수 있습니다./bubbles card(막대 없는 카드) · /bubbles mark(❯ 표식) · /bubbles off(끄기)로 모양을 바꿀 수 있고, Ctrl+O 전체 기록에서는 원래 모양으로 보입니다.✶ Pondering · 파일 2개 수정 · 도구 7회…).<img src="docs/images/palette-light.png" width="928" alt="명령 팔레트: 탭과 사용 한도, 고정한 명령, 대화·세션 명령, 설치한 스킬이 한국어 설명과 함께 표시된 화면">
[고정]으로 맨 위에 고정할 수 있습니다.[/명령]은 즉시 실행하고, [넣기]는 인자를 입력할 수 있도록 입력창에 명령만 넣습니다./idea로 저장한 메모), 가정(Claude의 가정 검토).Claude가 확인 없이 정한 가정을 log_assumption 도구로 기록합니다. 입력창 위 [assumptions N] 버튼이나 팔레트의 가정 탭에서 맞음·틀림을 선택하면, 틀린 가정만 수정하도록 요청합니다.
<img src="docs/images/sessions-light.png" width="928" alt="세션 패널: 보기 탭과 상태 필터, 고정한 대화와 폴더별 대화 목록, 상태·종류·관리 버튼이 표시된 화면">
/sessions, 같은 폴더 목록은 /sessions-here)./sessions): 실행 중인 대화와 기록만 남은 대화(유휴)를 폴더별·날짜별로 조회하고, 상태(켜짐·유휴)로 필터링합니다. 필터 버튼에는 해당 대화 수가 함께 표시됩니다. 줄 끝 [관리]에서 고정·이름 변경·보관·종료를 할 수 있으며, Claude가 답변하는 중에도 바로 열립니다.[이동]으로 해당 세션의 탭으로 전환하고, [열기]로 종료된 대화를 새 탭에서 다시 엽니다. 포그라운드 세션은 시작할 때 자신이 실행 중인 탭(Orca 탭·herdr 패널)을 세션 상태에 기록하므로, 이름을 붙이지 않은 세션도 정확한 탭으로 전환합니다. 백그라운드 세션은 탭 제목(세션 이름)으로 찾고, 탭이 없으면 새 탭에서 연결합니다. 백그라운드 세션을 claude agents 화면이나 이 mod가 연 탭에서 보고 있으면, 그 세션의 [이동]·[열기]는 처음 실행한 곳이 아니라 지금 보고 있는 프로그램(Orca·herdr)의 탭을 사용합니다. 직접 연 탭에서 claude attach로 보고 있으면 처음 실행한 곳을 따릅니다. 새 탭은 Claude 화면이 뜬 것을 확인한 뒤에만 앞으로 옮기고 그동안 그 줄에 "여는 중…"을 표시합니다. 연결할 작업이 이미 정리됐으면 실패한 탭을 닫고 기록으로 다시 엽니다. 다른 창에서 실행 중인 포그라운드 세션은 기록이 섞이지 않도록 새로 열지 않고 안내만 합니다. herdr는 터미널 안에서 실행하는 프로그램이므로 PowerShell·Windows Terminal에서도 herdr 안에서 Claude를 실행하면 같은 기능을 사용할 수 있습니다. 일반 터미널에서는 [여기서 열기](/resume <id>)와 [명령 복사]를 제공합니다.명령 팔레트와 세션 패널은 머리 줄 구성이 같습니다. 왼쪽에 탭(선택한 탭은 색을 채워 표시), 오른쪽 끝에 [닫기]가 있고 Esc로도 닫을 수 있습니다.
| 항목 | 내용 | | :- | :- | | Claude Code | v2.1.299 이상. mod 기능은 v2.1.287부터 제공되지만, 2.1.296 이하에서는 clickbar의 입력창 위 표시가 나타나지 않는 것을 확인했습니다 | | 테스트 환경 | Windows 11(Claude Code 2.1.299~2.1.301, Orca 터미널·herdr 0.9.4), macOS(자동 테스트와 Orca 터미널). Linux와 데스크톱 앱의 Code 탭은 테스트하지 않았습니다 | | 선택 사항 | Orca 또는 herdr: 탭 전환·새 탭 열기(다른 세션 이름 변경은 Orca만) / PowerShell 7(pwsh): 종료된 대화의 제목 표시(없으면 첫 질문을 표시합니다) | | 표시되지 않는 환경 | VS Code 확장의 채팅 창, claude -p(공식 문서 기준) |
[단축키] 탭은 macOS에서 Option 키(Option+P 등)와 이미지 붙여넣기 Ctrl+V로 표시합니다. Option+P를 눌렀을 때 π가 입력되면, 터미널에서 Option을 Alt로 전달하도록 설정합니다(Orca: 설정 → Terminal → Option as Alt).
[이동]·[열기]를 누르면 해당 줄 아래에 [여기서 열기]와 [명령 복사]가 표시됩니다. [여기서 열기]는 내장 /resume <id>로 현재 터미널에서 대화를 엽니다. 실행 중인 백그라운드 세션이면 현재 대화를 백그라운드로 전환하고 해당 세션에 연결합니다(Claude Code v2.1.285 이상, ←로 돌아옵니다). 다른 폴더의 종료된 대화는 [명령 복사]만 제공합니다. 다른 창에서 실행 중인 대화형 세션은 "다른 창"으로만 표시합니다. 현재 터미널에서 열면 두 터미널의 메시지가 하나의 기록에 섞이기 때문입니다.
Windows Terminal은 열린 탭 목록이나 탭에서 실행 중인 세션을 알려 주는 명령이 없어 탭 이동을 지원하지 않습니다. PowerShell이나 Windows Terminal에서 탭 이동을 쓰려면 그 안에서 herdr를 실행합니다.
| 기능 | 동작 | 끄는 방법 | | :- | :- | :- | | 가정 기록(clickbar) | Claude에게 log_assumption 도구를 제공합니다 | /assumptions off | | 명령 추천(clickbar) | 질문에 맞는 내장 명령을 한 번씩 추천합니다. 질문 내용은 변경하지 않습니다 | /tips off | | 작업 겹침 안내(session-tracker) | 같은 폴더의 다른 세션이 작업 중이거나 입력을 기다리면 질문에 그 세션의 이름과 요청을 알리는 안내 문구 한 줄을 추가합니다 | /sessions-here off | | 이름 변경 대리 입력(session-tracker) | 다른 세션의 이름을 변경하면 해당 세션의 Orca 탭에 /rename 새 이름을 입력합니다(Orca에서만). 승인 대기 중이 아니고 입력창이 비어 있을 때만 입력합니다 | 사용하지 않으면 동작하지 않습니다 |
[btw], 아이디어 탭의 [보내기], 가정 탭의 [틀린 가정 수정 요청]은 누르면 Claude에게 질문을 전송합니다. 누르지 않으면 아무것도 전송하지 않습니다.
~/.claude/plugins/store/에, 세션 상태는 ~/.claude/mods-state/session-tracker/에 저장합니다. 모두 이 PC에만 저장됩니다.claude plugin validate ./clickbar를 실행하면, mod가 사용하는 이벤트와 기능 목록을 확인할 수 있습니다.아닙니다. 팔레트 화면에만 표시됩니다. Claude가 스킬을 선택할 때 참고하는 SKILL.md의 설명은 변경되지 않습니다. / 메뉴의 한국어 설명도 Claude Code 내장 명령의 표시 문구에만 적용됩니다.
아닙니다. 팔레트는 해당 PC에 설치된 명령과 스킬만 표시합니다. 한국어 설명이 준비된 스킬은 용도별 분류에, 나머지는 "그 밖의 명령"에 원래 설명으로 표시됩니다.
마우스로 선택한 글의 각 줄 앞에 > 를 붙여 입력창에 넣습니다. Markdown 인용 표시이므로 Claude는 이 부분을 인용문으로 인식합니다. 그 아래에 질문을 이어서 작성하면 됩니다.
claude --version이 2.1.299 이상인지 확인합니다. 오래 실행 중인 백그라운드 세션은 시작 시점의 버전으로 계속 실행되므로, claude stop <id>로 종료한 뒤 claude attach <id>로 다시 엽니다. 회사 계정은 관리자가 사용자 설치 mod를 차단했을 수 있습니다.
claude plugin marketplace update mulgogi-mods # 마켓 목록 갱신
claude plugin update clickbar@mulgogi-mods # mod별 업데이트
claude plugin uninstall clickbar@mulgogi-mods # 삭제
자동 업데이트는 기본적으로 꺼져 있습니다. /plugin → Marketplaces → mulgogi-mods → Enable auto-update에서 켤 수 있습니다. 업데이트 후에는 /reload-plugins를 입력하거나 세션을 다시 시작합니다.
GitHub SSH 키가 없는 PC에서 Claude Code 안의 설치·업데이트가 실패하면, 환경 변수 CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1을 설정한 뒤 다시 시도합니다.
hooks/register.tsx 1113 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PromptFilled, Register, RenderSurface, SessionContextUsage, SessionUsage } from 'claude-code'
3
4import { addDelta, cardText, editDelta, progressText, whenText } from './card'
5import type { FileDelta } from './card'
6import { DEFAULT_FAVORITES, GROUPS, KO, NEEDS_ARGS, TIPS, keyGroupsFor, platformOf } from './catalog'
7import type { Platform } from './catalog'
8import type { Assumption, Idea, PaletteTab, PromptEntry } from '../types'
9import { ASSUME_DESCRIPTION, ASSUME_SCHEMA, ASSUME_SWITCH_KEY, ASSUME_TOOL_MATCH, ASSUME_TOOL_NAME, assumptionTexts, correctionPrompt } from './assumptions'
10import { bar, itemCells, kTokens, limitView, usdText, viewOf } from './meter'
11import type { MeterItem } from './meter'
12import { blocksText, isTypedPrompt, mergePrompts, promptsFromTranscript } from './prompts'
13import { finalReplies, previewLine, unfence } from './replies'
14import { parseSyncStatus, syncView } from './sync'
15
16const PANE = 'clickbar-palette'
17// session-tracker의 세션 패널 id. [sessions]는 이 패널을 직접 연다(openSessions)
18const SESSIONS_PANE = 'session-tracker-panel'
19// 폭이 이보다 좁으면 tasks·fork는 [palette]에서만 열고, 한도는 경고 수준일 때만, API 환산액은 숨긴다
20const WIDE_COLUMNS = 110
21// 팔레트 명령 줄의 열 폭(글자 칸): [/security-review]까지 한 줄에 들어가는 명령 열, [넣기] [고정] 버튼 열
22const COL_COMMAND = 20
23const COL_ROW_ACTIONS = 13
24// [단축키] 탭의 키 열 폭: 가장 긴 "Ctrl+W · K · U"(14칸)에 두 칸 여유
25const COL_KEY = 16
26// 내 질문·아이디어 탭의 시각 열 폭: 가장 긴 "10/15 16:10"(11칸). 줄지 않게 둬서 시각이 꺾이지 않고 세로로 맞는다
27const COL_WHEN = 11
28// band 맨 위 가로줄의 머리 글. 채팅과 band 사이 경계이고, 엔진의 [-]가 이 줄 끝에 붙는다
29const BAND_RULE_LABEL = '── clickbar '
30const NARROW_COLUMNS = 72
31// band 상태 줄: 항목 사이 빈칸 수, 한도 막대 칸 수(컨텍스트 막대는 10칸). 팔레트 한도 줄의 이름 열 폭("! 5시간"까지)
32const METER_GAP = 3
33const LIMIT_BAR_CELLS = 5
34const COL_LIMIT_NAME = 7
35// 클릭할 때 터미널이 선택 영역을 클립보드에 넣을 수 있어, 잠시 뒤 한 번 더 복사한다 (reply-tools와 같은 방식)
36const SELECTION_SETTLE_MS = 350
37// btw는 모델이 대화 전체를 보므로, 선택한 글은 어느 부분인지 가리킬 만큼만 붙인다
38const BTW_QUOTE_MAX = 200
39// 팔레트 답변 탭에 보여줄 최근 답변 수
40const RECENT_REPLIES = 20
41// 프로젝트마다 보관하는 아이디어 수. 넘으면 오래된 것부터 지운다
42const IDEAS_MAX = 50
43
44const meter = atom({ plugin: 'clickbar', key: 'meter' } as const, null)
45const usage = atom({ plugin: 'clickbar', key: 'usage' } as const, { limits: [], costUsd: null })
46const commands = atom({ plugin: 'clickbar', key: 'commands' } as const, [])
47const query = atom({ plugin: 'clickbar', key: 'query' } as const, '')
48const favorites = atom({ plugin: 'clickbar', key: 'favorites' } as const, DEFAULT_FAVORITES)
49const paletteTab = atom({ plugin: 'clickbar', key: 'paletteTab' } as const, 'commands')
50const replies = atom({ plugin: 'clickbar', key: 'replies' } as const, [])
51const ideas = atom({ plugin: 'clickbar', key: 'ideas' } as const, [])
52const ideaDraft = atom({ plugin: 'clickbar', key: 'ideaDraft' } as const, '')
53const prompts = atom({ plugin: 'clickbar', key: 'prompts' } as const, [])
54// 명령 추천은 명령마다 세션에서 한 번만 한다
55const tipped = new Set<string>()
56const TIPS_SWITCH_KEY = 'tips'
57// 내 질문 표시 모양(/bubbles로 고른다). card: 대화 칸 전체 폭의 옅은 카드(위아래 한 줄 여백), bar: 그 카드 왼쪽에 색 막대,
58// mark: 배경 없이 색 있는 ❯와 흐린 글. 카드와 막대는 글자가 아니라 칠한 칸이라 마우스로 끌어 복사해도 질문 글만 따라온다
59const BUBBLES_SWITCH_KEY = 'bubbles'
60const BUBBLE_STYLES = ['card', 'bar', 'mark'] as const
61type BubbleStyle = (typeof BUBBLE_STYLES)[number]
62const DEFAULT_BUBBLE_STYLE: BubbleStyle = 'bar'
63const BUBBLE_LABELS: Record<BubbleStyle, string> = { card: '카드', bar: '막대 카드', mark: '표식' }
64// 카드 바탕색: 흰·검은 바탕에서 카드로 보일 만큼만 옅게 둔다(진하면 형광펜처럼 보인다). ANSI 테마나 모르는 테마는 엔진의 내 질문 배경색 이름을 쓴다
65const BUBBLE_BAND: Record<string, string> = { light: '#f2f3f5', 'light-daltonized': '#f2f3f5', dark: '#2a2e35', 'dark-daltonized': '#2a2e35' }
66let bubbleStyle: BubbleStyle | 'off' = DEFAULT_BUBBLE_STYLE
67let theme = ''
68
69function bubbleStyleOf(saved: unknown): BubbleStyle | 'off' {
70 if (saved === 'off') return 'off'
71
72 return (BUBBLE_STYLES as readonly unknown[]).includes(saved) ? (saved as BubbleStyle) : DEFAULT_BUBBLE_STYLE
73}
74const assumptions = atom({ plugin: 'clickbar', key: 'assumptions' } as const, [])
75const syncStatus = atom({ plugin: 'clickbar', key: 'syncStatus' } as const, null)
76const progress = atom({ plugin: 'clickbar', key: 'turnProgress' } as const, { files: 0, tools: 0 })
77
78// [단축키] 탭이 보일 키의 OS. 세션을 시작할 때 환경 변수로 정한다(못 읽으면 목록을 적은 기준인 Windows)
79let platform: Platform = 'windows'
80let thresholdModel: string | undefined
81// 마지막 측정값. 턴을 시작할 때 이 값을 복사해 두고 턴이 끝날 때의 값과 비교한다
82let latest: { tokens: number | null; usd: number | null } = { tokens: null, usd: null }
83let turn: { tokens: number | null; usd: number | null; files: Map<string, FileDelta> } | undefined
84
85async function refreshMeter($: EngineInterface, context?: SessionContextUsage) {
86 const model = await $.session.model()
87 const needsThreshold = model !== thresholdModel
88 const live = needsThreshold || context === undefined ? (await $.session.usage(needsThreshold ? { breakdown: 'summary' } : {})).context : context
89 const breakdown = live.breakdown
90 if (breakdown !== undefined) thresholdModel = model
91 await update($, meter, old => ({
92 tokens: live.tokens ?? null,
93 window: live.window,
94 threshold:
95 breakdown === undefined
96 ? (old?.threshold ?? null)
97 : breakdown.isAutoCompactEnabled
98 ? (breakdown.autoCompactThreshold ?? null)
99 : null,
100 }))
101}
102
103async function setUsage($: EngineInterface, figures: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>) {
104 latest = { tokens: figures.context.tokens ?? null, usd: figures.cost?.usd ?? null }
105 await update($, usage, () => ({
106 limits: figures.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
107 costUsd: figures.cost?.usd ?? null,
108 }))
109}
110
111// 다시 로드되거나 /clear·/resume 직후에는 다음 응답 전까지 측정값이 오지 않아서, 지금 값을 한 번 읽어 둔다
112async function seedUsage($: EngineInterface) {
113 await setUsage($, await $.session.usage())
114}
115
116async function loadCommands($: EngineInterface) {
117 const list = await $.command.list()
118 await update($, commands, () => list.map(c => ({ name: c.name, description: c.description })))
119}
120
121// CCsetting sync.ps1이 남긴 마지막 동기화 결과를 읽는다. 파일이 없으면(동기화를 안 쓰는 PC) null
122async function loadSyncStatus($: EngineInterface) {
123 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
124 if (home === undefined) return
125 const path = `${home}/.claude/sync.status.json`
126 const status = (await $.fs.exists(path)) ? parseSyncStatus(String(await $.fs.read(path))) : null
127 await update($, syncStatus, () => status)
128}
129
130// [sync]는 session-tracker의 /sync를 실행한다. 끝나면 결과 파일을 다시 읽어 버튼 색을 바꾼다
131async function runSync($: EngineInterface) {
132 await run($, 'sync')
133 await loadSyncStatus($).catch(() => undefined)
134}
135
136async function loadFavorites($: EngineInterface) {
137 const saved = await $.store.get('favorites')
138 if (Array.isArray(saved) && saved.every(x => typeof x === 'string')) await update($, favorites, () => saved)
139}
140
141async function run($: EngineInterface, name: string) {
142 try {
143 const result = await $.command.run({ command: name })
144 // fork는 화면에 아무 변화가 없어 무엇이 생겼는지 알기 어렵다
145 if (name === 'fork') {
146 $.ui.toast(`fork: ${result.text?.trim() || '지금 대화를 백그라운드 사본으로 복사했습니다'} · 지금 대화는 그대로 이어집니다`, { timeoutMs: 8000 })
147 }
148 } catch (error) {
149 $.ui.toast(`/${name} 실행 실패: ${error instanceof Error ? error.message : String(error)}`, { timeoutMs: 8000 })
150 }
151}
152
153async function recentReplies($: EngineInterface): Promise<string[]> {
154 return finalReplies(await $.session.messages(), RECENT_REPLIES)
155}
156
157// 답변을 복사한다. 바깥 ````markdown 펜스는 벗겨, 붙여 넣으면 바로 마크다운 원문이 되게 한다
158async function copyText($: EngineInterface, reply: string, surface: RenderSurface, what: string) {
159 const text = unfence(reply)
160 await $.ui.copy({ text, surface })
161 await $.clock.sleep(SELECTION_SETTLE_MS)
162 const result = await $.ui.copy({ text, surface })
163 $.ui.toast(result.isCopied ? `${what}을 복사했습니다 (${text.split('\n').length}줄)` : `복사하지 못했습니다: ${result.reason}`)
164}
165
166async function copyLastReply($: EngineInterface, surface: RenderSurface) {
167 const last = (await recentReplies($))[0]
168 if (last === undefined) {
169 $.ui.toast('복사할 답변이 아직 없습니다')
170 return
171 }
172 await copyText($, last, surface, '마지막 답변')
173}
174
175// 아이디어 목록은 프로젝트(세션의 루트 폴더)마다 따로 두고, 세션이 바뀌어도 남도록 $.store에 둔다
176async function ideaKey($: EngineInterface) {
177 return `ideas:${await $.session.root()}`
178}
179
180async function loadIdeas($: EngineInterface) {
181 const saved = await $.store.get(await ideaKey($))
182 const list = Array.isArray(saved)
183 ? saved.filter((x): x is Idea => typeof x === 'object' && x !== null && typeof x.text === 'string' && typeof x.at === 'number')
184 : []
185 await update($, ideas, () => list)
186}
187
188async function saveIdeas($: EngineInterface, list: Idea[]) {
189 await $.store.set(await ideaKey($), list)
190 await update($, ideas, () => list)
191 await refreshStatus($)
192}
193
194// 입력창 아래 상태 줄: 보관한 아이디어 개수. 없으면 지운다
195async function refreshStatus($: EngineInterface) {
196 // 가정 개수는 band의 버튼이 이미 보여 주므로 상태 줄에는 아이디어만 둔다
197 const idea = (await read($, ideas)).length
198 $.ui.status(idea === 0 ? undefined : `보관한 아이디어 ${idea}개 · /idea로 확인`)
199}
200
201// 사용자 턴으로 보낸다. 들어가지 못했으면 false. Claude가 일하는 중이면 지금 턴이 끝난 뒤 실행된다
202async function submitAsUser($: EngineInterface, text: string) {
203 const result = await $.prompt.submit({ text, asUser: true })
204 if ('drop' in result && result.drop !== undefined) {
205 $.ui.toast('전송하지 못했습니다. 다른 mod나 훅이 전송을 차단했습니다')
206 return false
207 }
208 $.ui.toast('전송했습니다. Claude가 작업 중이면 현재 작업이 끝난 뒤 실행됩니다')
209 return true
210}
211
212// 보낸 뒤에만 아이디어 목록에서 지운다
213async function sendIdea($: EngineInterface, idea: Idea) {
214 if (await submitAsUser($, idea.text)) await removeIdea($, idea)
215}
216
217async function recordAssumptions($: EngineInterface, texts: string[], agent: string) {
218 const now = await $.clock.now()
219 const added = texts.map((text, i) => ({ id: `${now}-${i}`, text, at: now, status: 'open' as const, fix: '', agent }))
220 await update($, assumptions, old => [...added, ...old])
221 await refreshStatus($)
222}
223
224async function markAssumption($: EngineInterface, id: string, status: Assumption['status']) {
225 await update($, assumptions, old => old.map(a => (a.id === id ? { ...a, status } : a)))
226 await refreshStatus($)
227}
228
229// 목록 단위 조작은 화면 변화가 작아 눌렸는지 알기 어려우니 결과를 토스트로도 알린다
230async function markAllRight($: EngineInterface) {
231 const open = (await read($, assumptions)).filter(a => a.status === 'open').length
232 await update($, assumptions, old => old.map(a => (a.status === 'open' ? { ...a, status: 'right' as const } : a)))
233 await refreshStatus($)
234 $.ui.toast(open === 0 ? '확인할 가정이 남아 있지 않습니다' : `남은 가정 ${open}개를 맞음으로 표시했습니다`)
235}
236
237async function clearAssumptions($: EngineInterface) {
238 const n = (await read($, assumptions)).length
239 await update($, assumptions, () => [])
240 await refreshStatus($)
241 $.ui.toast(n === 0 ? '비울 가정이 없습니다' : `가정 ${n}개를 목록에서 삭제했습니다`)
242}
243
244async function addPrompts($: EngineInterface, list: PromptEntry[]) {
245 await update($, prompts, old => mergePrompts(old, list))
246}
247
248// 이어서 연 세션은 기록 파일에서 예전 질문을 채운다. 파일이 4MiB를 넘으면 읽을 수 없어 건너뛴다
249async function backfillPrompts($: EngineInterface, transcriptPath: string) {
250 try {
251 const stat = await $.fs.stat(transcriptPath)
252 if (stat.size > 4 * 1024 * 1024) return
253 await addPrompts($, promptsFromTranscript(String(await $.fs.read(transcriptPath))))
254 } catch (error) {
255 $.ui.log(`clickbar: 질문 목록을 기록 파일에서 채우지 못함: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
256 }
257}
258
259async function jumpTo($: EngineInterface, entry: PromptEntry) {
260 const failed = '해당 질문 위치로 이동할 수 없습니다. 압축 이전 대화이거나 화면에 표시되지 않은 질문입니다'
261 try {
262 const result = await $.ui.scroll({ to: { requestId: entry.uuid }, block: 'start' })
263 if (result.deny !== undefined) $.ui.toast(failed)
264 } catch {
265 $.ui.toast(failed)
266 }
267}
268
269async function suggestCommand($: EngineInterface, text: string) {
270 if (text.trim().startsWith('/') || (await $.store.get(TIPS_SWITCH_KEY)) === 'off') return
271 const installed = new Set((await read($, commands)).map(c => c.name))
272 const tip = TIPS.find(t => !tipped.has(t.command) && (installed.size === 0 || installed.has(t.command)) && t.pattern.test(text))
273 if (tip === undefined) return
274 tipped.add(tip.command)
275 $.ui.toast(`팁: /${tip.command} 명령으로 바로 실행할 수 있습니다. ${KO.get(tip.command) ?? ''} (끄려면 /tips off)`, { timeoutMs: 10000 })
276}
277
278async function sendCorrections($: EngineInterface) {
279 const list = await read($, assumptions)
280 const text = correctionPrompt(list)
281 if (text === null) {
282 $.ui.toast('틀림으로 표시한 가정이 없습니다')
283 return
284 }
285 if (await submitAsUser($, text)) {
286 await update($, assumptions, old => old.map(a => (a.status === 'wrong' ? { ...a, status: 'sent' as const } : a)))
287 await refreshStatus($)
288 }
289}
290
291async function addIdea($: EngineInterface, text: string) {
292 const trimmed = text.trim()
293 if (trimmed === '') {
294 $.ui.toast('보관할 내용이 없습니다. /idea 뒤에 내용을 입력하거나, 입력창에 작성한 뒤 [+ 입력 내용 보관]을 누르세요')
295 return false
296 }
297 await loadIdeas($)
298 const list = [{ text: trimmed, at: await $.clock.now() }, ...(await read($, ideas))].slice(0, IDEAS_MAX)
299 await saveIdeas($, list)
300 $.ui.toast(`아이디어를 보관했습니다 (이 프로젝트 ${list.length}개). /idea로 다시 확인할 수 있습니다`)
301 return true
302}
303
304// 입력창의 글을 보관한 뒤에만 입력창을 비운다. 저장이 실패하면 쓰던 글은 그대로 남는다
305async function shelveDraft($: EngineInterface) {
306 const draft = (await $.prompt.read()).text
307 if (!(await addIdea($, draft))) return
308 const cleared = await $.prompt.fill({ text: '', mode: 'replace' })
309 if (!cleared.isFilled) $.ui.toast('보관했지만 입력창을 비우지 못했습니다')
310}
311
312async function removeIdea($: EngineInterface, idea: Idea) {
313 await loadIdeas($)
314 await saveIdeas($, (await read($, ideas)).filter(x => !(x.at === idea.at && x.text === idea.text)))
315}
316
317async function showTab($: EngineInterface, tab: PaletteTab) {
318 if (tab === 'replies') {
319 const list = await recentReplies($)
320 await update($, replies, () => list)
321 }
322 if (tab === 'ideas') await loadIdeas($)
323 await update($, paletteTab, () => tab)
324}
325
326function reportFill($: EngineInterface, filled: PromptFilled) {
327 if (!filled.isFilled) $.ui.toast(filled.refusal === 'dialog' ? '대화상자가 열려 있어 입력창에 넣지 못했습니다' : '입력창에 넣지 못했습니다')
328}
329
330async function quoteSelection($: EngineInterface) {
331 const selected = (await $.ui.selection())?.text.trim() ?? ''
332 if (selected === '') {
333 $.ui.toast('먼저 마우스로 인용할 글을 선택하세요')
334 return
335 }
336 const quoted = selected
337 .split('\n')
338 .map(line => `> ${line}`)
339 .join('\n')
340 reportFill($, await $.prompt.fill({ text: `${quoted}\n\n`, mode: 'insert' }))
341}
342
343// 선택한 글을 가리키는 /btw를 입력창 맨 앞에 넣는다. 쓰던 글은 질문으로 뒤에 남긴다
344async function askAside($: EngineInterface) {
345 const selected = (await $.ui.selection())?.text.replace(/\s+/g, ' ').trim() ?? ''
346 const about = selected === '' ? '' : `"${selected.length > BTW_QUOTE_MAX ? `${selected.slice(0, BTW_QUOTE_MAX)}…` : selected}" `
347 const draft = (await $.prompt.read()).text
348 const question = draft.startsWith('/btw ') ? draft.slice('/btw '.length) : draft
349 reportFill($, await $.prompt.fill({ text: `/btw ${about}${question}`, mode: 'replace' }))
350}
351
352async function toggleFavorite($: EngineInterface, name: string) {
353 const list = await update($, favorites, old => (old.includes(name) ? old.filter(n => n !== name) : [...old, name]))
354 await $.store.set('favorites', list)
355}
356
357// session-tracker의 세션 패널을 직접 연다. /sessions를 $.command.run으로 부르면 엔진이 사람이 친 명령처럼 다뤄
358// 답변이 끝날 때까지 줄을 세우므로(immediate는 사람이 직접 친 명령에만 듣는다), 답변 중에도 바로 뜨게 패널을 연다.
359// 목록 읽기는 session-tracker가 이 패널이 열리는 것(ui.open)을 받아서 한다
360async function openSessions($: EngineInterface) {
361 try {
362 await $.ui.open({ id: SESSIONS_PANE, title: '세션', focus: true, closeOnEscape: true })
363 } catch {
364 await run($, 'sessions')
365 }
366}
367
368async function openPalette($: EngineInterface) {
369 await loadCommands($)
370 // 탭은 기억하되 검색어는 비운다. 지난 검색어가 남으면 목록이 걸러진 채 열려 명령이 없어 보인다
371 await update($, query, () => '')
372 const tab = await read($, paletteTab)
373 if (tab === 'replies' || tab === 'ideas') await showTab($, tab)
374 if (tab === 'assume') await update($, paletteTab, () => tab)
375 await $.ui.open({ id: PANE, title: '명령', focus: true, closeOnEscape: true })
376}
377
378export const register: Register = on => {
379 on('session.start', async ($, e, next) => {
380 await $.command.register({
381 name: 'palette',
382 description: '명령 팔레트를 엽니다. 명령·스킬·단축키·대화 기록을 한국어 설명과 함께 검색하고 바로 실행합니다',
383 immediate: true,
384 })
385 // Claude가 일하는 중에도 끊지 않고 보관할 수 있도록 immediate로 둔다
386 await $.command.register({
387 name: 'idea',
388 description: '아이디어를 프로젝트별로 저장합니다. 내용 없이 실행하면 저장한 아이디어 목록을 엽니다',
389 argumentHint: '[보관할 내용]',
390 immediate: true,
391 })
392 await $.command.register({
393 name: 'tips',
394 description: '질문에 맞는 내장 명령을 한 번씩 추천합니다. /tips off로 끌 수 있습니다',
395 argumentHint: '[on|off]',
396 immediate: true,
397 })
398 await $.command.register({
399 name: 'bubbles',
400 description: '대화에서 내 질문을 카드로 표시해 답변과 구분합니다. /bubbles card·bar·mark·off로 모양을 고릅니다',
401 argumentHint: '[card|bar|mark|off]',
402 immediate: true,
403 })
404 bubbleStyle = bubbleStyleOf(await $.store.get(BUBBLES_SWITCH_KEY))
405 theme = String((await $.config.list().catch(() => [])).find(row => row.key === 'theme')?.value ?? '')
406 await $.command.register({
407 name: 'assumptions',
408 description: 'Claude가 확인 없이 정한 가정을 검토하고 바로잡습니다. /assumptions off·on으로 끄고 켭니다(다음 세션부터 적용)',
409 argumentHint: '[on|off]',
410 immediate: true,
411 })
412 // 이 도구는 모든 세션의 모델에게 보인다. 사용자가 꺼 두었으면 등록하지 않는다
413 if ((await $.store.get(ASSUME_SWITCH_KEY)) !== 'off') {
414 await $.tool.register({ name: ASSUME_TOOL_NAME, description: ASSUME_DESCRIPTION, inputSchema: ASSUME_SCHEMA, isDeferred: false })
415 }
416 await loadFavorites($)
417 try {
418 platform = platformOf({ os: await $.env.get('OS'), cfEncoding: await $.env.get('__CF_USER_TEXT_ENCODING') })
419 } catch {
420 // 환경 변수를 못 읽으면 Windows 기준 목록을 그대로 보인다
421 }
422 // 다른 플러그인이 자기 명령을 등록할 시간을 준 뒤 읽는다 (quickbar와 같은 방식)
423 $.clock.after(1500, () => void loadCommands($).then(() => refreshMeter($)).then(() => seedUsage($)).then(() => loadIdeas($)).then(() => refreshStatus($)).then(() => loadSyncStatus($)))
424 // 한도의 초기화 시각이 지나는 것과 다른 세션이 돌린 동기화 결과를 보이도록 1분마다 다시 읽고 그린다
425 $.clock.every(60000, () => void loadSyncStatus($).catch(() => undefined).then(() => $.ui.invalidate('ui.render')))
426
427 return next(e)
428 })
429
430 // 가정 기록 도구는 이 mod가 직접 답한다. 화면에 목록을 쌓을 뿐이라 권한을 묻지 않는다
431 on('tool.check', { tool: ASSUME_TOOL_MATCH }, () => ({ decision: 'allow' as const, reason: 'clickbar: 가정 기록 전용 도구' }))
432
433 on('tool.call', { tool: ASSUME_TOOL_MATCH }, async ($, e) => {
434 if ((await $.store.get(ASSUME_SWITCH_KEY)) === 'off') return { result: '가정 기록이 꺼져 있다. 이 도구를 더 부르지 마라.' }
435 const texts = assumptionTexts(e)
436 if (texts.length === 0) return { result: '기록할 가정이 없었다. 계속 진행하라.' }
437 await recordAssumptions($, texts, e.agentId ?? '')
438
439 return { result: `${texts.length}개 기록됨. 사용자가 clickbar에서 확인한다. 계속 진행하라.` }
440 })
441
442 on('command.run', { command: 'assumptions' }, async ($, e) => {
443 const arg = e.args.trim().toLowerCase()
444 if (arg === 'off' || arg === 'on') {
445 await $.store.set(ASSUME_SWITCH_KEY, arg)
446 $.ui.toast(arg === 'off' ? '가정 기록을 껐습니다. 지금부터 기록하지 않으며, 다음 세션부터는 모델에 도구도 제공하지 않습니다' : '가정 기록을 켰습니다. 다음 세션부터 적용됩니다')
447 } else {
448 await update($, paletteTab, () => 'assume')
449 await openPalette($)
450 }
451
452 return {}
453 })
454
455 // /clear, /resume, /branch는 $.state를 초기화하고, 압축은 토큰 수를 바꾼다.
456 // Team 플랜에서는 내장 보안 mod가 user mod의 classic.* 훅을 건너뛰게 해서(2026-10-11 디버그 로그로 확인), 명령이 끝난 직후에 갱신한다
457 on('command.run', { command: ['clear', 'resume', 'branch', 'compact'] }, async ($, e, next) => {
458 const result = await next(e)
459 try {
460 if (e.command === 'clear') await update($, prompts, () => [])
461 await loadFavorites($)
462 thresholdModel = undefined
463 await refreshMeter($)
464 await seedUsage($)
465 } catch (error) {
466 $.ui.log(`clickbar: /${e.command} 뒤 갱신 실패: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
467 }
468
469 return result
470 })
471
472 // 내가 보낸 질문을 기록 파일에 들어가는 행 id와 함께 모은다. 기록은 받은 그대로 넘긴다
473 on('session.append', { door: 'prompt' }, async ($, e, next) => {
474 const result = await next(e)
475 try {
476 const text = blocksText(e.message.content)
477 if (e.agentId === undefined && e.message.isMeta !== true && isTypedPrompt(text) && result.uuid !== undefined) {
478 await addPrompts($, [{ uuid: result.uuid, at: await $.clock.now(), text }])
479 }
480 } catch (error) {
481 $.ui.log(`clickbar: 질문 기록 실패: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
482 }
483
484 return result
485 })
486
487 // 사람이 직접 쓴 질문에서 내장 명령으로 바로 할 수 있는 일을 알아보면 한 번 알려 준다. 질문은 바꾸지 않는다
488 on('prompt.submit', async ($, e, next) => {
489 const result = await next(e)
490 if (e.origin.kind === 'composer' || e.origin.kind === 'bridge') {
491 try {
492 await suggestCommand($, e.text)
493 } catch (error) {
494 $.ui.log(`clickbar: 명령 추천 실패: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
495 }
496 }
497
498 return result
499 })
500
501 // 입력창에 /를 쳤을 때 뜨는 기본 명령 목록의 설명을 한국어로 바꾼다
502 on('command.describe', async ($, e, next) => {
503 const result = await next(e)
504 // 엔진이 직접 제공하는 명령(core)만 바꾼다. 같은 이름의 플러그인 명령은 그대로 둔다
505 const ko = e.provider.tier === 'core' ? KO.get(e.command) : undefined
506
507 return ko === undefined ? result : { ...result, description: ko }
508 })
509
510 on('command.run', { command: 'bubbles' }, async ($, e) => {
511 const arg = e.args.trim().toLowerCase()
512 const choice = arg === 'on' ? DEFAULT_BUBBLE_STYLE : arg
513 if (choice === 'off' || (BUBBLE_STYLES as readonly string[]).includes(choice)) {
514 await $.store.set(BUBBLES_SWITCH_KEY, choice)
515 bubbleStyle = bubbleStyleOf(choice)
516 // 이미 그려진 질문도 바로 바뀌도록 다시 그린다
517 $.ui.invalidate('ui.render')
518 $.ui.toast(bubbleStyle === 'off' ? '내 질문 표시를 껐습니다' : `내 질문을 ${BUBBLE_LABELS[bubbleStyle]} 모양으로 표시합니다`)
519 } else {
520 $.ui.toast('/bubbles 뒤에 card(카드) · bar(막대 카드) · mark(표식) · off(끄기) 중 하나를 입력합니다')
521 }
522
523 return {}
524 })
525
526 on('command.run', { command: 'tips' }, async ($, e) => {
527 const arg = e.args.trim().toLowerCase()
528 if (arg === 'off' || arg === 'on') {
529 await $.store.set(TIPS_SWITCH_KEY, arg)
530 $.ui.toast(arg === 'off' ? '명령 추천을 껐습니다' : '명령 추천을 켰습니다')
531 } else {
532 $.ui.toast('/tips off로 명령 추천을 끄고, /tips on으로 다시 켭니다')
533 }
534
535 return {}
536 })
537
538 on('session.measure', async ($, e, next) => {
539 const result = await next(e)
540 await setUsage($, e)
541 if (e.changed.includes('context')) await refreshMeter($, e.context)
542
543 return result
544 })
545
546 // 턴 작업 카드. 집계가 실패해도 턴과 도구 호출에는 손대지 않는다
547 on('turn.start', async ($, e, next) => {
548 // 2.1.299부터 서브에이전트·팀원의 턴에도 온다(e.agentId가 있다). 이번 턴의 진행·요약은 메인 대화의 턴만 새로 센다
549 if ((e as { agentId?: string }).agentId !== undefined) return next(e)
550 turn = { tokens: latest.tokens, usd: latest.usd, files: new Map() }
551 await update($, progress, () => ({ files: 0, tools: 0 }))
552
553 return next(e)
554 })
555
556 // 스피너 줄의 "도구 N회"는 메인 에이전트가 이번 턴에 부른 도구 수다. 서브에이전트 안의 호출과 가정 기록 도구는 세지 않는다
557 on('tool.call', async ($, e, next) => {
558 if (turn !== undefined && e.agentId === undefined && !ASSUME_TOOL_MATCH.test(e.tool)) {
559 await update($, progress, old => ({ ...old, tools: old.tools + 1 }))
560 }
561
562 return next(e)
563 })
564
565 on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
566 const result = await next(e)
567 try {
568 const edit = turn !== undefined && result.deny === undefined && result.isError !== true ? editDelta(result.result) : null
569 if (turn !== undefined && edit !== null) {
570 addDelta(turn.files, edit.path, edit.delta)
571 const files = turn.files.size
572 await update($, progress, old => ({ ...old, files }))
573 }
574 } catch (error) {
575 $.ui.log(`clickbar: 작업 카드 집계 실패: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
576 }
577
578 return result
579 })
580
581 on('turn.complete', async ($, e, next) => {
582 const result = await next(e)
583 const started = turn
584 if (e.agentId !== undefined || started === undefined) return result
585 turn = undefined
586 try {
587 const live = await $.session.usage()
588 const tokens = live.context.tokens
589 const usd = live.cost?.usd
590 const text = cardText({
591 files: started.files,
592 tokens: tokens === undefined || started.tokens === null ? null : tokens - started.tokens,
593 usd: usd === undefined || started.usd === null ? null : usd - started.usd,
594 isAborted: e.isAborted,
595 })
596 if (text !== '') $.ui.log(text)
597 } catch (error) {
598 $.ui.log(`clickbar: 작업 카드 실패: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
599 }
600
601 return result
602 })
603
604 on('command.run', { command: 'palette' }, async $ => {
605 await openPalette($)
606
607 return {}
608 })
609
610 on('command.run', { command: 'idea' }, async ($, e) => {
611 if (e.args.trim() === '') {
612 await update($, paletteTab, () => 'ideas')
613 await openPalette($)
614 } else {
615 await addIdea($, e.args)
616 }
617
618 return {}
619 })
620
621 // 일하는 동안 스피너 줄에 이번 턴의 진행 상황을 붙인다: "✶ Pondering · 파일 2개 수정 · 도구 7회… (8s · ↓ 1.2k tokens)".
622 // suffix는 엔진의 "…" 자리라 그 앞에 끼워 넣는다. 엔진이 낱말 대신 안내(message)를 보이는 동안은 그대로 둔다
623 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
624 const extra = progressText(await read($, progress))
625 if (e.surface !== 'terminal' || e.props.message !== null || extra === '') return next(e)
626
627 return next({ ...e, props: { ...e.props, suffix: ` · ${extra}${e.props.suffix}` } })
628 })
629
630 // 내가 보낸 질문을 Claude의 답과 갈리게 그린다(레퍼런스: Codex CLI의 전체 폭 카드, opencode의 카드+왼쪽 막대, Gemini CLI의 ❯ 표식).
631 // 카드는 대화 칸 전체 폭에 위아래 한 줄 여백을 둬서 글 길이와 상관없이 한 덩어리로 보인다. 엔진은 앞 턴의 마지막 줄(✻ … done)과
632 // 내 질문 사이를 띄우지 않으므로 위에 한 줄을 띄운다.
633 // ctrl+o 전체 기록, 터미널이 아닌 화면, 다른 세션·알림이 보낸 글은 엔진이 그린 그대로 둔다
634 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
635 const kind = e.props.origin.kind
636 const text = e.props.text.replace(/\s+$/, '')
637 if (bubbleStyle === 'off' || e.surface !== 'terminal' || e.props.isExpanded || (kind !== 'composer' && kind !== 'bridge') || text === '') return next(e)
638 const { Box, Text } = $.ui.resolve(e)
639 if (bubbleStyle === 'mark') {
640 // 배경 없이 색 있는 ❯ 뒤에 흐린 글: 내 말은 한발 물러나고 Claude의 답이 앞으로 나온다
641 return (
642 <Box flexDirection="row" columnGap={1} marginTop={1}>
643 <Box flexShrink={0}>
644 <Text color="suggestion" bold>
645 ❯
646 </Text>
647 </Box>
648 <Box flexGrow={1} flexShrink={1}>
649 <Text dimColor wrap="wrap">
650 {text}
651 </Text>
652 </Box>
653 </Box>
654 )
655 }
656
657 return (
658 <Box flexDirection="row" marginTop={1}>
659 {bubbleStyle === 'bar' && <Box width={1} flexShrink={0} backgroundColor="suggestion" />}
660 <Box flexGrow={1} flexShrink={1} backgroundColor={BUBBLE_BAND[theme] ?? 'userMessageBackground'} paddingX={2} paddingY={1}>
661 <Text wrap="wrap">{text}</Text>
662 </Box>
663 </Box>
664 )
665 })
666
667 // /theme으로 테마를 바꾸면 말풍선 색도 바로 바꾼다
668 on('config.set', { key: 'theme' }, async ($, e, next) => {
669 const result = await next(e)
670 theme = String(e.value)
671 $.ui.invalidate('ui.render')
672
673 return result
674 })
675
676 // 저장된 답변은 그대로 두고(ctrl+o·/copy는 원문) 화면에서만 바깥 펜스를 벗겨 표·제목을 마크다운으로 그린다
677 on('ui.render', { component: 'AssistantMessage' }, async (_, e, next) => {
678 const text = unfence(e.props.text)
679 return next(text === e.props.text ? e : { ...e, props: { ...e.props, text } })
680 })
681
682 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
683 if (e.props.hasSurvey) return next(e)
684 const { Box, Text, Button } = $.ui.resolve(e)
685 const current = await read($, meter)
686 const stats = await read($, usage)
687 const now = await $.clock.now()
688 const view = current === null ? null : viewOf(current)
689 const isWide = e.props.bodyColumns >= WIDE_COLUMNS
690 const isNarrow = e.props.bodyColumns < NARROW_COLUMNS
691 const toneColor = (t: 'normal' | 'warn' | 'bad' | undefined) => (t === 'bad' ? 'error' : t === 'warn' ? 'warning' : undefined)
692 // 상태 줄 항목: 이름(기본색) · 막대 · 비율(굵게) · (설명, 흐리게). 넓을 때만 막대를 그린다
693 // 새 세션이나 압축 직후처럼 아직 값이 없으면 "컨텍스트 —"로 둔다. 이 자리도 폭 계산에 넣는다
694 const contextItem: MeterItem =
695 view === null
696 ? { key: 'band-meter-context', name: '컨텍스트', bar: '', figure: '', note: '—', tone: 'normal' }
697 : {
698 key: 'band-meter-context',
699 name: '컨텍스트',
700 bar: isWide ? bar(view.percent) : '',
701 figure: `${view.percent}%`,
702 note: isNarrow ? '' : `(${view.basis === 'compact' ? (isWide ? '자동 압축까지' : '압축까지') : '창 여유'} ${kTokens(view.left)})`,
703 tone: view.tone,
704 }
705 // 넓거나 중간 폭이면 비율과 초기화 시각이 늘 보이고, 좁으면 70% 이상만 보인다
706 const limitItems: MeterItem[] = stats.limits
707 .map(l => limitView(l, now))
708 .filter(l => !isNarrow || l.isHigh)
709 .map(l => ({ key: `band-limit-${l.kind}`, name: l.name, bar: isWide ? bar(l.percent, LIMIT_BAR_CELLS) : '', figure: `${l.percent}%`, note: l.reset === '' ? '' : `(${l.reset})`, tone: l.tone }))
710 const meterItems = [contextItem, ...limitItems]
711 // API 환산액은 넓은 band에서, 나머지 항목과 한 줄에 들어갈 때만 붙인다(넘치면 줄이 두 줄로 바뀌며 흔들린다)
712 const costItem: MeterItem | null =
713 !isWide || stats.costUsd === null ? null : { key: 'band-meter-cost', name: 'API 환산', bar: '', figure: usdText(stats.costUsd), note: '', tone: 'normal' }
714 const usedCells = meterItems.reduce((n, it, i) => n + itemCells(it) + (i > 0 ? METER_GAP : 0), 0)
715 if (costItem !== null && usedCells + METER_GAP + itemCells(costItem) <= e.props.bodyColumns) meterItems.push(costItem)
716 const meterView = (it: MeterItem) => {
717 const color = toneColor(it.tone)
718
719 return (
720 <Box key={it.key} flexShrink={0} columnGap={1}>
721 {it.tone === 'bad' && (
722 <Text color={color} bold>
723 !
724 </Text>
725 )}
726 <Text>{it.name}</Text>
727 {it.bar !== '' && (
728 <Text color={color} dimColor={color === undefined}>
729 {it.bar}
730 </Text>
731 )}
732 {it.figure !== '' && (
733 <Text color={color} bold>
734 {it.figure}
735 </Text>
736 )}
737 {it.note !== '' && <Text dimColor>{it.note}</Text>}
738 </Box>
739 )
740 }
741 const openAssumptions = (await read($, assumptions)).filter(a => a.status === 'open').length
742 // session-tracker가 설치돼 있으면 세션 패널 버튼을 둔다
743 const hasSessions = (await read($, commands)).some(c => c.name === 'sessions')
744 const hasSync = (await read($, commands)).some(c => c.name === 'sync')
745 const sync = syncView(await read($, syncStatus), now)
746 const others = await next(e)
747
748 // 회색 글자는 정보(누를 수 없음), 색 있는 [괄호]는 버튼이다. 윗줄은 상태, 아랫줄은 버튼만 둔다.
749 // 경고일 때만 색을 바꾼다: 상태는 그 글자를, 버튼은 [compact 권장]처럼 눌러야 할 것을 칠한다.
750 const act = (key: string, word: string, onPress: (press: { surface: RenderSurface }) => void, color: string = 'suggestion') => (
751 <Button key={key} plain label={`[${word}]`} onPress={onPress}>
752 <Text color={color}>[{word}]</Text>
753 </Button>
754 )
755 // 채팅과 band 사이에 흐린 가로줄을 긋는다. 폭은 band 폭(엔진의 [-] 다섯 칸 제외)에 맞춘다. 데스크톱은 엔진이 따로 그린다
756 const ruleWidth = e.props.bodyColumns
757 const rule = e.surface === 'terminal' && ruleWidth > 0 ? (BAND_RULE_LABEL + '─'.repeat(Math.max(0, ruleWidth - BAND_RULE_LABEL.length))).slice(0, ruleWidth) : null
758 return (
759 <Box flexDirection="column">
760 {rule !== null && (
761 <Text dimColor wrap="truncate-end">
762 {rule}
763 </Text>
764 )}
765 {/* 항목은 줄지 않는 묶음이라, 폭이 모자라면 항목째 다음 줄로 넘어간다. 구분 기호 대신 빈칸으로 띄워 줄 앞에 기호가 남지 않는다 */}
766 <Box key="status" columnGap={METER_GAP} flexWrap="wrap">
767 {meterItems.map(meterView)}
768 </Box>
769 {/* 버튼 이름은 모두 영어다. 명령이 있는 버튼은 그 명령 이름 그대로다(palette=/palette, assumptions=/assumptions).
770 용도별로 │로 묶는다: 컨텍스트 │ 대화 찾기·나누기 │ 답변 다루기 │ 도구. 나타났다 사라지는 assumptions는 맨 끝에 둬서 다른 버튼이 밀리지 않게 한다 */}
771 <Box key="actions" columnGap={1} flexWrap="wrap">
772 {act('band-compact', view?.tone === 'bad' ? 'compact now' : 'compact', () => void run($, 'compact'), view?.tone === 'bad' ? 'error' : 'suggestion')}
773 {act('band-context', 'context', () => void run($, 'context'))}
774 <Text key="band-sep-chat" dimColor>
775 │
776 </Text>
777 {/* 지난 대화 전체 목록. 켜진 백그라운드 대화는 연결되고, 꺼진 대화는 다시 켜진다(Ctrl+A로 모든 폴더) */}
778 {act('band-resume', 'resume', () => void run($, 'resume'))}
779 {hasSessions && act('band-sessions', 'sessions', () => void openSessions($))}
780 {isWide && act('band-fork', 'fork', () => void run($, 'fork'))}
781 <Text key="band-sep-reply" dimColor>
782 │
783 </Text>
784 {act('band-copy', 'copy', press => void copyLastReply($, press.surface))}
785 {act('band-quote', 'quote', () => void quoteSelection($))}
786 {act('band-btw', 'btw', () => void askAside($))}
787 <Text key="band-sep-tools" dimColor>
788 │
789 </Text>
790 {isWide && act('band-tasks', 'tasks', () => void run($, 'tasks'))}
791 {/* 동기화가 보류·실패했거나 하루 넘게 안 돌았으면 이름과 색이 바뀐다(sync.status.json) */}
792 {hasSync && act('band-sync', sync.word, () => void runSync($), sync.color)}
793 {act('band-more', 'palette', () => void openPalette($))}
794 {openAssumptions > 0 && (
795 <Text key="band-sep-assume" dimColor>
796 │
797 </Text>
798 )}
799 {openAssumptions > 0 &&
800 act('band-assume', `assumptions ${openAssumptions}`, () => void update($, paletteTab, () => 'assume').then(() => openPalette($)), 'warning')}
801 </Box>
802 {others}
803 </Box>
804 )
805 })
806
807 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
808 const elements = $.ui.resolve(e)
809 const { Box, Text, Button } = elements
810 const Input = 'Input' in elements ? elements.Input : undefined
811 // band와 같은 규칙: 회색 글자는 정보, 색 있는 [괄호]는 버튼
812 const act = (key: string, word: string, onPress: (press: { surface: RenderSurface }) => void, color: string = 'suggestion') => (
813 <Button key={key} plain label={`[${word}]`} onPress={onPress}>
814 <Text color={color}>[{word}]</Text>
815 </Button>
816 )
817 const installed = await read($, commands)
818 const raw = await read($, query)
819 const q = raw.trim().toLowerCase()
820 const favs = await read($, favorites)
821 const byName = new Map(installed.map(c => [c.name, c.description]))
822 const describe = (name: string) => KO.get(name) ?? byName.get(name) ?? ''
823 const matches = (name: string) => q === '' || name.includes(q) || describe(name).toLowerCase().includes(q)
824 const grouped = new Set(GROUPS.flatMap(g => g.entries.map(x => x.name)))
825 const others = installed.filter(c => !grouped.has(c.name) && matches(c.name)).map(c => c.name)
826
827 // 명령 · 버튼 · 설명을 고정 폭 열로 맞춘다. 설명이 길어 꺾여도 설명 열 안에서만 꺾여 표처럼 읽힌다
828 const row = (section: string, name: string) => (
829 <Box key={`${section}-${name}`} gap={1}>
830 <Box width={COL_COMMAND} flexShrink={0}>
831 {act(`${section}-run-${name}`, `/${name}`, () => void (NEEDS_ARGS.has(name) ? $.prompt.fill({ text: `/${name} `, mode: 'replace' }) : run($, name)))}
832 </Box>
833 <Box width={COL_ROW_ACTIONS} flexShrink={0} columnGap={1}>
834 {act(`${section}-fill-${name}`, '넣기', () => void $.prompt.fill({ text: `/${name} `, mode: 'replace' }))}
835 {act(`${section}-fav-${name}`, favs.includes(name) ? '해제' : '고정', () => void toggleFavorite($, name), favs.includes(name) ? 'warning' : 'suggestion')}
836 </Box>
837 <Box flexGrow={1} flexShrink={1}>
838 <Text dimColor>{describe(name)}</Text>
839 </Box>
840 </Box>
841 )
842
843 const section = (key: string, title: string, names: string[]) =>
844 names.length === 0 ? null : (
845 <Box key={`sec-${key}`} flexDirection="column" marginTop={1}>
846 <Text bold>{title}</Text>
847 {names.map(name => row(key, name))}
848 </Box>
849 )
850
851 const tab = await read($, paletteTab)
852 // 머리 줄은 세션 패널과 같은 모양이다: 왼쪽에 탭(고른 탭은 칠한 버튼), 오른쪽 끝에 [닫기]와 닫는 키.
853 // 탭 쪽만 줄고 꺾이게 해서, 좁은 화면에서도 [닫기]는 첫 줄 오른쪽에 남는다
854 const tabsRow = (
855 <Box key="tabs" flexGrow={1} flexShrink={1} columnGap={1} flexWrap="wrap">
856 {(
857 [
858 ['commands', '명령'],
859 ['keys', '단축키'],
860 ['prompts', '내 질문'],
861 ['replies', '답변'],
862 ['ideas', '아이디어'],
863 ['assume', '가정'],
864 ] as const
865 ).map(([id, title]) =>
866 id === tab ? (
867 // 고른 탭은 [ ] 대신 같은 폭의 칠한 칸으로 그린다. 폭이 같아 탭을 바꿔도 옆 탭이 밀리지 않는다(세션 패널과 같은 모양)
868 <Button key={`tab-${id}`} plain label={title} onPress={() => void showTab($, id)}>
869 <Text backgroundColor="suggestion" color="inverseText" bold>{` ${title} `}</Text>
870 </Button>
871 ) : (
872 <Button key={`tab-${id}`} plain label={`[${title}]`} onPress={() => void showTab($, id)}>
873 <Text color="suggestion">[{title}]</Text>
874 </Button>
875 ),
876 )}
877 </Box>
878 )
879 // band에는 짧게만 보이는 사용량 한도를 여기서 자세히 보여 준다(초기화 시각의 요일과 남은 시간)
880 const now = await $.clock.now()
881 const limitDetails = (await read($, usage)).limits.map(l => limitView(l, now))
882 const tabs = (
883 <Box key="tabs-area" flexDirection="column">
884 <Box key="pane-head" columnGap={1}>
885 {tabsRow}
886 <Box flexShrink={0} columnGap={1}>
887 <Button key="pane-close" plain role="dismiss" label="[닫기]" onPress={() => void $.ui.close({ id: PANE })}>
888 <Text color="suggestion">[닫기]</Text>
889 </Button>
890 <Text dimColor>Esc</Text>
891 </Box>
892 </Box>
893 {/* 한도는 한 줄에 하나씩, band와 같은 모양(이름·막대·비율)으로 열을 맞추고 초기화 시각과 남은 시간을 붙인다 */}
894 {limitDetails.map(l => {
895 const color = l.tone === 'bad' ? 'error' : l.tone === 'warn' ? 'warning' : undefined
896
897 return (
898 <Box key={`limit-detail-${l.kind}`} columnGap={1}>
899 <Box width={COL_LIMIT_NAME} flexShrink={0} columnGap={1}>
900 {l.tone === 'bad' && (
901 <Text color={color} bold>
902 !
903 </Text>
904 )}
905 <Text>{l.name}</Text>
906 </Box>
907 <Text color={color} dimColor={color === undefined}>
908 {bar(l.percent, LIMIT_BAR_CELLS)}
909 </Text>
910 <Box width={4} flexShrink={0} justifyContent="flex-end">
911 <Text color={color} bold>
912 {`${l.percent}%`}
913 </Text>
914 </Box>
915 {l.detail !== '' && <Text dimColor>{l.detail}</Text>}
916 </Box>
917 )
918 })}
919 </Box>
920 )
921
922 if (tab === 'keys') {
923 // 키는 줄지 않는 열에 기본색으로(파란 [괄호]는 버튼에만 쓴다), 설명은 그 옆 열 안에서만 꺾인다. [/명령]은 줄 끝에 둔다
924 const keyGroups = keyGroupsFor(platform).map(group => ({
925 title: group.title,
926 entries: group.entries.filter(entry => q === '' || entry.keys.toLowerCase().includes(q) || entry.ko.toLowerCase().includes(q)),
927 })).filter(group => group.entries.length > 0)
928
929 return (
930 <Box flexDirection="column" gap={1}>
931 {tabs}
932 <Text dimColor>단축키는 직접 눌러야 합니다 · 같은 기능의 명령이 있으면 줄 끝 [/명령]으로 바로 실행할 수 있습니다</Text>
933 {platform === 'mac' && <Text dimColor>Mac: Ctrl은 ⌘Command가 아닌 ⌃Control 키이며, Option은 ⌥ 키입니다</Text>}
934 {Input !== undefined && (
935 <Input key="search" placeholder="키 또는 설명으로 검색" value={raw} autoFocus onInput={(v: string) => void update($, query, () => v)} onSubmit={(v: string) => void update($, query, () => v)} />
936 )}
937 {keyGroups.length === 0 && <Text dimColor>일치하는 단축키가 없습니다</Text>}
938 {keyGroups.map(group => (
939 <Box key={`keys-${group.title}`} flexDirection="column">
940 <Text bold>{group.title}</Text>
941 {group.entries.map(entry => (
942 <Box key={`key-${entry.keys}`} gap={1}>
943 <Box width={COL_KEY} flexShrink={0}>
944 <Text>{entry.keys}</Text>
945 </Box>
946 <Box flexGrow={1} flexShrink={1}>
947 <Text dimColor>{entry.ko}</Text>
948 </Box>
949 {entry.command !== undefined && byName.has(entry.command) && (
950 <Box flexShrink={0}>{act(`key-run-${entry.command}`, `/${entry.command}`, () => void run($, entry.command ?? ''))}</Box>
951 )}
952 </Box>
953 ))}
954 </Box>
955 ))}
956 </Box>
957 )
958 }
959
960 if (tab === 'prompts') {
961 const list = [...(await read($, prompts))].reverse()
962 const now = await $.clock.now()
963
964 return (
965 <Box flexDirection="column" gap={1}>
966 {tabs}
967 <Text dimColor>보낸 질문을 최신순으로 표시합니다. [이동]은 대화에서 해당 질문 위치로 이동하고, [입력창에]는 질문을 입력창에 다시 넣습니다</Text>
968 {list.length === 0 && <Text dimColor>아직 기록된 질문이 없습니다. 이 기능을 켠 뒤 보낸 질문부터 기록합니다</Text>}
969 {list.map((p, i) => (
970 <Box key={`prompt-${p.uuid}`} gap={1}>
971 {/* 버튼과 시각은 줄지 않는 열, 질문 글은 남는 폭 안에서만 꺾인다(명령 탭과 같은 표 모양) */}
972 <Box flexShrink={0} columnGap={1}>
973 {act(`prompt-jump-${i}`, '이동', () => void jumpTo($, p))}
974 {act(`prompt-fill-${i}`, '입력창에', () => void $.prompt.fill({ text: p.text, mode: 'insert' }).then(filled => reportFill($, filled)))}
975 </Box>
976 <Box width={COL_WHEN} flexShrink={0}>
977 <Text dimColor>{p.at > 0 ? whenText(p.at, now) : '--:--'}</Text>
978 </Box>
979 <Box flexGrow={1} flexShrink={1}>
980 <Text>{p.text}</Text>
981 </Box>
982 </Box>
983 ))}
984 </Box>
985 )
986 }
987
988 if (tab === 'assume') {
989 const list = await read($, assumptions)
990 const wrong = list.filter(a => a.status === 'wrong').length
991 const mark = { open: '○', right: '✓', wrong: '✗', sent: '↗' } as const
992
993 return (
994 <Box flexDirection="column" gap={1}>
995 {tabs}
996 <Text dimColor>Claude가 확인 없이 정한 가정입니다. 틀린 항목에 [틀림]을 누르고 수정 내용을 적어 보내면 해당 부분만 수정합니다</Text>
997 <Box key="assume-head" columnGap={1} flexWrap="wrap">
998 {act('assume-send', `틀린 가정 수정 요청 (${wrong})`, () => void sendCorrections($), wrong > 0 ? 'warning' : 'suggestion')}
999 {act('assume-all-right', '나머지 모두 맞음', () => void markAllRight($))}
1000 {act('assume-clear', '목록 비우기', () => void clearAssumptions($))}
1001 </Box>
1002 {list.length === 0 && <Text dimColor>아직 기록된 가정이 없습니다. 기능을 끄려면 /assumptions off를 입력합니다</Text>}
1003 {list.map(a => (
1004 <Box key={`assume-${a.id}`} flexDirection="column">
1005 <Box gap={1}>
1006 <Text color={a.status === 'wrong' ? 'error' : a.status === 'open' ? 'warning' : undefined}>{mark[a.status]}</Text>
1007 {act(`assume-right-${a.id}`, '맞음', () => void markAssumption($, a.id, 'right'), a.status === 'right' ? 'success' : 'suggestion')}
1008 {act(`assume-wrong-${a.id}`, '틀림', () => void markAssumption($, a.id, 'wrong'), a.status === 'wrong' ? 'error' : 'suggestion')}
1009 <Text>{a.agent === '' ? a.text : `[서브에이전트] ${a.text}`}</Text>
1010 </Box>
1011 {a.status === 'wrong' && Input !== undefined && (
1012 <Input
1013 key={`assume-fix-${a.id}`}
1014 placeholder="올바른 내용 (비워 두면 다시 판단하도록 요청합니다)"
1015 value={a.fix}
1016 onInput={(v: string) => void update($, assumptions, old => old.map(x => (x.id === a.id ? { ...x, fix: v } : x)))}
1017 onSubmit={(v: string) => void update($, assumptions, old => old.map(x => (x.id === a.id ? { ...x, fix: v } : x)))}
1018 />
1019 )}
1020 </Box>
1021 ))}
1022 </Box>
1023 )
1024 }
1025
1026 if (tab === 'ideas') {
1027 const list = await read($, ideas)
1028 const draftIdea = await read($, ideaDraft)
1029 const now = await $.clock.now()
1030
1031 return (
1032 <Box flexDirection="column" gap={1}>
1033 {tabs}
1034 {/* 탭마다 안내 한 줄을 먼저 두고, 탭 전용 버튼은 그 아래 줄에 모은다(가정·답변 탭과 같은 순서) */}
1035 <Text dimColor>이 프로젝트 전용입니다 · [보내기]는 바로 전송하고 [입력창에]는 입력창에 넣기만 합니다 · /idea 내용으로도 보관할 수 있습니다</Text>
1036 <Box key="ideas-head" columnGap={1}>
1037 {act('idea-shelve', '+ 입력 내용 보관', () => void shelveDraft($))}
1038 </Box>
1039 {Input !== undefined && (
1040 <Input
1041 key="idea-new"
1042 placeholder="새 아이디어를 입력하고 Enter로 보관"
1043 value={draftIdea}
1044 onInput={(v: string) => void update($, ideaDraft, () => v)}
1045 onSubmit={(v: string) => void addIdea($, v).then(ok => (ok ? update($, ideaDraft, () => '') : undefined))}
1046 />
1047 )}
1048 {list.length === 0 && <Text dimColor>보관한 아이디어가 없습니다</Text>}
1049 {list.map((idea, i) => (
1050 <Box key={`idea-${i}`} gap={1}>
1051 <Box flexShrink={0} columnGap={1}>
1052 {act(`idea-send-${i}`, '보내기', () => void sendIdea($, idea))}
1053 {act(`idea-use-${i}`, '입력창에', () => void $.prompt.fill({ text: idea.text, mode: 'insert' }).then(filled => reportFill($, filled)))}
1054 {act(`idea-del-${i}`, '삭제', () => void removeIdea($, idea))}
1055 </Box>
1056 <Box width={COL_WHEN} flexShrink={0}>
1057 <Text dimColor>{whenText(idea.at, now)}</Text>
1058 </Box>
1059 <Box flexGrow={1} flexShrink={1}>
1060 <Text>{idea.text}</Text>
1061 </Box>
1062 </Box>
1063 ))}
1064 </Box>
1065 )
1066 }
1067
1068 if (tab === 'replies') {
1069 const list = await read($, replies)
1070
1071 return (
1072 <Box flexDirection="column" gap={1}>
1073 {tabs}
1074 <Text dimColor>최근 답변 {list.length}개를 최신순으로 표시합니다. [copy]는 답변을 클립보드에 복사합니다</Text>
1075 <Box key="replies-head" columnGap={1}>
1076 {act('replies-refresh', '다시 읽기', () => void showTab($, 'replies'))}
1077 </Box>
1078 {list.length === 0 && <Text dimColor>아직 답변이 없습니다</Text>}
1079 {list.map((text, i) => (
1080 <Box key={`reply-${i}`} gap={1}>
1081 <Box flexShrink={0}>{act(`reply-copy-${i}`, 'copy', press => void copyText($, text, press.surface, `${i + 1}번째 최근 답변`))}</Box>
1082 <Box width={2} flexShrink={0} justifyContent="flex-end">
1083 <Text dimColor>{i + 1}</Text>
1084 </Box>
1085 <Box flexGrow={1} flexShrink={1}>
1086 <Text>{previewLine(text)}</Text>
1087 </Box>
1088 </Box>
1089 ))}
1090 </Box>
1091 )
1092 }
1093
1094 // 고정한 명령은 맨 위에 한 번만 보이고, 아래 그룹에서는 뺀다(같은 줄이 두 번 나오지 않게)
1095 const unpinned = (n: string) => !favs.includes(n)
1096
1097 return (
1098 <Box flexDirection="column">
1099 {tabs}
1100 <Text dimColor>[/명령] 실행 · [넣기] 입력창에 넣기(인자 입력용) · [고정] 맨 위에 고정 · [해제] 고정 해제</Text>
1101 {Input !== undefined && (
1102 <Input key="search" placeholder="명령 이름이나 설명으로 검색" value={raw} autoFocus onInput={(v: string) => void update($, query, () => v)} onSubmit={(v: string) => void update($, query, () => v)} />
1103 )}
1104 {installed.length === 0 && <Text dimColor>명령 목록을 불러오는 중입니다…</Text>}
1105 {section('fav', '고정한 명령', favs.filter(n => byName.has(n) && matches(n)))}
1106 {GROUPS.map((group, i) => section(`g${i}`, group.title, group.entries.map(x => x.name).filter(n => byName.has(n) && matches(n) && unpinned(n))))}
1107 {/* 한국어 설명을 아직 달지 않은 명령도 숨기지 않는다. 설명은 엔진이 주는 원문 그대로다 */}
1108 {section('etc', `그 밖의 명령 ${others.filter(unpinned).length}개 (플러그인·스킬 포함, 원문 설명)`, others.filter(unpinned))}
1109 </Box>
1110 )
1111 })
1112}
1113hooks/card.ts 121 lines1// 턴 작업 카드: 턴이 끝날 때 대화 기록에 남기는 한 줄
2// 파일 변경은 Edit·Write·NotebookEdit 호출만 센다. Bash로 바꾼 파일(sed, 스크립트)은 잡히지 않는다.
3
4import { clockText, kTokens } from './meter'
5
6export type FileDelta = { add: number; del: number }
7
8type EditRecord = {
9 filePath?: string
10 notebook_path?: string
11 type?: string
12 content?: string
13 staged?: boolean
14 structuredPatch?: { lines: string[] }[]
15 old_source?: string
16 new_source?: string
17}
18
19export function countPatch(hunks: readonly { lines: readonly string[] }[]): FileDelta {
20 let add = 0
21 let del = 0
22 for (const hunk of hunks) {
23 for (const line of hunk.lines) {
24 if (line.startsWith('+')) add++
25 else if (line.startsWith('-')) del++
26 }
27 }
28
29 return { add, del }
30}
31
32function lines(text: string): string[] {
33 return text === '' ? [] : text.replace(/\n$/, '').split('\n')
34}
35
36// NotebookEdit 결과에는 diff가 없어서, 앞뒤로 같은 줄을 빼고 남은 줄 수로 어림한다
37export function roughDelta(before: string, after: string): FileDelta {
38 const a = lines(before)
39 const b = lines(after)
40 let head = 0
41 while (head < a.length && head < b.length && a[head] === b[head]) head++
42 let tail = 0
43 while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++
44
45 return { add: b.length - head - tail, del: a.length - head - tail }
46}
47
48/** 도구 결과에서 바뀐 파일과 줄 수를 꺼낸다. 파일이 바뀌지 않았으면 null */
49export function editDelta(output: unknown): { path: string; delta: FileDelta } | null {
50 if (typeof output !== 'object' || output === null) return null
51 const out = output as EditRecord
52 const path = out.filePath ?? out.notebook_path
53 if (path === undefined || out.staged === true) return null
54 const delta =
55 out.structuredPatch !== undefined && out.structuredPatch.length > 0
56 ? countPatch(out.structuredPatch)
57 : out.type === 'create' && out.content !== undefined
58 ? { add: lines(out.content).length, del: 0 }
59 : out.new_source !== undefined
60 ? roughDelta(out.old_source ?? '', out.new_source)
61 : { add: 0, del: 0 }
62
63 return { path, delta }
64}
65
66export function addDelta(files: Map<string, FileDelta>, path: string, delta: FileDelta) {
67 const prev = files.get(path) ?? { add: 0, del: 0 }
68 files.set(path, { add: prev.add + delta.add, del: prev.del + delta.del })
69}
70
71export type Card = {
72 files: ReadonlyMap<string, FileDelta>
73 /** 턴 동안 늘어난 컨텍스트 토큰. 모르면 null */
74 tokens: number | null
75 /** 턴 동안 늘어난 API 단가 환산액. 모르면 null */
76 usd: number | null
77 isAborted: boolean
78}
79
80/** 스피너 줄 끝에 붙이는 진행 상황: "파일 2개 수정 · 도구 7회". 둘 다 0이면 '' */
81export function progressText(progress: { files: number; tools: number }): string {
82 const parts: string[] = []
83 if (progress.files > 0) parts.push(`파일 ${progress.files}개 수정`)
84 if (progress.tools > 0) parts.push(`도구 ${progress.tools}회`)
85
86 return parts.join(' · ')
87}
88
89/** 오늘이면 시각만, 아니면 월/일도 붙인다 */
90export function whenText(ms: number, now: number): string {
91 const d = new Date(ms)
92 const today = new Date(now)
93 const sameDay = d.getFullYear() === today.getFullYear() && d.getMonth() === today.getMonth() && d.getDate() === today.getDate()
94
95 return sameDay ? clockText(ms) : `${d.getMonth() + 1}/${d.getDate()} ${clockText(ms)}`
96}
97
98const SHOWN_FILES = 3
99
100/**
101 * 끝난 시각과 걸린 시간은 내장 줄(✻ Baked for 3m 30s · done 4:37 PM)이 보여 주므로 넣지 않는다.
102 * 파일을 고친 턴에만 남긴다. 컨텍스트·비용만 늘어난 턴(질문·답변만 한 턴)까지 남기면 턴마다 한 줄씩 늘어나서다. 남길 것이 없으면 ''
103 */
104export function cardText(card: Card): string {
105 if (card.files.size === 0) return ''
106 const parts: string[] = card.isAborted ? ['중단됨'] : []
107 let add = 0
108 let del = 0
109 for (const d of card.files.values()) {
110 add += d.add
111 del += d.del
112 }
113 const names = [...card.files.keys()].map(path => path.split(/[\\/]/).pop() ?? path)
114 const shown = names.slice(0, SHOWN_FILES).join(', ') + (names.length > SHOWN_FILES ? ` 외 ${names.length - SHOWN_FILES}개` : '')
115 parts.push(`파일 ${names.length}개 +${add} −${del} (${shown})`)
116 if (card.tokens !== null && card.tokens !== 0) parts.push(`컨텍스트 ${card.tokens > 0 ? '+' : '−'}${kTokens(Math.abs(card.tokens))}`)
117 if (card.usd !== null && card.usd >= 0.005) parts.push(`API 환산 ≈$${card.usd.toFixed(2)}`)
118
119 return parts.join(' · ')
120}
121hooks/catalog.ts 293 lines1// 내장 명령의 한국어 설명. 출처: https://code.claude.com/docs/en/commands (2026-10-07 확인)
2// 설치된 Claude Code에 없는 명령은 팔레트에 나오지 않는다.
3
4export type Entry = { name: string; ko: string }
5
6export const GROUPS: { title: string; entries: Entry[] }[] = [
7 {
8 title: '컨텍스트',
9 entries: [
10 { name: 'compact', ko: '대화를 요약해 컨텍스트 공간을 확보합니다. 요약에 남길 내용을 함께 지정할 수 있습니다' },
11 { name: 'context', ko: '컨텍스트 사용량을 항목별로 확인합니다' },
12 { name: 'clear', ko: '빈 컨텍스트로 새 대화를 시작합니다. 이전 대화는 /resume으로 다시 열 수 있습니다' },
13 { name: 'btw', ko: '대화 기록에 남기지 않고 별도 질문을 합니다. 도구 없이 한 번만 답합니다' },
14 { name: 'autocompact', ko: '자동 압축을 시작할 컨텍스트 사용량을 설정합니다' },
15 { name: 'focus', ko: '집중 보기를 켜고 끕니다. 내 질문·요약·답변만 표시합니다' },
16 ],
17 },
18 // 출처: Claude Code 2.1.296 본체의 명령 설명문 (2026-10-14 확인)
19 {
20 title: '대화 · 세션',
21 entries: [
22 { name: 'resume', ko: '이전 대화를 선택해 엽니다. 실행 중인 백그라운드 대화는 연결하고, 종료된 대화는 다시 시작합니다 (Ctrl+A: 모든 폴더)' },
23 { name: 'rename', ko: '이 대화의 이름을 변경합니다' },
24 { name: 'background', ko: '이 대화를 백그라운드 세션으로 전환하고 터미널을 비웁니다 (/bg)' },
25 { name: 'stop', ko: '백그라운드 대화를 종료합니다. 기록과 worktree는 유지됩니다 (백그라운드 세션 전용)' },
26 { name: 'exit', ko: '포그라운드 대화는 종료하고, 백그라운드 대화는 화면만 닫습니다' },
27 { name: 'fork', ko: '현재 대화를 백그라운드 세션으로 복제하고, 지금 대화는 그대로 이어 갑니다' },
28 { name: 'branch', ko: '현재 시점에서 갈라진 사본으로 전환합니다. 원본은 /resume으로 다시 열 수 있습니다' },
29 { name: 'subtask', ko: '대화 전체 맥락을 전달한 서브에이전트를 실행합니다. 결과는 이 대화로 돌아옵니다' },
30 { name: 'rewind', ko: '대화나 코드를 이전 메시지 시점으로 되돌립니다 (Esc 두 번)' },
31 { name: 'list-agents', ko: '메시지를 보낼 수 있는 서브에이전트·팀원·다른 Claude 세션을 확인합니다 (/peers)' },
32 { name: 'cd', ko: '이 대화의 작업 폴더를 변경합니다. 이동할 경로를 함께 입력합니다' },
33 ],
34 },
35 {
36 title: '작업 · 변경 확인',
37 entries: [
38 { name: 'tasks', ko: '백그라운드에서 실행 중인 서브에이전트·셸 명령을 확인하고 중지합니다' },
39 { name: 'workflows', ko: '실행 중이거나 완료된 워크플로를 확인합니다' },
40 { name: 'loops', ko: '반복 실행(/loop)을 확인·생성·삭제합니다' },
41 { name: 'goal', ko: 'Claude가 작업을 마치기 전에 확인할 목표를 설정합니다. clear로 해제합니다' },
42 { name: 'diff', ko: 'Claude가 수정한 내용을 포함해 작업 트리의 변경 사항을 검토합니다' },
43 { name: 'copy', ko: '마지막 답변을 클립보드에 복사합니다. /copy 2는 그 이전 답변을 복사합니다' },
44 { name: 'export', ko: '대화를 텍스트 파일이나 클립보드로 내보냅니다' },
45 { name: 'recap', ko: '이 세션을 한 줄로 요약합니다' },
46 { name: 'review', ko: '현재 변경 사항이나 PR을 리뷰합니다' },
47 { name: 'security-review', ko: '현재 브랜치 변경의 보안 취약점을 검토합니다' },
48 ],
49 },
50 {
51 title: '모델 · 설정',
52 entries: [
53 { name: 'model', ko: '모델을 변경합니다. 인자 없이 실행하면 선택 목록을 표시합니다' },
54 { name: 'effort', ko: '추론 강도를 변경합니다: low ~ max, auto' },
55 { name: 'advisor', ko: '중요한 시점에 더 강력한 모델에게 조언을 구하도록 합니다' },
56 { name: 'plan', ko: '코드를 수정하기 전에 계획부터 세우는 plan 모드로 전환합니다' },
57 { name: 'permissions', ko: '도구 허용·확인·거부 규칙을 관리합니다' },
58 { name: 'config', ko: '설정 화면을 엽니다 (테마, 모델 등)' },
59 { name: 'memory', ko: 'CLAUDE.md 같은 메모리 파일을 편집합니다' },
60 { name: 'hooks', ko: '도구 이벤트에 연결된 hook 설정을 확인합니다' },
61 { name: 'tui', ko: '화면 렌더러를 변경합니다 (default / fullscreen)' },
62 { name: 'theme', ko: '테마를 변경합니다' },
63 { name: 'color', ko: '이 대화의 입력창 색을 지정합니다' },
64 ],
65 },
66 {
67 title: '플러그인 · 스킬 · MCP',
68 entries: [
69 { name: 'plugin', ko: '플러그인을 설치·관리합니다 (/plugins, /marketplace)' },
70 { name: 'reload-plugins', ko: '변경된 플러그인과 mod를 현재 대화에 적용합니다' },
71 { name: 'skills', ko: '사용할 수 있는 스킬 목록을 확인합니다' },
72 { name: 'reload-skills', ko: '대화 중 추가·수정한 스킬을 다시 불러옵니다' },
73 { name: 'skill-doctor', ko: '사용하지 않으면서 컨텍스트만 차지하는 스킬을 찾아 표시합니다' },
74 { name: 'mcp', ko: 'MCP 서버 연결을 관리합니다' },
75 ],
76 },
77 {
78 title: '상태 · 도움말',
79 entries: [
80 { name: 'usage', ko: '세션 비용과 플랜 사용 한도를 확인합니다' },
81 { name: 'status', ko: '버전·모델·계정·연결 상태를 확인합니다' },
82 { name: 'version', ko: '이 대화의 Claude Code 버전을 확인합니다' },
83 { name: 'insights', ko: 'Claude Code 사용 기록을 분석해 보고서를 만듭니다' },
84 { name: 'keybindings', ko: '키보드 단축키 설정 파일을 엽니다' },
85 { name: 'doctor', ko: '설치·설정 문제를 진단합니다' },
86 { name: 'powerup', ko: '짧은 실습 강좌로 Claude Code 기능을 익힙니다' },
87 { name: 'help', ko: '도움말과 명령 목록을 확인합니다' },
88 ],
89 },
90 {
91 title: '직접 만든 mod',
92 entries: [
93 { name: 'sessions', ko: '이 PC의 모든 대화를 폴더·날짜별로 조회하고 이동·고정·보관합니다' },
94 { name: 'sessions-here', ko: '같은 폴더에서 실행 중인 다른 세션을 확인합니다' },
95 { name: 'sync', ko: '설정 저장소(CCsetting)와 메모리·mod·설정을 즉시 동기화합니다' },
96 { name: 'idea', ko: '아이디어를 프로젝트별로 저장하거나 저장한 아이디어 목록을 엽니다' },
97 { name: 'assumptions', ko: 'Claude가 확인 없이 정한 가정을 검토합니다' },
98 { name: 'flow', ko: '이 대화의 서브에이전트를 트리 형태로 확인합니다. 다시 입력하면 닫힙니다' },
99 ],
100 },
101 // 설치한 스킬과 플러그인 명령. 팔레트에 보이는 설명만 바꾸고, Claude가 스킬을 고를 때 읽는 SKILL.md의 설명은 그대로다.
102 // / 메뉴의 한국어 설명(command.describe)도 엔진 내장 명령에만 쓰므로 이 묶음은 영향이 없다. 설치되지 않은 것은 보이지 않는다.
103 // "직접 불러야 한다"는 SKILL.md에 disable-model-invocation: true가 있어 Claude가 알아서 쓰지 않는 스킬이다(2026-10-15 확인)
104 {
105 title: '스킬 · 개발 방법',
106 entries: [
107 { name: 'tdd', ko: '테스트를 먼저 작성하고 통과시키는 순서(레드-그린-리팩터)로 기능을 개발하거나 버그를 수정합니다' },
108 { name: 'diagnosing-bugs', ko: '까다로운 버그나 성능 저하를 재현하고 가설을 검증해 원인을 찾습니다' },
109 { name: 'codebase-design', ko: '작은 인터페이스 뒤에 많은 일을 숨기는 깊은 모듈 관점으로 모듈 경계와 인터페이스를 설계합니다' },
110 { name: 'domain-modeling', ko: '도메인 용어를 정리해 CONTEXT.md(용어집)와 ADR(결정 기록)을 작성·수정합니다' },
111 { name: 'grilling', ko: '계획·결정·아이디어를 집요하게 질문해 빈틈을 찾습니다' },
112 { name: 'grill-with-docs', ko: '집요한 질문으로 계획·설계를 다듬으면서 ADR과 용어집도 함께 작성합니다. 직접 실행해야 합니다' },
113 { name: 'handoff', ko: '지금 대화를 다른 에이전트가 이어받을 인계 문서로 정리합니다. 직접 실행해야 합니다' },
114 { name: 'setup-matt-pocock-skills', ko: '개발 스킬을 처음 쓰기 전에 이 저장소의 이슈 트래커·라벨·문서 구조를 설정합니다. 직접 실행해야 합니다' },
115 { name: 'shadcn-lint', ko: 'Tailwind v4 프로젝트의 UI 코드가 디자인 시스템 규칙에 맞는지 @shadcn/lint로 검사합니다' },
116 { name: 'skill-creator', ko: '스킬을 만들거나 개선하고, 평가를 실행해 성능과 호출 정확도를 높입니다' },
117 ],
118 },
119 {
120 title: '스킬 · 문서와 파일',
121 entries: [
122 { name: 'docs', ko: '공유와 댓글이 가능한 claude.ai 문서를 작성·수정합니다. 보고서·제안서 같은 문서의 기본 형식입니다' },
123 { name: 'docx', ko: 'Word 문서(.docx·.dotx)를 작성·조회·수정합니다' },
124 { name: 'pdf', ko: 'PDF의 글·표 추출, 병합·분할, 생성, 양식 입력을 처리합니다' },
125 { name: 'pptx', ko: 'PowerPoint 파일(.pptx·.potx)을 작성·조회·수정합니다' },
126 { name: 'xlsx', ko: '엑셀·CSV 같은 표 파일을 편집·정리하거나 새로 만듭니다' },
127 { name: 'google-workspace', ko: 'Google Docs·Sheets·Slides 파일을 만들거나 수정할 때 먼저 참고하는 안내입니다' },
128 ],
129 },
130 {
131 title: '스킬 · 그림과 웹',
132 entries: [
133 { name: 'diagram-design:diagram-design', ko: '아키텍처·흐름도·시퀀스·ERD·간트 등 여러 다이어그램을 HTML·SVG·PNG로 그립니다' },
134 { name: 'diagram-design:import-mermaid', ko: 'Mermaid 코드를 원하는 형식·크기·상세도로 다시 그립니다' },
135 { name: 'diagram-design:import-drawio', ko: 'draw.io 파일을 원하는 형식·크기·상세도로 다시 그립니다' },
136 { name: 'diagram-design:import-excalidraw', ko: 'Excalidraw 보드를 원하는 형식·크기·상세도로 다시 그립니다' },
137 { name: 'diagram-design:export-diagram', ko: '다이어그램 HTML 파일을 같은 폴더에 .svg와 .png로 내보냅니다' },
138 { name: 'diagram-design:profile', ko: '고객사별 다이어그램 프로필을 저장·불러오기·확인·수정·초기화·삭제합니다' },
139 { name: 'diagram-design:doctor', ko: '다이어그램 도구의 실행 환경을 한 번에 점검합니다' },
140 { name: 'insane-search:insane-search', ko: '차단된 사이트·SNS·영상 자막 같은 웹 콘텐츠를 가능한 방법을 찾아 가져옵니다' },
141 ],
142 },
143 {
144 title: '스킬 · Orca',
145 entries: [
146 { name: 'orca-cli', ko: 'Orca의 워크트리·터미널·내장 브라우저·자동화를 orca 명령으로 다룹니다' },
147 { name: 'orchestration', ko: '여러 Orca 작업자에게 작업을 나눠 맡기고 메시지·완료 보고를 조율합니다' },
148 { name: 'computer-use', ko: '화면에 보이는 앱 창을 orca computer로 클릭·입력·메뉴·스크린샷 등으로 조작합니다' },
149 ],
150 },
151 {
152 title: '스킬 · 그 밖',
153 entries: [
154 { name: 'learn', ko: '작업 대행보다 원리 이해가 필요할 때 설명과 퀴즈로 학습을 돕습니다' },
155 { name: 'morning', ko: '아침 브리핑을 HTML 페이지로 만들거나 평일 반복 작업으로 설정합니다' },
156 { name: 'import-memory', ko: '다른 AI 비서에서 내보낸 메모리를 Claude 메모리로 가져옵니다' },
157 ],
158 },
159]
160
161export const DEFAULT_FAVORITES = ['compact', 'context', 'tasks', 'fork', 'rewind', 'btw']
162
163// 팔레트 버튼이 바로 실행하지 않고 입력창에 넣기만 하는 명령.
164// btw·cd는 인자가 있어야 하고, stop·exit는 잘못 누르면 하던 일이 멈추므로 Enter로 한 번 더 확인받는다
165export const NEEDS_ARGS = new Set(['btw', 'cd', 'stop', 'exit'])
166
167export const KO = new Map(GROUPS.flatMap(group => group.entries.map(entry => [entry.name, entry.ko] as const)))
168
169// 기본 단축키(~/.claude/keybindings.json이 없어 기본값 그대로다).
170// 출처: https://code.claude.com/docs/en/interactive-mode (2026-10-09 확인, macOS 키는 2026-10-14 확인). 목록 초안은 사본 세션의 v0.3 시안에서 가져왔다.
171// mod는 키를 대신 눌러 줄 수 없다. 그 키와 같은 일을 하는 명령이 있을 때만 command를 단다.
172// keys·ko는 Windows 기준이다. macOS·Linux에서 키나 설명이 다르면 mac·linux에 다른 것만 적는다
173export type Platform = 'windows' | 'mac' | 'linux'
174type KeyText = { keys?: string; ko?: string }
175export type KeyEntry = { keys: string; ko: string; command?: string; mac?: KeyText; linux?: KeyText }
176
177// 엔진은 OS를 알려 주지 않아 환경 변수로 가린다. Windows에는 늘 OS=Windows_NT가, macOS에는 __CF_USER_TEXT_ENCODING이 있다
178export function platformOf(env: { os?: string; cfEncoding?: string }): Platform {
179 if (env.os === 'Windows_NT') return 'windows'
180
181 return env.cfEncoding !== undefined ? 'mac' : 'linux'
182}
183
184// 이 OS에서 보일 단축키 목록. mac·linux에 적은 것만 바꾸고 나머지는 그대로 둔다
185export function keyGroupsFor(platform: Platform) {
186 return KEY_GROUPS.map(group => ({
187 title: group.title,
188 entries: group.entries.map(({ mac, linux, ...entry }) => ({ ...entry, ...(platform === 'mac' ? mac : platform === 'linux' ? linux : undefined) })),
189 }))
190}
191
192export const KEY_GROUPS: { title: string; entries: KeyEntry[] }[] = [
193 // 출처: 엔진 타입 2.1.295의 AbovePrompt 설명("collapses it (ctrl+x ctrl+a, [-]) or focuses it (a click, ctrl+x tab)").
194 // Tab·Enter로 버튼을 고르는 흐름은 AI LABS 영상(Prompt Enhancer 시연)에서 확인했다
195 {
196 title: 'clickbar 줄을 키보드로 쓰기 (클릭이 안 될 때)',
197 entries: [
198 { keys: 'Ctrl+X Tab', ko: '입력창 위 clickbar 줄로 이동합니다. Tab으로 버튼을 옮기고 Enter로 실행합니다' },
199 { keys: 'Ctrl+X Ctrl+A', ko: 'clickbar 줄을 접거나 펼칩니다 (줄 오른쪽 [-]와 같습니다)' },
200 ],
201 },
202 {
203 title: 'Claude가 일하는 중',
204 entries: [
205 { keys: 'Esc', ko: 'Claude를 멈추고 방향을 바꿉니다. 진행한 작업은 유지되며, 열린 대화상자는 닫힙니다' },
206 { keys: 'Ctrl+B', ko: '실행 중인 Bash 명령과 에이전트를 백그라운드로 보냅니다' },
207 { keys: 'Ctrl+X Ctrl+K', ko: '백그라운드 서브에이전트를 모두 멈춥니다 (3초 안에 두 번)' },
208 { keys: 'Ctrl+Enter', ko: '대기 중인 메시지를 즉시 보냅니다 (Ctrl+X Ctrl+S도 같습니다. 터미널이 Ctrl+Enter를 Enter로 넘기면 Ctrl+X Ctrl+S를 사용합니다)' },
209 { keys: 'Ctrl+C', ko: '작업 중이면 중단하고, 아니면 입력을 지웁니다. 빈 입력창에서 한 번 더 누르면 종료합니다' },
210 ],
211 },
212 {
213 title: '되돌리기 · 이전 입력',
214 entries: [
215 { keys: 'Esc Esc', ko: '입력이 있으면 지우고(↑로 복원), 비어 있으면 되감기 메뉴를 엽니다', command: 'rewind' },
216 { keys: 'Ctrl+R', ko: '이전에 보낸 질문을 검색합니다' },
217 { keys: '↑ / ↓', ko: '이전에 보낸 질문을 차례로 불러옵니다' },
218 { keys: 'Ctrl+S', ko: '작성 중인 글을 임시 보관하고 입력창을 비웁니다. 빈 입력창에서 다시 누르면 복원합니다' },
219 { keys: 'Ctrl+_', ko: '입력 편집을 한 단계 되돌립니다' },
220 ],
221 },
222 {
223 title: '모드 · 모델',
224 entries: [
225 {
226 keys: 'Shift+Tab',
227 ko: '권한 모드를 순서대로 전환합니다: Manual → acceptEdits → plan … (Windows에서 안 되면 Alt+M)',
228 mac: { ko: '권한 모드를 순서대로 전환합니다: Manual → acceptEdits → plan …' },
229 linux: { ko: '권한 모드를 순서대로 전환합니다: Manual → acceptEdits → plan …' },
230 },
231 // macOS의 Option 조합은 터미널이 Option을 Alt(Meta)로 넘겨야 된다. Option+T만 그 설정 없이 된다(공식 문서)
232 {
233 keys: 'Alt+P',
234 ko: '입력을 지우지 않고 모델을 변경합니다',
235 command: 'model',
236 mac: { keys: 'Option+P', ko: '입력을 지우지 않고 모델을 변경합니다. 터미널이 Option을 Alt로 전달해야 합니다(Orca: 설정 → Terminal → Option as Alt)' },
237 },
238 {
239 keys: 'Alt+T',
240 ko: '확장 사고를 켜고 끕니다. Opus 5.5 등은 항상 켜져 있어 효과가 없습니다',
241 mac: { keys: 'Option+T', ko: '확장 사고를 켜고 끕니다. Opus 5.5 등은 항상 켜져 있어 효과가 없습니다. Option 설정 없이도 동작합니다' },
242 },
243 // 단가 출처: https://platform.claude.com/docs/en/about-claude/pricing (2026-10-08 확인)
244 { keys: 'Alt+O', ko: 'fast 모드를 켜고 끕니다. Opus 5.5에서 토큰 단가가 2배입니다 (입력·출력 $8·$40, 보통 $4·$20)', command: 'fast', mac: { keys: 'Option+O' } },
245 ],
246 },
247 {
248 title: '화면 보기',
249 entries: [
250 { keys: 'Ctrl+O', ko: '대화 기록 뷰어를 엽니다. 도구 내역과 답변별 시각·모델을 표시합니다' },
251 { keys: 'Ctrl+T', ko: 'Claude의 할 일 목록을 표시하거나 숨깁니다' },
252 { keys: '?', ko: '빈 입력창에서 누르면 단축키 도움말을 엽니다' },
253 { keys: 'Ctrl+L', ko: '화면이 깨졌을 때 다시 그립니다' },
254 ],
255 },
256 {
257 title: '대화 기록 뷰어 안에서 (Ctrl+O 다음)',
258 entries: [
259 { keys: '{ / }', ko: '이전 · 다음 내 질문으로 이동합니다' },
260 { keys: '[', ko: '대화 전체를 터미널 스크롤백에 출력해 터미널 검색으로 찾을 수 있게 합니다' },
261 { keys: 'v', ko: '대화를 임시 파일로 저장해 편집기로 엽니다' },
262 { keys: 'q', ko: '뷰어를 닫습니다 (Esc도 같습니다)' },
263 ],
264 },
265 {
266 title: '입력',
267 entries: [
268 { keys: 'Ctrl+G', ko: '입력을 기본 텍스트 편집기에서 편집합니다' },
269 { keys: 'Alt+V', ko: '클립보드 이미지를 붙여 넣습니다', mac: { keys: 'Ctrl+V', ko: '클립보드 이미지를 붙여 넣습니다 (iTerm2는 Cmd+V)' }, linux: { keys: 'Ctrl+V' } },
270 { keys: '!', ko: '맨 앞에 입력하면 셸 모드로 전환합니다. 명령을 바로 실행하고 결과를 대화에 추가합니다' },
271 { keys: '@', ko: '파일 경로를 자동 완성합니다' },
272 { keys: 'Ctrl+W · K · U', ko: '앞 단어 / 줄 끝까지 / 줄 처음까지 지웁니다. Ctrl+Y로 지운 글을 붙여 넣습니다' },
273 ],
274 },
275]
276
277// 질문 문장에서 내장 명령으로 바로 할 수 있는 일을 알아보고 한 번만 알려 준다(command-hub의 방식).
278// 잘못 알아보면 귀찮기만 하므로, 명령이 하는 일과 거의 같은 말만 넣는다.
279export type Tip = { command: string; pattern: RegExp }
280
281export const TIPS: Tip[] = [
282 { command: 'compact', pattern: /(컨텍스트|대화).{0,6}(압축|줄여|비워)|압축해\s*줘/ },
283 { command: 'context', pattern: /컨텍스트.{0,8}(얼마나|몇\s*%|사용량|차지)/ },
284 { command: 'usage', pattern: /(한도|사용량|리밋|limit).{0,10}(얼마|남았|확인|초기화)/i },
285 { command: 'rewind', pattern: /(방금|아까|이전).{0,10}(되돌려|원래대로|롤백|취소해)|되돌려\s*줘/ },
286 { command: 'clear', pattern: /(대화|세션).{0,6}(새로|처음부터)\s*시작/ },
287 { command: 'resume', pattern: /(이전|예전|지난)\s*(대화|세션).{0,8}(이어|열어|불러)/ },
288 { command: 'model', pattern: /모델.{0,6}(바꿔|변경|교체)/ },
289 { command: 'diff', pattern: /(바뀐|변경된|수정된)\s*(내용|부분|파일|사항).{0,10}(보여|확인)/ },
290 { command: 'tasks', pattern: /(백그라운드|실행\s*중인)\s*(작업|태스크|에이전트).{0,10}(보여|확인|목록)/ },
291 { command: 'plan', pattern: /(계획만|먼저\s*계획|코드\s*건드리지\s*말고\s*계획)/ },
292]
293hooks/assumptions.ts 50 lines1// 가정 확인: Claude가 묻지 않고 정한 것을 도구로 기록하게 하고, 사용자가 맞음·틀림을 고른다.
2// AI LABS 영상의 assumption-tracker를 다시 만든 것이다. 별도의 모델 호출은 없다.
3
4import type { Assumption } from '../types'
5
6export const ASSUME_TOOL_NAME = 'log_assumption'
7export const ASSUME_TOOL = `mcp__clickbar__${ASSUME_TOOL_NAME}`
8// 훅 매처용. 등록 전에는 타입에 이 도구 이름이 없어서 정규식으로 맞춘다
9export const ASSUME_TOOL_MATCH = /^mcp__clickbar__log_assumption$/
10// 사용자가 꺼 두면 이 값이 $.store에 'off'로 남는다
11export const ASSUME_SWITCH_KEY = 'assumptions'
12
13export const ASSUME_DESCRIPTION = [
14 'Log the assumptions you made without asking the user, so they can confirm or correct them in the clickbar panel.',
15 'Only log assumptions that change the outcome: how you read an ambiguous request, defaults you chose, scope you added or left out, guesses about data, formats or environment. Skip trivial implementation details.',
16 'Batch them: call at most once per response with every new assumption, preferably alongside your other tool calls in the same response.',
17 "Write each assumption as one short sentence in the user's language. The result is only an acknowledgement; keep working.",
18].join(' ')
19
20export const ASSUME_SCHEMA = {
21 type: 'object',
22 properties: {
23 assumptions: { type: 'array', items: { type: 'string' }, minItems: 1, description: 'One short sentence per assumption' },
24 },
25 required: ['assumptions'],
26}
27
28/** 도구 입력에서 가정 문장들만 꺼낸다 */
29export function assumptionTexts(input: unknown): string[] {
30 const list = typeof input === 'object' && input !== null ? (input as { assumptions?: unknown }).assumptions : undefined
31 if (!Array.isArray(list)) return []
32
33 return list.filter((x): x is string => typeof x === 'string').map(x => x.trim()).filter(x => x !== '')
34}
35
36/** 틀렸다고 고른 가정을 고쳐 달라는 프롬프트. 맞다고 확인한 가정은 그대로 두라고 함께 알린다 */
37export function correctionPrompt(list: readonly Assumption[]): string | null {
38 const wrong = list.filter(a => a.status === 'wrong')
39 if (wrong.length === 0) return null
40 const right = list.filter(a => a.status === 'right')
41 const lines = ['방금 작업에서 세운 가정 중 아래는 틀렸어. 이 부분만 고쳐 줘.']
42 wrong.forEach((a, i) => {
43 lines.push(`${i + 1}. 가정: ${a.text}`)
44 lines.push(` → ${a.fix.trim() === '' ? '이 가정을 버리고 다시 판단해 줘' : `대신: ${a.fix.trim()}`}`)
45 })
46 if (right.length > 0) lines.push('', `맞다고 확인한 가정(그대로 둬): ${right.map(a => a.text).join(' / ')}`)
47
48 return lines.join('\n')
49}
50hooks/meter.ts 114 lines1import type { Limit, Meter } from '../types'
2
3export type MeterView = {
4 percent: number
5 left: number
6 /** compact: 자동 압축 임계값 기준, window: 모델 창 기준(자동 압축이 꺼졌을 때) */
7 basis: 'compact' | 'window'
8 tone: 'normal' | 'warn' | 'bad'
9}
10
11export function viewOf(meter: Meter): MeterView | null {
12 if (meter.tokens === null) return null
13 const isCompactBasis = meter.threshold !== null && meter.threshold > 0
14 const limit = isCompactBasis && meter.threshold !== null ? meter.threshold : meter.window
15 if (limit <= 0) return null
16 const percent = Math.round((meter.tokens / limit) * 100)
17 const tone = percent >= 85 ? 'bad' : percent >= 60 ? 'warn' : 'normal'
18
19 return { percent, left: Math.max(0, limit - meter.tokens), basis: isCompactBasis ? 'compact' : 'window', tone }
20}
21
22export function bar(percent: number, cells = 10): string {
23 const filled = Math.min(cells, Math.max(0, Math.round((percent / 100) * cells)))
24
25 return '█'.repeat(filled) + '░'.repeat(cells - filled)
26}
27
28export function kTokens(n: number): string {
29 return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
30}
31
32// 한도는 넉넉할 때 band 폭이 좁으면 숨기고, 이 값을 넘으면 항상 보인다
33export const LIMIT_ALERT = 70
34const LIMIT_NAMES: Record<string, string> = { five_hour: '5시간', seven_day: '주간', spend_limit: '지출' }
35
36/**
37 * 한도 한 줄. 이름·비율·막대는 band와 팔레트가 같은 모양으로 그린다.
38 * reset은 band의 괄호 안 초기화 시각(요일 없음, 모르거나 지났으면 ''), detail은 팔레트에서 그 옆에 쓰는 초기화 시각과 남은 시간이다
39 */
40export type LimitView = { kind: string; name: string; percent: number; reset: string; detail: string; isHigh: boolean; tone: 'normal' | 'warn' | 'bad' }
41
42export function limitView(limit: Limit, now: number): LimitView {
43 const name = LIMIT_NAMES[limit.kind] ?? limit.kind
44 const resetsAt = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt)
45 // 한도 값은 응답이 올 때만 바뀐다. 초기화 시각이 지난 뒤 응답이 없었다면 그동안 쓴 양도 없으므로 0%다
46 if (!Number.isNaN(resetsAt) && resetsAt <= now) {
47 return { kind: limit.kind, name, percent: 0, reset: '', detail: '초기화됨 · 다음 응답부터 다시 집계합니다', isHigh: false, tone: 'normal' }
48 }
49 const isHigh = limit.percentUsed >= LIMIT_ALERT
50 const hasReset = !Number.isNaN(resetsAt)
51
52 return {
53 kind: limit.kind,
54 name,
55 percent: Math.round(limit.percentUsed),
56 // band는 한 줄에 컨텍스트·한도를 모두 두므로 요일을 뺀다. 팔레트는 한도마다 한 줄이라 요일과 남은 시간을 함께 쓴다
57 reset: hasReset ? resetWhen(resetsAt, now, false) : '',
58 detail: hasReset ? `${resetWhen(resetsAt, now)} 초기화 · ${untilText(resetsAt - now)} 뒤` : '',
59 isHigh,
60 tone: limit.percentUsed >= 90 ? 'bad' : isHigh ? 'warn' : 'normal',
61 }
62}
63
64/** band 상태 줄의 항목 하나: [!] 이름 막대 비율 (설명). 막대·설명이 없으면 '' */
65export type MeterItem = { key: string; name: string; bar: string; figure: string; note: string; tone: 'normal' | 'warn' | 'bad' }
66
67/** 터미널 칸 수. 한글·한자 같은 넓은 글자는 두 칸, 막대(█░)를 포함한 나머지는 한 칸으로 센다 */
68export function cells(text: string): number {
69 let n = 0
70 for (const ch of text) n += /[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩]/.test(ch) ? 2 : 1
71
72 return n
73}
74
75/** 항목이 차지하는 칸 수. 조각 사이는 한 칸씩 띄운다(band가 columnGap 1로 그린다) */
76export function itemCells(item: MeterItem): number {
77 const parts = [item.tone === 'bad' ? '!' : '', item.name, item.bar, item.figure, item.note].filter(p => p !== '')
78
79 return parts.reduce((n, p) => n + cells(p), 0) + parts.length - 1
80}
81
82const WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토']
83
84/** 초기화 시각: 오늘이면 17:20, 내일이면 내일 09:00, 그 뒤면 10/14(화) 09:00. 좁은 곳에서는 요일을 뺀다 */
85export function resetWhen(at: number, now: number, withWeekday = true): string {
86 const d = new Date(at)
87 const today = new Date(now)
88 const days = Math.round((new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime() - new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime()) / 86_400_000)
89 if (days === 0) return clockText(at)
90 if (days === 1) return `내일 ${clockText(at)}`
91
92 return `${d.getMonth() + 1}/${d.getDate()}${withWeekday ? `(${WEEKDAYS[d.getDay()]})` : ''} ${clockText(at)}`
93}
94
95export function clockText(ms: number): string {
96 const d = new Date(ms)
97
98 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
99}
100
101export function untilText(ms: number): string {
102 const minutes = Math.max(0, Math.ceil(ms / 60000))
103 if (minutes < 60) return `${minutes}분`
104 const hours = Math.floor(minutes / 60)
105 if (hours < 24) return minutes % 60 === 0 ? `${hours}시간` : `${hours}시간 ${minutes % 60}분`
106
107 return `${Math.floor(hours / 24)}일 ${hours % 24}시간`
108}
109
110/** API 단가 환산액. 구독 계정에서는 실제 청구액이 아니라서 ≈를 붙인다 */
111export function usdText(usd: number): string {
112 return `≈$${usd < 10 ? usd.toFixed(2) : Math.round(usd)}`
113}
114hooks/prompts.ts 53 lines1// 내 질문 타임라인: 사람이 직접 보낸 질문만 모은다(where-were-we의 방식).
2// 시스템이 넣은 글(<command-name>, <task-notification> 같은 태그로 시작)과 도구 결과는 뺀다.
3
4import type { PromptEntry } from '../types'
5
6export const PROMPTS_MAX = 200
7
8export function isTypedPrompt(text: string): boolean {
9 const t = text.trim()
10
11 return t !== '' && !t.startsWith('<')
12}
13
14type Block = { type?: unknown; text?: unknown }
15
16export function blocksText(content: unknown): string {
17 if (typeof content === 'string') return content
18 if (!Array.isArray(content)) return ''
19
20 return (content as Block[])
21 .filter(b => b?.type === 'text' && typeof b.text === 'string')
22 .map(b => b.text as string)
23 .join('\n')
24}
25
26/** 기록 파일(JSONL)에서 사람이 보낸 질문을 순서대로 꺼낸다. 시각은 파일에 적힌 값이다 */
27export function promptsFromTranscript(jsonl: string): PromptEntry[] {
28 const out: PromptEntry[] = []
29 for (const line of jsonl.split('\n')) {
30 if (!line.includes('"type":"user"')) continue
31 let row: { type?: unknown; uuid?: unknown; timestamp?: unknown; isMeta?: unknown; isSidechain?: unknown; message?: { content?: unknown } }
32 try {
33 row = JSON.parse(line)
34 } catch {
35 continue
36 }
37 if (row.type !== 'user' || row.isMeta === true || row.isSidechain === true || typeof row.uuid !== 'string') continue
38 const text = blocksText(row.message?.content)
39 if (!isTypedPrompt(text)) continue
40 out.push({ uuid: row.uuid, at: typeof row.timestamp === 'string' ? Date.parse(row.timestamp) || 0 : 0, text })
41 }
42
43 return out.slice(-PROMPTS_MAX)
44}
45
46/** 같은 uuid는 한 번만 두고, 시각 순으로 정렬한다 */
47export function mergePrompts(a: readonly PromptEntry[], b: readonly PromptEntry[]): PromptEntry[] {
48 const byId = new Map<string, PromptEntry>()
49 for (const p of [...a, ...b]) if (!byId.has(p.uuid)) byId.set(p.uuid, p)
50
51 return [...byId.values()].sort((x, y) => x.at - y.at).slice(-PROMPTS_MAX)
52}
53hooks/replies.ts 39 lines1// 답변 목록: 턴마다 마지막 답변 하나만 고른다.
2// 한 턴 안의 assistant 행에는 도구 호출 사이의 중간 설명("~를 확인하겠습니다")도 섞여 있어서다.
3
4type Row = { role: 'user' | 'assistant'; text: string; toolResults?: readonly unknown[] }
5
6/** 턴마다 마지막 답변 글, 최신이 먼저. 도구 결과가 아닌 user 행을 새 턴의 시작으로 본다 */
7export function finalReplies(messages: readonly Row[], limit: number): string[] {
8 const out: string[] = []
9 let last: string | undefined
10 for (const m of messages) {
11 if (m.role === 'user' && (m.toolResults === undefined || m.toolResults.length === 0)) {
12 if (last !== undefined) out.push(last)
13 last = undefined
14 } else if (m.role === 'assistant' && m.text.trim() !== '') {
15 last = m.text
16 }
17 }
18 if (last !== undefined) out.push(last)
19
20 return out.slice(-limit).reverse()
21}
22
23// 전역 CLAUDE.md 규칙으로 답변 전체가 ````markdown 펜스 하나에 담겨 온다. 그대로 그리면 표가 글자 그대로 보인다.
24// 백틱 4개 이상에 markdown·md 표시가 붙었거나 표시가 없는 펜스만 벗긴다. ````python 같은 다른 언어와
25// 안쪽의 ``` 코드 블록은 그대로 둔다. 닫는 펜스가 아직 오지 않은(받는 중인) 답변은 바꾸지 않는다.
26const OUTER_FENCE = /^(`{4,})(?:markdown|md)?[ \t]*\n([\s\S]*?)\n\1[ \t]*$/gm
27
28/** 답변을 감싼 바깥 마크다운 펜스를 벗긴다. 없으면 그대로 */
29export function unfence(text: string): string {
30 return text.replace(OUTER_FENCE, '$2')
31}
32
33/** 목록에 보여줄 첫 줄. 코드 펜스 줄은 건너뛰고 제목·인용 기호는 뗀다 */
34export function previewLine(text: string): string {
35 const line = text.split('\n').find(l => l.trim() !== '' && !/^\s*`{3,}/.test(l)) ?? ''
36
37 return line.replace(/^\s*(#+|>)\s*/, '').trim()
38}
39hooks/sync.ts 32 lines1import type { SyncStatus } from '../types'
2
3// 마지막 동기화가 이보다 오래되면 [sync stale]로 알린다. 동기화는 세션이 열리고 닫힐 때만 돌아,
4// 세션 하나를 며칠 켜 두면 그동안 아무것도 올라가지 않는다
5export const SYNC_STALE_MS = 24 * 3600_000
6
7/** sync.status.json 내용을 검사해 꺼낸다. 형식이 다르면 null */
8export function parseSyncStatus(text: string): SyncStatus | null {
9 let raw: unknown
10 try {
11 raw = JSON.parse(text.trim())
12 } catch {
13 return null
14 }
15 const s = raw as Partial<Record<keyof SyncStatus, unknown>>
16 if (typeof s?.at !== 'string' || (s.result !== 'ok' && s.result !== 'held' && s.result !== 'failed')) return null
17 const held = Array.isArray(s.held) ? s.held.filter((x): x is string => typeof x === 'string') : []
18
19 return { at: s.at, result: s.result, held, message: typeof s.message === 'string' ? s.message : '' }
20}
21
22/** band의 [sync] 버튼 이름과 색. 문제가 없으면 그냥 [sync]다 */
23export function syncView(status: SyncStatus | null, now: number): { word: string; color: 'suggestion' | 'warning' | 'error' } {
24 if (status === null) return { word: 'sync', color: 'suggestion' }
25 if (status.result === 'failed') return { word: 'sync failed', color: 'error' }
26 if (status.result === 'held') return { word: `sync held ${status.held.length}`, color: 'warning' }
27 const at = Date.parse(status.at)
28 if (!Number.isNaN(at) && now - at > SYNC_STALE_MS) return { word: 'sync stale', color: 'warning' }
29
30 return { word: 'sync', color: 'suggestion' }
31}
32types/index.d.ts 71 lines1export type Meter = {
2 /** 마지막 API 응답이 보고한 입력 토큰. 새 세션이나 압축 직후에는 null */
3 tokens: number | null
4 /** 모델의 컨텍스트 창 크기 */
5 window: number
6 /** 자동 압축이 시작되는 토큰 수. 자동 압축이 꺼졌거나 아직 모르면 null */
7 threshold: number | null
8}
9
10export type CommandRow = { name: string; description: string }
11
12/** 요금제 사용 한도 창 하나. kind는 five_hour, seven_day, spend_limit */
13export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
14
15export type Usage = {
16 /** 구독 계정이 아니거나 첫 응답 전이면 빈 배열 */
17 limits: Limit[]
18 /** 이 세션의 API 단가 환산액(/cost와 같은 값). 모르면 null */
19 costUsd: number | null
20}
21
22export type PaletteTab = 'commands' | 'keys' | 'replies' | 'ideas' | 'assume' | 'prompts'
23
24/** 내가 보낸 질문 하나. uuid는 기록 파일의 행 id이자 화면 행의 id, at은 밀리초 */
25export type PromptEntry = { uuid: string; at: number; text: string }
26
27/**
28 * Claude가 log_assumption 도구로 남긴 가정 하나.
29 * status: open(아직 안 봄), right, wrong, sent(고쳐 달라고 보냄). agent는 서브에이전트 id, 메인이면 ''
30 */
31export type Assumption = { id: string; text: string; at: number; status: 'open' | 'right' | 'wrong' | 'sent'; fix: string; agent: string }
32
33/** 아이디어 목록의 한 줄. at은 보관한 시각($.clock.now()의 밀리초) */
34export type Idea = { text: string; at: number }
35
36/** 진행 중인 턴에서 지금까지 고친 파일 수와 메인 에이전트의 도구 호출 수. 일하는 동안 스피너 줄 끝에 붙인다 */
37export type TurnProgress = { files: number; tools: number }
38
39/**
40 * CCsetting sync.ps1이 ~/.claude/sync.status.json에 남기는 마지막 전체 동기화 결과.
41 * result: ok, held(비밀값으로 보이는 줄이 있는 파일만 커밋에서 뺌), failed. at은 ISO 시각
42 */
43export type SyncStatus = { at: string; result: 'ok' | 'held' | 'failed'; held: string[]; message: string }
44
45declare module 'claude-code' {
46 interface PluginState {
47 clickbar: {
48 meter: Meter | null
49 usage: Usage
50 commands: CommandRow[]
51 query: string
52 favorites: string[]
53 paletteTab: PaletteTab
54 /** 최근 답변 글, 최신이 먼저. 답변 탭을 열 때 다시 읽는다 */
55 replies: string[]
56 /** 이 프로젝트의 아이디어 목록, 최신이 먼저. $.store에 프로젝트 경로별로 저장한다 */
57 ideas: Idea[]
58 /** 아이디어 탭의 새 아이디어 입력칸 */
59 ideaDraft: string
60 /** 이 세션에서 기록된 가정, 최신이 먼저 */
61 assumptions: Assumption[]
62 /** 이 세션에서 보낸 질문, 오래된 것이 먼저 */
63 prompts: PromptEntry[]
64 /** 마지막 전체 동기화 결과. 상태 파일이 없거나 읽지 못하면 null */
65 syncStatus: SyncStatus | null
66 /** 진행 중인 턴의 진행 상황. 턴을 시작할 때 0으로 되돌린다 */
67 turnProgress: TurnProgress
68 }
69 }
70}
71