SLOPSHOPPER

git-graph

Shows the working directory's git log as a graph in a side pane

newpaneguardcommandprocess
v0.2.1no licenseupdated 2026-10-13binarythink/claude-marketplace/git-graph
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-graph
│ ┃ Git graph ✕ › fix the failing auth test and add an audit log call │ ┃ [ Refresh ] [ Newer ] [ Older ] 1-2/2 │ ┃ (detached) ● 2 changed · updated 14:18:52 ⏺ Read(src/auth.ts) │ ┃ a1b2c3d fix: refresh expired tokens (2 minut ⎿ Read 6 lines │ ┃ 9f8e7d6 feat: add audit log (3 hours ago) ⏺ 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 │ │ › /git-graph │ ⎿ git-graph: Git graph pane opened (2 lines). │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Git graph
[ Refresh ] [ Newer ] [ Older ] 1-2/2 (detached) ● 2 changed · updated 14:18:52 a1b2c3d fix: refresh expired tokens (2 minutes ago) 9f8e7d6 feat: add audit log (3 hours ago)
README

claude-marketplace

Personal Claude Code mods.

ModCommandDescription
git-graph/git-graphToggles a side pane showing the working directory's git log as a graph
pii-guard—Checks each prompt for personal data before it is sent: blocks Korean unique identifiers, asks before sending phone numbers, emails, cards, accounts, addresses and attachments
session-outputs/outputsToggles a pane listing what the current session produced — PRs, branches, Jira/Notion/Confluence/Slack links, edited files, scratchpad files — grouped into what the session produced (output) and links you gave it (input)
todo-band/todo, /auto-todoToggles a pane showing the session scratchpad's TODO.jsonl (or TODO.md); its todo-plan skill has Claude keep the plan there with dependencies, and /auto-todo has Claude keep it up to date on every request
herdr-screenshot—Its herdr-screenshot skill saves a Herdr pane's screen as a PNG by rendering the pane's ANSI dump in headless Chrome

The panes stay closed until you run the command; running it again closes the pane.

todo-band: TODO.jsonl

The todo-plan skill has Claude write one JSON object per line to the session scratchpad:

{"id":"api","title":"Implement API","status":"doing","deps":["schema"]}
{"id":"deploy","title":"Deploy to dev1","status":"waiting","deps":["api"],"note":"user syncs ArgoCD"}

status is todo, doing (agent running it), waiting (waiting on the user to run or confirm something), done or cancelled. The pane orders items by deps, marks a todo whose deps are not all done as blocked, and flags broken lines, unknown deps and cycles. The full rules are in todo-band/skills/todo-plan/SKILL.md.

The hide done button in the pane's header (or h while the pane has focus) hides done and cancelled items (in TODO.md, - [x] lines) for the rest of the session; press show done (N) to bring them back. show keys (or k) prefixes each TODO.jsonl row with its id, e.g. ▶ [api] Implement API; hide keys turns it off.

/auto-todo [on|off]

Without auto-todo, TODO.jsonl exists only once Claude picks the todo-plan skill for a multi-step task, and later requests in the session go untracked. With /auto-todo on, every prompt you type carries a hidden instruction to keep TODO.jsonl current: work requests (edits, commands, investigation) are appended as items and their statuses updated; questions that need no work get none. If a turn edits files or runs commands but leaves TODO.jsonl untouched, the next prompt also carries a one-time reminder.

  • No argument toggles; the pane header shows auto ● (on) or auto ○ (off).
  • The setting lasts for the session only. To start every session on, set the plugin option autoTodoDefault to true in /config.
  • /auto-todo off stops the instruction and tells Claude once to stop updating the file on its own; the file and the pane stay.

pii-guard: 전송 전 개인정보 확인

프롬프트(붙여넣은 텍스트 포함)와 @경로로 참조한 파일을 전송 전에 검사한다.

처리유형
확인 없이 차단주민등록번호·외국인등록번호(구분자 있는 형식), 여권번호·운전면허번호(키워드 또는 구분자 형식)
"그래도 보낼까요?" 확인구분자 없는 13자리 주민번호 형식, 휴대전화, 이메일, 카드(Luhn), 계좌(키워드), 주소(시·도~번지), 이미지·문서 첨부
  • 대화상자와 차단 사유에는 유형과 건수만 표시하고 감지한 값은 표시하지 않는다.
  • 막힌 프롬프트는 엔진이 입력창에 되돌려 두므로 값을 지우고 다시 보낼 수 있다.
  • 물어볼 수 없는 경우(-p, SDK)나 검사 중 오류가 나면 보내지 않는다(fail-closed).
  • 모델이 도구로 읽는 파일·명령 출력은 검사하지 않는다. 첨부 이미지·문서는 내용 검사 없이 확인만 한다.

herdr-screenshot: pane 화면 PNG 저장

Herdr pane 안에서 "이 화면 캡처해줘" 처럼 요청하면 스킬이 scripts/cap.sh 를 실행한다.

cap.sh [--pane <id>] [--out <png|dir>] [--title <text>] [--lines N]
  • 기본은 현재 pane($HERDR_PANE_ID)의 보이는 화면. --lines N 이면 scrollback 포함
  • 측정 패스로 콘텐츠 크기를 잰 뒤 그 크기로 2배 해상도 스크린샷을 찍으므로 넓은 pane 도 잘리지 않는다
  • 필요: Google Chrome, 폰트 D2KodingLigature Nerd Font (한글 2칸 정렬용)

session-outputs: 세션 산출물 뷰어

/outputs 로 pane 을 열고 닫는다. 현재 진행 중인 세션의 transcript(~/.claude/projects/<slug>/<session id>.jsonl)와 scratchpad 를 읽어 수집 출처별로 두 그룹에 나눠 보여준다. 다른 세션의 산출물은 보이지 않는다.

그룹출처
outputassistant 의 글과 도구 입력(MCP 호출 포함)에 나온 URL, Write·Edit·NotebookEdit 로 만든 파일, git·gh 명령의 입출력, 세션 중 checkout 된 branch, 세션 scratchpad 의 파일
input사용자 메시지의 글에 나온 URL
  • 같은 항목이 두 그룹에서 나오면 처음 나온 쪽에만 보인다. 사용자가 준 참고 링크를 assistant 가 다시 언급해도 input 에 남는다
  • 그룹 안에서는 세션에 처음 나온 순서이고, 파일과 링크가 섞인다. scratchpad 파일은 output 의 맨 끝에 온다
  • 각 행은 type(PR · Branch · Jira · Confluence · Notion · Slack · GitHub · Web · File · Scratch), name, path 순이다. 파일의 name 은 파일 이름, path 는 worktree 기준 폴더(root 는 .)이고, 링크의 path 는 URL 이다. path 는 흐리게 보인다. 지금 없는 파일은 (deleted) 로 표시한다
  • git·gh 명령에서는 입력·출력 모두 GitHub 링크(PR · Branch · GitHub)만 남긴다. 출력은 gh pr create·gh issue create·gh release create·git push 의 것만 읽는다. gh pr view·gh api·git log 같은 조회 명령의 출력과 PR 본문(--body)의 Jira·Notion 링크는 참고 자료라 제외한다
  • branch 는 git checkout -b·git switch -c·git worktree add -b·git push -u 명령, git push 출력의 pull/new/<branch> URL 에서 찾는다
  • scratchpad 는 /tmp 아래라 재부팅 후에는 사라진다
  • r 로 다시 읽는다. 열려 있으면 10초마다 갱신한다
  • pane 에 포커스가 있을 때 j/k 로 한 item 씩 이동하고, c 는 선택한 item 의 이름, p 는 경로(파일은 절대 경로, 링크는 URL, URL 없는 branch 는 이름)를 클립보드에 복사한다
  • 4 MiB 가 넘는 transcript 는 tail -c 로 나눠 읽는다 ($.fs.read 상한)
  • 링크는 path 칸의 URL 을 클릭하면 열린다 (OSC 8 hyperlink). 파일은 OSC 8 을 못 그리는 터미널에서 file:// URL 이 덧붙지 않도록 링크를 걸지 않는다. p 로 절대 경로를 복사한다
  • attribution 링크(claude.ai/code/session_…, claude.com/claude-code), api.* 엔드포인트, Read·WebFetch 등 도구 결과 속 URL 은 잡음이라 제외한다

Install

In a Claude Code terminal session:

/plugin install git-graph --marketplace binarythink/claude-marketplace
/plugin install todo-band --marketplace binarythink/claude-marketplace
/plugin install pii-guard --marketplace binarythink/claude-marketplace
/plugin install herdr-screenshot --marketplace binarythink/claude-marketplace
/plugin install session-outputs --marketplace binarythink/claude-marketplace

Answer y to add the marketplace, then pick a scope.

Or from the shell:

claude plugin marketplace add binarythink/claude-marketplace
claude plugin install git-graph@binarythink
claude plugin install todo-band@binarythink
claude plugin install pii-guard@binarythink
claude plugin install herdr-screenshot@binarythink
claude plugin install session-outputs@binarythink

Update

Bump version in the mod's .claude-plugin/plugin.json when publishing a change, then:

claude plugin marketplace update binarythink
claude plugin update git-graph@binarythink

Develop

claude --plugin-dir ./git-graph   # loads the working copy, reloads on save
claude plugin validate .
claude plugin test ./git-graph
Source 3 files
hooks/register.tsx 197 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GraphLine, GraphLog, RefKind, RepoStatus } from '../types'
5import {
6  GIT_LOG_ARGV, GIT_STATUS_ARGV, changesGraph, clockTime, cutToWidth, fitPieces, glyphOf,
7  parseGraphLog, parseRefs, parseStatus, relativeTime, widthOf,
8} from './parse'
9
10const PANE = 'git-graph'
11const TITLE = 'Git graph'
12const logAtom = atom({ plugin: 'git-graph', key: 'log' } as const, { lines: [] } as GraphLog)
13const offsetAtom = atom({ plugin: 'git-graph', key: 'offset' } as const, 0)
14
15// graph 열마다 색을 돌려 branch 선을 구분한다.
16const LANE_COLORS = ['cyan', 'magenta', 'yellow', 'green', 'blue', 'red'] as const
17
18const REF_COLORS: Record<RefKind, string> = {
19  head: 'cyan',
20  local: 'green',
21  remote: 'red',
22  tag: 'yellow',
23  other: 'magenta',
24}
25
26type Piece = { text: string; color?: string; bold?: boolean; dimColor?: boolean }
27
28async function readStatus($: EngineInterface): Promise<RepoStatus | undefined> {
29  try {
30    const { exitCode, stdout } = await $.process.run(GIT_STATUS_ARGV, { timeoutMs: 10_000 })
31    return exitCode === 0 ? parseStatus(stdout) : undefined
32  } catch {
33    return undefined
34  }
35}
36
37async function refresh($: EngineInterface): Promise<GraphLog> {
38  let log: GraphLog
39  try {
40    const [{ exitCode, stdout, stderr }, status] = await Promise.all([
41      $.process.run(GIT_LOG_ARGV, { timeoutMs: 10_000 }),
42      readStatus($),
43    ])
44    log = exitCode === 0
45      ? { lines: parseGraphLog(stdout), status, loadedAt: Date.now() }
46      : { lines: [], error: stderr.trim() || `git log exited with ${exitCode}`, loadedAt: Date.now() }
47  } catch (err) {
48    log = { lines: [], error: String(err), loadedAt: Date.now() }
49  }
50  await update($, logAtom, () => log)
51  return log
52}
53
54const paneOf = async ($: EngineInterface) =>
55  (await $.ui.panes()).find(pane => pane.id === PANE)
56
57// 다른 탭 뒤에 있어도 열려 있으면 graph 를 최신으로 유지한다.
58const isOpen = async ($: EngineInterface) => (await paneOf($)) !== undefined
59
60// header 의 상태 조각: branch, upstream 대비 ahead/behind, 변경 파일 수, 마지막 갱신 시각.
61const statusPieces = (status: RepoStatus | undefined, loadedAt: number | undefined): Piece[] => {
62  const pieces: Piece[] = []
63  if (status) {
64    pieces.push({ text: status.branch ?? '(detached)', color: 'cyan', bold: true })
65    if (status.upstream) {
66      if (status.ahead) pieces.push({ text: ` ↑${status.ahead}`, color: 'green' })
67      if (status.behind) pieces.push({ text: ` ↓${status.behind}`, color: 'red' })
68      if (!status.ahead && !status.behind) pieces.push({ text: ' =', dimColor: true })
69    }
70    pieces.push(status.changed
71      ? { text: ` ● ${status.changed} changed`, color: 'yellow' }
72      : { text: ' ✓ clean', color: 'green' })
73  }
74  if (loadedAt) pieces.push({ text: `${pieces.length ? ' · ' : ''}updated ${clockTime(loadedAt)}`, dimColor: true })
75  return pieces
76}
77
78export const register: Register = on => {
79  on('session.start', async ($, e, next) => {
80    await $.command.register({
81      name: 'git-graph',
82      description: 'Toggle the side pane showing the git log of the working directory as a graph',
83    })
84    // reload 로 session.start 가 다시 불려도 pane 이 열려 있을 때만 읽는다.
85    if (await isOpen($).catch(() => false)) void refresh($)
86
87    return next(e)
88  })
89
90  // /git-graph 는 pane 을 열고 닫는 toggle 이다. 세션 시작 시 자동으로 열지 않는다.
91  // 다른 탭 뒤에 열려 있으면 닫지 않고 앞으로 가져온다.
92  on('command.run', { command: 'git-graph' }, async $ => {
93    const pane = await paneOf($)
94    if (pane?.isShown) {
95      await $.ui.close({ id: PANE })
96      return { text: 'Git graph pane closed.' }
97    }
98    if (pane) {
99      // 열린 id 를 다시 open 하면 제목만 바뀌고 탭은 그대로라, 닫았다 열어 맨 앞 탭으로 올린다.
100      const log = await refresh($)
101      await $.ui.close({ id: PANE })
102      await $.ui.open({ id: PANE, title: TITLE })
103      return { text: log.error ? `Git graph: ${log.error}` : 'Git graph pane brought to front.' }
104    }
105    await update($, offsetAtom, () => 0)
106    const log = await refresh($)
107    await $.ui.open({ id: PANE, title: TITLE })
108
109    return { text: log.error ? `Git graph: ${log.error}` : `Git graph pane opened (${log.lines.length} lines).` }
110  })
111
112  // graph 를 바꾸는 git 명령이 Bash 로 실행되고 pane 이 열려 있으면 다시 읽는다.
113  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
114    const ran = await next(e)
115    if (e.tool === 'Bash' && changesGraph(e.command) && await isOpen($).catch(() => false)) void refresh($)
116
117    return ran
118  })
119
120  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
121    const { Box, Text, Button } = $.ui.resolve(e)
122    const log = await read($, logAtom)
123    const offset = await read($, offsetAtom)
124    const columns = Math.max(20, e.props.bodyColumns ?? e.viewport?.columns ?? 80)
125    const room = Math.max(5, (e.props.scroll?.bodyRows ?? (e.viewport?.rows ?? 30) - 4) - 3)
126    const maxOffset = Math.max(0, log.lines.length - room)
127    const start = Math.min(offset, maxOffset)
128    const shown = log.lines.slice(start, start + room)
129    const now = Date.now()
130
131    const scroll = (delta: number) => () =>
132      void update($, offsetAtom, n => Math.min(maxOffset, Math.max(0, (n ?? 0) + delta)))
133
134    const draw = (pieces: Piece[], key: string) =>
135      pieces.map((p, i) => <Text key={`${key}${i}`} color={p.color} bold={p.bold} dimColor={p.dimColor}>{p.text}</Text>)
136
137    const row = (line: GraphLine, i: number) => {
138      const graph = [...line.graph].map((ch, col) =>
139        ch === ' '
140          ? <Text>{ch}</Text>
141          : <Text color={LANE_COLORS[Math.floor(col / 2) % LANE_COLORS.length]} bold={ch === '*'}>{glyphOf(ch, line.isMerge)}</Text>,
142      )
143      if (!line.hash) return <Box key={`l${start + i}`} flexDirection="row">{graph}</Box>
144
145      const refs = line.refs ? parseRefs(line.refs) : []
146      const isHead = refs.some(ref => ref.kind === 'head')
147      let left = columns - widthOf(line.graph) - line.hash.length - 1
148
149      const refPieces: Piece[] = refs.length === 0 ? [] : [
150        { text: '(', dimColor: true },
151        ...refs.flatMap((ref, n): Piece[] => [
152          ...(n > 0 ? [{ text: ', ', dimColor: true }] : []),
153          { text: ref.name, color: REF_COLORS[ref.kind], bold: ref.kind === 'head' || ref.kind === 'local' },
154        ]),
155        { text: ') ', dimColor: true },
156      ]
157      const fitted = fitPieces(refPieces, Math.max(0, left))
158      left -= fitted.reduce((sum, p) => sum + widthOf(p.text), 0)
159
160      const meta = ` ${line.time ? relativeTime(line.time, now) : ''} ${line.author ?? ''}`.trimEnd()
161      const subject = cutToWidth(line.subject ?? '', Math.max(0, left - (left > 40 ? widthOf(meta) : 0)))
162      left -= widthOf(subject)
163      const tail = left >= widthOf(meta) ? meta : ''
164
165      return (
166        <Box key={`l${start + i}`} flexDirection="row">
167          {graph}
168          <Text color="yellow" inverse={isHead}>{line.hash}</Text>
169          <Text> </Text>
170          {draw(fitted, 'r')}
171          <Text bold={isHead}>{subject}</Text>
172          {tail && <Text dimColor>{tail}</Text>}
173        </Box>
174      )
175    }
176
177    const status = statusPieces(log.status, log.loadedAt)
178
179    return (
180      <Box flexDirection="column">
181        <Box flexDirection="row" gap={1}>
182          <Button key="refresh" label="Refresh" hotkey="r" onPress={() => void refresh($)} />
183          <Button key="newer" label="Newer" hotkey="k" onPress={scroll(-room)} />
184          <Button key="older" label="Older" hotkey="j" onPress={scroll(room)} />
185          <Text dimColor>
186            {log.lines.length > 0 ? `${start + 1}-${start + shown.length}/${log.lines.length}` : ''}
187          </Text>
188        </Box>
189        {status.length > 0 && <Box flexDirection="row">{draw(fitPieces(status, columns), 's')}</Box>}
190        {log.error && <Text color="red">{log.error}</Text>}
191        {!log.error && log.lines.length === 0 && <Text dimColor>Loading git log…</Text>}
192        {shown.map(row)}
193      </Box>
194    )
195  })
196}
197
hooks/parse.ts 142 lines
1import type { GraphLine, Ref, RepoStatus } from '../types'
2
3const SEP = '\x1f'
4
5export const GIT_LOG_ARGV = [
6  'git', 'log', '--graph', '--all', '--decorate=full', '--color=never',
7  `--format=${SEP}%h${SEP}%P${SEP}%D${SEP}%s${SEP}%an${SEP}%at`, '-n', '300',
8] as const
9
10export const GIT_STATUS_ARGV = ['git', 'status', '--porcelain=v2', '--branch'] as const
11
12// `git log --graph` 출력 한 줄을 graph 접두부와 commit 필드로 나눈다.
13// commit 이 없는 줄(분기·병합 연결선)은 graph 만 가진다.
14export const parseGraphLog = (stdout: string): GraphLine[] =>
15  stdout
16    .split('\n')
17    .filter(line => line.length > 0)
18    .map(line => {
19      const at = line.indexOf(SEP)
20      if (at < 0) return { graph: line.trimEnd() }
21      const [hash, parents, refs, subject, author, time] = line.slice(at + 1).split(SEP)
22      return {
23        graph: line.slice(0, at),
24        hash,
25        refs: refs || undefined,
26        subject,
27        author,
28        time: Number(time) || undefined,
29        isMerge: (parents ?? '').trim().split(/\s+/).length > 1,
30      }
31    })
32
33// `--decorate=full` 의 `%D` 를 종류별 ref 로 나눈다.
34export const parseRefs = (refs: string): Ref[] =>
35  refs.split(', ').filter(Boolean).map((ref): Ref => {
36    if (ref === 'HEAD') return { name: 'HEAD', kind: 'head' }
37    if (ref.startsWith('HEAD -> refs/heads/')) return { name: `HEAD -> ${ref.slice(19)}`, kind: 'head' }
38    if (ref.startsWith('tag: refs/tags/')) return { name: `tag: ${ref.slice(15)}`, kind: 'tag' }
39    if (ref.startsWith('refs/heads/')) return { name: ref.slice(11), kind: 'local' }
40    if (ref.startsWith('refs/remotes/')) return { name: ref.slice(13), kind: 'remote' }
41    return { name: ref.replace(/^refs\//, ''), kind: 'other' }
42  })
43
44// `git status --porcelain=v2 --branch` 에서 branch·upstream·ahead/behind·변경 파일 수를 읽는다.
45export const parseStatus = (stdout: string): RepoStatus => {
46  const status: RepoStatus = { ahead: 0, behind: 0, changed: 0 }
47  for (const line of stdout.split('\n')) {
48    if (line.startsWith('# branch.head ')) {
49      const head = line.slice(14)
50      if (head !== '(detached)') status.branch = head
51    } else if (line.startsWith('# branch.upstream ')) {
52      status.upstream = line.slice(18)
53    } else if (line.startsWith('# branch.ab ')) {
54      const [ahead, behind] = line.slice(12).split(' ')
55      status.ahead = Math.abs(Number(ahead)) || 0
56      status.behind = Math.abs(Number(behind)) || 0
57    } else if (line.length > 0 && !line.startsWith('#')) {
58      status.changed += 1
59    }
60  }
61  return status
62}
63
64// commit graph 를 바꾸는 git 하위 명령. status·diff·log 같은 읽기 전용 명령은 제외한다.
65const MUTATING_GIT =
66  /\bgit\s+(?:-\S+\s+(?:\S+\s+)?)*(?:commit|checkout|switch|merge|rebase|reset|pull|fetch|push|branch|tag|cherry-pick|revert|stash|am|restore|worktree|clone|init|gc|filter-branch)\b/
67
68export const changesGraph = (command: string): boolean => MUTATING_GIT.test(command)
69
70// graph 접두부의 ASCII 문자를 box-drawing 글리프로 바꾼다. 모두 1셀 폭이다.
71const GLYPHS: Record<string, string> = { '|': '│', '/': '╱', '\\': '╲', '-': '─' }
72
73export const glyphOf = (ch: string, isMerge = false): string =>
74  ch === '*' ? (isMerge ? '◉' : '●') : GLYPHS[ch] ?? ch
75
76// commit 시각을 현재 기준 상대 시간으로 줄여 쓴다.
77export const relativeTime = (seconds: number, nowMs: number): string => {
78  const diff = Math.max(0, Math.floor(nowMs / 1000 - seconds))
79  if (diff < 60) return 'just now'
80  const units: [number, string][] = [[31_536_000, 'y'], [2_592_000, 'mo'], [604_800, 'w'], [86_400, 'd'], [3_600, 'h'], [60, 'm']]
81  const [size, unit] = units.find(([size]) => diff >= size)!
82  return `${Math.floor(diff / size)}${unit} ago`
83}
84
85const pad2 = (n: number) => String(n).padStart(2, '0')
86
87export const clockTime = (ms: number): string => {
88  const d = new Date(ms)
89  return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
90}
91
92// 터미널 셀 기준 폭: 한글·CJK·전각 문자는 2칸.
93const cellWidth = (ch: string): number => {
94  const cp = ch.codePointAt(0) ?? 0
95  const isWide =
96    (cp >= 0x1100 && cp <= 0x115f) ||
97    (cp >= 0x2e80 && cp <= 0xa4cf) ||
98    (cp >= 0xac00 && cp <= 0xd7a3) ||
99    (cp >= 0xf900 && cp <= 0xfaff) ||
100    (cp >= 0xfe30 && cp <= 0xfe4f) ||
101    (cp >= 0xff00 && cp <= 0xff60) ||
102    (cp >= 0xffe0 && cp <= 0xffe6) ||
103    (cp >= 0x1f300 && cp <= 0x1faff)
104  return isWide ? 2 : 1
105}
106
107export const widthOf = (text: string): number =>
108  [...text].reduce((sum, ch) => sum + cellWidth(ch), 0)
109
110// 셀 폭 `room` 안에 들어가도록 자르고, 잘렸으면 끝에 `…` 을 붙인다.
111export const cutToWidth = (text: string, room: number): string => {
112  if (room <= 0) return ''
113  if (widthOf(text) <= room) return text
114  let out = ''
115  let used = 0
116  for (const ch of text) {
117    const w = cellWidth(ch)
118    if (used + w > room - 1) break
119    out += ch
120    used += w
121  }
122  return `${out}…`
123}
124
125// 색이 다른 조각들을 이어 붙인 문자열을 `room` 셀 안에 맞춘다. 넘치는 조각은 잘리고 그 뒤는 버린다.
126export const fitPieces = <P extends { text: string }>(pieces: P[], room: number): P[] => {
127  const out: P[] = []
128  let left = room
129  for (const piece of pieces) {
130    const w = widthOf(piece.text)
131    if (w <= left) {
132      out.push(piece)
133      left -= w
134      continue
135    }
136    const cut = cutToWidth(piece.text, left)
137    if (cut) out.push({ ...piece, text: cut })
138    break
139  }
140  return out
141}
142
types/index.d.ts 37 lines
1export type GraphLine = {
2  graph: string
3  hash?: string
4  refs?: string
5  subject?: string
6  author?: string
7  // author date, unix seconds
8  time?: number
9  isMerge?: boolean
10}
11
12export type RefKind = 'head' | 'local' | 'remote' | 'tag' | 'other'
13
14export type Ref = { name: string; kind: RefKind }
15
16export type RepoStatus = {
17  // undefined 면 detached HEAD
18  branch?: string
19  upstream?: string
20  ahead: number
21  behind: number
22  changed: number
23}
24
25export type GraphLog = {
26  lines: GraphLine[]
27  status?: RepoStatus
28  error?: string
29  loadedAt?: number
30}
31
32declare module 'claude-code' {
33  interface PluginState {
34    'git-graph': { log: GraphLog; offset: number }
35  }
36}
37