SLOPSHOPPER

clickbar

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

newpanebandspinnerrowsguard
v0.18.1MITupdated 2026-10-16SJvaca30/mulgogi-mods/clickbar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clickbar
│ ┃ 명령 ✕ ╭─────────────────────────────────────────╮ │ ┃ [명령] [단축키] [내 질문] [답변] [아이디어] │ clickbar │ │ ┃ 5시간 ██░░░ 31% fix the failing auth │ /tips off로 명령 추천을 끄고, /tips on으로 다시 켭니다 │ │ ┃ ╰─────────────────────────────────────────╯ │ ┃ Claude가 확인 없이 정한 가정입니다. 틀린 ╭────────────────────────────────────────────╮ │ ┃ 항목에 [틀림]을 누르고 수정 내용을 적어 ⏺ Read(src/auth.ts) │ clickbar │ │ ┃ 보내면 해당 부분만 수정합니다 ⎿ Read 6 lines │ /bubbles 뒤에 card(카드) · bar(막대 카드) · │ │ ┃ ⏺ Update(src/auth.ts) │ mark(표식) · off(끄기) 중 하나를 입력합니다 │ │ ┃ [틀린 가정 수정 요청 (0)] [나머지 모두 맞음] ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ┃ ⏺ Bash(bun test) │ ┃ 아직 기록된 가정이 없습니다. 기능을 끄려면 ⎿ 3 pass, 1 fail │ ┃ /assumptions off를 입력합니다 │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /palette │ │ ✻ Thinking · 도구 9회… ── clickbar ──────────────────────────────────────────────────────────────────────────────────────── 컨텍스트 49% (창 여유 103k) 5시간 31% [compact] [context] │ [resume] │ [copy] [quote] [btw] │ [palette] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
── clickbar ──────────────────────────────────────────────────────────────────────────────────────── 컨텍스트 49% (창 여유 103k) 5시간 31% [compact] [context] │ [resume] │ [copy] [quote] [btw] │ [palette] ⟨Claude Code's own drawing⟩
Pane · 명령
[명령] [단축키] [내 질문] [답변] [아이디어] 가정 [닫기] Esc 5시간 ██░░░ 31% Claude가 확인 없이 정한 가정입니다. 틀린 항목에 [틀림]을 누르고 수정 내용을 적어 보내면 해당 부분만 수정합니다 [틀린 가정 수정 요청 (0)] [나머지 모두 맞음] [목록 비우기] 아직 기록된 가정이 없습니다. 기능을 끄려면 /assumptions off를 입력합니다
Your message
fix the failing auth test and add an audit log call
README

mulgogi-mods

Claude Code를 더 빠르고 편하게 쓰는 한국어 mod 모음

Claude Code에 입력창 위 대시보드, 명령 팔레트, 세션 관리 패널을 추가합니다.

Claude Code mods Platform License: MIT

<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

clickbar

입력창 위 대시보드

  • 상태 줄: 컨텍스트 사용률과 자동 압축까지 남은 양, 5시간·주간 사용 한도와 초기화 시각, API 환산 비용을 한 줄로 표시합니다. 항목마다 이름·막대·비율을 묶어 구분하고, 위험 수준에 가까워지면 막대와 비율의 색이 바뀌며 [compact now] 버튼이 나타납니다.
  • 버튼 줄: 자주 쓰는 명령을 용도별로 묶어 배치합니다. 컨텍스트([compact] [context]), 대화([resume] [sessions] [fork]), 답변([copy] [quote] [btw]), 도구([tasks] [palette]) 순서입니다. 화면이 좁으면 일부 버튼이 숨겨지며, 팔레트에서 실행할 수 있습니다.

