SLOPSHOPPER

quick-replies

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

newbandspinnertoastpromptmodel
★ 1v0.13.2MITupdated 2026-10-16oobg/claude-code-mods/mods/quick-replies
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · quick-replies
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM 전역 $e: 편집 $0: 접기 $1: 지금 진행상황 $2: 맞아 그렇게 진행해 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
전역 $e: 편집 $0: 접기 $1: 지금 진행상황 $2: 맞아 그렇게 진행해
README

<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> &nbsp;|&nbsp; <a href="#설치"><b>설치</b></a> &nbsp;|&nbsp; <a href="#모드-목록"><b>모드 목록</b></a> &nbsp;|&nbsp; <a href="#상세-문서"><b>상세 문서</b></a> &nbsp;|&nbsp; <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처럼 두 자리 번호도 쓸 수 있습니다.
  • 프리셋도 입력창에서 편집합니다. 편집 화면에서 프리셋을 추가하거나 수정할 때는 입력창에 내용을 쓰고 Enter를 누르면 됩니다.
  • 상태 표시줄 대신 입력창 아래에 대시보드를 표시합니다. 컨텍스트 사용량, 5시간과 7일 한도, Fable 한도, 모델, 브랜치를 두 줄로 확인할 수 있습니다.
  • 파일을 경로 멘션으로 붙여넣습니다. Finder에서 복사한 파일을 붙여넣으면 파일 이름 대신 @경로 멘션이 입력됩니다.
  • 넓은 터미널에서는 diff를 좌우로 나눠 표시합니다. 원래 줄과 바뀐 줄이 같은 높이에 나란히 표시됩니다.
  • 따로 설치할 프로그램이나 직접 고칠 설정 파일이 없습니다. 마켓플레이스에서 필요한 모드만 골라 설치합니다.

설치

요구 사항

  • Claude Code 2.1.296 이상이 필요합니다. 2.1.296~2.1.299에서 테스트했습니다. 모드 기능은 아직 공개 초기 단계라 이후 버전에서는 동작이 달라질 수 있습니다.
  • key-assist는 macOS 전용입니다. Cmd+Z를 사용하려면 Command 키 입력을 Claude Code에 전달할 수 있는 터미널이 필요합니다. Orca, Ghostty, kitty, WezTerm처럼 Kitty 키보드 프로토콜을 지원하는 터미널이 해당합니다.

마켓플레이스에서 설치

이 저장소가 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 라이선스에 따라 배포됩니다.

Source 4 files
hooks/register.tsx 669 lines
1import { 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}
669
hooks/band.ts 107 lines
1// 입력창 위 띠(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}
107
hooks/title.ts 42 lines
1// 프리셋 문구를 접은 띠에 보일 짧은 제목으로 줄이는 부분. $를 쓰지 않는 순수 함수만 둔다.
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}
42
types/index.d.ts 37 lines
1/**
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