SLOPSHOPPER

deck

Flight Deck — Claude Code 화면 한 벌: 입력창 위 두 줄 계기판(statusline 대체)·프롬프트 캐시 keepwarm·커밋 검문, 답 속 mermaid·chart·이미지를 그림으로, 크게 보기 pane, 오른쪽 끝 펫(노란 슬라임). herdr 테마를 따라…

newpanebandrowsguardcommand
v0.8.5MITupdated 2026-10-08JongDeug/dotfiles/ai/claude/mods/deck
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · deck
│ ┃ deck-view ✕ › fix the failing auth test and add an audit log call │ ┃ 보여 줄 그림이 없다. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /keepwarm │ ⎿ deck: keepwarm 6h 00m 켬. 쉴 때마다 50m 뒤 핑을 보내 캐시를 다시 쓰지 않고 읽게 한다 │ │ ⟨Claude Code's own drawing⟩ 📁 /work/app ⎇ (detached) ◆ Opus 5.5 dev ✓ 깨끗 ╭───────────────────────────────────────────────────────── CTX ▰▰▰▰▱▱▱▱ 49% 5H ▰▰▱▱▱▱▱▱ 31% $0.42 ☕ 캐시 │ 앗, 에러... ╰───────────────────────────────────────────────────────── ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ 📁 /work/app ⎇ (detached) ◆ Opus 5.5 dev ✓ 깨끗 ╭───────────────────────────────────────── CTX ▰▰▰▰▱▱▱▱ 49% 5H ▰▰▱▱▱▱▱▱ 31% $0.42 ☕ 캐시 │ 앗, 에러... ╰─────────────────────────────────────────
Pane · deck-view
보여 줄 그림이 없다.
README

Flight Deck (deck)

/deck — 하는 일과 명령을 한 장으로 대화에 찍는다. /deck sync — push 한 deck 을 ~/.claude·~/.claude-work 에 깔고, herdr 안의 쉬는 Claude 세션에 /reload-plugins 를 보낸다(작업 중·입력 중·부른 세션은 건너뜀). 끝나면 알림으로 요약.

Claude Code 화면 한 벌. 입력창 위 계기판, 답 속 그림, 크게 보기 pane, 프롬프트 캐시 keepwarm 이 한 mod 다. 색은 herdr 테마(gruvbox dark) — hooks/theme.ts 하나에서 바꾼다.

/plugin marketplace add JongDeug/dotfiles
/plugin install deck@jongdeug

계기판 — 입력창 위 두 줄 (statusline 대체)

 📁 ~/…/com.m1ucs/detector   ⎇ develop ↑1   ◆ Opus 5.5  high   ● 1 +0 −0   CI ✓ build · 3분 전
 CTX ▰▰▰▰▰▱▱▱ 63%   5H ▱▱▱▱▱▱▱▱ 6% ⟲ 4h 46m   7D ▰▰▰▱▱▱▱▱ 40% ⟲ 2d 16h   $97.87 🔥 $2.0/h   ☕ 캐시 42m 남음      ⠹ 12s  Bash  도구 3
  • 윗줄 — 어디서: 경로(노랑), 브랜치(↑↓, 아쿠아), 모델·effort(주황), 바뀐 파일 수와 +/− 줄, CI(GitHub gh · GitLab glab, 현재 브랜치 마지막 실행). 변경 알약에 포인터를 올리면 파일 목록이 위로 펼쳐진다.
  • 아랫줄 — 얼마나 남았나: 컨텍스트(늘 초록)·5시간·7일을 같은 ▰▱ 8칸 게이지로(한도는 50%·80% 에서 초록 → 노랑 → 빨강, 초기화까지 남은 시간), 비용과 시간당 소모율, 프롬프트 캐시. 오른쪽 끝은 도는 동안 스피너·경과·지금 도구·도구/편집/에이전트 수, 끝나면 지난 턴 ….
  • 좁은 pane — 한 줄에 안 들어가면 덜 중요한 것부터 뺀다(지난 턴 → 깨끗 표시 → 🔥/h → 초기화 시각 → effort · 캐시 설명 → CI · 비용 → 7D …). 브랜치·경로·CTX 는 끝까지 남는다.
  • 갱신: 도는 동안 0.25초(스피너), 컨텍스트·한도 5초, git 10초(명령·편집이 끝날 때도), CI 1분. 값은 $.session.usage()(statusline 과 같은 숫자), $.session.model(), git·gh·glab. effort 는 훅에 오지 않아서 설정 파일의 effortLevel 을 읽는다.

프롬프트 캐시 — keepwarm · 식은 채 보내기 경고

cache-tax 2.2.1(Karan Bansal, MIT — LICENSE-cache-tax)을 합쳤다. 동작은 원본 그대로이고 그리기는 아랫줄 캐시 알약이 맡는다.

  • /keepwarm — 쉬는 동안 캐시를 데워 둔다. 그냥 치면 6시간, 90m 같은 시간, always(세션마다 자동), off, status. 마지막 요청 뒤 50분 쉬면 짧은 핑을 보낸다(토큰을 쓴다). 핑이 캐시를 다시 쓰면(이미 식었으면) 스스로 멈춘다.
  • 식은 채 보내기 경고 — 컨텍스트가 5만 토큰을 넘는데 캐시가 식었으면 첫 메시지를 한 번 막고 다시 쓰는 값을 보인다. 다시 보내면 들어간다. /cache guard warn 이면 막지 않고 값만 보인다.
  • /cache — 상태 카드: 식은 비용, keepwarm, 손익분기, 이 세션에 낸 식은 쓰기.
  • 알약: ☕ 캐시 42m 남음 · keepwarm 5h12m · 핑 8m 뒤 → 식으면 🧊 캐시 식음 · 다음 요청이 371k 다시 씀 ≈$3.71, 멈추면 ⏸ keepwarm 멈춤 · 이유.

답 속 그림

Claude 에게 "이 터미널은 그림을 그린다"는 안내를 붙여, 그림이 나은 곳에 아래 문법을 쓰게 한다. 그림 밑에 포인터를 올리면 ⤢ 크게 보기(차트는 ↓ PNG 저장 도) — 입력창 위 넓은 pane 하나에 꽉 차게, Esc 로 닫는다.

쓰는 것그림그리는 것
``` `mermaid ```다이어그램. 그림이 안 되는 터미널(iTerm2, tmux 안)이면 선 문자 도형bin/mermaid.mjs — beautiful-mermaid 레이아웃 + resvg. 한글 칸 맞춤, style: sketch 면 손그림(rough.js · Gaegu, fonts/OFL.txt) — 상자·마름모·원·실린더·선 모두, 작은 아이콘(시작·끝 점, ER 기호)과 화살촉은 반듯하게
``` `chart ``` (Vega-Lite JSON)막대·선·도넛·나란히(hconcat)bin/chart.mjs — vega-lite → vega → SVG → resvg PNG 2배. style: sketch 면 rough.js 로 흔들리는 모양(단색 채움)·손그림 선. 크게 보기는 pane 크기로 다시 그린다
설명 줄그림(한 줄에 여러 개면 나란히), 영상은 프레임 6장bin/convert.sh — macOS sips, 영상은 ffmpeg. ~/.cache/claude-img 에 둔다
Read 로 읽은 이미지그 도구 줄 아래〃

그림 요소(kitty 그래픽)가 되는 터미널이 필요하다 — herdr 안이면 settings 의 env 에 CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1. 못 그리면 코드 그대로 두고 아래에 이유를 흐리게 단다.

커밋 검문

git-commit 스킬이 앞에서 쓰는 법을 이끌고, deck 은 실행 직전에 기계로 가를 수 있는 규칙만 한 번 더 본다. 걸리면 git commit·git add 를 실행하지 않고 고칠 곳(몇째 줄이 왜)을 짚어 돌려보낸다 — 모델이 고쳐 다시 한다.

  • 원천 — attribution.text: 엔진이 커밋·PR 에 서명을 붙이라고 시키는 문구를 비운다. 모델이 처음부터 안 쓴다.
  • 서명 줄: Co-Authored-By: Claude… · Claude-Session: · 🤖 Generated with [Claude Code]. 사람 공동작성자 줄은 건드리지 않는다.
  • 한꺼번에 담기: git add -A · git add . · git commit -a.
  • 제목: type(scope): 제목, type 은 feat · fix · refactor · perf · test · docs · chore · style · build · ci · revert. Merge·Revert 기본 제목과 fixup! 은 넘긴다.
  • 브랜치: fix/… 에선 fix: 처럼 브랜치 prefix 와 type 을 맞춘다.
  • 본문: 60칸 넘는 줄이 문장부호 없이 끝나고 다음 줄이 이어지면(폭 맞춰 끊은 줄), 표(|), 공백 여러 칸으로 맞춘 열.
  • 팀 레포만: develop · main · master 에 바로 커밋. 팀 레포 = 저장소 CLAUDE.md 가 backend-llm-wiki/REPO-CLAUDE.md 를 import 하는 곳.

명령 속 글만 읽으므로 -F 파일 로 넘긴 메시지와 편집기로 쓴 메시지는 못 본다. 따옴표 속 글·heredoc 은 명령으로 치지 않는다(글 속 "git commit" 에 속지 않는다).

펫 (슬라임 말랑이)

계기판 오른쪽 끝에 펫이 산다(설정 pet: slime 기본 · off, 100칸보다 좁으면 숨는다). 펫은 그림만 — 글자는 없다.

  • 슬라임(21칸 × 4줄): 바닥에 앉은 반타원 젤리를 너비·높이·기울기·위치로 그때그때 계산해 찍는다(slime.ts). 일하는 동안엔 깜빡이다 왼쪽을 힐끔 보고, 왼쪽으로 사르륵 녹아 흘러가 사라졌다가(바닥 자국이 말라 간다) 오른쪽 위에서 뚝 떨어져 뿅 하고 다시 선다.
  • 사건이 있으면 슬라임 왼쪽에 말풍선 상자가 3초 뜬다: 커밋 성공(냠! 커밋 깔끔 ✓) · 검문에 걸림 · 도구 오류 · 1분 넘은 턴 끝 · 캐시가 식는 순간 · keepwarm 핑 · 5H 80% 넘는 순간 · CI 실패 · 세션 시작. 같은 말은 10분에 한 번, 띠 높이는 그대로다.
  • 기다리는 동안엔 천천히 숨만 쉬고(가끔 깜빡), 캐시가 식으면 웅덩이로 꾸벅, 커밋 검문·도구 오류 뒤 20초는 시무룩, 5H 한도 80% 넘으면 지쳐서 납작.
  • /pet off 는 이 세션에서만 숨기고 /pet 은 다시 띄운다.

테마

herdr 테마를 따른다. ~/.config/herdr/config.toml 의 [theme] name(herdr 내장 18개 — gruvbox·catppuccin·tokyo-night·dracula·nord·one-dark·solarized·kanagawa·rose-pine·vesper 와 그 라이트판)을 10초마다 읽고, auto_switch = true 면 맥 라이트·다크 모드로 짝을 고른다. 계기판 알약·게이지·안내·답 속 mermaid·chart·슬라임이 다 같이 바뀌고, 리로드는 필요 없다. 그림은 테마마다 따로 그려 둔다(돌아오면 다시 안 그린다). terminal 처럼 고정 색이 없는 테마는 gruvbox.

설정 (/plugin → deck → configure)

키기본
cell_aspect2.2칸 높이÷너비. 그림이 길쭉하면 줄이고 납작하면 키운다
max_rows30답 속 이미지·mermaid 하나의 최대 높이(줄)
scale1다이어그램 크기(0.5~2). 1 이면 라벨이 터미널 글자 크기
petslimeoff 면 안 띄움
stylecleansketch 면 손그림 — 다이어그램은 rough 선·손글씨체, 차트는 흔들리는 모양을 단색으로 채우고 축·글자도 손그림

구조

훅 모듈은 플러그인마다 하나이고 $ 는 import 한 함수로 넘길 수 없어서, 타이머·핑·저장·그리기·훅은 모두 hooks/register.tsx 에 있다. $ 를 안 쓰는 것만 나눴다 — theme.ts(herdr 테마 18개 팔레트·설정 읽기), blocks.ts(답을 블록으로), board.ts(git·시간·칸 맞추기·서명 줄 찾기), commit.ts(커밋 검문), pet.ts(펫 상태·반 칸 블록), slime.ts(슬라임 모션), help.ts(/deck 안내), cache.ts·cacheview.ts(keepwarm 문구·캐시 알약), images.ts·fences.ts·parse.ts(이미지 줄·차트·mermaid 찾기). 렌더러는 bin/ 의 node·sh 다(훅 모듈엔 Node 가 없다). 그린 그림은 ~/.cache/claude-deck/pictures/ 에 남겨(diskcache.mjs, 렌더러 소스까지 해시) 리로드·재시작해도 다시 그리지 않는다. sync.mjs 는 /deck sync.