대화 화면

  • 내 질문 카드: 대화에서 내 질문을 왼쪽에 색 막대가 있는 옅은 카드로 표시해, 칠하지 않은 Claude의 답변과 한눈에 구분합니다. 장식이 글자가 아니라 칠한 칸이라 마우스로 복사해도 질문 글만 복사됩니다. /bubbles card(막대 없는 카드) · /bubbles mark(❯ 표식) · /bubbles off(끄기)로 모양을 바꿀 수 있고, Ctrl+O 전체 기록에서는 원래 모양으로 보입니다.
  • 진행 상황: Claude가 작업하는 동안 스피너 줄에 이번 턴에 수정한 파일 수와 도구 호출 수를 함께 표시합니다(예: ✶ Pondering · 파일 2개 수정 · 도구 7회…).
  • 턴 요약: 파일을 수정한 턴이 끝나면 변경된 파일과 줄 수, 컨텍스트 증가량, API 환산 비용을 한 줄로 기록합니다. 질문·답변만 오간 턴은 기록하지 않습니다. 소요 시간과 완료 시각은 Claude Code의 기본 표시를 그대로 사용합니다.

명령 팔레트

<img src="docs/images/palette-light.png" width="928" alt="명령 팔레트: 탭과 사용 한도, 고정한 명령, 대화·세션 명령, 설치한 스킬이 한국어 설명과 함께 표시된 화면">

  • 내장 명령과 설치된 스킬을 용도별로 분류하고 한국어 설명을 제공합니다. 자주 쓰는 명령은 [고정]으로 맨 위에 고정할 수 있습니다.
  • [/명령]은 즉시 실행하고, [넣기]는 인자를 입력할 수 있도록 입력창에 명령만 넣습니다.
  • 탭 구성: 명령, 단축키(OS별 키 표기), 내 질문(보낸 질문으로 이동·재입력), 답변(복사), 아이디어(/idea로 저장한 메모), 가정(Claude의 가정 검토).
  • 머리 줄 아래에 5시간·주간 한도의 초기화 시각과 남은 시간을 표시합니다.
  • 명령 이름이나 설명으로 검색할 수 있습니다. 팔레트를 열 때마다 검색어는 초기화됩니다.

Claude의 가정 확인

Claude가 확인 없이 정한 가정을 log_assumption 도구로 기록합니다. 입력창 위 [assumptions N] 버튼이나 팔레트의 가정 탭에서 맞음·틀림을 선택하면, 틀린 가정만 수정하도록 요청합니다.

session-tracker

<img src="docs/images/sessions-light.png" width="928" alt="세션 패널: 보기 탭과 상태 필터, 고정한 대화와 폴더별 대화 목록, 상태·종류·관리 버튼이 표시된 화면">

  • 세션 줄: 현재 대화의 종류(백그라운드·포그라운드)와 탭을 닫았을 때의 동작을 입력창 위에 표시합니다. 같은 폴더에서 작업 중이거나 입력(도구 승인·질문 답변·계획 승인)을 기다리는 다른 세션이 있으면 그 세션의 이름·상태·이어진 시간·요청을 함께 표시하고, 이름을 누르면 그 세션의 탭으로 이동합니다. 쉬고 있는 세션은 겹칠 일도 할 일도 없어 표시하지 않습니다(전체 목록은 /sessions, 같은 폴더 목록은 /sessions-here).
  • 세션 패널(/sessions): 실행 중인 대화와 기록만 남은 대화(유휴)를 폴더별·날짜별로 조회하고, 상태(켜짐·유휴)로 필터링합니다. 필터 버튼에는 해당 대화 수가 함께 표시됩니다. 줄 끝 [관리]에서 고정·이름 변경·보관·종료를 할 수 있으며, Claude가 답변하는 중에도 바로 열립니다.
  • 탭 이동(Orca·herdr): Orca 탭이나 herdr 패널에서는 [이동]으로 해당 세션의 탭으로 전환하고, [열기]로 종료된 대화를 새 탭에서 다시 엽니다. 포그라운드 세션은 시작할 때 자신이 실행 중인 탭(Orca 탭·herdr 패널)을 세션 상태에 기록하므로, 이름을 붙이지 않은 세션도 정확한 탭으로 전환합니다. 백그라운드 세션은 탭 제목(세션 이름)으로 찾고, 탭이 없으면 새 탭에서 연결합니다. 백그라운드 세션을 claude agents 화면이나 이 mod가 연 탭에서 보고 있으면, 그 세션의 [이동]·[열기]는 처음 실행한 곳이 아니라 지금 보고 있는 프로그램(Orca·herdr)의 탭을 사용합니다. 직접 연 탭에서 claude attach로 보고 있으면 처음 실행한 곳을 따릅니다. 새 탭은 Claude 화면이 뜬 것을 확인한 뒤에만 앞으로 옮기고 그동안 그 줄에 "여는 중…"을 표시합니다. 연결할 작업이 이미 정리됐으면 실패한 탭을 닫고 기록으로 다시 엽니다. 다른 창에서 실행 중인 포그라운드 세션은 기록이 섞이지 않도록 새로 열지 않고 안내만 합니다. herdr는 터미널 안에서 실행하는 프로그램이므로 PowerShell·Windows Terminal에서도 herdr 안에서 Claude를 실행하면 같은 기능을 사용할 수 있습니다. 일반 터미널에서는 [여기서 열기](/resume <id>)와 [명령 복사]를 제공합니다.
  • 작업 겹침 방지: 세션 줄에 다른 세션이 보일 때(같은 폴더에서 작업 중이거나 입력을 기다릴 때) 보내는 질문에 그 세션의 이름과 요청을 알리는 안내 문구를 추가해, 같은 파일을 동시에 수정하지 않도록 합니다. 여러 세션이 같은 파일을 고쳐야 하면 git worktree로 폴더를 나누는 편이 안전합니다.

