전역과 로컬(저장소별) 프리셋 문구를 입력창 위에 버튼으로 표시합니다. $4처럼 입력하면 해당 문구로 바뀌고, 문구를 클릭하면 바로 보냅니다. /스킬도 실행할 수 있으며, $e로 편집 화면을 열거나 $0으로 프리셋을 한 줄로 접을 수 있습니다

<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 669 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Deleted, EditInput, Mode, Reply, Scope } from '../types'
5import { exclusiveBand, expandNumbers, numberLabel, typedAfterDollar, typedNumber, withBandSection } from './band'
6import { TITLE_SYSTEM, needsTitle, parseTitle, shortTitle, titlePrompt } from './title'
7
8export const DEFAULT_REPLIES: readonly string[] = ['지금 진행상황', '맞아 그렇게 진행해']
9
10// 띠 안의 순서. next-prompts의 다음 제안(10) 아래에 온다.
11const ORDER = 20
12// 스킬, 슬래시 명령 프리셋의 명령 부분 색. 버튼 번호와 입력창의 /명령에 Claude Code가 쓰는 테마 색과 같다.
13const ACCENT = 'suggestion'
14
15const repo = atom({ plugin: 'quick-replies', key: 'repo' } as const, null)
16const globalReplies = atom({ plugin: 'quick-replies', key: 'globalReplies' } as const, null)
17const projectReplies = atom({ plugin: 'quick-replies', key: 'projectReplies' } as const, null)
18const mode = atom({ plugin: 'quick-replies', key: 'mode' } as const, { kind: 'view' } as Mode)
19const compact = atom({ plugin: 'quick-replies', key: 'compact' } as const, null)
20
21// next-prompts가 띄운 다음 제안. 프리셋 번호는 그 개수 다음부터 매긴다. next-prompts가 없으면 비어 있다.
22// 이 모드의 상태가 아니라서 계약에 적지 않고, 모양도 믿지 않고 걸러 읽는다.
23const nextSuggestions = atom({ plugin: 'next-prompts', key: 'suggestions' } as never, [] as never)
24
25async function suggestionCount($: EngineInterface): Promise<number> {
26 try {
27 const list: unknown = await read($, nextSuggestions)
28 return Array.isArray(list) ? list.filter(item => typeof item === 'string').length : 0
29 } catch {
30 return 0
31 }
32}
33
34const GLOBAL_KEY = 'global'
35const COMPACT_KEY = 'compact'
36const projectKey = (root: string) => `repo:${root}`
37
38// git 저장소 안이면 그 최상위 폴더, 아니면 세션 폴더가 프로젝트 프리셋의 단위다.
39async function findRepo($: EngineInterface): Promise<string> {
40 const cwd = await $.session.cwd()
41 try {
42 const { exitCode, stdout } = await $.process.run(['git', 'rev-parse', '--show-toplevel'])
43 return exitCode === 0 && stdout.trim() !== '' ? stdout.trim() : cwd
44 } catch {
45 return cwd
46 }
47}
48
49const defaults = (): Reply[] => DEFAULT_REPLIES.map((text, i) => ({ id: `default-${i}`, text }))
50
51type Presets = { root: string; global: Reply[]; project: Reply[] }
52
53// 그리기는 상태를 쓰지 못하므로 읽기만 한다. 세션 시작 때 채운 값이 있으면 그것을, 없으면 저장소에서 바로 읽는다.
54// 전역은 한 번도 고치지 않았으면 기본 두 개, 프로젝트는 비어 있다.
55async function current($: EngineInterface): Promise<Presets> {
56 const root = (await read($, repo)) ?? (await findRepo($))
57 const global =
58 (await read($, globalReplies)) ??
59 (((await $.store.get(GLOBAL_KEY)) as Reply[] | undefined) ??
60 defaults())
61 const project = (await read($, projectReplies)) ?? (((await $.store.get(projectKey(root))) as Reply[] | undefined) ?? [])
62 return { root, global, project }
63}
64
65// 접힘도 프리셋처럼 세션 시작 때 채운 값을 먼저, 없으면 저장소에서 읽는다. 처음에는 펼쳐 둔다.
66async function isCompact($: EngineInterface): Promise<boolean> {
67 return (await read($, compact)) ?? (((await $.store.get(COMPACT_KEY)) as boolean | undefined) ?? false)
68}
69
70async function toggleCompact($: EngineInterface): Promise<void> {
71 const next = !(await isCompact($))
72 await update($, compact, () => next)
73 await $.store.set(COMPACT_KEY, next)
74}
75
76async function load($: EngineInterface): Promise<void> {
77 const { root, global, project } = await current($)
78 await update($, repo, () => root)
79 await update($, globalReplies, () => global)
80 await update($, projectReplies, () => project)
81 const folded = await isCompact($)
82 await update($, compact, () => folded)
83}
84
85// 다른 세션에서 고친 프리셋을 이 세션에 들인다. 저장소는 파일 하나라서 바뀌었다는 알림이 없으므로 주기적으로 읽어 비교한다.
86// 띠는 세션 값을 읽어 그리므로, 값이 달라질 때만 바꾸면 그때만 다시 그려진다.
87export const SYNC_MS = 5000
88
89async function sync($: EngineInterface): Promise<void> {
90 const root = await read($, repo)
91 if (root === null) {
92 return
93 }
94 const stored = {
95 global: ((await $.store.get(GLOBAL_KEY)) as Reply[] | undefined) ?? defaults(),
96 project: ((await $.store.get(projectKey(root))) as Reply[] | undefined) ?? [],
97 }
98 if (JSON.stringify(stored.global) !== JSON.stringify(await read($, globalReplies))) {
99 await update($, globalReplies, () => stored.global)
100 }
101 if (JSON.stringify(stored.project) !== JSON.stringify(await read($, projectReplies))) {
102 await update($, projectReplies, () => stored.project)
103 }
104}
105
106// 두 목록을 함께 바꾼다. 한 프리셋을 전역과 프로젝트 사이로 옮길 때 양쪽을 한 번에 저장하기 위해서다.
107async function save($: EngineInterface, change: (presets: Presets) => Presets): Promise<void> {
108 const next = change(await current($))
109 await update($, repo, () => next.root)
110 await update($, globalReplies, () => next.global)
111 await update($, projectReplies, () => next.project)
112 await $.store.set(GLOBAL_KEY, next.global)
113 await $.store.set(projectKey(next.root), next.project)
114}
115
116// "/ux-writing 문구 다듬어줘"처럼 /로 시작하는 프리셋은 슬래시 명령(스킬 포함)으로 실행한다.
117// 이름과 나머지를 나눈다. 명령이 아니면 null.
118export function parseCommand(text: string): { command: string; args: string } | null {
119 const match = /^\/([^\s/]+)(?:\s+([\s\S]*))?$/.exec(text.trim())
120 return match === null ? null : { command: match[1] ?? '', args: (match[2] ?? '').trim() }
121}
122
123// 프리셋 하나를 보낸다. 슬래시 명령은 사람이 입력한 것처럼 실행한다.
124// /로 시작하는 글은 일반 프롬프트로 보낼 수 없으므로, 없는 명령이면 알림만 띄운다.
125async function send($: EngineInterface, text: string): Promise<void> {
126 const command = parseCommand(text)
127 if (command === null) {
128 await $.prompt.submit({ text, asUser: true })
129 return
130 }
131 const known = (await $.command.list()).some(c => c.name === command.command)
132 if (!known) {
133 $.ui.toast(`/${command.command}에 해당하는 명령이나 스킬을 찾지 못했어요`)
134 return
135 }
136 await $.command.run(command)
137}
138
139const without = (list: Reply[], id: string) => list.filter(r => r.id !== id)
140const untitled = ({ title: _title, autoTitle: _auto, ...rest }: Reply): Reply => rest
141// 문구를 고치면 모델이 지은 제목은 버리고 다시 짓는다. 사람이 단 제목은 그대로 둔다.
142const renamed = (list: Reply[], id: string, text: string) =>
143 list.map(r => (r.id !== id ? r : r.autoTitle ? { ...untitled(r), text } : { ...r, text }))
144// 사람이 단 제목. 빈 제목은 제목을 지워, 모델이 다시 짓게 한다.
145const retitled = (list: Reply[], id: string, title: string) =>
146 list.map(r => (r.id !== id ? r : title === '' ? untitled(r) : { ...untitled(r), title }))
147// 모델이 지은 제목. 그사이 문구가 바뀌었거나 사람이 제목을 달았으면 버린다.
148const autoTitled = (list: Reply[], id: string, text: string, title: string) =>
149 list.map(r => (r.id === id && r.text === text && r.title === undefined ? { ...r, title, autoTitle: true as const } : r))
150
151// 지금 제목을 짓고 있는 프리셋 id와 그때의 문구. 같은 문구로 두 번 묻지 않는다.
152const titling = new Map<string, string>()
153
154// 제목이 없는 긴 프리셋에 작은 모델(haiku)로 제목을 지어 단다. 제목은 덤이라 실패하면 문구를 줄여 보인다.
155async function autoTitle($: EngineInterface, reply: Reply): Promise<void> {
156 if (!needsTitle(reply) || titling.get(reply.id) === reply.text) {
157 return
158 }
159 titling.set(reply.id, reply.text)
160 try {
161 const answer = await $.model.complete({
162 model: 'haiku',
163 system: TITLE_SYSTEM,
164 prompt: titlePrompt(reply.text),
165 maxTokens: 40,
166 effort: 'low',
167 timeoutMs: 20_000,
168 })
169 const title = answer.isAnswered ? parseTitle(answer.text) : ''
170 if (title !== '') {
171 await save($, p => ({
172 ...p,
173 global: autoTitled(p.global, reply.id, reply.text, title),
174 project: autoTitled(p.project, reply.id, reply.text, title),
175 }))
176 }
177 } catch {
178 // 제목을 못 지으면 접은 띠에 문구를 줄여 보인다.
179 } finally {
180 if (titling.get(reply.id) === reply.text) {
181 titling.delete(reply.id)
182 }
183 }
184}
185
186// 한 프리셋의 제목을 다시 살핀다. 기다리지 않는다.
187async function titleLater($: EngineInterface, id: string): Promise<void> {
188 const { global, project } = await current($)
189 const reply = [...global, ...project].find(r => r.id === id)
190 if (reply !== undefined) {
191 void autoTitle($, reply)
192 }
193}
194
195// 세션을 열 때 제목이 없는 긴 프리셋에 차례로 제목을 단다. 한 번 지은 제목은 저장되어 다시 묻지 않는다.
196async function titleAll($: EngineInterface): Promise<void> {
197 const { global, project } = await current($)
198 for (const reply of [...global, ...project]) {
199 await autoTitle($, reply)
200 }
201}
202
203// 편집 화면을 연다. 키보드는 입력창에 그대로 두고, 편집하는 동안 입력창에 치는 키를 이 모드가 가로챈다.
204// 띠로 포커스를 옮기면 모드가 입력창으로 되돌릴 방법이 없어서다.
205async function openEditor($: EngineInterface): Promise<void> {
206 await update($, mode, () => ({ kind: 'edit', selected: null, input: null }) as Mode)
207}
208
209// 문구를 쓰는 동안 입력창 글자에 칠하는 색. 입력창 테두리는 모드가 바꿀 수 없어 글자로 편집 중임을 보인다.
210const inputPaint = (text: string) => [{ start: 0, end: text.length, color: ACCENT } as const]
211
212// 입력칸을 연다. 입력창이 곧 입력칸이라, 고칠 때는 원래 글을 입력창에 채워 둔다.
213async function startInput($: EngineInterface, editing: EditInput): Promise<void> {
214 const presets = await current($)
215 const target = editing.kind === 'add' ? undefined : [...presets.global, ...presets.project].find(r => r.id === editing.id)
216 const text = editing.kind === 'rename' ? (target?.text ?? '') : editing.kind === 'title' ? (target?.title ?? '') : ''
217 await update($, mode, m => (m.kind === 'edit' ? { ...m, input: editing } : m))
218 await $.prompt.fill({ text, decorations: inputPaint(text) })
219}
220
221async function cancelInput($: EngineInterface): Promise<void> {
222 await update($, mode, m => (m.kind === 'edit' ? { ...m, input: null } : m))
223 await $.prompt.fill({ text: '' })
224}
225
226// 입력칸에서 Enter를 누르면 추가하거나 고친다. 빈 문구는 무시하고, 빈 제목은 제목을 지운다.
227// 제목이 비면 모델이 뒤에서 제목을 짓는다.
228async function submitInput($: EngineInterface, editing: EditInput, text: string): Promise<void> {
229 if (editing.kind === 'title') {
230 await save($, p => ({
231 ...p,
232 global: retitled(p.global, editing.id, text),
233 project: retitled(p.project, editing.id, text),
234 }))
235 await titleLater($, editing.id)
236 } else if (text !== '') {
237 if (editing.kind === 'add') {
238 const reply = { id: crypto.randomUUID(), text }
239 await save($, p =>
240 editing.scope === 'global' ? { ...p, global: [...p.global, reply] } : { ...p, project: [...p.project, reply] },
241 )
242 await titleLater($, reply.id)
243 } else {
244 await save($, p => ({
245 ...p,
246 global: renamed(p.global, editing.id, text),
247 project: renamed(p.project, editing.id, text),
248 }))
249 await titleLater($, editing.id)
250 }
251 }
252 await update($, mode, m => (m.kind === 'edit' ? { ...m, input: null } : m))
253}
254
255// 지우고 커서는 그 자리에 들어선 프리셋(맨 뒤였으면 앞의 것)으로 옮긴다. 지운 것은 `u`로 되살릴 수 있게 남긴다.
256async function removePreset($: EngineInterface, id: string): Promise<void> {
257 const before = ordered(await current($))
258 const at = before.findIndex(item => item.reply.id === id)
259 const target = before[at]
260 if (target === undefined) {
261 return
262 }
263 const deleted: Deleted = { reply: target.reply, scope: target.scope, index: before.filter(i => i.scope === target.scope).indexOf(target) }
264 await save($, p => ({ ...p, global: without(p.global, id), project: without(p.project, id) }))
265 const after = before.filter(item => item.reply.id !== id)
266 const next = after[Math.min(at, after.length - 1)]?.reply.id ?? null
267 await update($, mode, m => (m.kind === 'edit' ? { ...m, selected: next, deleted } : m))
268}
269
270// 마지막으로 지운 프리셋을 원래 범위의 원래 자리에 되살리고 커서를 둔다.
271async function restorePreset($: EngineInterface, deleted: Deleted): Promise<void> {
272 const put = (list: Reply[]) => [...list.slice(0, deleted.index), deleted.reply, ...list.slice(deleted.index)]
273 await save($, p => (deleted.scope === 'global' ? { ...p, global: put(p.global) } : { ...p, project: put(p.project) }))
274 await update($, mode, m => (m.kind === 'edit' ? { ...m, selected: deleted.reply.id, deleted: undefined } : m))
275}
276
277// 전역과 로컬 사이로 옮긴다. 옮긴 프리셋은 그쪽 목록 맨 뒤에 붙는다.
278async function movePreset($: EngineInterface, id: string): Promise<void> {
279 await save($, p => {
280 const inGlobal = p.global.find(r => r.id === id)
281 if (inGlobal !== undefined) {
282 return { ...p, global: without(p.global, id), project: [...p.project, inGlobal] }
283 }
284 const inProject = p.project.find(r => r.id === id)
285 return inProject === undefined ? p : { ...p, project: without(p.project, id), global: [...p.global, inProject] }
286 })
287}
288
289// 같은 범위 안에서 한 칸 위(-1)나 아래(+1)로 옮긴다.
290function shifted(list: Reply[], id: string, by: number): Reply[] {
291 const from = list.findIndex(r => r.id === id)
292 const to = from + by
293 if (from < 0 || to < 0 || to >= list.length) {
294 return list
295 }
296 const next = [...list]
297 const [moved] = next.splice(from, 1)
298 next.splice(to, 0, moved as Reply)
299 return next
300}
301
302async function reorder($: EngineInterface, id: string, by: number): Promise<void> {
303 await save($, p => ({ ...p, global: shifted(p.global, id, by), project: shifted(p.project, id, by) }))
304}
305
306// 전역 다음 로컬 순서로 늘어놓은 목록. 편집 화면의 번호와 커서는 이 순서를 따른다.
307function ordered(presets: Presets): { reply: Reply; scope: Scope }[] {
308 return [
309 ...presets.global.map(reply => ({ reply, scope: 'global' as Scope })),
310 ...presets.project.map(reply => ({ reply, scope: 'project' as Scope })),
311 ]
312}
313
314// 두 번 쳐야 하는 키. 첫 글자는 `pending`에 두었다가 같은 글자가 오면 실행한다.
315const SEQUENCES = ['dd', 'gg']
316
317// 편집 화면에서 고르는 중일 때 입력창에 친 글자 하나를 vim처럼 동작으로 바꾼다. 띠의 버튼도 같은 글자로 이 함수를 부른다.
318// j/k 커서 아래/위, gg/G 처음/끝, 1~9 그 번호로, J/K 프리셋을 아래/위로 옮기기
319// i 문구 수정, t 제목, dd 삭제, u 되살리기, m 전역↔로컬, o 커서가 있는 범위에 추가, q 완료
320async function editorKey($: EngineInterface, key: string): Promise<void> {
321 const shown = await read($, mode)
322 if (shown.kind !== 'edit') {
323 return
324 }
325 const pending = shown.pending ?? ''
326 if (pending !== '') {
327 await update($, mode, m => (m.kind === 'edit' ? { ...m, pending: undefined } : m))
328 }
329 const typed = SEQUENCES.includes(pending + key) ? pending + key : key
330 if (SEQUENCES.some(seq => seq[0] === typed && seq.length === 2)) {
331 await update($, mode, m => (m.kind === 'edit' ? { ...m, pending: typed } : m))
332 return
333 }
334 const all = ordered(await current($))
335 const select = async (index: number) => {
336 const picked = all[Math.max(0, Math.min(index, all.length - 1))]
337 if (picked !== undefined) {
338 await update($, mode, m => (m.kind === 'edit' ? { ...m, selected: picked.reply.id, input: null } : m))
339 }
340 }
341 if (/^[1-9]$/.test(typed)) {
342 if (Number(typed) <= all.length) {
343 await select(Number(typed) - 1)
344 }
345 return
346 }
347 const at = all.findIndex(item => item.reply.id === shown.selected)
348 const selected = all[at]
349 const actions: Record<string, () => Promise<void>> = {
350 j: () => select(at < 0 ? 0 : at + 1),
351 k: () => select(at < 0 ? all.length - 1 : at - 1),
352 gg: () => select(0),
353 G: () => select(all.length - 1),
354 o: () => startInput($, { kind: 'add', scope: selected?.scope ?? 'global' }),
355 q: async () => {
356 await update($, mode, () => ({ kind: 'view' }) as Mode)
357 },
358 ...(shown.deleted === undefined ? {} : { u: () => restorePreset($, shown.deleted as Deleted) }),
359 ...(selected === undefined
360 ? {}
361 : {
362 i: () => startInput($, { kind: 'rename', id: selected.reply.id }),
363 t: () => startInput($, { kind: 'title', id: selected.reply.id }),
364 dd: () => removePreset($, selected.reply.id),
365 m: () => movePreset($, selected.reply.id),
366 K: () => reorder($, selected.reply.id, -1),
367 J: () => reorder($, selected.reply.id, 1),
368 }),
369 }
370 await actions[typed]?.()
371}
372
373const SAVED: Record<EditInput['kind'], string> = { add: '프리셋을 추가했어요', rename: '문구를 고쳤어요', title: '제목을 바꿨어요' }
374
375export const register: Register = on => {
376 on('session.start', async ($, e, next) => {
377 const result = await next(e)
378 await load($)
379 void titleAll($)
380 $.clock.every(SYNC_MS, () => void sync($))
381 return result
382 })
383
384 // 입력창에 `$5 `, `$12 `처럼 번호 뒤에 스페이스를 치면 그 자리를 그 번호의 프리셋 문구로 바꾼다. 번호는 띠에 보이는 것과 같게 셈한다.
385 // `$e`는 편집 화면을 열고 `$0`은 프리셋을 접거나 편다. 이때 친 `$e`, `$0`은 입력창에 남기지 않는다.
386 on('prompt.edit', async ($, e, next) => {
387 const shown = await read($, mode)
388 if (shown.kind === 'edit') {
389 // 고르는 중에는 입력창에 친 글자를 하나도 넣지 않고 편집 동작으로만 쓴다.
390 if (shown.input === null) {
391 if (e.key !== undefined && e.inputText.length === 1) {
392 void editorKey($, e.inputText)
393 }
394 return next({ ...e, start: e.cursor, end: e.cursor, inputText: '' })
395 }
396 // 문구를 쓰는 중에는 입력창이 입력칸이다. `$q`를 치면 취소한다.
397 if (typedAfterDollar(e) === 'q') {
398 const box = await next({ ...e, start: e.start - 1, inputText: '' })
399 void cancelInput($)
400 return box
401 }
402 const box = await next(e)
403 return { ...box, decorations: inputPaint(box.text) }
404 }
405 const typed = typedAfterDollar(e)
406 if ((typed === 'e' || typed === '0') && (await read($, mode)).kind === 'view') {
407 const box = await next({ ...e, start: e.start - 1, inputText: '' })
408 void (typed === 'e' ? openEditor($) : toggleCompact($))
409 return box
410 }
411 const typedN = typedNumber(e)
412 if (typedN !== undefined && (await read($, mode)).kind === 'view') {
413 const presets = await current($)
414 const reply = [...presets.global, ...presets.project][typedN.n - 1 - (await suggestionCount($))]
415 if (reply !== undefined) {
416 return next({ ...e, start: typedN.start, inputText: `${reply.text} ` })
417 }
418 }
419 return next(e)
420 })
421
422 // 편집하는 동안 Enter는 Claude에게 보내지 않는다. 문구를 쓰는 중이면 그 글을 저장하고, 결과를 알림 대신 보내기를 막은 이유로 보인다.
423 on('prompt.submit', async ($, e, next) => {
424 const shown = await read($, mode)
425 if (e.origin.kind !== 'composer') {
426 return next(e)
427 }
428 // 스페이스 없이 `$번호`에서 바로 Enter를 눌러도 그 번호의 프리셋으로 바꿔 보낸다. 다음 제안의 번호는 next-prompts가 바꾼다.
429 // 글 전체가 `/명령` 프리셋 하나면 클릭한 것처럼 명령으로 실행한다.
430 if (shown.kind === 'view') {
431 const { global, project } = await current($)
432 const presets = [...global, ...project]
433 const offset = await suggestionCount($)
434 const lookup = (n: number) => (n > offset ? presets[n - 1 - offset]?.text : undefined)
435 const whole = /^\$([1-9][0-9]*)$/.exec(e.text.trim())
436 const single = whole === null ? undefined : lookup(Number(whole[1]))
437 const command = single === undefined ? null : parseCommand(single)
438 if (single !== undefined && command !== null) {
439 void send($, single)
440 void $.prompt.fill({ text: '' })
441 return { drop: `/${command.command} 프리셋을 실행해요` }
442 }
443 const text = expandNumbers(e.text, lookup)
444 return next(text === e.text ? e : { ...e, text })
445 }
446 if (shown.input === null) {
447 return { drop: '프리셋 편집 중이에요. q로 편집을 끝낸 뒤 보내 주세요' }
448 }
449 const editing = shown.input
450 const text = e.text.trim()
451 await submitInput($, editing, text)
452 void $.prompt.fill({ text: '' })
453 return { drop: text === '' && editing.kind !== 'title' ? '문구가 비어 있어서 저장하지 않았어요' : SAVED[editing.kind] }
454 })
455
456 // 편집하는 동안 입력창 아래 안내 줄을 vim의 모드 표시로 바꾼다. 두 번 치는 키의 첫 글자(d, g)는 vim처럼 옆에 보인다.
457 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
458 const shown = await read($, mode)
459 if (shown.kind !== 'edit') {
460 return next(e)
461 }
462 const hint =
463 shown.input === null
464 ? `-- NORMAL -- ${shown.pending ?? ''}`.trimEnd()
465 : '-- INSERT -- Enter 저장 · $q 취소'
466 return next({ ...e, props: { ...e.props, hint } })
467 })
468
469 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
470 // 띠는 터미널과 데스크톱에만 그려진다.
471 if (e.props.hasSurvey || (e.surface !== 'terminal' && e.surface !== 'desktop')) {
472 return next(e)
473 }
474 const presets = await current($)
475 const shown = await read($, mode)
476 const { Box, Button, Text } = $.ui.resolve(e)
477
478 if (shown.kind === 'view') {
479 const below = await next(e)
480 const folded = await isCompact($)
481 // 번호는 다음 제안 다음부터 이어 붙인다. 작업 중에는 다음 제안이 숨으므로 1부터다.
482 // 프리셋 하나는 버튼 두 개다. 번호(`$1:`)는 클릭하면 문구를 입력창에 넣기만 하고, 문구는 클릭하면 바로 보낸다.
483 // 입력창에 `$1 `처럼 번호와 스페이스를 치면 번호를 클릭하지 않아도 그 자리에 들어간다(prompt.edit).
484 let number = e.props.isWorking ? 0 : await suggestionCount($)
485 const item = (reply: Reply) => {
486 number += 1
487 // 접으면 제목을, 펼치면 문구를 보인다. 보이는 글이 /명령으로 시작하면 그 부분만 칠한다.
488 // 펼쳐도 한 줄만 쓴다. 줄바꿈은 띄어쓰기로 잇고, 폭을 넘으면 말줄임표로 자른다. 전체 문구는 편집 화면에서 보인다.
489 const label = folded ? shortTitle(reply) : reply.text.replace(/\s*\n\s*/g, ' ')
490 const command = parseCommand(reply.text)
491 const prefix = command === null ? '' : `/${command.command}`
492 const isCommand = prefix !== '' && label.startsWith(prefix)
493 return (
494 <Box key={`item:${reply.id}`} flexDirection="row" columnGap={1}>
495 <Button
496 key={`fill:${reply.id}`}
497 label={numberLabel(number)}
498 plain
499 onPress={() => {
500 void $.prompt.fill({ text: reply.text })
501 }}
502 >
503 <Text color={ACCENT}>{numberLabel(number)}</Text>
504 </Button>
505 <Button
506 key={`send:${reply.id}`}
507 label={label}
508 plain
509 onPress={() => {
510 void send($, reply.text)
511 }}
512 >
513 <Text wrap="truncate-end">
514 {/* 스킬이나 슬래시 명령은 명령 부분만 번호와 같은 색으로 칠해 일반 문장과 구분한다. */}
515 {isCommand && <Text color={ACCENT}>{prefix}</Text>}
516 {isCommand ? label.slice(prefix.length) : label}
517 </Text>
518 </Button>
519 </Box>
520 )
521 }
522 // 펼치면 다음 제안과 같은 모양으로, 제목 줄 아래에 한 줄에 하나씩 둔다. 그래서 두 목록의 번호가 같은 열에 선다.
523 // 접으면 한 범위를 한 줄에 `4: 제목 | 5: 제목`으로 잇는다.
524 const group = (scope: Scope, list: Reply[]) =>
525 folded ? (
526 <Box key={`group:${scope}`} flexDirection="row" flexWrap="wrap" columnGap={1} paddingLeft={2}>
527 {list.flatMap((reply, i) => [
528 ...(i > 0 ? [<Text key={`sep:${reply.id}`} dimColor>|</Text>] : []),
529 item(reply),
530 ])}
531 </Box>
532 ) : (
533 list.map(reply => (
534 <Box key={`line:${reply.id}`} paddingLeft={2}>
535 {item(reply)}
536 </Box>
537 ))
538 )
539 // 0은 프리셋 줄만 접고 편다. 다음 제안은 늘 전체 문구로 보인다.
540 const rows = (
541 <Box flexDirection="column">
542 <Box flexDirection="row" columnGap={2}>
543 <Text dimColor>전역</Text>
544 {/* 입력창에 $e를 치거나 클릭하면 편집 화면을 연다. 그 뒤로는 키 하나로 다 고친다. $0은 접고 편다. */}
545 <Button key="edit" label="$e: 편집" plain onPress={() => openEditor($)}>
546 <Text>
547 <Text color={ACCENT}>$e:</Text> <Text dimColor>편집</Text>
548 </Text>
549 </Button>
550 <Button key="compact" label={folded ? '$0: 펼치기' : '$0: 접기'} plain onPress={() => toggleCompact($)}>
551 <Text>
552 <Text color={ACCENT}>$0:</Text> <Text dimColor>{folded ? '펼치기' : '접기'}</Text>
553 </Text>
554 </Button>
555 </Box>
556 {presets.global.length > 0 && group('global', presets.global)}
557 {presets.project.length > 0 && <Text dimColor>로컬</Text>}
558 {presets.project.length > 0 && group('project', presets.project)}
559 </Box>
560 )
561 return withBandSection(below, 'quick-replies', ORDER, rows)
562 }
563
564 // 편집 화면: 띠를 혼자 쓰고, vim처럼 키 하나(또는 dd, gg)로 동작한다. 키는 editorKey에 적었다.
565 const all = ordered(presets)
566 const selected = all.find(item => item.reply.id === shown.selected) ?? null
567 const setEdit = (patch: Partial<Extract<Mode, { kind: 'edit' }>>) =>
568 update($, mode, m => (m.kind === 'edit' ? { ...m, ...patch } : m))
569 const projectName = presets.root.split('/').filter(Boolean).at(-1) ?? presets.root
570
571 const item = (entry: { reply: Reply; scope: Scope }, index: number) => {
572 const isSelected = entry.reply.id === shown.selected
573 return (
574 <Box key={`row:${entry.reply.id}`} flexDirection="row">
575 <Text color={ACCENT}>{isSelected ? '▸ ' : ' '}</Text>
576 <Button
577 key={`select:${entry.reply.id}`}
578 label={entry.reply.text}
579 plain
580 {...(index < 9 ? { hotkey: String(index + 1) } : {})}
581 onPress={() => setEdit({ selected: entry.reply.id, input: null })}
582 >
583 <Text>
584 {entry.reply.text}
585 {entry.reply.title !== undefined && <Text dimColor>{` 제목: ${entry.reply.title}`}</Text>}
586 </Text>
587 </Button>
588 </Box>
589 )
590 }
591 const group = (title: string, scope: Scope) => {
592 const entries = all.filter(entry => entry.scope === scope)
593 return (
594 <Box flexDirection="column">
595 <Text dimColor>{title}</Text>
596 {entries.length === 0 && <Text dimColor> (비어 있음)</Text>}
597 {entries.map(entry => item(entry, all.indexOf(entry)))}
598 </Box>
599 )
600 }
601 // 버튼을 클릭해도 입력창에 그 글자를 친 것과 같은 동작을 한다. 소문자 한 글자는 띠의 단축키로 달아 `a: 라벨`로 그려진다.
602 // 버튼 단축키는 소문자 한 글자만 받으므로, 대문자(J, K)와 두 글자(dd)는 단축키 없이 글자만 직접 그린다. dd는 두 번 눌러야 지운다.
603 const action = (key: string, typed: string, label: string) =>
604 /^[a-z]$/.test(typed) ? (
605 <Button key={key} label={label} hotkey={typed} plain onPress={() => void editorKey($, typed)} />
606 ) : (
607 <Button key={key} label={`${typed}: ${label}`} plain onPress={() => void editorKey($, typed[0] ?? '')}>
608 <Text>
609 <Text color={ACCENT}>{typed}:</Text> {label}
610 </Text>
611 </Button>
612 )
613
614 let inputRow = null
615 if (shown.input !== null) {
616 const editing = shown.input
617 const title =
618 editing.kind === 'rename'
619 ? '문구 수정'
620 : editing.kind === 'title'
621 ? '제목'
622 : editing.scope === 'global'
623 ? '전역에 추가'
624 : '로컬에 추가'
625 inputRow = (
626 <Box flexDirection="row" columnGap={1}>
627 <Text color={ACCENT}>{title}</Text>
628 <Text dimColor>
629 {editing.kind === 'title' ? '프리셋을 접었을 때 보일 짧은 이름을 입력창에 쓰고 Enter를 눌러 주세요. 비워 두면 문구를 줄여서 보여 줘요' : '입력창에 문구를 쓰거나 /스킬 이름과 문구를 함께 쓰고 Enter를 눌러 주세요'}
630 </Text>
631 <Button key="cancel" label="$q: 취소" plain onPress={() => cancelInput($)}>
632 <Text>
633 <Text color={ACCENT}>$q:</Text> <Text dimColor>취소</Text>
634 </Text>
635 </Button>
636 </Box>
637 )
638 }
639
640 const editor = (
641 <Box flexDirection="column">
642 <Box flexDirection="row" columnGap={2}>
643 <Text bold>프리셋 편집</Text>
644 {action('add', 'o', selected?.scope === 'project' ? '로컬에 추가' : '전역에 추가')}
645 {shown.deleted !== undefined && action('undo', 'u', '되살리기')}
646 {action('done', 'q', '완료')}
647 </Box>
648 {group('전역 (모든 저장소)', 'global')}
649 {group(`로컬 (${projectName})`, 'project')}
650 {inputRow}
651 {selected === null ? (
652 <Text dimColor>입력창에 j, k 또는 프리셋 번호를 쳐서 고르면 문구 수정, 제목 달기, 삭제, 이동을 할 수 있어요.</Text>
653 ) : (
654 <Box flexDirection="row" columnGap={2}>
655 <Text dimColor>고른 프리셋</Text>
656 {action('act-edit', 'i', '문구 수정')}
657 {action('act-title', 't', '제목')}
658 {action('act-delete', 'dd', '삭제')}
659 {action('act-move', 'm', selected.scope === 'global' ? '로컬로' : '전역으로')}
660 {action('act-up', 'K', '위로')}
661 {action('act-down', 'J', '아래로')}
662 </Box>
663 )}
664 </Box>
665 )
666 return exclusiveBand(editor)
667 })
668}
669hooks/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/title.ts 42 lines1// 프리셋 문구를 접은 띠에 보일 짧은 제목으로 줄이는 부분. $를 쓰지 않는 순수 함수만 둔다.
2import type { Reply } from '../types'
3
4// 접은 띠에서 한 줄에 여러 개가 놓이므로 제목은 이 길이까지만 쓴다.
5export const MAX_TITLE_CHARS = 12
6
7export const TITLE_SYSTEM = [
8 '너는 자주 보내는 요청 문구에 짧은 제목을 붙인다.',
9 '규칙:',
10 `- ${MAX_TITLE_CHARS}자 이내의 제목 하나만 쓴다. 따옴표, 번호, 설명은 붙이지 않는다.`,
11 '- 문구의 언어와 말투를 따르고, 무엇을 하는 요청인지 드러나게 줄인다.',
12 '- 문구가 /로 시작하면 슬래시 명령이다. 명령 이름보다 그 요청이 하는 일로 짓는다.',
13].join('\n')
14
15export function titlePrompt(text: string): string {
16 return ['<문구>', text, '</문구>', '제목:'].join('\n')
17}
18
19function cut(text: string): string {
20 return text.length <= MAX_TITLE_CHARS ? text : `${text.slice(0, MAX_TITLE_CHARS).trimEnd()}…`
21}
22
23// 모델 답의 첫 줄을 제목으로 쓴다. 기호와 따옴표는 걷어 내고, 길면 자른다. 쓸 게 없으면 빈 문자열.
24export function parseTitle(reply: string): string {
25 const line = (reply.split('\n').find(l => l.trim() !== '') ?? '')
26 .trim()
27 .replace(/^(제목\s*[::]|\d+[.)]|[-*•])\s*/, '')
28 .replace(/^["'“‘`](.*)["'”’`]$/, '$1')
29 .trim()
30 return cut(line)
31}
32
33// 짧은 문구는 그대로 제목이 되니 모델에 묻지 않는다.
34export function needsTitle(reply: Reply): boolean {
35 return reply.title === undefined && reply.text.length > MAX_TITLE_CHARS
36}
37
38// 접은 띠에 보일 이름. 제목이 아직 없으면 문구를 줄여 보인다.
39export function shortTitle(reply: Reply): string {
40 return reply.title ?? cut(reply.text)
41}
42types/index.d.ts 37 lines1/**
2 * 프리셋 하나. `text`는 보내는 문구, `title`은 접은 띠에 보이는 짧은 이름(없으면 `text`를 줄여 쓴다).
3 * `autoTitle`은 제목을 모델이 지었다는 표시다. 문구를 고치면 다시 짓는다. 사람이 단 제목에는 없다.
4 */
5export type Reply = { id: string; text: string; title?: string; autoTitle?: true }
6
7/** 프리셋이 어디에 저장되는가: 모든 저장소에 보이는 `global`, 지금 저장소에만 보이는 `project`. */
8export type Scope = 'global' | 'project'
9
10/** 편집 화면의 입력칸: 새 프리셋을 쓰는 `add`, 고른 프리셋의 문구를 고치는 `rename`, 제목을 고치는 `title`. */
11export type EditInput = { kind: 'add'; scope: Scope } | { kind: 'rename'; id: string } | { kind: 'title'; id: string }
12
13/** `dd`로 지운 프리셋과 그 자리. `u`로 그 자리에 되살린다. */
14export type Deleted = { reply: Reply; scope: Scope; index: number }
15
16/**
17 * 띠의 화면: 프리셋 버튼만 보이는 `view`, 목록을 고치는 `edit`.
18 * `selected`는 커서가 있는 프리셋, `input`은 지금 열린 입력칸(없으면 null)이다.
19 * `pending`은 `dd`, `gg`처럼 두 번 치는 키의 첫 글자, `deleted`는 마지막으로 지운 프리셋이다.
20 */
21export type Mode =
22 | { kind: 'view' }
23 | { kind: 'edit'; selected: string | null; input: EditInput | null; pending?: string; deleted?: Deleted }
24
25declare module 'claude-code' {
26 interface PluginState {
27 'quick-replies': {
28 repo: string | null
29 globalReplies: Reply[] | null
30 projectReplies: Reply[] | null
31 mode: Mode
32 /** 프리셋 줄을 접어 한 범위를 한 줄에 제목만으로 보이는가. 다음 제안은 접지 않는다. 읽기 전에는 null. */
33 compact: boolean | null
34 }
35 }
36}
37