SLOPSHOPPER

next-prompts

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

newbandpromptmodel
★ 1v0.8.1MITupdated 2026-10-16oobg/claude-code-mods/mods/next-prompts
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · next-prompts
› 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 다음 제안 닫기 $1: OK ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
다음 제안 닫기 $1: OK
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 231 lines
1import { 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}
231
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/suggest.ts 58 lines
1// 작은 모델에게 보낼 요청과, 그 답에서 제안을 골라내는 부분. $를 쓰지 않는 순수 함수만 둔다.
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}
58
types/index.d.ts 16 lines
1/** 지금 띄워 둔 제안, 늘 전체 문구다. 비어 있으면 띠를 그리지 않는다. 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