턴이 끝나면 다음에 보낼 만한 프롬프트 2~3개를 입력창 위에 제안합니다. 입력창에 $1처럼 입력하면 해당 문구로 바뀝니다. 번호를 클릭하면 초안으로 넣고, 문구를 클릭하면 바로 보냅니다

<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 231 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Reveal, Suggestions } from '../types'
5import { expandNumbers, numberLabel, typedNumber, withBandSection } from './band'
6import { MIN_ANSWER_CHARS, SYSTEM, buildPrompt, parseSuggestions } from './suggest'
7
8// 띠 안의 순서. quick-replies 프리셋 줄(20) 위에 온다.
9const ORDER = 10
10// 번호 색. Claude Code가 입력창의 /명령에 쓰는 테마 색이다.
11const ACCENT = 'suggestion'
12
13const suggestions = atom({ plugin: 'next-prompts', key: 'suggestions' } as const, [] as Suggestions)
14const reveal = atom({ plugin: 'next-prompts', key: 'reveal' } as const, [] as Reveal)
15const lastPrompt = atom({ plugin: 'next-prompts', key: 'lastPrompt' } as const, '')
16
17// 제안이 나타나는 모양. 줄은 LINE_GAP_MS 간격으로 하나씩 나오고, 한 줄은 LINE_MS 안팎에 앞에서부터 흘려 쓴다.
18// 엔진은 띠를 초당 30번까지 다시 그리므로 상태는 FRAME_MS(33ms 이상)마다 한 번만 바꾼다.
19const FRAME_MS = 40
20const LINE_GAP_MS = 120
21const LINE_MS = 320
22const LINE_GAP_FRAMES = Math.round(LINE_GAP_MS / FRAME_MS)
23const LINE_FRAMES = Math.round(LINE_MS / FRAME_MS)
24
25const chars = (text: string) => Array.from(text)
26
27// f번째 프레임(0부터)에 각 줄이 보여 줄 글자 수.
28function revealAt(list: Suggestions, f: number): number[] {
29 return list.map((text, i) => {
30 const length = chars(text).length
31 const elapsed = f - i * LINE_GAP_FRAMES + 1
32 if (elapsed <= 0) {
33 return 0
34 }
35 return Math.min(length, elapsed * Math.ceil(length / LINE_FRAMES))
36 })
37}
38
39// 전체 제안을 한 번에 넣고, 보여 줄 진행 정도만 프레임마다 늘린다.
40// 새 프롬프트나 닫기로 generation이 바뀌면 그 자리에서 멈춘다(제안은 그쪽에서 비운다).
41async function animate($: EngineInterface, picked: Suggestions, mine: number): Promise<void> {
42 await update($, reveal, () => revealAt(picked, 0))
43 await update($, suggestions, () => picked)
44 for (let f = 1; ; f++) {
45 await $.clock.sleep(FRAME_MS)
46 if (mine !== generation) {
47 return
48 }
49 const next = revealAt(picked, f)
50 if (next.every((n, i) => n >= chars(picked[i]!).length)) {
51 await update($, reveal, () => [])
52 return
53 }
54 await update($, reveal, () => next)
55 }
56}
57
58// 제안을 만드는 동안 사용자가 새 프롬프트를 보냈으면, 늦게 온 제안은 버린다.
59let generation = 0
60
61async function suggest($: EngineInterface, answer: string, mine: number): Promise<void> {
62 try {
63 const reply = await $.model.complete({
64 model: 'haiku',
65 system: SYSTEM,
66 prompt: buildPrompt(await read($, lastPrompt), answer),
67 maxTokens: 200,
68 effort: 'low',
69 timeoutMs: 20_000,
70 })
71 if (!reply.isAnswered || mine !== generation) {
72 return
73 }
74 const picked = parseSuggestions(reply.text)
75 if (picked.length === 0) {
76 return
77 }
78 await animate($, picked, mine)
79 } catch {
80 // 제안은 덤이다. 실패하면 띠를 띄우지 않는다.
81 }
82}
83
84export const register: Register = on => {
85 // 다시 불러오면 돌던 애니메이션은 사라지니, 남은 진행 정도를 지워 제안을 다 보이게 한다.
86 on('session.start', async ($, e, next) => {
87 await update($, reveal, () => [])
88 return next(e)
89 })
90
91 // 프롬프트를 보내면(슬래시 명령 포함) 지난 제안을 지우고, 다음 제안의 재료로 그 문장을 남긴다.
92 // 사람이 입력창에서 보낸 글에 남은 `$번호`(스페이스 없이 Enter)는 그 번호의 제안으로 바꿔 보낸다.
93 // 제안은 보낸 뒤에 비운다. quick-replies가 이 훅 안쪽에서 돌 때도 프리셋 번호를 셀 제안 개수가 남아 있어야 하기 때문이다.
94 on('prompt.submit', async ($, e, next) => {
95 generation += 1
96 const list = (await read($, suggestions)).filter(item => typeof item === 'string')
97 const text = e.origin?.kind === 'composer' ? expandNumbers(e.text, n => list[n - 1]) : e.text
98 try {
99 const result = await next(text === e.text ? e : { ...e, text })
100 // 막혀서 들어가지 않은 글은 마지막 요청으로 남기지 않는다.
101 if (result.drop === undefined) {
102 await update($, lastPrompt, () => result.text ?? text)
103 }
104 return result
105 } finally {
106 await update($, suggestions, () => [])
107 await update($, reveal, () => [])
108 }
109 })
110
111 // 입력창에 `$2 `처럼 번호 뒤에 스페이스를 치면 그 자리를 2번 제안으로 바꾼다. 제안은 늘 $1부터라 다른 모드의 번호를 몰라도 된다.
112 on('prompt.edit', async ($, e, next) => {
113 const typed = typedNumber(e)
114 if (typed !== undefined) {
115 const list = (await read($, suggestions)).filter(item => typeof item === 'string')
116 const text = list[typed.n - 1]
117 if (text !== undefined) {
118 return next({ ...e, start: typed.start, inputText: `${text} ` })
119 }
120 }
121 return next(e)
122 })
123
124 // 메인 대화의 턴이 답으로 끝나면 작은 모델에 한 번 물어 제안을 만든다. 턴 마무리를 붙잡지 않도록 기다리지 않는다.
125 // 서브에이전트의 턴(agentId가 있음)도 이 훅을 지나가는데, 그 보고는 사람이 읽을 답이 아니라 건너뛴다.
126 on('turn.complete', async ($, e, next) => {
127 const result = await next(e)
128 if (e.agentId === undefined && e.reason === 'answer' && !e.isAborted && e.answer.length >= MIN_ANSWER_CHARS) {
129 void suggest($, e.answer, generation)
130 }
131 return result
132 })
133
134 // 띠 안의 맨 윗줄(순서 10)에 제안을 그린다. quick-replies 프리셋 줄과 한 틀을 나눠 쓴다.
135 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
136 if (e.props.hasSurvey || (e.surface !== 'terminal' && e.surface !== 'desktop')) {
137 return next(e)
138 }
139 const below = await next(e)
140 // 0.3~0.4가 남긴 제목 붙은 제안({ title, text })은 버린다.
141 const list = (await read($, suggestions)).filter(item => typeof item === 'string')
142 if (e.props.isWorking || list.length === 0) {
143 return withBandSection(below, 'next-prompts', ORDER, null)
144 }
145
146 const shown = await read($, reveal)
147 // i번째 줄에서 지금 보이는 글자 수. 진행 정도가 없으면 다 보인다.
148 const visible = (text: string, i: number) =>
149 shown.length !== list.length ? chars(text).length : Math.min(chars(text).length, shown[i] ?? 0)
150
151 const { Box, Button, Text } = $.ui.resolve(e)
152 // 닫으면 돌고 있는 애니메이션도 멈춘다.
153 const clear = async () => {
154 generation += 1
155 await update($, suggestions, () => [])
156 await update($, reveal, () => [])
157 }
158
159 // 제안 하나는 버튼 두 개다. 화면에는 흘려 쓰는 중인 글자만 보여도, 버튼이 넣고 보내는 것은 늘 전체 문구다.
160 // 번호(`$1:`)는 클릭하면 입력창에 초안으로 넣기만 하고, 문구는 클릭하면 바로 보낸다.
161 // 입력창에 `$1 `처럼 번호와 스페이스를 치면 번호를 클릭하지 않아도 그 자리에 들어간다(prompt.edit).
162 // 다 나온 제안은 접지 않고 늘 전체 문구로 보인다(quick-replies의 0은 프리셋만 접는다). 닫기는 클릭으로만 한다.
163 const pick = (text: string, i: number, count: number) => {
164 const isTyping = count < chars(text).length
165 return (
166 <Box key={`item:${i + 1}`} flexDirection="row" columnGap={1}>
167 <Button
168 key={`pick:${i + 1}`}
169 label={numberLabel(i + 1)}
170 plain
171 onPress={async () => {
172 await $.prompt.fill({ text })
173 await clear()
174 }}
175 >
176 <Text color={ACCENT}>{numberLabel(i + 1)}</Text>
177 </Button>
178 {/* 문구가 두 줄 이상이면 터미널의 포인터 반전이 첫 줄에만 닿는다. 문구만 감싼 Box에 hover를 걸어 모든 줄을 함께 반전한다. */}
179 <Box key={`go-box:${i + 1}`}>
180 <Button
181 key={`go:${i + 1}`}
182 label={text}
183 plain
184 hover={{ inverse: true }}
185 onPress={async () => {
186 // 이 모드가 보낸 프롬프트에는 자기 prompt.submit 훅이 돌지 않으니, 그 일을 여기서 한다(clear가 generation을 올린다).
187 await clear()
188 await update($, lastPrompt, () => text)
189 await $.prompt.submit({ text, asUser: true })
190 }}
191 >
192 {/* 다 나올 때까지는 흐리게, 다 나오면 보통 색으로 그려 페이드처럼 보이게 한다. */}
193 <Text dimColor={isTyping}>{isTyping ? chars(text).slice(0, count).join('') : text}</Text>
194 </Button>
195 </Box>
196 </Box>
197 )
198 }
199 // 제목 줄 다음에 제안을 한 줄에 하나씩 둔다. 애니메이션 동안에도 줄 수는 처음부터 다 잡아 두고,
200 // 아직 나오지 않은 줄(제목 줄은 첫 제안이 나오기 전까지)은 빈 줄로 그려 띠 높이가 흔들리지 않게 한다.
201 const blank = (key: string) => (
202 <Box key={key}>
203 <Text> </Text>
204 </Box>
205 )
206 const row = (
207 <Box flexDirection="column">
208 {visible(list[0]!, 0) > 0 ? (
209 <Box key="title" flexDirection="row" columnGap={2}>
210 <Text dimColor>다음 제안</Text>
211 <Button key="dismiss" label="닫기" plain dimColor role="dismiss" onPress={clear} />
212 </Box>
213 ) : (
214 blank('title')
215 )}
216 {list.map((text, i) => {
217 const count = visible(text, i)
218 return count > 0 ? (
219 <Box key={`line:${i + 1}`} paddingLeft={2}>
220 {pick(text, i, count)}
221 </Box>
222 ) : (
223 blank(`line:${i + 1}`)
224 )
225 })}
226 </Box>
227 )
228 return withBandSection(below, 'next-prompts', ORDER, row)
229 })
230}
231hooks/band.ts 107 lines1// 입력창 위 띠(AbovePrompt)를 여러 모드가 나눠 쓰는 약속.
2// 이 파일은 quick-replies와 next-prompts에 똑같이 들어 있다. 고칠 때는 둘 다 고친다.
3//
4// 띠 자리에는 한 트리만 그려지므로, 각 모드는 아래 모양의 "띠 틀"을 주고받는다.
5// Box(key=oobg-band, column)
6// Box(key=oobg-band:<순서>:<이름>) ← 각 모드의 줄, 순서대로
7// 모드는 next(e)로 받은 틀에 자기 줄만 끼워 넣어 돌려준다. 틀이 아니면 새로 만든다.
8//
9// 번호($1~$9)는 각 모드가 스스로 매긴다. 다음 제안이 $1부터, 프리셋은 떠 있는 제안 개수 다음부터다.
10// 띠를 합치는 순서는 모드마다 다르게 보이므로 번호를 여기서 매기지 않는다.
11// 입력창에 `$2 `, `$12 `처럼 번호 뒤에 스페이스를 치면 그 번호의 문구로 바뀐다(`typedNumber`). 빈 입력창의 숫자 하나는 더 이상 버튼을 누르지 않는다.
12// 스페이스 없이 `$12`에서 바로 Enter를 눌러도 보낼 때 문구로 바꾼다(`expandNumbers`).
13// `$e`, `$0`처럼 `$` 뒤의 글자 하나는 모드가 정한 동작을 부른다(`typedAfterDollar`).
14//
15// 한 모드가 띠를 혼자 써야 할 때(편집 화면 등)는 `exclusiveBand`로 만든 틀을 돌려준다.
16// 다른 모드는 그 틀을 보면 자기 줄을 넣지 않고 그대로 넘긴다.
17import type { PromptEditInput, RenderElement, RenderNode } from 'claude-code'
18
19const BAND_KEY = 'oobg-band'
20const EXCLUSIVE_KEY = 'oobg-band-exclusive'
21const SECTION_PREFIX = `${BAND_KEY}:`
22
23function isElement(node: RenderNode | undefined | null): node is RenderElement {
24 return typeof node === 'object' && node !== null && 'type' in node
25}
26
27function keyOf(node: RenderNode): string | undefined {
28 if (!isElement(node) || node.type !== 'Box') {
29 return undefined
30 }
31 const key = node.props?.key
32 return typeof key === 'string' ? key : undefined
33}
34
35function orderOf(node: RenderNode): number {
36 return Number(keyOf(node)?.split(':')[1] ?? 0)
37}
38
39// 띠에 보이는 번호 표시.
40export const numberLabel = (n: number) => `$${n}:`
41
42// 방금 친 글자 하나가 `$` 바로 뒤이고 그 `$`가 글 처음이나 공백 뒤에 있으면 그 글자를 낸다.
43export function typedAfterDollar(e: PromptEditInput): string | undefined {
44 if (e.key === undefined || !/^[0-9a-z]$/.test(e.inputText) || e.start !== e.end || e.text[e.start - 1] !== '$') {
45 return undefined
46 }
47 const before = e.text[e.start - 2]
48 return before === undefined || /\s/.test(before) ? e.inputText : undefined
49}
50
51// `$12`처럼 번호를 친 뒤 스페이스를 치면 그 번호와 `$`의 자리를 낸다. 스페이스로 끝을 알려야 두 자리 번호도 고를 수 있다.
52// `$`는 글 처음이나 공백 뒤에 있어야 한다. 바꿀 때는 `next({ ...e, start: typed.start, inputText: 문구 + ' ' })`로 `$`부터 친 스페이스까지 덮고, 이어 쓰기 좋게 문구 뒤에 스페이스 하나를 붙인다.
53export function typedNumber(e: PromptEditInput): { n: number; start: number } | undefined {
54 if (e.key === undefined || e.inputText !== ' ' || e.start !== e.end) {
55 return undefined
56 }
57 const match = /(?:^|\s)\$([1-9][0-9]*)$/.exec(e.text.slice(0, e.start))
58 return match === null ? undefined : { n: Number(match[1]), start: e.start - match[1]!.length - 1 }
59}
60
61// 스페이스 없이 보낸 글에 남은 `$12` 같은 번호를 문구로 바꾼다. `$`는 글 처음이나 공백 뒤, 번호 뒤는 공백이나 글 끝이어야 한다.
62// `lookup`이 문구를 주지 않는 번호(다른 모드의 번호, 없는 번호)는 그대로 둔다.
63export function expandNumbers(text: string, lookup: (n: number) => string | undefined): string {
64 return text.replace(/(^|\s)\$([1-9][0-9]*)(?=\s|$)/g, (whole, before: string, digits: string) => {
65 const phrase = lookup(Number(digits))
66 return phrase === undefined ? whole : before + phrase
67 })
68}
69
70// 띠를 혼자 쓰는 틀. 다른 모드는 이 틀에 줄을 넣지 않는다.
71export function exclusiveBand(section: RenderNode): RenderElement {
72 return { type: 'Box', props: { key: EXCLUSIVE_KEY, flexDirection: 'column', marginTop: 1 }, children: [section] }
73}
74
75// `below`(next(e)가 돌려준 트리)에 이 모드의 줄을 넣은 띠 틀을 돌려준다.
76// `section`이 null이면 이 모드의 줄을 뺀다. 남은 줄이 하나도 없으면 `below`를 그대로 돌려준다.
77export function withBandSection(
78 below: RenderElement,
79 name: string,
80 order: number,
81 section: RenderNode | null,
82): RenderElement {
83 if (below.type === 'Box' && below.props?.key === EXCLUSIVE_KEY) {
84 return below
85 }
86 const ownKey = `${SECTION_PREFIX}${order}:${name}`
87 const isBand = below.type === 'Box' && below.props?.key === BAND_KEY
88 const others = isBand
89 ? (below.children ?? []).filter(child => {
90 const key = keyOf(child)
91 return key !== undefined && key.startsWith(SECTION_PREFIX) && key !== ownKey
92 })
93 : []
94 const mine: RenderNode[] =
95 section === null
96 ? []
97 : // 줄마다 위에 한 칸을 띄워 제안과 프리셋이 구분되어 읽히게 한다.
98 [{ type: 'Box', props: { key: ownKey, flexDirection: 'column', marginTop: 1 }, children: [section] }]
99 const sections = [...others, ...mine].sort((a, b) => orderOf(a) - orderOf(b))
100
101 if (sections.length === 0) {
102 return isBand ? { type: 'Box', props: { key: BAND_KEY }, children: [] } : below
103 }
104 // 띠와 입력창 사이의 빈 줄은 Claude Code가 늘 두는 간격이다. 음수 여백으로 당기면 띠가 아예 그려지지 않아 그대로 둔다.
105 return { type: 'Box', props: { key: BAND_KEY, flexDirection: 'column' }, children: sections }
106}
107hooks/suggest.ts 58 lines1// 작은 모델에게 보낼 요청과, 그 답에서 제안을 골라내는 부분. $를 쓰지 않는 순수 함수만 둔다.
2
3// 짧은 답 뒤에는 제안할 거리가 적고 호출 비용만 든다.
4export const MIN_ANSWER_CHARS = 120
5// 제안은 한 줄에 하나씩 놓이므로 그 줄 안에 들어갈 길이까지 받는다.
6const MAX_SUGGESTION_CHARS = 60
7const MAX_SUGGESTIONS = 3
8// 모델에 넘기는 대화는 끝부분만 쓴다.
9const MAX_CONTEXT_CHARS = 4000
10
11export const SYSTEM = [
12 '너는 코딩 어시스턴트 세션을 보고, 사용자가 바로 다음에 입력할 만한 짧은 요청을 고른다.',
13 '규칙:',
14 '- 2개 또는 3개를 고른다. 한 줄에 하나, 번호나 기호 없이 요청 문장만 쓴다.',
15 '- 사용자가 쓴 언어와 말투를 따른다.',
16 `- 각 요청은 ${MAX_SUGGESTION_CHARS}자 이내로, 바로 실행할 수 있게 구체적으로 쓴다.`,
17 '- 어시스턴트가 이미 끝낸 일이나 사용자가 이미 거절한 일은 고르지 않는다.',
18 '- 고를 만한 게 없으면 아무것도 쓰지 않는다.',
19].join('\n')
20
21function tail(text: string, max: number): string {
22 return text.length <= max ? text : `…${text.slice(text.length - max)}`
23}
24
25export function buildPrompt(userPrompt: string, answer: string): string {
26 return [
27 '<사용자의 마지막 요청>',
28 tail(userPrompt, 1000),
29 '</사용자의 마지막 요청>',
30 '<어시스턴트의 마지막 답>',
31 tail(answer, MAX_CONTEXT_CHARS),
32 '</어시스턴트의 마지막 답>',
33 '다음에 입력할 만한 요청:',
34 ].join('\n')
35}
36
37// 모델의 답에서 제안 줄만 고른다. 번호, 글머리표, 따옴표는 걷어 낸다.
38export function parseSuggestions(reply: string): string[] {
39 const seen = new Set<string>()
40 const out: string[] = []
41 for (const raw of reply.split('\n')) {
42 const line = raw
43 .trim()
44 .replace(/^(\d+[.)]|[-*•])\s*/, '')
45 .replace(/^["'“‘`](.*)["'”’`]$/, '$1')
46 .trim()
47 if (line === '' || line.length > MAX_SUGGESTION_CHARS || seen.has(line)) {
48 continue
49 }
50 seen.add(line)
51 out.push(line)
52 if (out.length === MAX_SUGGESTIONS) {
53 break
54 }
55 }
56 return out
57}
58types/index.d.ts 16 lines1/** 지금 띄워 둔 제안, 늘 전체 문구다. 비어 있으면 띠를 그리지 않는다. quick-replies가 번호를 이어 매기려고 읽는다. */
2export type Suggestions = readonly string[]
3
4/**
5 * 제안을 차례로 보여 주는 진행 정도. i번째 값은 i번째 제안에서 지금 보이는 글자 수다.
6 * 0이면 아직 나오지 않은 줄(빈 줄로 자리만 잡는다), 문구 길이 이상이면 다 나온 줄이다.
7 * 빈 배열이면 애니메이션이 없거나 끝났다는 뜻으로, 모든 제안을 다 보여 준다.
8 */
9export type Reveal = readonly number[]
10
11declare module 'claude-code' {
12 interface PluginState {
13 'next-prompts': { suggestions: Suggestions; reveal: Reveal; lastPrompt: string }
14 }
15}
16