claude plugin validate ai/claude/mods/deck
claude plugin test ai/claude/mods/deck     # 88개
node ai/claude/mods/deck/bin/check.mjs     # mermaid 글꼴 점검 (npm ci 뒤)
Source 13 files
hooks/register.tsx 1132 lines
1import type { EngineInterface, PromptSubmitInput, RenderElement, PromptSubmitResult, Register, TurnCompleteInput, TurnCompleteResult } from 'claude-code'
2
3import {
4  armedText, AUTO_WARM_MS, BIG_TOKENS, card, deadlineKey, DEFAULT_WINDOW_MS, disarm, everyKey, freshState, guardText, KEY_ALWAYS, KEY_DEADLINE, KEY_EVERY, KEY_GUARD,
5  MIN_PING_MS, parseDuration, PING_AFTER_MS, PING_PROMPT, pingUsd, resetForClear, seedFromResume, statusText, type State,
6} from './cache'
7import { fmtDuration, fmtTok, fmtUsd, isCold, priceOf, TTL_MS, viewOf } from './cacheview'
8import { helpText } from './help'
9import { commitProblems, readCommit, type Repo } from './commit'
10import { stateOf, type PetState } from './pet'
11import { SLIME_COLS, slimeRows } from './slime'
12import { gridFor, isImagePath } from './images'
13import { pickMode, type Mode } from './parse'
14import { C, herdrTheme, setTheme, soft } from './theme'
15import { splitAll } from './blocks'
16import { ago, cellWidth, fit, type Fit, ciFromGithub, ciFromGitlab, elapsed, modelName, parseGit, shortPath, tokensOf, untilText, type Ci, type Git } from './board'
17
18// 입력창 위 두 줄 계기판 — statusline 이 하던 것(모델·경로·브랜치·컨텍스트·비용·사용량 한도)에
19// git 변경·CI·지금 도는 작업을 더한다. 윗줄은 "어디서 무엇을", 아랫줄은 "얼마나 남았나".
20// 모듈 변수는 리로드 때 비워지고 session.start 는 다시 오지 않는다 — 처음 그릴 때 시작한다.
21const EDITS = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
22const FILES = 'board-files'
23const SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
24
25let started = false
26let cwd = ''
27let home = ''
28let effort = ''
29let model = ''
30let git: Git | null = null
31let ci: Ci | null = null
32let host: 'github' | 'gitlab' | null = null
33const busy = { git: false, ci: false, usage: false }
34
35type Turn = { startedAt: number; tools: number; current: string; agents: number; edits: Set<string> }
36let turn: Turn | null = null
37// 펫(슬라임): 설정에서 껐나(pet: off), 이 세션에서 /pet off 로 숨겼나, 다친 때(거절·도구 오류)까지.
38const pet = { kind: 'slime' as 'slime' | 'off', hidden: false, hurtUntil: 0 }
39// 말풍선: 사건 때 3초. 같은 말(key)은 gap 안에 다시 안 한다(기본 10분). 계속되는 상태(캐시 식음·5H)는 바뀌는 순간에만.
40let bubble: { text: string; color: string; until: number } | null = null
41const said = new Map<string, number>()
42const seen = { cold: false, tired: false }
43function say(now: number, key: string, text: string, color: string, gap = 600_000) {
44  if (now - (said.get(key) ?? -Infinity) < gap) return
45  said.set(key, now)
46  bubble = { text, color, until: now + 3000 }
47}
48let last: { seconds: number; tools: number; edits: number } | null = null
49
50type Usage = {
51  tokens: number
52  window: number
53  percent: number
54  limits: { kind: string; percent: number; resetsAt: number }[]
55  usd?: number
56  startedAt: number
57}
58let usage: Usage | null = null
59
60async function sh($: EngineInterface, argv: string[]): Promise<string> {
61  const { exitCode, stdout } = await $.process.run(argv, { cwd, timeoutMs: 15_000 })
62  return exitCode === 0 ? stdout.trim() : ''
63}
64
65
66function petState(now: number): PetState {
67  return stateOf({
68    working: turn !== null,
69    hurtUntil: pet.hurtUntil,
70    now,
71    fiveHour: usage?.limits.find(l => l.kind === 'five_hour')?.percent,
72    cold: viewOf(cache, now)?.tone === 'cold',
73  })
74}
75
76async function commitCheck($: EngineInterface, command: string): Promise<string | null> {
77  const plan = readCommit(command, cwd)
78  if (!plan) return null
79  let repo: Repo | undefined
80  if (plan.commit && plan.dir) {
81    try {
82      const git = async (...args: string[]) => {
83        const r = await $.process.run(['git', '-C', plan.dir!, ...args], { timeoutMs: 5_000 })
84        return r.exitCode === 0 ? r.stdout.trim() : ''
85      }
86      const branch = await git('symbolic-ref', '--short', 'HEAD') // 커밋 없는 새 브랜치도 읽힌다(detached 면 빈 값)
87      const root = await git('rev-parse', '--show-toplevel')
88      // 팀 레포 = CLAUDE.md 가 백엔드팀 공통 블록(backend-llm-wiki/REPO-CLAUDE.md)을 import 하는 저장소.
89      const md = root ? await $.fs.read(`${root}/CLAUDE.md`).catch(() => '') : ''
90      if (branch) repo = { branch, team: /backend-llm-wiki\/REPO-CLAUDE\.md/.test(String(md)) }
91    } catch {
92      // 저장소를 못 읽으면 브랜치 규칙만 건너뛴다.
93    }
94  }
95  const problems = commitProblems(plan, repo)
96  if (!problems.length) return null
97  return `deck: 커밋을 막았다 — git-commit 스킬 규칙에 걸린다. 아래를 고쳐 다시 한다.\n${problems.map(p => `- ${p}`).join('\n')}`
98}
99
100async function guarded($: EngineInterface, key: keyof typeof busy, work: () => Promise<void>): Promise<void> {
101  if (busy[key]) return
102  busy[key] = true
103  try {
104    await work()
105  } catch {
106    // 못 읽은 칸은 그리지 않을 뿐이다.
107  } finally {
108    busy[key] = false
109  }
110  $.ui.invalidate('ui.render')
111}
112
113const refreshGit = ($: EngineInterface) =>
114  guarded($, 'git', async () => {
115    const status = await sh($, ['git', 'status', '--porcelain=v2', '--branch'])
116    git = status ? parseGit(status, await sh($, ['git', 'diff', '--numstat', 'HEAD'])) : null
117  })
118
119const refreshCi = ($: EngineInterface) =>
120  guarded($, 'ci', async () => {
121    if (!git?.branch) return
122    if (host === null) {
123      const url = await sh($, ['git', 'remote', 'get-url', 'origin'])
124      host = url.includes('github.com') ? 'github' : url.includes('gitlab') ? 'gitlab' : null
125    }
126    const was = ci?.state
127    if (host === 'github') ci = ciFromGithub(await sh($, ['gh', 'run', 'list', '-b', git.branch, '-L', '1', '--json', 'status,conclusion,updatedAt,workflowName,url']))
128    else if (host === 'gitlab') ci = ciFromGitlab(await sh($, ['glab', 'ci', 'list', '-b', git.branch, '-P', '1', '-F', 'json']))
129    if (ci?.state === 'fail' && was && was !== 'fail') say(await $.clock.now(), 'ci', 'CI 깨졌어!', C.red, 0)
130  })
131
132const refreshUsage = ($: EngineInterface) =>
133  guarded($, 'usage', async () => {
134    const u = await $.session.usage()
135    const c = u.context
136    const tokens = c.tokens ?? 0
137    usage = {
138      tokens,
139      window: c.window,
140      percent: c.percent ?? Math.round((tokens / c.window) * 100),
141      limits: u.rateLimits.map(l => ({ kind: l.kind, percent: l.percentUsed, resetsAt: Date.parse(l.resetsAt ?? '') || 0 })),
142      usd: u.cost?.usd,
143      startedAt: u.startedAt,
144    }
145    model = modelName(await $.session.model())
146  })
147
148// herdr 테마를 따른다 — 설정의 [theme] name, auto_switch 면 맥 라이트·다크로 짝을 고른다. 바뀌면 다시 그린다.
149async function refreshTheme($: EngineInterface): Promise<void> {
150  try {
151    const xdg = (await $.env.get('XDG_CONFIG_HOME')) || `${home}/.config`
152    const toml = String(await $.fs.read(`${xdg}/herdr/config.toml`))
153    const dark = /auto_switch\s*=\s*true/.test(toml) ? (await sh($, ['defaults', 'read', '-g', 'AppleInterfaceStyle'])) === 'Dark' : true
154    if (setTheme(herdrTheme(toml, dark))) $.ui.invalidate('ui.render')
155  } catch {
156    // herdr 설정이 없으면 지금 색(기본 gruvbox) 그대로
157  }
158}
159
160async function start($: EngineInterface): Promise<void> {
161  if (started) return
162  started = true
163  await initCache($, 'terminal')
164  cwd = await $.session.cwd()
165  home = (await $.env.get('HOME')) ?? ''
166  // effort 는 훅 모듈에 바로 오지 않는다 — 설정 파일에서 읽는다.
167  const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) || `${home}/.claude`
168  effort = (await $.env.get('CLAUDE_EFFORT')) || (await sh($, ['node', '-e', `try{console.log(require(${JSON.stringify(`${dir}/settings.json`)}).effortLevel||'')}catch{}`]))
169  await Promise.all([refreshGit($), refreshUsage($)])
170  void refreshCi($)
171  await refreshTheme($)
172  say(await $.clock.now(), 'hi', '안녕!', C.teal)
173  $.clock.every(10_000, () => {
174    void refreshGit($)
175    void refreshTheme($)
176  })
177  $.clock.every(5_000, () => void refreshUsage($))
178  $.clock.every(60_000, () => void refreshCi($))
179  // 도는 동안만 스피너를 돌린다.
180  $.clock.every(250, () => {
181    if (turn) $.ui.invalidate('ui.render')
182  })
183  // 쉬는 동안엔 펫만 0.25초마다 — 도는 동안은 위 스피너가 다시 그린다.
184  $.clock.every(250, () => {
185    if (!turn && pet.kind !== 'off' && !pet.hidden) $.ui.invalidate('ui.render')
186  })
187}
188
189// ── 프롬프트 캐시 keepwarm · 식은 채 보내기 경고 ─────────────────────────────
190// cache-tax 2.2.1(Karan Bansal, MIT — ../LICENSE-cache-tax)의 타이머·핑·저장. 동작은 원본 그대로다.
191const cache: State = freshState()
192
193function updateStatus($: EngineInterface, s: State, now: number) {
194  if (s.hasBand) $.ui.invalidate('ui.render')
195  else $.ui.status(statusText(s, now))
196}
197
198/** Clears this session's own dead window and the bare keys a store written before 2.1.1 still holds. Other sessions' keys are never touched: a read followed by a delete cannot be made atomic against their renewal. */
199async function prune($: EngineInterface, s: State, now: number) {
200  for (const key of [KEY_DEADLINE, deadlineKey(s)]) {
201    const deadline = await $.store.get(key)
202    if (deadline === undefined) continue
203    if (typeof deadline === 'number' && deadline > now) continue
204    await $.store.delete(key)
205    await $.store.delete(KEY_EVERY + key.slice(KEY_DEADLINE.length))
206  }
207}
208
209async function stop($: EngineInterface, s: State, why: string | null, forgetAlways = false) {
210  s.deadline = 0
211  s.every = PING_AFTER_MS
212  s.stopped = why
213  disarm(s)
214  await $.store.delete(deadlineKey(s))
215  await $.store.delete(everyKey(s))
216  if (forgetAlways) {
217    s.always = false
218    await $.store.delete(KEY_ALWAYS)
219  }
220  updateStatus($, s, await $.clock.now())
221}
222
223async function arm($: EngineInterface, s: State) {
224  disarm(s)
225  if (!s.deadline) return
226  const now = await $.clock.now()
227  if (now >= s.deadline) return stop($, s, null)
228  // A cold window still needs expiry cleanup, but must not send a model request.
229  if (s.lastRequestAt && !s.compacted && !isCold(cache, now)) {
230    const untilCold = s.lastRequestAt + TTL_MS - now
231    const delay = Math.min(s.deadline - now, untilCold, Math.max(1000, s.lastRequestAt + s.every - now))
232    s.pending = $.clock.after(delay, () => { void ping($, s) })
233  } else {
234    s.pending = $.clock.after(s.deadline - now, () => { void arm($, s) })
235  }
236  updateStatus($, s, now)
237}
238
239async function ping($: EngineInterface, s: State) {
240  s.pending = null
241  if (!s.deadline) return
242  const now = await $.clock.now()
243  if (now >= s.deadline) return arm($, s)
244  // A turn in the meantime re-armed the timer; this callback is stale.
245  if (isCold(cache, now)) return arm($, s)
246  if (now - s.lastRequestAt < s.every - 1000) return
247  let reply
248  try {
249    reply = await $.model.fork({ prompt: PING_PROMPT })
250  } catch (err) {
251    return stop($, s, `핑 실패, ${err instanceof Error ? err.message : String(err)}`)
252  }
253  if (reply === null) return stop($, s, '엔진이 핑을 보내지 않았다 — 스냅숏이 식었거나 API 호출이 실패했다')
254  if (reply.isAnswered === false) {
255    const reason = reply.reason === 'nothing-to-fork' ? '아직 데울 대화가 없다'
256      : reply.reason === 'api-error' ? `API 호출 실패${reply.status === null ? '' : ` (${reply.status})`}`
257      : reply.reason === 'aborted' ? '핑이 중단됐다'
258      : '핑 답이 비었다'
259    return stop($, s, reason)
260  }
261  const u = reply.usage
262  const price = priceOf(s.lastModel)
263  // A warm ping reads the prefix and writes only its own message; a write of a tenth of the read or more means the prefix broke.
264  const warm = u.cache_read_input_tokens > 0 && u.cache_creation_input_tokens < 0.1 * u.cache_read_input_tokens
265  const usd = price ? pingUsd(u, price) : null
266  s.last = { at: now, read: u.cache_read_input_tokens, write: u.cache_creation_input_tokens, usd, warm }
267  if (!warm) return stop($, s, `핑이 ${fmtTok(u.cache_read_input_tokens)}을 읽고 ${fmtTok(u.cache_creation_input_tokens)}토큰을 썼다(${fmtUsd(usd)}) — 캐시가 이미 사라졌다`)
268  s.lastRequestAt = now
269  say(now, 'ping', '캐시 데웠어 ☕', C.blue)
270  await arm($, s)
271}
272
273async function startWindow($: EngineInterface, s: State, windowMs: number, every: number) {
274  const now = await $.clock.now()
275  s.every = every
276  if (every === PING_AFTER_MS) await $.store.delete(everyKey(s))
277  else await $.store.set(everyKey(s), every)
278  s.deadline = now + windowMs
279  s.stopped = null
280  await $.store.set(deadlineKey(s), s.deadline)
281  await arm($, s)
282}
283
284// 세션 시작 때 하는 일(저장된 keepwarm 복원, 명령 등록). /reload-plugins 뒤엔 session.start 가 다시 오지 않으니
285// 계기판이 처음 그려질 때도 부른다 — 한 번만.
286let cacheReady = false
287async function initCache($: EngineInterface, surface: string | null, fresh = false): Promise<void> {
288  // 진짜 session.start 는 매번 새로 읽는다(always 창 다시 켜기 등). 그리기 쪽은 아직 안 했을 때만.
289  if (cacheReady && !fresh) return
290  cacheReady = true
291  cache.hasBand = surface === 'terminal' || surface === 'desktop'
292  if (cache.hasBand) $.ui.status(undefined)
293  cache.sid = await $.session.id()
294  const now = await $.clock.now()
295  await prune($, cache, now)
296  const saved = await $.store.get(deadlineKey(cache))
297  const savedEvery = await $.store.get(everyKey(cache))
298  const savedGuard = await $.store.get(KEY_GUARD)
299  cache.deadline = typeof saved === 'number' && saved > now ? saved : 0
300  cache.every = typeof savedEvery === 'number' && savedEvery >= MIN_PING_MS ? savedEvery : PING_AFTER_MS
301  cache.guard = savedGuard === 'warn' ? 'warn' : 'refuse'
302  cache.always = (await $.store.get(KEY_ALWAYS)) === true
303  // Always means a fresh default window every session, whatever the last one left behind.
304  if (cache.always) await startWindow($, cache, DEFAULT_WINDOW_MS, PING_AFTER_MS)
305  const usage = await $.session.usage()
306  if (usage.context.tokens) cache.ctx = usage.context.tokens
307  await $.command.register({
308    name: 'keepwarm',
309    description: '쉬는 동안 프롬프트 캐시를 데워 둔다: 그냥 치면 6h, 90m 같은 시간, always, off, status',
310    argumentHint: '[6h | always | off | status]',
311    immediate: true,
312  })
313  await $.command.register({
314    name: 'deck',
315    description: 'deck 이 하는 일과 명령 한 장. sync: push 한 deck 을 두 설정 폴더에 깔고 쉬는 세션을 리로드',
316    argumentHint: '[sync]',
317    immediate: true,
318  })
319  await $.command.register({
320    name: 'pet',
321    description: '계기판 오른쪽 펫을 이 세션에서 띄운다. off 면 숨긴다',
322    argumentHint: '[off]',
323    immediate: true,
324  })
325  await $.command.register({
326    name: 'cache',
327    description: '프롬프트 캐시 상태 카드: 식은 비용·keepwarm·이 세션에 낸 식은 쓰기. guard warn|refuse',
328    argumentHint: '[status | guard warn | guard refuse]',
329    immediate: true,
330  })
331  // The hook form of cache-tax ships a /cache-tax:status skill; both installed means two guards.
332  const commands = await $.command.list()
333  if (commands.some(c => c.name === 'cache-tax:status')) {
334    $.ui.log('cache-tax 훅 버전(cache-tax@claude-code-hooks)도 깔려 있어서 식은 채 보내기를 두 번 경고하거나 막는다. 그쪽을 지우거나 여기서 /cache guard warn.')
335  }
336  updateStatus($, cache, now)
337}
338
339// The message that pays. Only its first character is read.
340async function guardColdSend($: EngineInterface, e: PromptSubmitInput, next: (e: PromptSubmitInput) => Promise<PromptSubmitResult>): Promise<PromptSubmitResult> {
341  if (e.origin.kind === 'plugin') return next(e)
342  if (typeof e.text !== 'string' || e.text.trimStart().startsWith('/')) return next(e)
343  const now = await $.clock.now()
344  if (!isCold(cache, now) || cache.ctx < BIG_TOKENS) return next(e)
345  if (cache.guard === 'warn') {
346    $.ui.log(`${guardText(cache, now)} 그대로 보낸다 — 들어가면 keepwarm 이 ${fmtDuration(AUTO_WARM_MS)} 동안 캐시를 데워 둔다.`)
347    cache.coldWritePending = true
348    return next(e)
349  }
350  if (cache.ackedAt === cache.lastRequestAt) {
351    cache.ackedAt = 0
352    cache.coldWritePending = true
353    return next(e)
354  }
355  cache.ackedAt = cache.lastRequestAt
356  return { drop: `cache: ${guardText(cache, now)} 다시 보내면 그 값을 내고 들어가고, 그 뒤 keepwarm 이 ${fmtDuration(AUTO_WARM_MS)} 동안 데워 둔다. 아니면 /clear 하고 메모에서 시작하자.` }
357}
358
359async function scoreTurn($: EngineInterface, e: TurnCompleteInput, next: (e: TurnCompleteInput) => Promise<TurnCompleteResult>): Promise<TurnCompleteResult> {
360  const r = await next(e)
361  if (e.agentId) return r
362  const now = await $.clock.now()
363  // A sleeping host may deliver this turn before the expired window's timer.
364  if (cache.deadline && now >= cache.deadline) await stop($, cache, null)
365  // turn.step stamps the exact request time; when no step of this turn did, the turn's end is the floor.
366  if (now - cache.lastRequestAt > e.durationMs) cache.lastRequestAt = now
367  cache.compacted = false
368  cache.ackedAt = 0
369  const u = e.usage
370  if (u) {
371    if (u.model) cache.lastModel = u.model
372    const prev = cache.ctx
373    // A turn's usage is its responses summed, so a ten-step turn reports ten
374    // reads of the context. The live window is the engine's figure; the sum
375    // is only the fallback for a host that reports no tokens.
376    const write = u.cache_creation_input_tokens
377    const live = (await $.session.usage()).context.tokens
378    cache.ctx = live && live > 0 ? live : u.input_tokens + u.cache_read_input_tokens + write
379    const full = prev > 20000 && write >= 0.5 * prev
380    if (full || cache.coldWritePending) {
381      const price = priceOf(cache.lastModel)
382      const usd = price ? write * price[1] / 1e6 : null
383      cache.misses.push({ at: now, tokens: write, usd })
384      if (cache.deadline < now + AUTO_WARM_MS) {
385        await startWindow($, cache, AUTO_WARM_MS, cache.every)
386        $.ui.log(`식은 쓰기 ${fmtTok(write)}토큰 냈다(${fmtUsd(usd)}). 오늘 또 내지 않게 ${fmtDuration(AUTO_WARM_MS)} 동안 캐시를 데워 둔다 — 그만하려면 /keepwarm off.`)
387      }
388    }
389  }
390  cache.coldWritePending = false
391  await arm($, cache)
392  return r
393}
394
395// ── 그림: 답 속 ```mermaid · ```chart · 이미지 줄, Read 로 읽은 이미지 ─────────────
396// 그리기는 node(bin/*.mjs)·sh(bin/convert.sh)가 하고, 여기선 쌓아 두었다가 한 번에 넘긴다.
397// session.start 에 기대지 않는다 — 쌓을 때마다 그리기를 깨우고, 끝나면 그 사이 쌓인 것을 한 번 더.
398type Png = { png: string; columns: number; rows: number }
399type Failed = { error: string }
400type ImageFile = { file: string; width: number; height: number }
401type MermaidJob = { key: string; source: string; kind: 'png' | 'text'; maxColumns: number }
402type ChartJob = { key: string; spec: string; maxColumns: number; maxRows?: number }
403
404const pic = { cellAspect: 2.2, maxRows: 30, scale: 1, style: 'clean', mode: 'auto' }
405let picMode: Mode | undefined
406const mermaidDrawn = new Map<string, Png | { text: string } | Failed>()
407const mermaidQueue = new Map<string, MermaidJob>()
408const chartDrawn = new Map<string, Png | Failed>()
409const chartQueue = new Map<string, ChartJob>()
410const converted = new Map<string, ImageFile | Failed>()
411const convertQueue = new Set<string>()
412const picBusy = { mermaid: false, chart: false, convert: false }
413
414const PICTURE_PROMPT = [
415  '# Pictures in this terminal',
416  'This terminal draws pictures in your replies; use them when a picture is clearer than text.',
417  '- Diagrams: a ```mermaid block at the top level of the reply (not inside a list) for a flow, sequence, state machine or entity model. Keep it small: about 12 nodes, short one-line labels (no <br/>). Node ids in ASCII; labels may be Korean.',
418  '- Charts: a ```chart block holding a Vega-Lite JSON spec for numbers that read better as a chart (a comparison, a trend, a breakdown). Put the data inline under "data": {"values": [...]}; leave out width, height and colors (they are fitted to the terminal); add a short "title"; labels may be Korean. Keep a table of the key numbers in text too if the user needs exact values. Two charts side by side: {"hconcat": [spec1, spec2]}; a pie or donut: mark "arc" with a theta encoding.',
419  '- Images: `![short description](/absolute/path.png)` on a line of its own. Several on one line separated by spaces sit side by side. A video path (`.mp4` …) shows six frames spread over the video. Reading an image file with the Read tool also shows it under the tool call.',
420].join('\n')
421
422async function modeOf($: EngineInterface): Promise<Mode> {
423  picMode ??= pickMode(pic.mode, {
424    TERM: await $.env.get('TERM'),
425    TERM_PROGRAM: await $.env.get('TERM_PROGRAM'),
426    KITTY_WINDOW_ID: await $.env.get('KITTY_WINDOW_ID'),
427    TMUX: await $.env.get('TMUX'),
428    CLAUDE_CODE_FORCE_TERMINAL_IMAGES: await $.env.get('CLAUDE_CODE_FORCE_TERMINAL_IMAGES'),
429  })
430  return picMode
431}
432
433// 쌓인 일을 렌더러 한 번에 넘기고 key 별 결과를 받는다.
434async function runRenderer($: EngineInterface, script: string, payload: object): Promise<({ key: string } & Record<string, unknown>)[]> {
435  const { exitCode, stdout, stderr, isStdoutTruncated } = await $.process.run(['node', `${$.plugin.root}/bin/${script}`], { stdin: JSON.stringify(payload), timeoutMs: 60_000 })
436  const why = stderr.trim().split('\n').pop()   // 빈 stderr 는 '' — ?? 로는 exit 코드가 안 나온다
437  if (exitCode !== 0) throw new Error(why || `exit ${exitCode}`)
438  // 빈·잘린 출력을 그대로 JSON.parse 하면 "Unexpected EOF" 만 남아 원인이 안 보인다(2026-10-08).
439  if (isStdoutTruncated) throw new Error('렌더러 출력이 4MiB 를 넘어 잘렸다')
440  if (!stdout.trim()) throw new Error(`렌더러가 아무것도 내지 않았다${why ? ` — ${why}` : ''}`)
441  return JSON.parse(stdout).results
442}
443
444// 프로세스 쪽 실패(빈·잘린 출력, 비정상 종료)는 대개 한 번뿐이다 — 묶음 전부를 '못 그림'으로 박으면
445// 그 그림들은 세션 내내 안 그려진다. 한 번 더 줄을 세우고, 또 실패하면 그때 못 그린 것으로.
446// 렌더러가 답했는데 결과를 빠뜨린 블록(그 블록 자체의 문제)은 다시 부르지 않는다.
447const retried = new Set<string>()
448function failOrRetry<T>(items: T[], keyOf: (it: T) => string, queue: Map<string, T>, drawn: Map<string, unknown>, error: unknown) {
449  for (const item of items) {
450    const key = keyOf(item)
451    if (retried.has(key)) drawn.set(key, { error: String(error) })
452    else { retried.add(key); queue.set(key, item) }
453  }
454}
455
456async function drainMermaid($: EngineInterface): Promise<void> {
457  if (picBusy.mermaid || mermaidQueue.size === 0) return
458  picBusy.mermaid = true
459  const items = [...mermaidQueue.values()]
460  mermaidQueue.clear()
461  try {
462    const colors = { bg: C.base, fg: C.text, line: C.overlay, accent: C.yellow, muted: C.sub }
463    const results = await runRenderer($, 'mermaid.mjs', { items, colors, cellAspect: pic.cellAspect, scale: pic.scale, style: pic.style, maxRows: pic.maxRows })
464    for (const { key, ...d } of results) mermaidDrawn.set(key, d as Png | { text: string } | Failed)
465    // 결과가 빠진 블록은 못 그린 것으로 — 안 그러면 그릴 때마다 다시 줄을 선다.
466    for (const item of items) if (!mermaidDrawn.has(item.key)) mermaidDrawn.set(item.key, { error: '렌더러가 결과를 주지 않았다' })
467  } catch (error) {
468    failOrRetry(items, (it) => it.key, mermaidQueue, mermaidDrawn, error)
469  } finally {
470    picBusy.mermaid = false
471  }
472  $.ui.invalidate('ui.render')
473  void drainMermaid($)
474}
475
476async function drainChart($: EngineInterface): Promise<void> {
477  if (picBusy.chart || chartQueue.size === 0) return
478  picBusy.chart = true
479  const items = [...chartQueue.values()]
480  chartQueue.clear()
481  try {
482    const colors = { text: C.text, grid: C.overlay, category: [C.blue, C.green, C.peach, C.mauve, C.yellow, C.teal, C.red, C.sub] }
483    const results = await runRenderer($, 'chart.mjs', { items, colors, cellAspect: pic.cellAspect, style: pic.style })
484    for (const { key, ...d } of results) chartDrawn.set(key, d as Png | Failed)
485    // 결과가 빠진 블록은 못 그린 것으로 — 안 그러면 그릴 때마다 다시 줄을 선다.
486    for (const item of items) if (!chartDrawn.has(item.key)) chartDrawn.set(item.key, { error: '렌더러가 결과를 주지 않았다' })
487  } catch (error) {
488    failOrRetry(items, (it) => it.key, chartQueue, chartDrawn, error)
489  } finally {
490    picBusy.chart = false
491  }
492  $.ui.invalidate('ui.render')
493  void drainChart($)
494}
495
496async function drainConvert($: EngineInterface): Promise<void> {
497  if (picBusy.convert || convertQueue.size === 0) return
498  picBusy.convert = true
499  const paths = [...convertQueue]
500  convertQueue.clear()
501  try {
502    const cacheDir = `${(await $.env.get('HOME')) ?? '/tmp'}/.cache/claude-img`
503    const { stdout } = await $.process.run(['/bin/sh', `${$.plugin.root}/bin/convert.sh`, cacheDir, ...paths], { timeoutMs: 60_000 })
504    for (const line of stdout.split('\n')) {
505      if (!line.startsWith('{')) continue
506      const { path, ...rest } = JSON.parse(line) as { path: string } & (ImageFile | Failed)
507      converted.set(path, rest)
508    }
509    for (const p of paths) if (!converted.has(p)) converted.set(p, { error: '변환 결과가 없다' })
510  } catch (error) {
511    for (const p of paths) converted.set(p, { error: String(error) })
512  } finally {
513    picBusy.convert = false
514  }
515  $.ui.invalidate('ui.render')
516  void drainConvert($)
517}
518
519function mermaidOf($: EngineInterface, source: string, kind: 'png' | 'text', maxColumns: number) {
520  const key = `${kind}|${kind === 'png' ? `${maxColumns}|${C.name}` : ''}|${source}` // 테마가 바뀌면 다른 그림
521  const hit = mermaidDrawn.get(key)
522  if (!hit && !mermaidQueue.has(key)) {
523    mermaidQueue.set(key, { key, source, kind, maxColumns })
524    void drainMermaid($)
525  }
526  return hit
527}
528
529// 같은 spec 은 같은 그림 — 크기만 다르면 다시 그린다.
530function chartOf($: EngineInterface, spec: string, maxColumns: number, maxRows?: number) {
531  const key = `${maxColumns}x${maxRows ?? ''}|${C.name}|${spec}`
532  const hit = chartDrawn.get(key)
533  if (!hit && !chartQueue.has(key)) {
534    chartQueue.set(key, { key, spec, maxColumns, maxRows })
535    void drainChart($)
536  }
537  return hit
538}
539
540function imageOf($: EngineInterface, path: string) {
541  const hit = converted.get(path)
542  if (!hit && !convertQueue.has(path)) {
543    convertQueue.add(path)
544    void drainConvert($)
545  }
546  return hit
547}
548
549const chartTitle = (spec: string): string => {
550  try {
551    const t = (JSON.parse(spec) as { title?: unknown }).title
552    return typeof t === 'string' ? t : typeof (t as { text?: unknown })?.text === 'string' ? (t as { text: string }).text : 'chart'
553  } catch {
554    return 'chart'
555  }
556}
557
558// 크게 보기 pane 하나 — 무엇을 띄웠느냐에 따라 그린다.
559const VIEW = 'deck-view'
560type Viewing = { kind: 'png'; title: string; png: Png } | { kind: 'chart'; title: string; spec: string } | { kind: 'file'; title: string; img: ImageFile }
561let viewing: Viewing | null = null
562
563async function openView($: EngineInterface, v: Viewing): Promise<void> {
564  viewing = v
565  // 입력창 위에 넓게 — 터미널 폭을 다 쓰고 높이는 엔진이 내줄 만큼.
566  await $.ui.open({ id: VIEW, title: v.title.slice(0, 40) || 'deck', focus: true, closeOnEscape: true, rows: 40 })
567  $.ui.invalidate('ui.render')
568}
569
570// ~/Downloads 에 PNG 로. 이름 뒤에 시각을 붙인다.
571async function savePng($: EngineInterface, png: string, title: string): Promise<void> {
572  const name = title.replace(/[\\/:*?"<>|\s]+/g, '-').slice(0, 60) || 'chart'
573  const home = (await $.env.get('HOME')) ?? '/tmp'
574  const stamp = new Date().toISOString().slice(0, 19).replace(/[-:T]/g, '')
575  const file = `${home}/Downloads/${name}-${stamp}.png`
576  const { exitCode } = await $.process.run(['/bin/sh', '-c', 'base64 -D > "$1"', 'sh', file], { stdin: png })
577  $.ui.toast(exitCode === 0 ? `저장했다: ${file}` : '저장하지 못했다')
578}
579
580const level = (percent: number) => (percent >= 80 ? C.red : percent >= 50 ? C.yellow : C.green)
581
582
583export const register: Register = (on, options) => {
584  // 설정(/plugin → deck → configure). 비우면 기본값.
585  if (typeof options.cell_aspect === 'number') pic.cellAspect = options.cell_aspect
586  if (typeof options.max_rows === 'number') pic.maxRows = options.max_rows
587  if (typeof options.scale === 'number') pic.scale = options.scale
588  if (options.style === 'sketch') pic.style = 'sketch'
589  if (options.pet === 'off') pet.kind = 'off'
590
591
592  on('session.start', async ($, e, next) => {
593    const r = await next(e)
594    await initCache($, e.surface, true)
595    return r
596  })
597
598  // Resume fields seed the guard before any turn of the resumed session has run.
599  on('classic.SessionStart', async ($, e, next) => {
600    const r = await next(e)
601    if (e.source === 'clear') {
602      await stop($, cache, null)
603      cache.stopped = null
604      resetForClear(cache)
605      cache.sid = await $.session.id()
606      updateStatus($, cache, await $.clock.now())
607      return r
608    }
609    const line = seedFromResume(cache, e, await $.clock.now())
610    // The resume payload may omit the model; without it the guard cannot price the cold write.
611    if (!cache.lastModel) cache.lastModel = await $.session.model()
612    if (line) $.ui.log(line)
613    // The seeded clock decides whether a restored or always window pings before the first turn: never when it is cold.
614    await arm($, cache)
615    return r
616  })
617
618  on('command.run', { command: 'keepwarm' }, async ($, e) => {
619    const words = String(e.args ?? '').trim().split(/\s+/).filter(Boolean)
620    const now = await $.clock.now()
621    if (words[0] === 'off') {
622      const wasAlways = cache.always
623      await stop($, cache, null, true)
624      return { text: wasAlways ? 'keepwarm 꺼짐. 세션 시작 때 저절로 켜지지도 않는다' : 'keepwarm 꺼짐' }
625    }
626    if (words[0] === 'always') {
627      cache.always = true
628      await $.store.set(KEY_ALWAYS, true)
629      await startWindow($, cache, DEFAULT_WINDOW_MS, PING_AFTER_MS)
630      const cold = isCold(cache, now) ? `. 지금은 캐시가 식어서 다음 턴 ${fmtDuration(cache.every)} 뒤에 첫 핑이 간다` : ''
631      return { text: `keepwarm 항상 켬: 세션마다 ${fmtDuration(DEFAULT_WINDOW_MS)} 창으로 시작한다. /keepwarm off 하면 아주 꺼진다${cold}` }
632    }
633    if (!words.length) {
634      await startWindow($, cache, DEFAULT_WINDOW_MS, PING_AFTER_MS)
635      return { text: armedText(cache, now, DEFAULT_WINDOW_MS) }
636    }
637    if (words[0] !== 'status') {
638      const window = parseDuration(words[0] ?? '')
639      if (window == null) return { text: 'keepwarm 은 6h · 90m 같은 시간, 또는 always · off · status 를 받는다' }
640      // "every 2m" is a testing knob and lasts only for the window it was given with.
641      let every = PING_AFTER_MS
642      if (words[1] === 'every') {
643        const period = parseDuration(words[2] ?? '')
644        if (period == null || period < MIN_PING_MS) return { text: 'every 는 1m 이상' }
645        every = period
646      }
647      await startWindow($, cache, window, every)
648      return { text: armedText(cache, now, window) }
649    }
650    return { text: statusText(cache, now) ?? 'keepwarm 꺼짐' }
651  })
652
653  on('command.run', { command: 'deck' }, async ($, e) => {
654    if (String(e.args ?? '').trim() !== 'sync') return { text: helpText() }
655    // 설치·리로드는 30초 넘게 걸려 기다리지 않는다 — 끝나면 알림으로 요약을 띄운다.
656    void $.process
657      .run(['node', `${$.plugin.root}/bin/sync.mjs`], { timeoutMs: 300_000 })
658      .then(r => $.ui.toast(`deck sync: ${r.stdout.trim().split('\n').pop() || r.stderr.trim().split('\n').pop() || `exit ${r.exitCode}`}`))
659      .catch(error => $.ui.toast(`deck sync 실패: ${String(error)}`))
660    return { text: 'deck sync: 두 설정 폴더에 깔고 쉬는 세션을 리로드하는 중 — 끝나면 알림으로 알린다' }
661  })
662
663  // /pet: 이 세션에서 숨기기·띄우기만. 아예 끄기는 설정 pet: off.
664  on('command.run', { command: 'pet' }, async ($, e) => {
665    const off = String(e.args ?? '').trim() === 'off'
666    pet.hidden = off
667    $.ui.invalidate('ui.render')
668    return { text: off ? '펫을 이 세션에서 숨겼다 (/pet 으로 다시)' : '펫을 띄웠다 (/pet off 로 숨김 · 아예 끄기는 /plugin → deck → configure 의 pet)' }
669  })
670
671  on('command.run', { command: 'cache' }, async ($, e) => {
672    const words = String(e.args ?? '').trim().split(/\s+/).filter(Boolean)
673    const now = await $.clock.now()
674    if (words[0] === 'guard') {
675      if (words[1] !== 'warn' && words[1] !== 'refuse') return { text: '/cache guard 는 warn 또는 refuse' }
676      cache.guard = words[1]
677      await $.store.set(KEY_GUARD, cache.guard)
678      return { text: cache.guard === 'refuse' ? '가드: 식은 채 보내면 가격을 보여 주고 한 번 막는다. 다시 보내면 간다' : '가드: 식은 채 보내도 가격만 보여 주고 보낸다' }
679    }
680    return { text: card(cache, now) }
681  })
682
683
684  on('turn.step', async function* ($, e, next) {
685    if (!e.agentId) cache.lastRequestAt = await $.clock.now()
686    yield* next(e)
687  })
688
689
690  on('session.compact', async ($, e, next) => {
691    const r = await next(e)
692    if (!e.agentId) {
693      cache.compacted = true
694      cache.ctx = 0
695      cache.ackedAt = 0
696      disarm(cache)
697      await arm($, cache)
698    }
699    return r
700  })
701
702  on('prompt.submit', async ($, e, next) => {
703    const startedAt = await $.clock.now()
704    const r = await guardColdSend($, e, next)
705    // 식은 캐시 경고가 메시지를 막았으면 턴이 시작되지 않는다 — 스피너를 돌리지 않는다.
706    if (r.drop === undefined) {
707      turn = { startedAt, tools: 0, current: '생각 중', agents: 0, edits: new Set() }
708      $.ui.invalidate('ui.render')
709    }
710    return r
711  }).catch(($, e, next) => next(e)) // 계기판이 깨져도 프롬프트는 막지 않는다
712
713  on('tool.call', async ($, e, next) => {
714    // 커밋 검문: git-commit 스킬 규칙에 걸리면 실행 전에 돌려보낸다 — 고칠 곳을 짚어 주면 모델이 고쳐 다시 한다.
715    const why = e.tool === 'Bash' ? await commitCheck($, e.command) : null
716    if (why) {
717      say(await $.clock.now().catch(() => 0), 'deny', '검문에 걸렸어... 고쳐서 다시!', C.red, 60_000)
718      pet.hurtUntil = (await $.clock.now().catch(() => 0)) + 20_000 // 시계를 못 읽어도 검문·결과는 그대로
719      return { deny: why }
720    }
721    const t = turn
722    if (t) {
723      t.tools += 1
724      t.current = e.tool
725      if (e.tool === 'Agent') t.agents += 1
726      const path = (e as { file_path?: unknown }).file_path ?? (e as { notebook_path?: unknown }).notebook_path
727      if (EDITS.has(e.tool) && typeof path === 'string') t.edits.add(path)
728      $.ui.invalidate('ui.render')
729    }
730    try {
731      const r = await next(e)
732      const at = await $.clock.now().catch(() => 0) // 시계를 못 읽어도 검문·결과는 그대로
733      if (r.isError) {
734        pet.hurtUntil = at + 20_000
735        say(at, 'error', '앗, 에러...', C.red)
736      } else if (e.tool === 'Bash' && readCommit(e.command, cwd)?.commit) say(at, 'commit', '냠! 커밋 깔끔 ✓', C.green, 0)
737      return r
738    } finally {
739      if (t && e.tool === 'Agent') t.agents -= 1
740      if (e.tool === 'Bash' || EDITS.has(e.tool)) void refreshGit($)
741    }
742  }).catch(($, e, next) => next(e))
743
744  // 커밋·PR 에 Claude 서명을 붙이라는 엔진의 지시를 비운다 — 모델이 처음부터 안 쓰게.
745  on('attribution.text', ($, e, next) => (e.kind === 'commit' || e.kind === 'pr' ? { text: '' } : next(e)))
746
747  on('turn.complete', async ($, e, next) => {
748    const r = await scoreTurn($, e, next)
749    // 서브에이전트의 턴이 끝난 건 이 대화의 턴이 끝난 게 아니다.
750    if (e.agentId) return r
751    if (turn) last = { seconds: Math.round(((await $.clock.now()) - turn.startedAt) / 1000), tools: turn.tools, edits: turn.edits.size }
752    if (turn && last && last.seconds >= 60) say(await $.clock.now(), 'done', `다 했다! ${elapsed(last.seconds)}`, C.yellow, 0)
753    turn = null
754    void refreshGit($)
755    void refreshUsage($)
756    return r
757  }).catch(($, e, next) => next(e))
758
759  // ── 그림 ───────────────────────────────────────────
760  on('prompt.compose', async ($, e, next) => {
761    const composed = await next(e)
762    if (!e.surfaces.includes('terminal')) return composed
763    return { sections: [...composed.sections, { id: 'deck:pictures', text: PICTURE_PROMPT, scope: 'session' as const }] }
764  })
765
766  // 답을 블록으로 나눠, 그림이 준비된 블록은 그림으로, 아직이거나 못 그린 블록은 원래 글로.
767  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
768    if (e.surface !== 'terminal') return next(e)
769    const blocks = splitAll(e.props.text)
770    if (!blocks.some(b => b.kind !== 'text')) return next(e)
771    const mode = await modeOf($)
772    const { Box, Button, Image, Text } = $.ui.resolve(e)
773    // 왼쪽 2칸 거터(⏺)와 마지막 빈 칸을 뺀 폭.
774    const width = Math.max(20, (e.viewport?.columns ?? 80) - 3)
775    const rows: RenderElement[] = []
776    let isFirst = e.props.isFirstOfReply
777    const asText = async (text: string) => {
778      const drawn = await next({ ...e, props: { ...e.props, text, isFirstOfReply: isFirst } })
779      // 엔진은 답의 첫 덩어리에만 거터를 붙인다 — 이어지는 조각은 여기서 맞춘다.
780      rows.push(isFirst ? drawn : <Box flexDirection="row"><Box width={2} flexShrink={0} /><Box flexDirection="column" flexGrow={1} flexShrink={1}>{drawn}</Box></Box>)
781    }
782    const failed = (what: string, error: string) => rows.push(<Box paddingLeft={2}><Text dimColor>{what} 그리지 못했다: {error}</Text></Box>)
783    const picture = (body: RenderElement) =>
784      rows.push(
785        <Box flexDirection="row" marginTop={1}>
786          <Box width={2} flexShrink={0}><Text>{isFirst ? '⏺' : ' '}</Text></Box>
787          {body}
788        </Box>,
789      )
790    // 그림 밑 한 줄: 설명은 흐리게, 버튼은 그림에 포인터를 올렸을 때만.
791    const under = (scope: string, caption: string, buttons: RenderElement[]) => (
792      <Box flexDirection="row" columnGap={2} minHeight={1}>
793        {caption ? <Text dimColor wrap="truncate-end">{caption}</Text> : null}
794        <Box display="none" columnGap={2} hover={{ display: 'flex', scope }}>{buttons}</Box>
795      </Box>
796    )
797    for (const [n, b] of blocks.entries()) {
798      if (b.kind === 'text') await asText(b.text)
799      else if (b.kind === 'mermaid') {
800        const d = mode === 'off' ? undefined : mermaidOf($, b.source, mode === 'pictures' ? 'png' : 'text', Math.min(110, width)) // 폭 상한을 키에 — 창 폭이 바뀌어도 다시 안 그린다
801        if (!d || 'error' in d) {
802          await asText(b.raw)
803          if (d) failed('다이어그램을', d.error)
804        } else if ('text' in d) picture(<Text wrap="truncate-end">{d.text}</Text>)
805        else {
806          const title = b.source.trim().split('\n')[0] ?? 'mermaid'
807          const scope = `deck-m-${n}-${d.png.length}`.slice(0, 64)
808          picture(
809            <Box flexDirection="column" hover={{ scope }}>
810              <Image source={{ png: d.png }} columns={d.columns} rows={d.rows} alt={`[mermaid: ${title}]`} />
811              {under(scope, '', [<Button key={`${scope}-big`} plain dimColor onPress={() => void openView($, { kind: 'png', title, png: d })}>⤢ 크게 보기</Button>])}
812            </Box>,
813          )
814        }
815      } else if (b.kind === 'chart') {
816        const d = chartOf($, b.spec, Math.min(110, width - 1))
817        if (!d || 'error' in d) {
818          await asText(b.raw)
819          if (d) failed('차트를', d.error)
820        } else {
821          const title = chartTitle(b.spec)
822          const spec = b.spec
823          const scope = `deck-c-${n}-${spec.length}`.slice(0, 64)
824          picture(
825            <Box flexDirection="column" hover={{ scope }}>
826              <Image source={{ png: d.png }} columns={d.columns} rows={d.rows} alt={`[chart: ${title}]`} />
827              {under(scope, '', [
828                <Button key={`${scope}-big`} plain dimColor onPress={() => void openView($, { kind: 'chart', title, spec })}>⤢ 크게 보기</Button>,
829                <Button key={`${scope}-save`} plain dimColor onPress={() => void savePng($, d.png, title)}>↓ PNG 저장</Button>,
830              ])}
831            </Box>,
832          )
833        }
834      } else {
835        // 한 줄의 그림이 다 준비돼야 그린다 — 그 전엔 원래 글(이미지 문법)로 둔다.
836        const imgs = b.pictures.map(p => imageOf($, p.path))
837        if (!imgs.every(i => i && !('error' in i))) await asText(b.raw)
838        else {
839          // 여러 장이면 폭을 나눠 나란히. 각 그림 밑에 설명.
840          const gap = 2
841          const each = Math.floor((Math.min(100, width) - gap * (b.pictures.length - 1)) / b.pictures.length)
842          picture(
843            <Box flexDirection="row" columnGap={gap}>
844              {b.pictures.map((p, i) => {
845                const img = imgs[i] as ImageFile
846                const grid = gridFor(img.width, img.height, each, pic.maxRows, pic.cellAspect)
847                const title = p.alt || (p.path.split('/').pop() ?? p.path)
848                // 같은 그림이 대화에 두 번 나와도 함께 밝아질 뿐이라 캐시 파일 이름으로 묶는다.
849                const scope = `deck-i-${img.file.split('/').pop() ?? i}`.slice(0, 64)
850                return (
851                  <Box flexDirection="column" hover={{ scope }}>
852                    <Image source={{ file: img.file, format: 'png' }} columns={grid.columns} rows={grid.rows} alt={`[이미지: ${title}]`} />
853                    {under(scope, title, [<Button key={`${scope}-big`} plain dimColor onPress={() => void openView($, { kind: 'file', title, img })}>⤢ 크게 보기</Button>])}
854                  </Box>
855                )
856              })}
857            </Box>,
858          )
859        }
860      }
861      isFirst = false
862    }
863    return <Box flexDirection="column">{rows}</Box>
864  })
865
866  // Read 로 이미지를 읽은 줄 아래에 그 그림.
867  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
868    if (e.surface !== 'terminal' || e.props.tool !== 'Read' || e.props.isRunning || e.props.isErrored) return next(e)
869    const path = (e.props.input as { file_path?: unknown } | null)?.file_path
870    if (!isImagePath(path)) return next(e)
871    const row = await next(e)
872    const img = imageOf($, path)
873    if (!img || 'error' in img) return row
874    const { Box, Button, Image, Text } = $.ui.resolve(e)
875    const grid = gridFor(img.width, img.height, Math.min(100, (e.viewport?.columns ?? 80) - 6), pic.maxRows, pic.cellAspect)
876    const title = path.split('/').pop() ?? path
877    const scope = `deck-r-${img.file.split('/').pop() ?? ''}`.slice(0, 64)
878    return (
879      <Box flexDirection="column">
880        {row}
881        <Box paddingLeft={5} flexDirection="column" hover={{ scope }}>
882          <Image source={{ file: img.file, format: 'png' }} columns={grid.columns} rows={grid.rows} alt={`[이미지: ${path}]`} />
883          <Box flexDirection="row" columnGap={2}>
884            <Text dimColor wrap="truncate-end">{title}</Text>
885            <Box display="none" hover={{ display: 'flex', scope }}>
886              <Button key={`${scope}-big`} plain dimColor onPress={() => void openView($, { kind: 'file', title, img })}>⤢ 크게 보기</Button>
887            </Box>
888          </Box>
889        </Box>
890      </Box>
891    )
892  })
893
894  // 크게 보기: 위에 제목 줄, 아래 그림을 pane 에 꽉 차게.
895  on('ui.render', { component: 'Pane', requestId: VIEW }, async ($, e, next) => {
896    if (e.surface !== 'terminal') return next(e)
897    const { Box, Button, Image, Text } = $.ui.resolve(e)
898    if (!viewing) return <Text dimColor>보여 줄 그림이 없다.</Text>
899    const v = viewing
900    const cols = Math.max(10, e.props.bodyColumns)
901    const room = Math.max(4, e.props.scroll.bodyRows - 2)
902    let body: RenderElement
903    let meta = ''
904    let save: string | null = null
905    if (v.kind === 'file') {
906      const grid = gridFor(v.img.width, v.img.height, cols, room, pic.cellAspect)
907      body = <Image source={{ file: v.img.file, format: 'png' }} columns={grid.columns} rows={grid.rows} alt={v.title} />
908      meta = `${v.img.width}×${v.img.height}`
909    } else if (v.kind === 'png') {
910      // 이미 그린 PNG 를 키워 보인다(1행 40px 로 그려 두어 세 배까지 선명하다).
911      const grow = Math.min(3, cols / v.png.columns, room / v.png.rows)
912      body = <Image source={{ png: v.png.png }} columns={Math.max(1, Math.floor(v.png.columns * grow))} rows={Math.max(1, Math.floor(v.png.rows * grow))} alt={v.title} />
913    } else {
914      // 차트는 pane 크기에 맞춰 다시 그린다.
915      const d = chartOf($, v.spec, Math.max(20, cols - 1), Math.max(8, room - 1))
916      body = !d ? <Text dimColor>그리는 중…</Text>
917        : 'error' in d ? <Text dimColor>차트를 그리지 못했다: {d.error}</Text>
918        : <Image source={{ png: d.png }} columns={d.columns} rows={d.rows} alt={v.title} />
919      if (d && !('error' in d)) save = d.png
920    }
921    const png = save
922    return (
923      <Box flexDirection="column" rowGap={1}>
924        <Box flexDirection="row" columnGap={2}>
925          <Text bold color={C.yellow}>{v.title}</Text>
926          {meta ? <Text dimColor>{meta}</Text> : null}
927          <Box flexGrow={1} />
928          {png ? <Button key="save" plain dimColor hotkey="s" onPress={() => void savePng($, png, v.title)}>↓ PNG 저장</Button> : null}
929          <Text dimColor>Esc 닫기</Text>
930        </Box>
931        <Box flexDirection="row" justifyContent="center">{body}</Box>
932      </Box>
933    )
934  })
935
936  on('ui.close', { id: VIEW }, async ($, e, next) => {
937    viewing = null
938    return next(e)
939  }).catch(($, e, next) => next(e))
940
941  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
942    const below = await next(e)
943    if (e.surface !== 'terminal' || e.props.hasSurvey) return below
944    void start($)
945    const { Box, Text } = $.ui.resolve(e)
946    const now = await $.clock.now()
947    // 바탕색은 테마 바탕 쪽으로 누그러뜨려(soft) 칠한다.
948    const pill = (bg: string, fg: string, text: string, bold = true) => <Text backgroundColor={soft(bg)} color={fg} bold={bold}>{` ${text} `}</Text>
949    const label = (bg: string, text: string) => pill(bg, C.ink, text)
950
951    const full = Math.max(20, e.props.bodyColumns)
952    // 펫: 100칸 넘을 때만. 그 폭만큼 계기판 알약이 줄어든다.
953    const showPet = pet.kind !== 'off' && !pet.hidden && full >= 100
954    const petCols = SLIME_COLS
955    const cols = showPet ? full - petCols - 2 : full
956    // 알약 하나 = 그림 + 칸 수 + 중요도(작을수록 끝까지 남는다). 좁으면 fit 이 큰 p 부터 뺀다.
957    type Item = Fit & { el: RenderElement }
958    const item = (el: RenderElement, text: string, p: number, glue = false): Item => ({ el, w: cellWidth(text), p, glue })
959    const pillItem = (bg: string, fg: string, text: string, p: number, opts: { bold?: boolean; glue?: boolean } = {}) =>
960      item(pill(bg, fg, text, opts.bold ?? true), ` ${text} `, p, opts.glue)
961    // ▰▱ 8칸 게이지 + 퍼센트. CTX 와 사용량 한도가 같은 모양이다.
962    const gauge = (name: string, percent: number, p: number, fixed?: string): Item => {
963      const tone = fixed ?? level(percent)
964      const filled = Math.min(8, Math.round((percent / 100) * 8))
965      const lit = ` ${'▰'.repeat(filled)}`
966      const off = '▱'.repeat(8 - filled)
967      const num = ` ${Math.round(percent)}% `
968      return item(
969        <Text>
970          {label(tone, name)}
971          <Text backgroundColor={C.surface} color={soft(tone)}>{lit}</Text>
972          <Text backgroundColor={C.surface} color={C.overlay}>{off}</Text>
973          <Text backgroundColor={C.surface} color={C.text} bold>{num}</Text>
974        </Text>,
975        ` ${name} ${lit}${off}${num}`, p,
976      )
977    }
978
979    // ── 윗줄: 어디서 ────────────────────────────────────
980    const top: Item[] = []
981    if (cwd) top.push(pillItem(C.yellow, C.ink, `📁 ${shortPath(cwd, home)}`, 1))
982    if (git) {
983      const ab = `${git.ahead ? ` ↑${git.ahead}` : ''}${git.behind ? ` ↓${git.behind}` : ''}`
984      top.push(pillItem(C.teal, C.ink, `⎇ ${git.branch || '(detached)'}${ab}`, 0))
985    }
986    // 모델은 브랜치 바로 옆.
987    if (model) {
988      top.push(pillItem(C.peach, C.ink, `◆ ${model}`, 2))
989      if (effort) top.push(pillItem(C.overlay, C.peach, effort, 6, { bold: false, glue: true }))
990    }
991    if (git) {
992      if (git.files.length > 0) {
993        const t = [` ● ${git.files.length} `, `+${git.added} `, `−${git.deleted} `]
994        top.push(item(
995          <Box hover={{ scope: FILES }}>
996            <Text backgroundColor={C.surface} color={C.yellow} bold>{t[0]}</Text>
997            <Text backgroundColor={C.surface} color={C.green}>{t[1]}</Text>
998            <Text backgroundColor={C.surface} color={C.red}>{t[2]}</Text>
999          </Box>,
1000          t.join(''), 3,
1001        ))
1002      } else top.push(pillItem(C.surface, C.green, '✓ 깨끗', 8, { bold: false }))
1003    }
1004    if (ci) {
1005      const [bg, mark] = ci.state === 'ok' ? [C.green, '✓'] : ci.state === 'fail' ? [C.red, '✗'] : ci.state === 'run' ? [C.yellow, '⟳'] : [C.surface, '·']
1006      top.push(pillItem(bg, ci.state === 'other' ? C.sub : C.ink, `CI ${mark} ${ci.name}${ci.at ? ` · ${ago(ci.at, now)}` : ''}`, 5))
1007    }
1008
1009    // ── 아랫줄: 얼마나 남았나 ───────────────────────────
1010    const bottom: Item[] = []
1011    if (usage) {
1012      const u = usage
1013      bottom.push(gauge(u.percent >= 80 ? 'CTX 압축 임박' : 'CTX', u.percent, 0, C.green))
1014      u.limits.forEach((l, i) => {
1015        const name = l.kind === 'five_hour' ? '5H' : l.kind === 'seven_day' ? '7D' : l.kind === 'spend_limit' ? '한도' : l.kind
1016        bottom.push(gauge(name, l.percent, i === 0 ? 1 : 4))
1017        if (l.resetsAt) {
1018          const t = `⟲ ${untilText(l.resetsAt, now)} `
1019          bottom.push(item(<Text backgroundColor={C.surface} color={C.sub}>{t}</Text>, t, 7, true))
1020        }
1021      })
1022      if (u.usd !== undefined) {
1023        bottom.push(item(label(C.teal, `$${u.usd.toFixed(2)}`), ` $${u.usd.toFixed(2)} `, 5))
1024        const hours = (now - u.startedAt) / 3_600_000
1025        if (hours > 0.1) {
1026          const t = ` 🔥 $${(u.usd / hours).toFixed(1)}/h `
1027          bottom.push(item(<Text backgroundColor={C.surface} color={C.peach}>{t}</Text>, t, 8, true))
1028        }
1029      }
1030    }
1031    const cachePill = viewOf(cache, now)
1032    if (turn) bottom.push(item(label(C.blue, '☕ 캐시 데우는 중'), ' ☕ 캐시 데우는 중 ', 3))
1033    else if (cachePill) {
1034      const bg = cachePill.tone === 'warm' ? C.blue : cachePill.tone === 'cold' ? C.red : C.overlay
1035      const head = ` ${cachePill.head} `
1036      const body = ` ${cachePill.body} `
1037      bottom.push(item(pill(bg, cachePill.tone === 'warm' || cachePill.tone === 'cold' ? C.ink : C.text, cachePill.head), head, 3))
1038      bottom.push(item(<Text backgroundColor={C.surface} color={C.text}>{body}</Text>, body, 6, true))
1039    }
1040    const bottomRight: Item[] = []
1041    // 펫은 그림만. 캐시가 식는 순간 · 5H 80% 를 넘는 순간엔 말풍선.
1042    const dstate = petState(now)
1043    const coldNow = viewOf(cache, now)?.tone === 'cold'
1044    if (coldNow && !seen.cold) say(now, 'cold', '캐시 식었어... zzz', C.sub)
1045    seen.cold = coldNow
1046    const tiredNow = (usage?.limits.find(l => l.kind === 'five_hour')?.percent ?? 0) >= 80
1047    if (tiredNow && !seen.tired) say(now, '5h', '5H 거의 다 썼어', C.yellow, 3_600_000)
1048    seen.tired = tiredNow
1049    if (turn) {
1050      const parts = [`${SPIN[Math.floor(now / 250) % SPIN.length]} ${elapsed(Math.round((now - turn.startedAt) / 1000))}`, turn.current, `도구 ${turn.tools}`]
1051      if (turn.edits.size) parts.push(`편집 ${turn.edits.size}`)
1052      if (turn.agents > 0) parts.push(`에이전트 ${turn.agents}`)
1053      bottomRight.push(pillItem(C.mauve, C.ink, parts.join('  '), 2))
1054    } else if (last) {
1055      const t = `지난 턴 ${elapsed(last.seconds)} · 도구 ${last.tools}${last.edits ? ` · 편집 ${last.edits}` : ''}`
1056      bottomRight.push(item(<Text color={C.sub}>{t}</Text>, t, 9))
1057    }
1058    if (top.length + bottom.length + bottomRight.length === 0) return below
1059
1060    // 한 줄: 들어가는 알약만, 붙은 것(glue)은 사이 칸 없이. right 는 오른쪽 끝으로 민다.
1061    const line = (left: Item[], right: Item[], width = cols) => {
1062      const all = [...left, ...right]
1063      const keep = fit(all, width)
1064      const draw = (items: Item[], offset: number) => {
1065        const out: RenderElement[] = []
1066        items.forEach((it, i) => {
1067          if (!keep[offset + i]) return
1068          if (out.length && !it.glue) out.push(<Text> </Text>)
1069          out.push(it.el)
1070        })
1071        return out
1072      }
1073      return (
1074        <Box flexDirection="row" flexGrow={1}>
1075          {draw(left, 0)}
1076          <Box flexGrow={1} />
1077          {draw(right, left.length)}
1078        </Box>
1079      )
1080    }
1081
1082    // 변경 알약에 포인터를 올리면 그 위 줄에 파일 목록 — 띠는 아래가 붙박이라 위로 펼쳐야 알약이 안 움직인다.
1083    const shown = git?.files.slice(0, 8) ?? []
1084    const files = shown.length ? (
1085      <Box display="none" hover={{ display: 'flex', scope: FILES }} flexDirection="row" columnGap={2} flexWrap="wrap">
1086        {shown.map(f => (
1087          <Text>
1088            <Text color={f.code === '?' ? C.sub : f.code === 'D' ? C.red : f.code === 'A' ? C.green : C.yellow}>{f.code}</Text>
1089            <Text color={C.text}> {f.path}</Text>
1090          </Text>
1091        ))}
1092        {git && git.files.length > shown.length ? <Text color={C.sub}>외 {git.files.length - shown.length}개</Text> : null}
1093      </Box>
1094    ) : null
1095
1096    // 말풍선: 슬라임 바로 왼쪽 위에 둥근 상자(엔진이 테두리를 그린다)를 띄워(absolute) 띠 높이는 그대로 둔다.
1097    // 상자가 계기판 윗줄 오른쪽 끝에 걸치므로 그동안 윗줄 알약 폭을 그만큼 줄인다.
1098    const bub = showPet && bubble && now < bubble.until ? bubble : null
1099    const boxW = bub ? cellWidth(bub.text) + 5 : 0 // 테두리 2 · 안쪽 여백 2 · 꼬리 1
1100    const dash = (
1101      <Box flexDirection="column" flexGrow={1} justifyContent="flex-end">
1102        {files}
1103        {top.length ? line(top, [], cols - boxW) : null}
1104        {bottom.length + bottomRight.length ? line(bottom, bottomRight) : null}
1105      </Box>
1106    )
1107    const bubbleEl = bub ? (
1108      <Box position="absolute" top={0} right={petCols + 2} flexDirection="row" alignItems="center">
1109        <Box borderStyle="round" borderColor={C.overlay} paddingX={1}>
1110          <Text color={bub.color} bold>{bub.text}</Text>
1111        </Box>
1112        <Text color={C.overlay}>╴</Text>
1113      </Box>
1114    ) : null
1115    if (!showPet) return <Box flexDirection="column">{below}{dash}</Box>
1116    return (
1117      <Box flexDirection="column">
1118        {below}
1119        <Box flexDirection="row" columnGap={2}>
1120          {dash}
1121          {bubbleEl}
1122          <Box flexDirection="column" width={petCols} flexShrink={0}>
1123            {slimeRows(dstate, now).map((runs, y) => (
1124              <Text key={`d${y}`}>{runs.map((r, i) => <Text key={`r${i}`} color={r.fg} backgroundColor={r.bg}>{r.text}</Text>)}</Text>
1125            ))}
1126          </Box>
1127        </Box>
1128      </Box>
1129    )
1130  })
1131}
1132
hooks/cache.ts 159 lines
1// 프롬프트 캐시 keepwarm 과 식은 채 보내기 경고 — 문구와 계산(순수 함수). 타이머·핑·훅은 register.tsx 에 있다
2// (한 플러그인은 훅 모듈이 하나이고, $ 는 import 한 함수로 넘길 수 없다).
3// cache-tax 2.2.1(Karan Bansal, MIT — ../LICENSE-cache-tax)을 가져와 board 에 합쳤다. 동작은 원본 그대로이고,
4// 바뀐 것은 셋: 입력창 위 줄을 직접 그리지 않고 board 의 캐시 알약이 viewOf 로 그린다, 문구가 한국어다,
5// 상태 명령이 /cache-tax 가 아니라 /cache 다.
6import { coldUsd, fmtDuration, fmtTok, fmtUsd, isCold, priceOf, TTL_MS, type CacheSnap, type PingRecord } from './cacheview'
7
8export { fmtDuration }
9
10export const PING_AFTER_MS = 50 * 60 * 1000
11export const MIN_PING_MS = 60 * 1000
12export const AUTO_WARM_MS = 3 * 60 * 60 * 1000
13export const DEFAULT_WINDOW_MS = 6 * 60 * 60 * 1000
14export const BIG_TOKENS = 50000
15export const PING_PROMPT = 'Reply with the single word: warm'
16export const KEY_DEADLINE = 'deadline'
17export const KEY_EVERY = 'every'
18export const KEY_GUARD = 'guard'
19export const KEY_ALWAYS = 'always'
20
21export type Miss = { at: number; tokens: number; usd: number | null }
22export type GuardMode = 'refuse' | 'warn'
23
24export type State = CacheSnap & {
25  hasBand: boolean
26  sid: string
27  always: boolean
28  guard: GuardMode
29  ackedAt: number
30  coldWritePending: boolean
31  misses: Miss[]
32  pending: { cancel: () => void } | null
33}
34
35export function parseDuration(text: string): number | null {
36  const m = /^(?:(\d+)h)?(?:(\d+)m)?$/.exec(text.trim())
37  if (!m || (m[1] === undefined && m[2] === undefined)) return null
38  return (Number(m[1] ?? 0) * 60 + Number(m[2] ?? 0)) * 60 * 1000
39}
40
41export function warmUsd(s: State): number | null {
42  const price = priceOf(s.lastModel)
43  return price ? s.ctx * price[0] / 1e6 : null
44}
45
46/** Everything a fork bills: the cache read, any cache write, uncached input at the base rate (half the 1h write rate), and the output. */
47export function pingUsd(u: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }, price: [number, number, number]): number {
48  return (u.cache_read_input_tokens * price[0] + u.cache_creation_input_tokens * price[1] + u.input_tokens * price[1] / 2 + u.output_tokens * price[2]) / 1e6
49}
50
51/** The read-only upper bound: pings at the cache-read rate that cost as much as one cold write of the same context. */
52export function breakEvenPings(s: State): number | null {
53  const price = priceOf(s.lastModel)
54  if (!price || s.ctx <= 0) return null
55  return Math.floor(price[1] / price[0])
56}
57
58export function guardText(s: State, now: number): string {
59  const price = priceOf(s.lastModel)
60  const rate = price ? `$${price[1]}/MTok` : '캐시 쓰기 단가'
61  const warm = warmUsd(s)
62  return `프롬프트 캐시가 ${fmtDuration(now - s.lastRequestAt - TTL_MS)} 전에 식었다. 이대로 보내면 ` +
63    `최대 ${s.ctx.toLocaleString('en-US')}토큰을 ${rate}에 다시 쓴다 = ${fmtUsd(coldUsd(s))}` +
64    (warm == null ? '' : ` (데워져 있었으면 ${fmtUsd(warm)})`) + '.'
65}
66
67export type ResumeFields = {
68  source: string
69  model?: string
70  context_tokens?: number
71  seconds_since_last_response?: number
72  prompt_cache_likely_expired?: boolean
73  estimated_cache_write_usd?: number
74}
75
76/** /clear starts a new conversation in the same process; nothing priced before it still exists. */
77export function resetForClear(s: State) {
78  s.ctx = 0
79  s.lastRequestAt = 0
80  s.compacted = false
81  s.ackedAt = 0
82  s.coldWritePending = false
83  s.misses = []
84  disarm(s)
85}
86
87/** Applies a resumed session's fields to the state; returns the line to log, if any. */
88export function seedFromResume(s: State, e: ResumeFields, now: number): string | null {
89  if (e.source !== 'resume' && e.source !== 'fork') return null
90  if (typeof e.context_tokens === 'number' && e.context_tokens > 0) s.ctx = e.context_tokens
91  if (typeof e.seconds_since_last_response === 'number') s.lastRequestAt = now - e.seconds_since_last_response * 1000
92  if (typeof e.model === 'string') s.lastModel = e.model
93  s.compacted = false
94  if (e.prompt_cache_likely_expired !== true || s.ctx < BIG_TOKENS) return null
95  const usd = typeof e.estimated_cache_write_usd === 'number' ? fmtUsd(e.estimated_cache_write_usd) : fmtUsd(coldUsd(s))
96  return `식은 채로 이어 왔다. 첫 메시지가 ${s.ctx.toLocaleString('en-US')}토큰을 다시 쓴다, 약 ${usd}. 결론만 필요하면 /clear 하고 요약을 붙여 넣자.`
97}
98
99export function statusText(s: State, now: number): string | undefined {
100  if (s.stopped) return `keepwarm 멈춤: ${s.stopped}`
101  if (!s.deadline) return undefined
102  const pingText = s.last ? ` · 지난 핑 ${fmtTok(s.last.read)} 읽음 ${fmtUsd(s.last.usd)}` : ''
103  const nextText = !s.lastRequestAt ? ' · 첫 턴 기다림'
104    : s.compacted ? ' · 압축 뒤 첫 턴 기다림'
105    : isCold(s, now) ? ` · 지금 식음, 다음 턴 ${fmtDuration(s.every)} 뒤 첫 핑`
106    : ` · 핑 ${fmtDuration(s.lastRequestAt + s.every - now)} 뒤`
107  return `keepwarm ${fmtDuration(s.deadline - now)} 남음${nextText}${pingText}`
108}
109
110export function disarm(s: State) {
111  if (s.pending) s.pending.cancel()
112  s.pending = null
113}
114
115// The window and its ping period belong to the session that armed them, so a
116// second session, or one resumed from another transcript, never inherits them
117// and cannot turn them off. The always switch and the guard mode stay global.
118export function deadlineKey(s: State): string {
119  return `${KEY_DEADLINE}:${s.sid}`
120}
121
122export function everyKey(s: State): string {
123  return `${KEY_EVERY}:${s.sid}`
124}
125
126/** The reply to an arming command; on a cold cache it says when the first ping can come. */
127export function armedText(s: State, now: number, windowMs: number): string {
128  if (isCold(s, now)) return `keepwarm ${fmtDuration(windowMs)} 켬. 지금은 캐시가 식어서 다음 턴 ${fmtDuration(s.every)} 뒤에 첫 핑이 간다`
129  return `keepwarm ${fmtDuration(windowMs)} 켬. 쉴 때마다 ${fmtDuration(s.every)} 뒤 핑을 보내 캐시를 다시 쓰지 않고 읽게 한다`
130}
131
132export function card(s: State, now: number): string {
133  const lines: string[] = []
134  lines.push(`${s.lastModel ?? '아직 본 모델 없음'}`)
135  if (s.compacted) lines.push('상태       압축으로 초기화됨, 첫 턴 기다림')
136  else if (!s.lastRequestAt) lines.push('상태       이 세션엔 아직 요청 없음')
137  else if (isCold(s, now)) lines.push(`상태       식음, 마지막 요청 ${fmtDuration(now - s.lastRequestAt)} 전`)
138  else lines.push(`상태       데워짐, ${fmtDuration(s.lastRequestAt + TTL_MS - now)} 남음`)
139  lines.push(`컨텍스트   ${s.ctx.toLocaleString('en-US')}토큰`)
140  lines.push(`식은 비용  다시 쓰면 ${fmtUsd(coldUsd(s))} (데워진 턴 ${fmtUsd(warmUsd(s))})`)
141  const always = s.always ? ' (항상)' : ''
142  const idle = s.always ? '꺼짐, 다음 세션 시작 때 6h00m 켜짐 (항상)' : '꺼짐 (/keepwarm 하면 6h00m 켜짐)'
143  lines.push(`keepwarm   ${s.deadline ? (statusText(s, now) ?? '').replace(/^keepwarm /, '켬, ') + always : s.stopped ? `멈춤, ${s.stopped}${always}` : idle}`)
144  const pings = breakEvenPings(s)
145  if (pings != null) lines.push(`손익분기   읽기 단가 핑 ${pings}번 = 식은 쓰기 1번, 핑 ${fmtDuration(s.every)} 간격이면 쉬는 시간 약 ${fmtDuration(pings * s.every)}`)
146  lines.push(`가드       ${s.guard === 'refuse' ? '한 번 막기 (/cache guard warn 이면 가격만 보여 줌)' : '경고만 (/cache guard refuse 면 한 번 막음)'}`)
147  const paid = s.misses.reduce((a, m) => a + (m.usd ?? 0), 0)
148  lines.push(`이 세션    식은 쓰기 ${s.misses.length}번, ${fmtUsd(paid)}`)
149  return lines.join('\n')
150}
151
152export function freshState(): State {
153  return {
154    hasBand: false, sid: '', deadline: 0, every: PING_AFTER_MS, always: false, lastRequestAt: 0, lastModel: null, ctx: 0, compacted: false,
155    guard: 'refuse', ackedAt: 0, coldWritePending: false, misses: [], pending: null, last: null, stopped: null,
156  }
157}
158
159
hooks/cacheview.ts 75 lines
1// 캐시 알약의 계산만 — 화면도 $ 도 모른다. cache.ts(keepwarm 모듈)와 register.tsx(계기판)가 같이 쓴다.
2// 가격·시간 표기는 cache-tax 2.2.1(Karan Bansal, MIT — ../LICENSE-cache-tax)에서 왔다.
3
4export const TTL_MS = 60 * 60 * 1000
5
6// $ per million tokens, [cache read, 1h cache write, output], list prices September 2026.
7// Longer family names first: a model id matches the first row it contains.
8const PRICES: Array<[string, number, number, number]> = [
9  ['fable-5-1', 0.25, 20, 50],
10  ['fable-5', 1, 20, 50],
11  ['opus-5', 0.5, 10, 25],
12  ['opus-4', 0.5, 10, 25],
13  ['sonnet-5', 0.2, 4, 10],
14  ['sonnet', 0.3, 6, 15],
15  ['haiku', 0.1, 2, 5],
16]
17
18export type PingRecord = { at: number; read: number; write: number; usd: number | null; warm: boolean }
19
20/** keepwarm 모듈이 계기판에 넘기는 몫($.state board.cache). 시각에 따라 바뀌는 글자는 그릴 때 viewOf 가 만든다. */
21export type CacheSnap = {
22  deadline: number
23  every: number
24  lastRequestAt: number
25  lastModel: string | null
26  ctx: number
27  compacted: boolean
28  last: PingRecord | null
29  stopped: string | null
30}
31
32export function priceOf(model: string | null): [number, number, number] | null {
33  const m = (model ?? '').toLowerCase().replace(/[\s.]+/g, '-')
34  for (const [family, read, write, output] of PRICES) if (m.includes(family)) return [read, write, output]
35  return null
36}
37
38export function fmtDuration(ms: number): string {
39  const total = Math.max(0, Math.round(ms / 60000))
40  const h = Math.floor(total / 60)
41  const m = total % 60
42  if (h >= 48) return `${Math.floor(h / 24)}d ${h % 24}h`
43  return h > 0 ? `${h}h ${String(m).padStart(2, '0')}m` : `${m}m` // 1h 45m — 남은 시간(⟲)과 같이 띄운다
44}
45
46export function fmtUsd(usd: number | null): string {
47  return usd == null ? '알 수 없음' : '$' + (usd >= 100 ? usd.toFixed(0) : usd.toFixed(2))
48}
49
50export function fmtTok(n: number): string {
51  return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1000 ? Math.round(n / 1000) + 'k' : String(n)
52}
53
54export function coldUsd(s: CacheSnap): number | null {
55  const price = priceOf(s.lastModel)
56  return price ? s.ctx * price[1] / 1e6 : null
57}
58
59export function isCold(s: CacheSnap, now: number): boolean {
60  return s.lastRequestAt > 0 && !s.compacted && now - s.lastRequestAt >= TTL_MS
61}
62
63/** 계기판의 캐시 알약 한 칸: 색 이름과 글자. 보여 줄 게 없으면 null. */
64export type CacheView = { tone: 'warm' | 'cold' | 'unknown' | 'stopped'; head: string; body: string }
65export function viewOf(s: CacheSnap, now: number): CacheView | null {
66  const keep = s.deadline && now < s.deadline ? ` · keepwarm ${fmtDuration(s.deadline - now)}` : ''
67  if (s.stopped) return { tone: 'stopped', head: '⏸ keepwarm 멈춤', body: s.stopped }
68  if (s.compacted) return { tone: 'unknown', head: '☕ 캐시', body: `압축 뒤 첫 턴 기다림${keep}` }
69  if (!s.lastRequestAt) return keep ? { tone: 'unknown', head: '☕ 캐시', body: `첫 턴 기다림${keep}` } : null
70  if (isCold(s, now)) return { tone: 'cold', head: '🧊 캐시 식음', body: `다음 요청이 ${fmtTok(s.ctx)} 다시 씀 ≈${fmtUsd(coldUsd(s))}${keep}` }
71  const ping = keep ? ` · 핑 ${fmtDuration(s.lastRequestAt + s.every - now)} 뒤` : ''
72  const last = keep && s.last ? ` · 지난 핑 ${fmtUsd(s.last.usd)}` : ''
73  return { tone: 'warm', head: '☕ 캐시', body: `${fmtDuration(s.lastRequestAt + TTL_MS - now)} 남음${keep}${ping}${last}` }
74}
75
hooks/help.ts 64 lines
1// /deck — deck 이 하는 일과 명령을 한 장으로. 줄마다 [무엇, 설명].
2import { cellWidth } from './board'
3
4export type HelpSection = { title: string; rows: [string, string][] }
5
6export const HELP: HelpSection[] = [
7  {
8    title: '📊 계기판 — 입력창 위 두 줄',
9    rows: [
10      ['위치 줄', '경로 · 브랜치 · 모델 · 바뀐 파일(올리면 목록) · CI'],
11      ['남은 양 줄', 'CTX · 5H · 7D 게이지와 리셋까지 · 비용 · 🔥/h · 캐시 · 작업 중/지난 턴'],
12      ['좁으면', '덜 중요한 알약부터 뺀다'],
13    ],
14  },
15  {
16    title: '💾 프롬프트 캐시',
17    rows: [
18      ['/keepwarm', '쉬는 동안 50분마다 핑으로 캐시를 데운다 — 6h · 90m · always · off · status'],
19      ['/cache', '캐시 상태 카드 — guard warn | refuse (식은 채 큰 컨텍스트를 보낼 때 막을지)'],
20    ],
21  },
22  {
23    title: '🎨 답 속 그림 — 포인터를 올리면 ⤢ 크게 보기 · ↓ PNG 저장',
24    rows: [
25      ['```mermaid', '흐름 · 시퀀스 · 상태 · ER 다이어그램'],
26      ['```chart', 'Vega-Lite 차트 (막대 · 선 · 원)'],
27      ['![설명](/경로)', '이미지 · 영상(장면 여섯 장) · 한 줄에 여러 개면 나란히'],
28    ],
29  },
30  {
31    title: '🔒 저절로 하는 일',
32    rows: [
33      ['커밋 검문', 'git-commit 스킬 규칙(서명 줄 · add -A · 제목 type · 브랜치 · 본문 줄바꿈·표 · 팀 레포 develop)에 걸리면 실행 전에 돌려보낸다'],
34    ],
35  },
36  {
37    title: '🟡 펫 — 계기판 오른쪽 끝 슬라임 말랑이 (100칸 넘을 때)',
38    rows: [
39      ['일하는 중', '왼쪽으로 사르륵 녹아 흘러갔다가 위에서 뚝 떨어져 돌아온다'],
40      ['기다리는 중', '천천히 숨만 쉰다'],
41      ['캐시 식음 · 검문·오류', '웅덩이로 꾸벅 · 20초 시무룩'],
42      ['5H 80% 넘음', '지쳐서 납작'],
43      ['말풍선', '커밋 · 검문 · 오류 · 긴 턴 끝 · 캐시 식음·데움 · 5H · CI 실패 때 3초 (같은 말은 10분에 한 번)'],
44      ['/pet', '이 세션에서 띄우기 · /pet off 숨기기'],
45    ],
46  },
47  {
48    title: '🔧 설정 — /plugin → deck → configure',
49    rows: [
50      ['style', 'clean | sketch (손그림)'],
51      ['pet', 'slime | off'],
52      ['scale · max_rows', '그림 크기 · 그림 최대 줄 수'],
53      ['/deck', '이 안내'],
54      ['/deck sync', 'push 한 deck 을 ~/.claude · ~/.claude-work 에 깔고, 쉬는 herdr 세션을 리로드'],
55    ],
56  },
57]
58
59// 명령 결과로 대화에 찍을 글 — 왼쪽 열을 화면 칸 폭으로 맞춘다(한글은 두 칸).
60export function helpText(): string {
61  const w = Math.max(...HELP.flatMap(sec => sec.rows.map(([k]) => cellWidth(k)))) + 2
62  return '🛫 Flight Deck\n\n' + HELP.map(sec => [sec.title, ...sec.rows.map(([k, d]) => `  ${k}${' '.repeat(w - cellWidth(k))}${d}`)].join('\n')).join('\n\n')
63}
64
hooks/commit.ts 122 lines
1// 커밋 검문 — git-commit 스킬 규칙 중 기계로 가를 수 있는 것만. 판단(왜를 쓰기·나누기·스쿼시)은 스킬 몫이다.
2// $ 를 안 쓴다: 명령을 읽고(readCommit), 저장소 사정(브랜치·팀 레포)을 받아 걸리는 곳을 문장으로 돌려준다(commitProblems).
3import { cellWidth, claudeTrailer } from './board'
4
5export const TYPES = ['feat', 'fix', 'refactor', 'perf', 'test', 'docs', 'chore', 'style', 'build', 'ci', 'revert']
6// 브랜치 prefix → 맞는 커밋 type. 목록에 없는 브랜치(develop·main·이름만 있는 것)는 따지지 않는다.
7const BRANCH_TYPE: Record<string, string> = {
8  feature: 'feat', feat: 'feat', fix: 'fix', bugfix: 'fix', hotfix: 'fix',
9  refactor: 'refactor', perf: 'perf', test: 'test', docs: 'docs', chore: 'chore', style: 'style', build: 'build', ci: 'ci',
10}
11const MAIN = new Set(['develop', 'main', 'master'])
12
13export type CommitPlan = {
14  bulkAdd: string | null // git add -A · git add . · git commit -a 중 걸린 것
15  commit: boolean
16  message: string | null // -m 들(빈 줄로 이음) 또는 heredoc 본문. 편집기로 쓰거나 --no-edit 면 null
17  dir: string | null // 커밋이 도는 곳(-C · 앞의 cd). 모르면 null — 그럼 브랜치 규칙은 건너뛴다
18  command: string
19}
20export type Repo = { branch: string; team: boolean }
21
22// 따옴표 속 글과 heredoc 본문을 걷어 낸 명령 — 메시지 안의 ; · && · -a 에 속지 않게.
23function bare(command: string): string {
24  return command
25    .replace(/<<-?\s*['"]?(\w+)['"]?[^\n]*\n[\s\S]*?\n\s*\1\b/g, ' ')
26    .replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, ' Q ')
27}
28
29function subcommand(tokens: string[]): { sub: string; dir: string | null; rest: string[] } | null {
30  if (!/(^|\/)git$/.test(tokens[0] ?? '')) return null
31  let dir: string | null = null
32  let i = 1
33  while (i < tokens.length && tokens[i]!.startsWith('-')) {
34    if (tokens[i] === '-C') dir = tokens[i + 1] ?? null
35    i += tokens[i] === '-C' || tokens[i] === '-c' ? 2 : 1
36  }
37  return { sub: tokens[i] ?? '', dir, rest: tokens.slice(i + 1) }
38}
39
40function unquote(raw: string): string {
41  if (raw.startsWith("'")) return raw.slice(1, -1)
42  if (raw.startsWith('"')) return raw.slice(1, -1).replace(/\\(["\\$`])/g, '$1')
43  return raw
44}
45
46export function readCommit(command: string, cwd: string): CommitPlan | null {
47  const parts = bare(command).split(/&&|\|\||[;|\n]/).map(p => p.trim().split(/\s+/).filter(Boolean))
48  let bulkAdd: string | null = null
49  let commit = false
50  let dir: string | null = cwd
51  let here: string | null = cwd
52  for (const tokens of parts) {
53    if (tokens[0] === 'cd') here = tokens[1] && tokens[1] !== 'Q' && !tokens[1].startsWith('~') ? join(here, tokens[1]) : null
54    const g = subcommand(tokens)
55    if (!g) continue
56    if (g.sub === 'add' && g.rest.some(t => t === '-A' || t === '--all' || t === '.')) bulkAdd = `git add ${g.rest.join(' ')}`
57    if (g.sub === 'commit') {
58      commit = true
59      if (g.rest.some(t => t === '--all' || /^-[a-zA-Z]*a[a-zA-Z]*$/.test(t))) bulkAdd = `git commit ${g.rest.filter(t => t !== 'Q').join(' ')}`
60      dir = g.dir === null ? here : g.dir === 'Q' || g.dir.startsWith('~') ? null : join(here, g.dir)
61    }
62  }
63  if (!commit && !bulkAdd) return null
64  let message: string | null = null
65  if (commit) {
66    // 메시지는 git commit 뒤에서만 찾는다 — 앞선 다른 명령의 heredoc(스크립트)을 메시지로 읽지 않게.
67    const spans = [...command.matchAll(/<<-?\s*['"]?(\w+)['"]?[^\n]*\n[\s\S]*?\n\s*\1\b/g)].map(h => [h.index, h.index + h[0].length] as const)
68    const at = [...command.matchAll(/\bgit\b[^\n;&|]*?\bcommit\b/g)].find(c => !spans.some(([a, b]) => c.index > a && c.index < b))?.index ?? 0
69    const tail = command.slice(at)
70    const heredoc = /<<-?\s*['"]?(\w+)['"]?[^\n]*\n([\s\S]*?)\n\s*\1\b/.exec(tail)
71    if (heredoc) message = heredoc[2]!
72    else {
73      const ms = [...tail.matchAll(/(?:^|\s)(?:-[a-zA-Z]*m|--message)(?:\s+|=)("(?:[^"\\]|\\.)*"|'[^']*'|[^\s;&|]+)/g)].map(m => unquote(m[1]!))
74      if (ms.length) message = ms.join('\n\n')
75    }
76  }
77  return { bulkAdd, commit, message, dir, command }
78}
79
80function join(base: string | null, path: string): string | null {
81  if (path.startsWith('/')) return path
82  return base === null ? null : `${base}/${path}`
83}
84
85// 문장 끝 = 문장부호로 끝난 줄. 한글 어미(다·요)로는 가르지 않는다 — "때마다"처럼 문장 중간에도 온다.
86const SENTENCE_END = /[.!?。:;…)\]」』"'`]$/
87const LIST_ITEM = /^\s*(?:[-*•]|\d+[.)])\s/
88
89export function commitProblems(plan: CommitPlan, repo?: Repo): string[] {
90  const out: string[] = []
91  const sig = plan.commit ? claudeTrailer(plan.command) : null
92  if (sig) out.push(`Claude 서명 줄(${sig})을 뺀다 — 이 사용자는 커밋에 트레일러를 넣지 않는다.`)
93  if (plan.bulkAdd) out.push(`\`${plan.bulkAdd}\` 로 한꺼번에 담지 않는다 — 확인한 파일만 이름으로 \`git add\` 한다.`)
94  if (plan.commit && repo && repo.team && MAIN.has(repo.branch)) out.push(`팀 레포의 ${repo.branch} 에 바로 커밋하지 않는다 — feature/* · fix/* 브랜치에서 한다(task-flow).`)
95  const msg = plan.message
96  if (msg === null) return out
97  const lines = msg.split('\n')
98  const title = lines[0]!.trim()
99  if (/^(Merge |Revert "|fixup! |squash! |amend! )/.test(title)) return out
100  const m = /^([a-z]+)(\([^)]*\))?!?: \S/.exec(title)
101  if (!m) out.push(`제목 「${title}」 이 \`type(scope): 제목\` 꼴이 아니다 — type 은 ${TYPES.join(' · ')}.`)
102  else if (!TYPES.includes(m[1]!)) out.push(`제목의 type \`${m[1]}\` 은 쓰지 않는다 — ${TYPES.join(' · ')} 중 하나.`)
103  else if (repo) {
104    const want = BRANCH_TYPE[repo.branch.split('/')[0]!]
105    if (repo.branch.includes('/') && want && want !== m[1]) out.push(`브랜치 ${repo.branch} 에서는 제목 type 을 \`${want}\` 로 맞춘다(지금 \`${m[1]}\`).`)
106  }
107  let fence = false
108  lines.forEach((raw, i) => {
109    if (i === 0) return
110    const line = raw.trimEnd()
111    if (/^\s*```/.test(line)) fence = !fence
112    if (fence || !line.trim()) return
113    const n = i + 1
114    if (/^\s*\|/.test(line) || (line.match(/\|/g)?.length ?? 0) >= 2) out.push(`${n}째 줄이 표다 — 표 대신 문장이나 목록으로 쓴다.`)
115    else if (/\S {2,}\S/.test(line.trim())) out.push(`${n}째 줄이 공백으로 열을 맞췄다 — 정렬하지 말고 한 칸만 띄운다.`)
116    const next = lines[i + 1]?.trimEnd() ?? ''
117    if (cellWidth(line) >= 60 && !SENTENCE_END.test(line) && next.trim() && !LIST_ITEM.test(next) && !/^\s*```/.test(next))
118      out.push(`${n}째 줄이 문장 중간에서 끊겼다 — 폭을 맞추려 끊지 말고 ${n + 1}째 줄과 한 줄로 잇는다.`)
119  })
120  return out
121}
122
hooks/pet.ts 32 lines
1// 펫 공용: 지금 상태를 고르고, 픽셀 판을 반 칸 블록 글자로 바꾼다. $ 를 안 쓴다.
2
3// work 일하는 중 · hurt 커밋 검문·도구 오류 뒤 · tired 5H 한도 80% 넘음 · cold 캐시 식음 · idle 쉬는 중
4export type PetState = 'work' | 'idle' | 'cold' | 'hurt' | 'tired'
5
6// 앞에 있는 것이 이긴다: 일하는 중 > 다침 > 지침 > 캐시 식음 > 쉬는 중.
7export function stateOf(s: { working: boolean; hurtUntil: number; now: number; fiveHour?: number; cold: boolean }): PetState {
8  if (s.working) return 'work'
9  if (s.hurtUntil > s.now) return 'hurt'
10  if ((s.fiveHour ?? 0) >= 80) return 'tired'
11  if (s.cold) return 'cold'
12  return 'idle'
13}
14
15// 줄마다 [글자, 글자색, 바탕색] 덩어리 — 위·아래 픽셀 둘을 반 칸 블록 하나로, 같은 모양이 이어지면 한 덩어리로 묶는다.
16export type Run = { text: string; fg?: string; bg?: string }
17export function runsOf(px: (string | null)[][]): Run[][] {
18  const rows: Run[][] = []
19  for (let y = 0; y < px.length; y += 2) {
20    const runs: Run[] = []
21    for (let x = 0; x < px[y]!.length; x++) {
22      const t = px[y]![x] ?? null, b = px[y + 1]?.[x] ?? null
23      const cell: Run = t && b ? { text: '▀', fg: t, bg: b } : t ? { text: '▀', fg: t } : b ? { text: '▄', fg: b } : { text: ' ' }
24      const last = runs.at(-1)
25      if (last && last.fg === cell.fg && last.bg === cell.bg && last.text.at(-1) === cell.text) last.text += cell.text
26      else runs.push(cell)
27    }
28    rows.push(runs)
29  }
30  return rows
31}
32
hooks/slime.ts 145 lines
1// 계기판 오른쪽 끝의 노란 슬라임 — 바닥에 앉은 반타원 젤리를 너비·높이·기울기·위치로 그때그때 계산해 21×8 픽셀에 찍는다.
2// 21칸 × 4줄. $ 를 안 쓴다: 상태와 시각(ms)을 받아 줄마다 글자 덩어리를 돌려준다.
3import { runsOf, type PetState, type Run } from './pet'
4import { C, mix } from './theme'
5
6export const SLIME_COLS = 21
7const W = 21
8const H = 8
9const GROUND = 7
10const HOME = 14 // 서 있는 자리(바닥 가운데)
11
12// 테마 색에서: 몸은 테마의 노랑, 테두리·그늘은 노랑을 어둡게, 반짝은 밝게. 눈은 밝은 테마에서도 보이게 어두운 쪽 글자색.
13const colors = () => {
14  const y = C.yellow
15  return {
16    O: mix(y, '#000000', 0.32), y, s: mix(y, '#000000', 0.1), h: mix(y, '#ffffff', 0.65),
17    E: C.light ? C.text : C.base, c: C.peach, tear: C.blue, z: C.sub,
18    // 막 지나간 자국 → 말라 가는 자국(바탕 쪽으로 바랜다)
19    trail: [mix(y, '#000000', 0.15), mix(y, '#000000', 0.32), mix(y, C.base, 0.55), mix(y, C.base, 0.78)],
20  }
21}
22let K = colors()
23
24type Eyes = 'open' | 'closed' | 'sad' | 'none'
25// cx 바닥 가운데 · a 반폭 · b 높이 · lean 꼭대기가 밀린 칸 · lift 바닥에서 뜬 높이 · look 눈동자 좌우
26type Body = { cx: number; a: number; b: number; lean?: number; lift?: number; look?: number; eyes?: Eyes }
27type Px = (string | null)[][]
28
29function draw(body: Body | null, trail: [number, number][] = [], dots: [number, number, string][] = []): Px {
30  const px: Px = Array.from({ length: H }, () => Array<string | null>(W).fill(null))
31  for (const [x, shade] of trail) if (x >= 0 && x < W) px[GROUND]![x] = K.trail[shade]!
32  if (body) {
33    const { cx, a, b, lean = 0, lift = 0, look = 0, eyes = 'open' } = body
34    const base = GROUND - lift
35    const inside = (x: number, y: number) => y <= base && ((x - (cx + (lean * (base - y)) / b)) / a) ** 2 + ((base - y) / b) ** 2 <= 1
36    const at = (x: number, y: number) => px[y]?.[x]
37    const put = (x: number, y: number, c: string, over: (string | null)[] = [K.y, K.h, K.s]) => {
38      if (y >= 0 && y < H && x >= 0 && x < W && over.includes(px[y]![x] ?? null)) px[y]![x] = c
39    }
40    for (let y = 0; y < H; y++)
41      for (let x = 0; x < W; x++) {
42        if (!inside(x + 0.5, y + 0.5)) continue
43        const edge = y === base || !inside(x + 1.5, y + 0.5) || !inside(x - 0.5, y + 0.5) || !inside(x + 0.5, y - 0.5)
44        px[y]![x] = edge ? K.O : K.y
45      }
46    // 그늘(아래 왼쪽 안) · 반짝(왼쪽 위)
47    for (let x = 1; x < W; x++) if (at(x, base - 1) === K.y && at(x - 1, base - 1) === K.O) px[base - 1]![x] = K.s
48    const top = Math.max(0, Math.ceil(base - b + 0.5))
49    const hx = Math.round(cx + lean * 0.7 - a * 0.45)
50    put(hx, top + 1, K.h, [K.y])
51    put(hx, top + 2, K.h, [K.y])
52    if (eyes !== 'none' && b >= 2.5) {
53      const ey = Math.round(base - b * 0.5) + (eyes === 'sad' ? 1 : 0)
54      const mid = cx + (lean * (base - ey)) / b
55      for (const dx of [-a * 0.32, a * 0.32]) {
56        const ex = Math.round(mid + dx + look)
57        if (eyes === 'closed') {
58          put(ex, ey, K.O)
59          continue
60        }
61        put(ex, ey, K.E)
62        if (b >= 5 && eyes === 'open') put(ex, ey - 1, K.E)
63        put(ex + Math.sign(dx), ey + 1, K.c, [K.y]) // 볼: 눈 아래 바깥쪽(안쪽이면 코처럼 보인다)
64        if (eyes === 'sad' && dx < 0) put(ex, ey + 1, K.tear)
65      }
66    }
67  }
68  for (const [x, y, c] of dots) if (y >= 0 && y < H && x >= 0 && x < W && !px[y]![x]) px[y]![x] = c
69  return px
70}
71
72const STAND: Body = { cx: HOME, a: 6, b: 7 }
73const ease = (t: number) => (1 - Math.cos(Math.PI * t)) / 2
74
75// 일하는 동안 한 바퀴: 서서 깜빡 → 왼쪽 힐끔 → 왼쪽으로 사르륵 녹아 흘러 사라짐 → 자국이 마름 → 오른쪽 위에서 뚝 → 철퍼덕 → 뿅.
76type Step = { ms: number; px: Px }
77function build() {
78  const GLIDE: Step[] = (() => {
79    const steps: Step[] = [
80      { ms: 1200, px: draw(STAND) },
81      { ms: 150, px: draw({ ...STAND, eyes: 'closed' }) },
82      { ms: 700, px: draw(STAND) },
83      { ms: 700, px: draw({ ...STAND, look: -1 }) },
84    ]
85    const N = 14
86    for (let i = 1; i <= N; i++) {
87      const t = ease(i / N)
88      const cx = HOME - t * 22
89      const trail: [number, number][] = []
90      for (let x = Math.round(cx + 6); x <= HOME + 4; x++) trail.push([x, Math.min(3, Math.floor((x - (cx + 6)) / 6))])
91      steps.push({ ms: 220, px: draw({ cx, a: 6 + t * 2.5, b: 7 - t * 4.5, lean: 1.5 + t * 2, look: -1 }, trail) })
92    }
93    steps.push(
94      { ms: 700, px: draw(null, [[9, 3], [13, 3], [17, 3]]) },
95      { ms: 900, px: draw(null) },
96      { ms: 120, px: draw({ cx: HOME, a: 4.5, b: 8, lift: 5 }) },
97      { ms: 120, px: draw({ cx: HOME, a: 4.5, b: 8, lift: 2 }) },
98      { ms: 260, px: draw({ cx: HOME, a: 8, b: 4.5 }) },
99      { ms: 200, px: draw({ cx: HOME, a: 5, b: 8 }) },
100    )
101    return steps
102  })()
103  const GLIDE_MS = GLIDE.reduce((n, s) => n + s.ms, 0)
104
105
106  // 기다리는 동안: 천천히 숨만 쉰다 — 1.6초마다 살짝 낮고 넓게 가라앉았다 돌아오고, 가끔 깜빡.
107  const EXHALE = draw({ cx: HOME, a: 6.4, b: 6.3 })
108  const BREATH: Step[] = [
109    { ms: 1600, px: draw(STAND) },
110    { ms: 1600, px: EXHALE },
111    { ms: 1600, px: draw(STAND) },
112    { ms: 1450, px: EXHALE },
113    { ms: 150, px: draw({ cx: HOME, a: 6.4, b: 6.3, eyes: 'closed' }) },
114  ]
115  const BREATH_MS = BREATH.reduce((n, step) => n + step.ms, 0)
116  const PUDDLE = (z: boolean) => draw({ cx: HOME, a: 7.5, b: 3, eyes: 'closed' }, [], z ? [[HOME + 6, 2, K.z], [HOME + 7, 1, K.z]] : [[HOME + 6, 3, K.z]])
117  const SAD = draw({ cx: HOME, a: 6.5, b: 6, eyes: 'sad' })
118  const TIRED = draw({ cx: HOME, a: 8, b: 4, eyes: 'closed' })
119  return { GLIDE, GLIDE_MS, BREATH, BREATH_MS, PUDDLE: [PUDDLE(false), PUDDLE(true)], SAD, TIRED }
120}
121
122// 테마가 바뀌면 색을 다시 받아 프레임을 새로 만든다.
123let built: { name: string; f: ReturnType<typeof build> } | null = null
124function frames() {
125  if (built?.name !== C.name) {
126    K = colors()
127    built = { name: C.name, f: build() }
128  }
129  return built.f
130}
131
132export function slimeRows(state: PetState, now: number): Run[][] {
133  const f = frames()
134  if (state === 'cold') return runsOf(f.PUDDLE[Math.floor(now / 1000) % 2]!)
135  if (state === 'hurt') return runsOf(f.SAD)
136  if (state === 'tired') return runsOf(f.TIRED)
137  const [steps, total] = state === 'idle' ? [f.BREATH, f.BREATH_MS] : [f.GLIDE, f.GLIDE_MS]
138  let t = now % total
139  for (const step of steps) {
140    if (t < step.ms) return runsOf(step.px)
141    t -= step.ms
142  }
143  return runsOf(steps[0]!.px)
144}
145
hooks/images.ts 48 lines
1// 화면도 엔진도 모르는 규칙만 — 테스트가 여기를 본다.
2
3const IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp|tiff?|heic)$/i
4// 영상은 프레임 몇 장을 한 장으로 붙여 보여 준다(bin/convert.sh).
5const VIDEO_EXT = /\.(mp4|mov|mkv|webm|avi|m4v)$/i
6
7export const isImagePath = (path: unknown): path is string => typeof path === 'string' && path.startsWith('/') && IMAGE_EXT.test(path)
8export const isVideoPath = (path: unknown): path is string => typeof path === 'string' && path.startsWith('/') && VIDEO_EXT.test(path)
9export const isMediaPath = (path: unknown): path is string => isImagePath(path) || isVideoPath(path)
10
11export type Picture = { path: string; alt: string }
12export type Segment = { kind: 'text'; text: string } | { kind: 'image'; pictures: Picture[]; raw: string }
13
14// 줄 하나가 통째로 `![설명](/절대/경로.png)` 들인 것만 그림으로 바꾼다 — 여러 개면 나란히.
15// 문장 중간의 이미지 문법·URL 은 글로 둔다.
16// 경로에 공백이 있어도 된다(macOS 의 `Desktop Pictures` 같은) — 닫는 괄호까지.
17const ONE = '!\\[([^\\]\\n]*)\\]\\((\\/[^)\\n]+)\\)'
18const LINE = new RegExp(`^${ONE}(?:[ \\t]+${ONE})*[ \\t]*$`, 'gm')
19const EACH = new RegExp(ONE, 'g')
20
21export function splitImages(text: string): Segment[] {
22  const out: Segment[] = []
23  let at = 0
24  const pushText = (s: string) => {
25    if (s.trim()) out.push({ kind: 'text', text: s.replace(/^\n+|\n+$/g, '') })
26  }
27  for (const m of text.matchAll(LINE)) {
28    const pictures = [...m[0].matchAll(EACH)].map(x => ({ alt: x[1] ?? '', path: x[2] ?? '' }))
29    if (!pictures.every(p => isMediaPath(p.path))) continue
30    pushText(text.slice(at, m.index))
31    out.push({ kind: 'image', pictures, raw: m[0] })
32    at = (m.index ?? 0) + m[0].length
33  }
34  pushText(text.slice(at))
35  return out
36}
37
38// 그림 비율을 지키는 칸 수. 한 칸은 높이가 너비의 cellAspect 배. 폭·높이 한도 안에서 가장 크게.
39export function gridFor(width: number, height: number, maxColumns: number, maxRows: number, cellAspect = 2.2): { columns: number; rows: number } {
40  let columns = Math.max(1, Math.min(maxColumns, 255))
41  let rows = Math.round((columns * height) / (width * cellAspect))
42  if (rows > maxRows) {
43    rows = maxRows
44    columns = Math.max(1, Math.round((rows * width * cellAspect) / height))
45  }
46  return { columns, rows: Math.max(1, Math.min(rows, 255)) }
47}
48
hooks/parse.ts 37 lines
1export type Segment = { kind: 'text'; text: string } | { kind: 'mermaid'; source: string; raw: string }
2
3// 줄 맨 앞에서 열고 닫힌 ```mermaid 블록만. 스트리밍 중 아직 안 닫힌 블록은 글로 남는다.
4// 들여쓴 블록(리스트 안)은 건드리지 않는다 — 거기서 자르면 리스트가 끊긴다.
5const FENCE = /^(`{3,}|~{3,})mermaid[^\n`]*\n([\s\S]*?)\n\1[ \t]*$/gm
6
7export function splitReply(text: string): Segment[] {
8  const out: Segment[] = []
9  let at = 0
10  const pushText = (s: string) => {
11    if (s.trim()) out.push({ kind: 'text', text: s.replace(/^\n+|\n+$/g, '') })
12  }
13  for (const m of text.matchAll(FENCE)) {
14    pushText(text.slice(at, m.index))
15    out.push({ kind: 'mermaid', source: m[2] ?? '', raw: m[0] })
16    at = (m.index ?? 0) + m[0].length
17  }
18  pushText(text.slice(at))
19  return out
20}
21
22export type Mode = 'pictures' | 'text' | 'off'
23
24// auto: kitty·Ghostty 면 그림, 아니면 선 도형. tmux 는 그림을 못 넘긴다.
25// herdr 안에서는 바깥 터미널을 알 길이 없어(서버를 띄운 터미널의 env 를 물려받는다) TERM 으로는
26// 못 고른다. 대신 Claude Code 가 그림을 억지로 켜는 CLAUDE_CODE_FORCE_TERMINAL_IMAGES 를 따른다
27// (zshrc 가 herdr 안에서 켠다).
28export function pickMode(mode: string, env: Record<string, string | undefined>): Mode {
29  if (mode === 'off' || mode === 'text') return mode
30  if (mode === 'on') return 'pictures'
31  if (env.CLAUDE_CODE_FORCE_TERMINAL_IMAGES) return 'pictures'
32  if (env.TMUX) return 'text'
33  const kitty = env.TERM === 'xterm-kitty' || Boolean(env.KITTY_WINDOW_ID)
34  const ghostty = env.TERM_PROGRAM === 'ghostty' || env.TERM === 'xterm-ghostty'
35  return kitty || ghostty ? 'pictures' : 'text'
36}
37
hooks/theme.ts 86 lines
1// Flight Deck 의 색 — herdr 테마를 따른다. ~/.config/herdr/config.toml 의 [theme] name(auto_switch 면 맥 라이트·다크)을
2// 읽어 setTheme 으로 C 를 그 테마 색으로 바꾼다. 계기판·안내·펫이 C 를 읽고, 렌더러(bin/)에는 그림 그릴 때 색을 넘긴다.
3
4type Colors = {
5  light: boolean // 밝은 바탕 테마
6  base: string // 바탕
7  surface: string // 알약 바탕
8  overlay: string // 한 단 더 바탕 · 선
9  text: string // 글자
10  sub: string // 흐린 글자
11  yellow: string
12  teal: string
13  peach: string
14  green: string
15  red: string
16  blue: string
17  mauve: string
18}
19export type Palette = Colors & { name: string; ink: string } // ink: 색 알약 위 글자(어두운 테마는 바탕색, 밝은 테마는 글자색)
20
21// herdr 내장 테마 이름 그대로. 값은 각 테마의 공식 팔레트에서 같은 역할의 색.
22const PALETTES: Record<string, Colors> = {
23  gruvbox: { light: false, base: '#282828', surface: '#3c3836', overlay: '#504945', text: '#ebdbb2', sub: '#a89984', yellow: '#fabd2f', teal: '#8ec07c', peach: '#fe8019', green: '#b8bb26', red: '#fb4934', blue: '#83a598', mauve: '#d3869b' },
24  'gruvbox-light': { light: true, base: '#fbf1c7', surface: '#ebdbb2', overlay: '#d5c4a1', text: '#3c3836', sub: '#7c6f64', yellow: '#b57614', teal: '#427b58', peach: '#af3a03', green: '#79740e', red: '#9d0006', blue: '#076678', mauve: '#8f3f71' },
25  catppuccin: { light: false, base: '#1e1e2e', surface: '#313244', overlay: '#45475a', text: '#cdd6f4', sub: '#a6adc8', yellow: '#f9e2af', teal: '#94e2d5', peach: '#fab387', green: '#a6e3a1', red: '#f38ba8', blue: '#89b4fa', mauve: '#cba6f7' },
26  'catppuccin-latte': { light: true, base: '#eff1f5', surface: '#ccd0da', overlay: '#bcc0cc', text: '#4c4f69', sub: '#6c6f85', yellow: '#df8e1d', teal: '#179299', peach: '#fe640b', green: '#40a02b', red: '#d20f39', blue: '#1e66f5', mauve: '#8839ef' },
27  'tokyo-night': { light: false, base: '#1a1b26', surface: '#24283b', overlay: '#414868', text: '#c0caf5', sub: '#a9b1d6', yellow: '#e0af68', teal: '#73daca', peach: '#ff9e64', green: '#9ece6a', red: '#f7768e', blue: '#7aa2f7', mauve: '#bb9af7' },
28  'tokyo-night-day': { light: true, base: '#e1e2e7', surface: '#d0d5e3', overlay: '#c4c8da', text: '#3760bf', sub: '#6172b0', yellow: '#8c6c3e', teal: '#118c74', peach: '#b15c00', green: '#587539', red: '#f52a65', blue: '#2e7de9', mauve: '#9854f1' },
29  dracula: { light: false, base: '#282a36', surface: '#44475a', overlay: '#565a72', text: '#f8f8f2', sub: '#a4abcb', yellow: '#f1fa8c', teal: '#8be9fd', peach: '#ffb86c', green: '#50fa7b', red: '#ff5555', blue: '#8be9fd', mauve: '#bd93f9' },
30  nord: { light: false, base: '#2e3440', surface: '#3b4252', overlay: '#434c5e', text: '#eceff4', sub: '#a5adba', yellow: '#ebcb8b', teal: '#8fbcbb', peach: '#d08770', green: '#a3be8c', red: '#bf616a', blue: '#81a1c1', mauve: '#b48ead' },
31  'one-dark': { light: false, base: '#282c34', surface: '#31353f', overlay: '#3e4451', text: '#abb2bf', sub: '#7f848e', yellow: '#e5c07b', teal: '#56b6c2', peach: '#d19a66', green: '#98c379', red: '#e06c75', blue: '#61afef', mauve: '#c678dd' },
32  'one-light': { light: true, base: '#fafafa', surface: '#e5e5e6', overlay: '#d3d3d4', text: '#383a42', sub: '#696c77', yellow: '#c18401', teal: '#0184bc', peach: '#986801', green: '#50a14f', red: '#e45649', blue: '#4078f2', mauve: '#a626a4' },
33  solarized: { light: false, base: '#002b36', surface: '#073642', overlay: '#28505a', text: '#93a1a1', sub: '#839496', yellow: '#b58900', teal: '#2aa198', peach: '#cb4b16', green: '#859900', red: '#dc322f', blue: '#268bd2', mauve: '#d33682' },
34  'solarized-light': { light: true, base: '#fdf6e3', surface: '#eee8d5', overlay: '#ddd6c1', text: '#586e75', sub: '#657b83', yellow: '#b58900', teal: '#2aa198', peach: '#cb4b16', green: '#859900', red: '#dc322f', blue: '#268bd2', mauve: '#d33682' },
35  kanagawa: { light: false, base: '#1f1f28', surface: '#2a2a37', overlay: '#363646', text: '#dcd7ba', sub: '#a6a69c', yellow: '#e6c384', teal: '#7aa89f', peach: '#ffa066', green: '#98bb6c', red: '#e46876', blue: '#7e9cd8', mauve: '#957fb8' },
36  'kanagawa-lotus': { light: true, base: '#f2ecbc', surface: '#e5ddb0', overlay: '#dcd5ac', text: '#545464', sub: '#716e61', yellow: '#77713f', teal: '#597b75', peach: '#cc6d00', green: '#6f894e', red: '#c84053', blue: '#4d699b', mauve: '#624c83' },
37  'rose-pine': { light: false, base: '#191724', surface: '#26233a', overlay: '#403d52', text: '#e0def4', sub: '#908caa', yellow: '#f6c177', teal: '#9ccfd8', peach: '#ebbcba', green: '#31748f', red: '#eb6f92', blue: '#9ccfd8', mauve: '#c4a7e7' },
38  'rose-pine-dawn': { light: true, base: '#faf4ed', surface: '#f2e9e1', overlay: '#dfdad9', text: '#575279', sub: '#797593', yellow: '#ea9d34', teal: '#56949f', peach: '#d7827e', green: '#286983', red: '#b4637a', blue: '#56949f', mauve: '#907aa9' },
39  vesper: { light: false, base: '#101010', surface: '#1c1c1c', overlay: '#343434', text: '#ffffff', sub: '#a0a0a0', yellow: '#ffc799', teal: '#99ffe4', peach: '#ffc799', green: '#99ffe4', red: '#ff8080', blue: '#a0c4e8', mauve: '#d5b3f0' },
40}
41// auto_switch 때 라이트·다크 짝. 짝이 없는 테마는 그대로 둔다.
42const PAIRS: [string, string][] = [
43  ['gruvbox', 'gruvbox-light'], ['catppuccin', 'catppuccin-latte'], ['tokyo-night', 'tokyo-night-day'], ['one-dark', 'one-light'],
44  ['solarized', 'solarized-light'], ['kanagawa', 'kanagawa-lotus'], ['rose-pine', 'rose-pine-dawn'],
45]
46
47const paint = (name: string): Palette => {
48  const c = PALETTES[name] ?? PALETTES.gruvbox!
49  return { ...c, name: PALETTES[name] ? name : 'gruvbox', ink: c.light ? c.text : c.base }
50}
51
52// 지금 테마 색. 필드를 바꿔 끼우므로 import 한 쪽은 늘 지금 색을 읽는다.
53export const C: Palette = paint('gruvbox')
54
55/** 테마를 바꾼다. 바뀌었으면 true. 모르는 이름(terminal 등)은 gruvbox. */
56export function setTheme(name: string): boolean {
57  const next = paint(name)
58  if (next.name === C.name) return false
59  Object.assign(C, next)
60  return true
61}
62
63// herdr 설정 글에서 쓸 테마 이름. auto_switch 면 dark(맥이 다크 모드인지)에 따라 짝으로.
64export function herdrTheme(toml: string, dark: boolean): string {
65  let section = ''
66  let inTheme = false
67  for (const line of toml.split('\n')) {
68    if (/^\s*\[/.test(line)) inTheme = /^\s*\[theme\]\s*$/.test(line)
69    else if (inTheme) section += `${line}\n`
70  }
71  const name = /^\s*name\s*=\s*"([^"]+)"/m.exec(section)?.[1] ?? 'gruvbox'
72  if (!/^\s*auto_switch\s*=\s*true/m.test(section)) return name
73  for (const [d, l] of PAIRS) if (name === d || name === l) return dark ? d : l
74  return name
75}
76
77const rgb = (hex: string) => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16))
78/** a 를 b 쪽으로 t 만큼 섞는다(0 이면 a, 1 이면 b). */
79export function mix(a: string, b: string, t: number): string {
80  const x = rgb(a), y = rgb(b)
81  return `#${x.map((v, i) => Math.round(v + (y[i]! - v) * t).toString(16).padStart(2, '0')).join('')}`
82}
83
84/** 계기판 알약 바탕 — 색을 바탕 쪽으로 35% 누그러뜨린다. 바탕 계열(surface·overlay)은 그대로. */
85export const soft = (c: string) => (c === C.surface || c === C.overlay ? c : mix(c, C.base, 0.35))
86
hooks/blocks.ts 29 lines
1// 답 하나를 한 번에 나눈다: ```mermaid · ```chart · 이미지 줄 · 나머지 글.
2import { splitCharts } from './fences'
3import { splitImages, type Picture } from './images'
4import { splitReply } from './parse'
5
6export type Block =
7  | { kind: 'text'; text: string }
8  | { kind: 'mermaid'; source: string; raw: string }
9  | { kind: 'chart'; spec: string; raw: string }
10  | { kind: 'image'; pictures: Picture[]; raw: string }
11
12export function splitAll(text: string): Block[] {
13  const out: Block[] = []
14  for (const a of splitReply(text)) {
15    if (a.kind !== 'text') {
16      out.push(a)
17      continue
18    }
19    for (const b of splitCharts(a.text)) {
20      if (b.kind !== 'text') {
21        out.push(b)
22        continue
23      }
24      out.push(...splitImages(b.text))
25    }
26  }
27  return out
28}
29
hooks/board.ts 134 lines
1// 화면도 엔진도 모르는 규칙만 — 테스트가 여기를 본다.
2
3export type Git = { branch: string; ahead: number; behind: number; files: { code: string; path: string }[]; added: number; deleted: number }
4export type Ci = { state: 'ok' | 'fail' | 'run' | 'other'; name: string; url: string; at: number }
5
6// `git status --porcelain=v2 --branch` 와 `git diff --numstat HEAD` 를 합친다.
7export function parseGit(status: string, numstat: string): Git {
8  const git: Git = { branch: '', ahead: 0, behind: 0, files: [], added: 0, deleted: 0 }
9  for (const line of status.split('\n')) {
10    // 커밋이 하나도 없는 저장소는 git 이 (unknown) 이라고 답한다.
11    if (line.startsWith('# branch.head ')) git.branch = line.slice(14) === '(unknown)' ? '커밋 없음' : line.slice(14)
12    else if (line.startsWith('# branch.ab ')) {
13      const [a, b] = line.slice(12).split(' ')
14      git.ahead = Math.abs(Number(a) || 0)
15      git.behind = Math.abs(Number(b) || 0)
16    } else if (line.startsWith('1 ') || line.startsWith('2 ')) {
17      // 1 XY sub mH mI mW hH hI path / 2 … path\torig
18      const parts = line.split(' ')
19      const xy = parts[1] ?? '..'
20      const path = parts.slice(line.startsWith('1 ') ? 8 : 9).join(' ').split('\t')[0] ?? ''
21      git.files.push({ code: (xy[0] !== '.' ? xy[0] : xy[1]) ?? 'M', path })
22    } else if (line.startsWith('? ')) git.files.push({ code: '?', path: line.slice(2) })
23    else if (line.startsWith('u ')) git.files.push({ code: 'U', path: line.split(' ').slice(10).join(' ') })
24  }
25  for (const line of numstat.split('\n')) {
26    const [a, d] = line.split('\t')
27    git.added += Number(a) || 0
28    git.deleted += Number(d) || 0
29  }
30  return git
31}
32
33// `gh run list --json status,conclusion,updatedAt,workflowName,url` 의 첫 줄.
34export function ciFromGithub(json: string): Ci | null {
35  const run = (JSON.parse(json || '[]') as { status?: string; conclusion?: string; updatedAt?: string; workflowName?: string; url?: string }[])[0]
36  if (!run) return null
37  const state = run.status !== 'completed' ? 'run' : run.conclusion === 'success' ? 'ok' : run.conclusion === 'failure' ? 'fail' : 'other'
38  return { state, name: run.workflowName ?? 'CI', url: run.url ?? '', at: Date.parse(run.updatedAt ?? '') || 0 }
39}
40
41// `glab ci list -F json` 의 첫 줄.
42export function ciFromGitlab(json: string): Ci | null {
43  const run = (JSON.parse(json || '[]') as { status?: string; updated_at?: string; web_url?: string; ref?: string }[])[0]
44  if (!run) return null
45  const s = run.status ?? ''
46  const state = s === 'success' ? 'ok' : s === 'failed' ? 'fail' : ['running', 'pending', 'created', 'preparing', 'waiting_for_resource'].includes(s) ? 'run' : 'other'
47  return { state, name: 'pipeline', url: run.web_url ?? '', at: Date.parse(run.updated_at ?? '') || 0 }
48}
49
50// 72 → "1m12s", 9 → "9s"
51export const elapsed = (seconds: number): string => (seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}s`)
52
53// 지난 시각을 "방금 · 3분 전 · 2시간 전 · 5일 전" 으로.
54export function ago(at: number, now: number): string {
55  const s = Math.max(0, Math.round((now - at) / 1000))
56  if (s < 60) return '방금'
57  if (s < 3600) return `${Math.floor(s / 60)}분 전`
58  if (s < 86400) return `${Math.floor(s / 3600)}시간 전`
59  return `${Math.floor(s / 86400)}일 전`
60}
61
62// 325_412 → "325k", 1_000_000 → "1M"
63export const tokensOf = (n: number): string => (n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
64
65// claude-opus-5-5 → "Opus 5.5", claude-haiku-4-5-20251001 → "Haiku 4.5", 그 밖은 그대로.
66export function modelName(id: string): string {
67  const m = /claude-([a-z]+)-(\d+)(?:-(\d+))?/i.exec(id)
68  if (!m) return id
69  const name = (m[1] ?? '').replace(/^./, c => c.toUpperCase())
70  return m[3] && m[3].length <= 2 ? `${name} ${m[2]}.${m[3]}` : `${name} ${m[2]}`
71}
72
73// /Users/x/Documents/m1ucs/com.m1ucs/detector → "~/…/com.m1ucs/detector"
74export function shortPath(path: string, home: string): string {
75  const p = home && path.startsWith(home) ? `~${path.slice(home.length)}` : path
76  const parts = p.split('/').filter(Boolean)
77  return parts.length <= 3 ? p : `${p.startsWith('~') ? '~' : ''}/…/${parts.slice(-2).join('/')}`
78}
79
80// 남은 시간: "42m" · "2h 22m" · "3d 3h"
81export function untilText(at: number, now: number): string {
82  const m = Math.max(0, Math.round((at - now) / 60_000))
83  if (m < 60) return `${m}m`
84  if (m < 1440) return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
85  return `${Math.floor(m / 1440)}d ${Math.floor((m % 1440) / 60)}h`
86}
87
88// 터미널 칸 수: 한글·한자·전각과 그림 문자(☕🔥🧊⏸ 등)는 2칸, 나머지는 1칸.
89export function cellWidth(text: string): number {
90  let n = 0
91  for (const ch of text) {
92    const c = ch.codePointAt(0) ?? 0
93    const wide =
94      (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) || (c >= 0xf900 && c <= 0xfaff) ||
95      (c >= 0xfe30 && c <= 0xfe4f) || (c >= 0xff00 && c <= 0xff60) || (c >= 0xffe0 && c <= 0xffe6) ||
96      (c >= 0x23e9 && c <= 0x23fa) || c === 0x2614 || c === 0x2615 || (c >= 0x1f300 && c <= 0x1faff)
97    if (c === 0xfe0f || c === 0x200d) continue
98    n += wide ? 2 : 1
99  }
100  return n
101}
102
103// 한 줄에 다 안 들어가면 덜 중요한 것(p 가 큰 것)부터 뺀다. glue 는 앞 알약에 붙어 사이 칸이 없다.
104export type Fit = { w: number; p: number; glue?: boolean }
105export function fit(items: Fit[], cols: number): boolean[] {
106  const keep = items.map(() => true)
107  const total = () => {
108    let n = 0
109    let first = true
110    items.forEach((it, i) => {
111      if (!keep[i]) return
112      n += it.w + (first || it.glue ? 0 : 1)
113      first = false
114    })
115    return n
116  }
117  while (total() > cols) {
118    let drop = -1
119    items.forEach((it, i) => {
120      if (keep[i] && (drop < 0 || it.p >= (items[drop]?.p ?? -1))) drop = i
121    })
122    if (drop < 0) break
123    keep[drop] = false
124  }
125  return keep
126}
127
128// 글에 Claude 서명 줄(Co-Authored-By: Claude… · Claude-Session: · 🤖 Generated with)이 들었으면 그 줄을, 아니면 null.
129// 이 사용자는 커밋에 트레일러를 넣지 않는다 — 들어간 채 커밋되기 전에 막으려고. git commit 인지는 commit.ts 가 가린다.
130const CLAUDE_TRAILER = /Co-Authored-By:\s*Claude\b[^\n"']*|Claude-Session:[^\n"']*|(?:🤖\s*)?Generated with \[?Claude Code\]?[^\n"']*/i
131export function claudeTrailer(command: string): string | null {
132  return CLAUDE_TRAILER.exec(command)?.[0].trim() ?? null
133}
134