터미널 폭이 120칸 이상이면 Edit와 Write 결과의 diff에서 원래 내용은 왼쪽에, 바뀐 내용은 오른쪽에 표시합니다. 바뀐 단어는 더 밝은 색으로 강조합니다

<img alt="claude-code-mods 로고" src="./docs/images/logo.png" width="160">
<h1 align="center">claude-code-mods</h1>
<a href="./LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-111111?style=flat-square"></a> <a href="https://claude.com/claude-code"><img alt="Claude Code 2.1.299" src="https://img.shields.io/badge/Claude%20Code-2.1.299-111111?style=flat-square"></a> <a href="#설치"><img alt="marketplace oobg" src="https://img.shields.io/badge/marketplace-oobg-111111?style=flat-square"></a> <img alt="5 mods" src="https://img.shields.io/badge/mods-5-D97757?style=flat-square"> <a href="https://github.com/oobg/claude-code-mods/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/oobg/claude-code-mods?style=flat-square&color=111111"></a>
<a href="#주요-기능"><b>주요 기능</b></a> | <a href="#설치"><b>설치</b></a> | <a href="#모드-목록"><b>모드 목록</b></a> | <a href="#상세-문서"><b>상세 문서</b></a> | <a href="https://oobg.github.io/claude-code-mods/"><b>문서 사이트</b></a>
claude-code-mods는 Claude Code의 입력창과 도구 결과에 기능을 추가하는 모드 5개를 모은 저장소입니다. 입력창 위에서는 다음 요청을 제안하고 자주 쓰는 문구 프리셋을 표시하며, 입력창 아래에서는 세션 상태를 보여 줍니다. Finder에서 복사한 파일을 붙여넣으면 파일 이름을 @경로 멘션으로 바꾸고, Cmd+Z로 입력 내용을 되돌릴 수 있습니다. 넓은 터미널에서는 Edit와 Write 결과의 diff를 좌우로 비교할 수 있습니다. 모드는 필요한 것만 골라 설치하면 되고, 따로 설치할 프로그램은 없습니다.
<table class="cards"> <tr> <td width="50%" valign="top"> <a href="./docs/next-prompts.md"><picture> <img alt="next-prompts: 턴이 끝나면 다음에 보낼 만한 요청이 하나씩 나타난다" src="./docs/images/showcase/suggest-light.gif" width="100%"> <sub>턴이 끝나면 다음에 보낼 만한 요청을 2~3개 제안합니다. <code>$1</code>을 입력하고 스페이스를 누르거나 번호를 클릭하면 문구가 입력창에 들어가고, 문구를 클릭하면 바로 보냅니다</sub> </td> <td width="50%" valign="top"> <a href="./docs/quick-replies.md"><picture> <img alt="quick-replies: 입력창에 $1을 치고 스페이스를 누르면 $1이 1번 프리셋 문구로 바뀐다" src="./docs/images/showcase/preset-light.gif" width="100%"> <sub>자주 쓰는 문구를 전역 또는 저장소별 프리셋으로 저장합니다. 입력창에 <code>$4</code>를 치고 스페이스를 누르면 해당 문구로 바뀝니다</sub> </td> </tr> <tr> <td width="50%" valign="top"> <a href="./docs/dashboard.md"><picture> <img alt="dashboard: 컨텍스트 게이지가 차면서 색이 바뀐다" src="./docs/images/showcase/gauge-light.gif" width="100%"> <sub>모델, 컨텍스트, 사용 한도, 브랜치를 입력창 아래 두 줄에 표시합니다. 게이지가 찰수록 색이 노란색, 주황색, 빨간색 순으로 바뀝니다</sub> </td> <td width="50%" valign="top"> <a href="./docs/key-assist.md"><picture> <img alt="key-assist: 붙여넣은 파일 이름이 @경로로 바뀐다" src="./docs/images/showcase/paste-light.gif" width="100%"> <sub>Finder에서 복사한 파일을 붙여넣으면 파일 이름이 <code>@경로</code>로 바뀝니다. Cmd+Z로 입력 내용을 되돌릴 수 있습니다</sub> </td> </tr> <tr> <td colspan="2" valign="top"> <a href="./docs/split-diff.md"><picture> <img alt="split-diff: 터미널 폭이 120칸 이상이면 좌우 diff, 미만이면 원래 세로 diff로 전환된다" src="./docs/images/showcase/split-diff-light.gif" width="100%"> <sub>터미널 폭이 120칸 이상이면 Edit와 Write 결과의 diff에서 원래 내용은 왼쪽에, 바뀐 내용은 오른쪽에 표시합니다. 바뀐 단어는 더 밝은 색으로 강조합니다</sub> </td> </tr> </table>
$2를 입력하고 스페이스를 누르면 2번 문구가 그 자리에 들어갑니다. $12처럼 두 자리 번호도 쓸 수 있습니다.@경로 멘션이 입력됩니다.이 저장소가 oobg 마켓플레이스입니다. 먼저 Claude Code 입력창에서 마켓플레이스를 등록합니다.
/plugin marketplace add oobg/claude-code-mods
그다음 필요한 모드만 골라 설치합니다.
/plugin install next-prompts@oobg
/plugin install quick-replies@oobg
/plugin install dashboard@oobg
/plugin install key-assist@oobg
/plugin install split-diff@oobg
설치한 모드는 Claude Code를 다시 시작하거나 /reload-plugins를 실행하면 적용됩니다. 설치 여부는 /plugin 화면에서 확인하거나 터미널에서 claude plugin list를 실행해 확인할 수 있습니다.
Claude Code를 시작하면 quick-replies의 기본 프리셋과 dashboard가 바로 표시됩니다. next-prompts의 제안은 첫 턴이 끝난 뒤에 나타납니다.
| 모드 | 기능 | 위치 |
|---|---|---|
| next-prompts | 턴이 끝나면 다음에 보낼 만한 요청을 2~3개 제안합니다 | 입력창 위 |
| quick-replies | 자주 쓰는 문구를 전역 프리셋이나 저장소별 프리셋에 저장합니다 | 입력창 위 |
| dashboard | 모델, 컨텍스트, 사용 한도, 브랜치를 두 줄로 표시합니다 | 입력창 아래 |
| key-assist | 붙여넣은 파일 이름을 @경로로 바꾸고, Cmd+Z로 입력한 내용을 되돌립니다 | 입력창 |
| split-diff | 넓은 터미널에서 Edit와 Write 결과의 diff를 좌우로 나눠 표시합니다 | 도구 결과 |
next-prompts와 quick-replies를 함께 쓰면 입력창 위에 표시되는 두 목록의 번호가 이어집니다. 다음 제안은 $1부터, 프리셋은 그다음 번호부터 시작합니다.
<table class="cards"> <tr> <td width="50%" valign="top"> <a href="./docs/quick-replies.md"><picture> <img alt="입력창에 $2를 치고 스페이스를 누르면 $2가 2번 문구로 바뀐다" src="./docs/images/showcase/substitute-light.gif" width="100%"> <sub>입력창에 <code>$2</code>를 치고 스페이스를 누르면 <code>$2</code>가 2번 문구로 바뀝니다. 문장 중간에서도 사용할 수 있습니다</sub> </td> <td width="50%" valign="top"> <a href="./docs/quick-replies.md"><picture> <img alt="번호를 클릭하면 입력창 내용이 그 문구로 바뀐다" src="./docs/images/showcase/pick-light.gif" width="100%"> <sub>번호(<code>$2:</code>)를 클릭하면 입력창의 기존 내용을 모두 2번 문구로 바꿉니다</sub> </td> </tr> <tr> <td width="50%" valign="top"> <a href="./docs/next-prompts.md"><picture> <img alt="문구를 클릭하면 그 문구만 바로 보내진다" src="./docs/images/showcase/send-light.gif" width="100%"> <sub>문구를 클릭하면 해당 문구만 바로 보냅니다. 입력창에 적어 둔 내용은 그대로 남습니다</sub> </td> <td width="50%" valign="top"> <a href="./docs/quick-replies.md"><picture> <img alt="$e를 입력하면 프리셋 편집 화면이 열린다" src="./docs/images/showcase/editor-light.gif" width="100%"> <sub>입력창에 <code>$e</code>를 입력하면 프리셋 편집 화면이 열립니다</sub> </td> </tr> <tr> <td width="50%" valign="top"> <a href="./docs/quick-replies.md"><picture> <img alt="$0을 입력하면 프리셋을 한 줄로 접고 다시 입력하면 펼친다" src="./docs/images/showcase/fold-light.gif" width="100%"> <sub>입력창에 <code>$0</code>을 입력하면 프리셋을 한 줄로 접고, 다시 입력하면 펼칩니다</sub> </td> <td width="50%"></td> </tr> </table>
입력창 맨 앞이나 공백 뒤에 $를 입력하고 번호와 스페이스를 쳐야 문구로 바뀌며, 바뀐 내용은 ctrl+_로 되돌릴 수 있습니다.
먼저 마켓플레이스 목록을 갱신합니다.
/plugin marketplace update oobg
그다음 /plugin 화면에서 설치한 모드를 업데이트하거나, 터미널에서 claude plugin update <모드>@oobg를 실행합니다. 업데이트한 모드는 Claude Code를 다시 시작하면 적용됩니다.
모드를 삭제하려면 다음 명령을 실행합니다.
/plugin uninstall next-prompts@oobg
/plugin uninstall quick-replies@oobg
/plugin uninstall dashboard@oobg
/plugin uninstall key-assist@oobg
/plugin uninstall split-diff@oobg
[!NOTE] key-assist를 삭제해도 키 설정에 추가된
cmd+z항목은 남습니다. 필요하지 않으면~/.claude/keybindings.json에서 직접 지웁니다.
| 증상 | 해결 |
|---|---|
| 설치 목록에는 있는데 모드가 표시되지 않습니다 | Claude Code를 다시 시작합니다 |
| next-prompts 제안이 나오지 않습니다 | 첫 턴이 끝나야 제안이 나옵니다. 답 본문이 120자 미만이면 제안하지 않습니다 |
| Cmd+Z가 동작하지 않습니다 | Command 키 입력을 전달하는 터미널인지 확인합니다. 아니면 ctrl+_를 씁니다 |
| Ctrl+Z를 눌렀더니 Claude Code가 멈췄습니다 | Ctrl+Z는 터미널에서 실행 중인 프로세스를 일시 중지합니다. fg를 입력하면 다시 실행됩니다 |
모드별 자세한 해결 방법은 각 상세 문서의 "문제 해결" 절에서 확인할 수 있습니다.
각 모드의 상세 문서는 문서 사이트에서도 볼 수 있습니다.
| 문서 | 내용 |
|---|---|
| next-prompts | 제안이 나오는 조건, 나타나는 방식, 모델 호출과 사용량 |
| quick-replies | 프리셋 범위와 번호, 접기와 자동 제목, 편집 화면 키, 저장 데이터 |
| dashboard | 항목 설명, 표시 형태와 색상, /usage 조회, 참고 사항 |
| key-assist | 파일 붙여넣기 동작, Cmd+Z 키 설정, 제한 사항 |
| split-diff | 좌우 표시 조건, 줄 맞추기, 단어 강조, 제한 사항 |
Copyright (c) 2026 oobg
이 프로젝트는 MIT 라이선스에 따라 배포됩니다.
hooks/register.tsx 259 lines1import type { EngineInterface, Register, RenderInputOf } from 'claude-code'
2
3// 이 너비(칸)부터 좌우로 나눈다. 좁으면 엔진의 원래 diff를 그린다.
4export const MIN_COLUMNS = 120
5
6// 결과 블록 본문의 들여쓰기. 엔진의 diff처럼 `⎿` 표시 아래로 맞춘다.
7const INDENT = 4
8
9type Hunk = { oldStart: number; oldLines: number; newStart: number; newLines: number; lines: string[] }
10type Side = { no: number; text: string; kind: 'ctx' | 'del' | 'add' }
11export type Row = { kind: 'line'; left?: Side; right?: Side; spans?: { del: [number, number][]; add: [number, number][] } } | { kind: 'gap' }
12
13// 구간(hunk)들을 좌우 줄로 펼친다. 연달아 지운 줄과 추가한 줄은 순서대로 짝지어 같은 높이에 둔다.
14export function toRows(hunks: Hunk[]): Row[] {
15 const rows: Row[] = []
16 hunks.forEach((hunk, h) => {
17 if (h > 0) {
18 rows.push({ kind: 'gap' })
19 }
20 let oldNo = hunk.oldStart
21 let newNo = hunk.newStart
22 let dels: Side[] = []
23 let adds: Side[] = []
24 const flush = () => {
25 for (let i = 0; i < Math.max(dels.length, adds.length); i++) {
26 const left = dels[i]
27 const right = adds[i]
28 rows.push({ kind: 'line', left, right, spans: left && right ? changedSpans(left.text, right.text) : undefined })
29 }
30 dels = []
31 adds = []
32 }
33 for (const line of hunk.lines) {
34 const mark = line[0]
35 const text = line.slice(1)
36 if (mark === '-') {
37 dels.push({ no: oldNo++, text, kind: 'del' })
38 } else if (mark === '+') {
39 adds.push({ no: newNo++, text, kind: 'add' })
40 } else if (mark === ' ') {
41 flush()
42 rows.push({ kind: 'line', left: { no: oldNo++, text, kind: 'ctx' }, right: { no: newNo++, text, kind: 'ctx' } })
43 }
44 // `\ No newline at end of file` 같은 줄은 건너뛴다.
45 }
46 flush()
47 })
48 return rows
49}
50
51// 터미널 칸 폭. 한글, 한자, 전각 문자는 두 칸을 차지한다.
52export function cellWidth(ch: string): number {
53 const c = ch.codePointAt(0) ?? 0
54 return (c >= 0x1100 && c <= 0x115f) ||
55 (c >= 0x2e80 && c <= 0xa4cf) ||
56 (c >= 0xac00 && c <= 0xd7a3) ||
57 (c >= 0xf900 && c <= 0xfaff) ||
58 (c >= 0xfe30 && c <= 0xfe4f) ||
59 (c >= 0xff00 && c <= 0xff60) ||
60 (c >= 0xffe0 && c <= 0xffe6) ||
61 (c >= 0x1f300 && c <= 0x1faff) ||
62 (c >= 0x20000 && c <= 0x3fffd)
63 ? 2
64 : 1
65}
66
67// 두 줄을 단어, 공백, 기호 단위로 나눠 비교하고 바뀐 구간을 글자 위치 [시작, 끝)으로 돌려준다.
68// 바뀐 부분이 너무 많으면 줄 전체가 바뀐 것과 다르지 않으므로 강조하지 않는다.
69export function changedSpans(before: string, after: string): { del: [number, number][]; add: [number, number][] } {
70 const split = (t: string) => t.match(/\w+|\s+|[^\w\s]/gu) ?? []
71 const a = split(before)
72 const b = split(after)
73 const lcs: number[][] = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0))
74 for (let i = a.length - 1; i >= 0; i--) {
75 for (let j = b.length - 1; j >= 0; j--) {
76 lcs[i]![j] = a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!)
77 }
78 }
79 const del: [number, number][] = []
80 const add: [number, number][] = []
81 const push = (spans: [number, number][], from: number, to: number) => {
82 const last = spans[spans.length - 1]
83 if (last !== undefined && last[1] === from) last[1] = to
84 else spans.push([from, to])
85 }
86 let i = 0
87 let j = 0
88 let at = 0
89 let bt = 0
90 let same = 0
91 while (i < a.length || j < b.length) {
92 if (i < a.length && j < b.length && a[i] === b[j]) {
93 same += a[i]!.trim().length
94 at += a[i++]!.length
95 bt += b[j++]!.length
96 } else if (j < b.length && (i >= a.length || lcs[i]![j + 1]! >= lcs[i + 1]![j]!)) {
97 push(add, bt, bt + b[j]!.length)
98 bt += b[j++]!.length
99 } else {
100 push(del, at, at + a[i]!.length)
101 at += a[i++]!.length
102 }
103 }
104 const total = Math.max(before.trim().length, after.trim().length)
105 if (total === 0 || same / total < 0.4) {
106 return { del: [], add: [] }
107 }
108 return { del, add }
109}
110
111// 글자 구간을 칸 위치로 바꾼다. 엔진의 Code는 내용 칸 너비(width)에서 글자 단위로 줄을 바꾼다.
112// 결과는 행마다 { row, col, text } 조각이다.
113export function placeSpans(text: string, spans: [number, number][], width: number): { row: number; col: number; text: string }[] {
114 const pieces: { row: number; col: number; text: string }[] = []
115 let row = 0
116 let col = 0
117 let index = 0
118 for (const ch of text) {
119 const w = cellWidth(ch)
120 if (col + w > width) {
121 row++
122 col = 0
123 }
124 const inside = spans.some(([from, to]) => index >= from && index < to)
125 if (inside) {
126 const last = pieces[pieces.length - 1]
127 if (last !== undefined && last.row === row && last.col + widthOf(last.text) === col) last.text += ch
128 else pieces.push({ row, col, text: ch })
129 }
130 col += w
131 index += ch.length
132 }
133 return pieces
134}
135
136function widthOf(text: string): number {
137 let w = 0
138 for (const ch of text) w += cellWidth(ch)
139 return w
140}
141
142export function counts(hunks: Hunk[]): { added: number; removed: number } {
143 let added = 0
144 let removed = 0
145 for (const hunk of hunks) {
146 for (const line of hunk.lines) {
147 if (line[0] === '+') added++
148 else if (line[0] === '-') removed++
149 }
150 }
151 return { added, removed }
152}
153
154function relative(path: string, cwd: string): string {
155 return path.startsWith(cwd + '/') ? path.slice(cwd.length + 1) : path
156}
157
158// Edit 결과, 그리고 기존 파일을 덮어쓴 Write 결과만 다룬다.
159function patchOf(tool: string, output: unknown): { filePath: string; hunks: Hunk[] } | undefined {
160 if (tool !== 'Edit' && tool !== 'Write') {
161 return undefined
162 }
163 const out = output as { filePath?: unknown; structuredPatch?: unknown; type?: unknown } | null
164 if (!out || typeof out.filePath !== 'string' || !Array.isArray(out.structuredPatch) || out.structuredPatch.length === 0) {
165 return undefined
166 }
167 if (tool === 'Write' && out.type !== 'update') {
168 return undefined
169 }
170 return { filePath: out.filePath, hunks: out.structuredPatch as Hunk[] }
171}
172
173export const register: Register = on => {
174 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
175 if (e.surface !== 'terminal') {
176 return next(e)
177 }
178 const columns = e.viewport?.columns
179 const patch = e.props.isErrored ? undefined : patchOf(e.props.tool, e.props.output)
180 if (patch === undefined || columns === undefined || columns < MIN_COLUMNS) {
181 return next(e)
182 }
183 return draw($, e, patch, columns)
184 })
185}
186
187async function draw($: EngineInterface, e: RenderInputOf<'ToolResult', 'terminal'>, patch: { filePath: string; hunks: Hunk[] }, columns: number) {
188 const { Box, Text, Code } = $.ui.resolve(e)
189 const rows = toRows(patch.hunks)
190 const { added, removed } = counts(patch.hunks)
191 const path = relative(patch.filePath, await $.session.cwd())
192
193 // 들여쓰기와 가운데 구분선(" │ ") 몫을 빼고 반씩 나눈다.
194 const half = Math.floor((columns - INDENT - 3) / 2)
195 const lastNo = Math.max(...patch.hunks.map(h => Math.max(h.oldStart + h.oldLines, h.newStart + h.newLines)))
196 const gutter = String(lastNo).length
197
198 // 칸마다 한 줄짜리 diff 구간을 만들어 엔진의 Code(diff 형식)에 맡긴다.
199 // 그러면 줄 번호, 표시, 배경색, 문법 색이 엔진의 diff와 똑같이 칠해진다.
200 const hunkLine = (side: Side) => {
201 const head =
202 side.kind === 'del' ? `@@ -${side.no},1 +${side.no},0 @@` : side.kind === 'add' ? `@@ -${side.no},0 +${side.no},1 @@` : `@@ -${side.no},1 +${side.no},1 @@`
203 const mark = side.kind === 'del' ? '-' : side.kind === 'add' ? '+' : ' '
204 return `${head}\n${mark}${side.text}`
205 }
206
207 // 바뀐 단어는 엔진의 단어 강조보다 눈에 띄도록, 엔진이 그린 줄 위의 같은 자리에 같은 글자를 밝은 배경으로 덮어 그린다.
208 // 엔진 diff 한 줄은 `빈칸, 줄 번호, 빈칸, 표시` 다음에 내용이 오고, 줄바꿈된 줄도 같은 열에서 이어진다.
209 const contentWidth = half - gutter - 3
210 const cell = (side: Side | undefined, spans: [number, number][], key: string) => {
211 if (side === undefined) {
212 return <Box key={key} width={half} />
213 }
214 const offset = String(side.no).length + 3
215 // 탭은 엔진이 몇 칸으로 그리는지 알 수 없어 강조를 건너뛴다.
216 const pieces = spans.length === 0 || side.text.includes('\t') ? [] : placeSpans(side.text, spans, contentWidth)
217 const color = side.kind === 'del' ? 'diffRemovedWord' : 'diffAddedWord'
218 // 엔진은 칸마다 줄 번호 폭을 그 칸의 번호에 맞춘다. 짧은 번호 앞을 비워 끝자리를 맞추면 내용 열도 맞는다.
219 return (
220 <Box key={key} width={half} paddingLeft={gutter - String(side.no).length}>
221 <Box position="relative" flexGrow={1}>
222 <Code source={hunkLine(side)} path={patch.filePath} format="diff" wrap="wrap" />
223 {pieces.map((piece, i) => (
224 <Box key={`hl:${i}`} position="absolute" top={piece.row} left={offset + piece.col}>
225 <Text backgroundColor={color} color="text" bold>
226 {piece.text}
227 </Text>
228 </Box>
229 ))}
230 </Box>
231 </Box>
232 )
233 }
234
235 return (
236 <Box flexDirection="column">
237 <Text>
238 <Text dimColor>{' ⎿ '}</Text>
239 Updated <Text bold>{path}</Text> <Text dimColor>{`(+${added} -${removed})`}</Text>
240 </Text>
241 <Box flexDirection="column" paddingLeft={INDENT}>
242 {rows.map((row, i) =>
243 row.kind === 'gap' ? (
244 <Text key={`gap:${i}`} dimColor>
245 {' '.repeat(gutter)}...
246 </Text>
247 ) : (
248 <Box key={`row:${i}`} flexDirection="row">
249 {cell(row.left, row.spans?.del ?? [], 'left')}
250 <Text dimColor> │ </Text>
251 {cell(row.right, row.spans?.add ?? [], 'right')}
252 </Box>
253 ),
254 )}
255 </Box>
256 </Box>
257 )
258}
259