명령 팔레트와 세션 패널은 머리 줄 구성이 같습니다. 왼쪽에 탭(선택한 탭은 색을 채워 표시), 오른쪽 끝에 [닫기]가 있고 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를 실행합니다.

Claude에게 영향을 주는 기능

| 기능 | 동작 | 끄는 방법 | | :- | :- | :- | | 가정 기록(clickbar) | Claude에게 log_assumption 도구를 제공합니다 | /assumptions off | | 명령 추천(clickbar) | 질문에 맞는 내장 명령을 한 번씩 추천합니다. 질문 내용은 변경하지 않습니다 | /tips off | | 작업 겹침 안내(session-tracker) | 같은 폴더의 다른 세션이 작업 중이거나 입력을 기다리면 질문에 그 세션의 이름과 요청을 알리는 안내 문구 한 줄을 추가합니다 | /sessions-here off | | 이름 변경 대리 입력(session-tracker) | 다른 세션의 이름을 변경하면 해당 세션의 Orca 탭에 /rename 새 이름을 입력합니다(Orca에서만). 승인 대기 중이 아니고 입력창이 비어 있을 때만 입력합니다 | 사용하지 않으면 동작하지 않습니다 |

[btw], 아이디어 탭의 [보내기], 가정 탭의 [틀린 가정 수정 요청]은 누르면 Claude에게 질문을 전송합니다. 누르지 않으면 아무것도 전송하지 않습니다.

데이터와 보안

  • 네트워크 요청을 하지 않으며, mod가 스스로 모델을 호출하지 않습니다.
  • 설정·고정 목록·아이디어는 ~/.claude/plugins/store/에, 세션 상태는 ~/.claude/mods-state/session-tracker/에 저장합니다. 모두 이 PC에만 저장됩니다.
  • mod는 사용자 권한으로 실행되는 코드입니다. 설치 전에 저장소를 내려받아 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을 설정한 뒤 다시 시도합니다.

라이선스

MIT

Source 9 files
hooks/register.tsx 1113 lines
1import { 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}
1113
hooks/card.ts 121 lines
1// 턴 작업 카드: 턴이 끝날 때 대화 기록에 남기는 한 줄
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}
121
hooks/catalog.ts 293 lines
1// 내장 명령의 한국어 설명. 출처: 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]
293
hooks/assumptions.ts 50 lines
1// 가정 확인: 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}
50
hooks/meter.ts 114 lines
1import 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}
114
hooks/prompts.ts 53 lines
1// 내 질문 타임라인: 사람이 직접 보낸 질문만 모은다(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}
53
hooks/replies.ts 39 lines
1// 답변 목록: 턴마다 마지막 답변 하나만 고른다.
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}
39
hooks/sync.ts 32 lines
1import 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}
32
types/index.d.ts 71 lines
1export 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