Claude Code 새 버전이 설치되면 입력창 위에 알립니다. /relaunch로 다시 시작해 같은 대화를 이어 열고, 권한 모드도 바꿀 수 있습니다. 자동으로 다시 열기는 Orca 터미널 탭에서만 지원합니다.

<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.303" src="https://img.shields.io/badge/Claude%20Code-2.1.303-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="6 mods" src="https://img.shields.io/badge/mods-6-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의 입력창과 도구 결과에 기능을 추가하는 모드 6개를 모은 저장소입니다. 입력창 위에서는 다음 요청을 제안하고 자주 쓰는 문구 프리셋을 표시하며, 입력창 아래에서는 세션 상태를 보여 줍니다. Finder에서 복사한 파일을 붙여넣으면 파일 이름을 @경로 멘션으로 바꾸고, Cmd+Z로 입력 내용을 되돌릴 수 있습니다. 넓은 터미널에서는 Edit와 Write 결과의 diff를 좌우로 비교할 수 있습니다. 새 버전이 설치되면 알리고, Orca 탭에서는 기존 대화를 이어서 다시 엽니다. 필요한 모드만 골라 설치할 수 있습니다. relaunch의 자동 다시 열기에는 Orca가 필요합니다.
<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>턴이 끝나면 다음에 보낼 만한 요청을 최대 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> <tr> <td colspan="2" valign="top"> <a href="./docs/relaunch.md"><picture> <img alt="relaunch: 업데이트 알림의 다시 열기를 누르면 같은 탭에서 새 버전으로 같은 대화가 열린다" src="./docs/images/showcase/relaunch-light.gif" width="49%"> <img alt="relaunch: 입력창에 /relaunch를 치고 Enter를 누르면 같은 탭에서 새 버전으로 같은 대화가 열린다" src="./docs/images/showcase/relaunch-type-light.gif" width="49%"> <sub>새 버전이 설치되면 입력창 위에 알립니다. Orca 탭에서 <code>다시 열기</code>를 누르거나(왼쪽) <code>/relaunch</code>를 입력하면(오른쪽) Claude Code를 다시 시작하고 기존 대화를 이어서 엽니다</sub> </td> </tr> </table>
$2를 치고 스페이스를 누르면 2번 문구가 그 자리에 들어가고, 이어서 쓸 수 있게 뒤에 스페이스가 하나 붙습니다. 스페이스 없이 바로 Enter를 눌러도 문구로 바꿔 보냅니다. $12처럼 두 자리 번호도 쓸 수 있습니다.@경로 멘션이 입력됩니다./relaunch로 다시 시작해 기존 대화를 이어서 엽니다.orca 명령이 필요합니다. 업데이트 알림은 다른 터미널에서도 나타납니다.이 저장소가 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
/plugin install relaunch@oobg
설치한 모드는 Claude Code를 다시 시작하거나 /reload-plugins를 실행하면 적용됩니다. 설치 여부는 /plugin 화면에서 확인하거나 터미널에서 claude plugin list를 실행해 확인할 수 있습니다.
Claude Code를 시작하면 quick-replies의 기본 프리셋과 dashboard가 바로 표시됩니다. next-prompts의 제안은 첫 턴이 끝난 뒤에 나타납니다.
| 모드 | 기능 | 위치 |
|---|---|---|
| next-prompts | 턴이 끝나면 다음에 보낼 만한 요청을 최대 3개 제안합니다 | 입력창 위 |
| quick-replies | 자주 쓰는 문구를 전역 프리셋이나 저장소별 프리셋에 저장합니다 | 입력창 위 |
| dashboard | 모델, 컨텍스트, 사용 한도, 브랜치를 두 줄로 표시합니다 | 입력창 아래 |
| key-assist | 붙여넣은 파일 이름을 @경로로 바꾸고, Cmd+Z로 입력한 내용을 되돌립니다 | 입력창 |
| split-diff | 넓은 터미널에서 Edit와 Write 결과의 diff를 좌우로 나눠 표시합니다 | 도구 결과 |
| relaunch | 새 버전 설치를 알리고, Orca 탭에서 기존 대화를 이어서 다시 엽니다 | 입력창 위, /relaunch |
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>
입력창 맨 앞이나 공백 뒤에 $와 번호를 치고 스페이스를 누르면 그 자리에서 문구로 바뀝니다. 스페이스 없이 Enter를 누르면 입력창에는 보이지 않고 보내는 내용만 바뀝니다. 입력창에서 바뀐 내용은 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
/plugin uninstall relaunch@oobg
[!NOTE] key-assist를 삭제해도 키 설정에 추가된
cmd+z항목은 남습니다. 필요하지 않으면~/.claude/keybindings.json에서 직접 지웁니다.
모드별 자세한 해결 방법은 각 상세 문서의 "문제 해결" 절에서 확인할 수 있습니다.
각 모드의 상세 문서는 문서 사이트에서도 볼 수 있습니다.
| 문서 | 내용 |
|---|---|
| next-prompts | 제안을 넣고 보내고 닫는 방법, quick-replies와 함께 쓰기, 알아 둘 점 |
| quick-replies | 전역 프리셋과 로컬 프리셋, 불러오기, 스킬과 슬래시 명령, 접기, 편집 화면 |
| dashboard | 표시하는 항목, 모양 바꾸기, 색 바꾸기, 상태줄과 함께 쓸 때 |
| key-assist | 파일 붙여넣기, Cmd+Z로 되돌리기, 알아 둘 점 |
| split-diff | 좌우로 나뉘는 조건, 실제 화면, 알아 둘 점 |
| relaunch | 다시 열기 버튼과 /relaunch, 알아 둘 점 |
Copyright (c) 2026 oobg
이 프로젝트는 MIT 라이선스에 따라 배포됩니다.
hooks/register.tsx 559 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Installed } from '../types'
5import { withBandSection } from './band'
6
7// 띠 안의 순서. 업데이트 알림은 다음 제안(10)과 프리셋(20) 아래, 입력창 바로 위에 둔다.
8const ORDER = 30
9// 설치된 버전을 다시 보는 간격. 시작하고 조금 뒤에 한 번 보고, 그 뒤로는 이 간격마다 본다.
10const FIRST_CHECK_MS = 5_000
11const CHECK_EVERY_MS = 10 * 60_000
12// 다시 연 기록은 이 시간 안에 새 세션이 열렸을 때만 믿는다. 도우미가 기다리는 10분보다 조금 길다.
13const PENDING_TTL_MS = 11 * 60_000
14const PENDING_KEY = 'pending'
15
16const installed = atom({ plugin: 'relaunch', key: 'installed' } as const, null as Installed)
17
18// 다시 열기 직전에 남기는 기록. 대화 기록이 없어 새 세션으로 열면 id가 바뀌므로 Orca 탭 핸들로도 알아본다.
19type Pending = { sessionId: string; handle: string; from: string; at: number }
20
21// `2.1.303 (Claude Code)`나 `2.1.303-dev.2026…`에서 숫자 세 개를 뽑는다.
22export function parseVersion(text: string): string | undefined {
23 return /(\d+)\.(\d+)\.(\d+)/.exec(text)?.[0]
24}
25
26// a가 b보다 새 버전이면 true. 문자열로 비교하면 2.1.99가 2.1.100보다 크게 나오므로 숫자로 비교한다.
27export function isNewer(a: string, b: string): boolean {
28 const x = a.split('.').map(Number)
29 const y = b.split('.').map(Number)
30 for (let i = 0; i < 3; i++) {
31 if ((x[i] ?? 0) !== (y[i] ?? 0)) {
32 return (x[i] ?? 0) > (y[i] ?? 0)
33 }
34 }
35 return false
36}
37
38const basename = (path: string) => path.slice(path.lastIndexOf('/') + 1)
39
40// 로그인 셸은 comm이 `-/bin/zsh`처럼 `-`로 시작한다.
41export function isShell(comm: string): boolean {
42 return ['zsh', 'bash', 'fish', 'sh', 'dash', 'ksh', 'tcsh'].includes(basename(comm.trim().replace(/^-/, '')))
43}
44
45export type Claude = { pid: string; started: string; shell: string; shellStarted: string }
46
47// PROBE의 출력에서 claude 프로세스와 그 부모 셸을 읽는다. 하나라도 어긋나면 undefined다.
48// 줄마다 claude의 `pid ppid tty comm`, claude의 시작 시각, 셸의 `pid tty comm`, 셸의 시작 시각이다.
49export function parseProbe(stdout: string): Claude | undefined {
50 const [self, started, parent, shellStarted] = stdout.split('\n').map(line => line.trim())
51 const me = /^(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/.exec(self ?? '')
52 const sh = /^(\d+)\s+(\S+)\s+(.+)$/.exec(parent ?? '')
53 if (me === null || sh === null || !started || !shellStarted) {
54 return undefined
55 }
56 const [, pid, ppid, tty, comm] = me
57 const [, shell, shellTty, shellComm] = sh
58 // 이 모드를 돌리는 claude여야 하고, 터미널에 붙어 있어야 하고(tty가 `??`가 아님),
59 // 같은 터미널의 셸이 부모여야 다시 칠 곳이 있다.
60 if (basename(comm!) !== 'claude' || tty === '??' || ppid !== shell || shellTty !== tty || !isShell(shellComm!)) {
61 return undefined
62 }
63 const squash = (text: string) => text.replace(/\s+/g, ' ')
64 return { pid: pid!, started: squash(started), shell: shell!, shellStarted: squash(shellStarted) }
65}
66
67// `$.process.run`이 띄운 sh의 부모가 이 모드를 돌리는 claude다. 그 claude와 부모 셸을 한 번에 본다.
68const PROBE = 'ps -o pid=,ppid=,tty=,comm= -p $PPID; ps -o lstart= -p $PPID; s=$(ps -o ppid= -p $PPID); ps -o pid=,tty=,comm= -p $s; ps -o lstart= -p $s'
69
70// `orca terminal show --json`의 답에서 그 탭이 살아 있고 글자를 받을 수 있는지 읽는다.
71export function isWritable(stdout: string): boolean {
72 try {
73 const terminal = (JSON.parse(stdout) as { ok?: boolean; result?: { terminal?: { connected?: boolean; writable?: boolean } } })
74 return terminal.ok === true && terminal.result?.terminal?.connected === true && terminal.result.terminal.writable === true
75 } catch {
76 return false
77 }
78}
79
80// 세션 id는 셸에 그대로 쳐 넣으니 UUID 모양만 받는다.
81const isSessionId = (id: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
82
83// 옵션이 값을 받는 방식. `<값>`은 꼭, `[값]`은 있으면, `...`은 다음 옵션 전까지 여러 개를 받는다.
84export type Arity = 'none' | 'required' | 'optional' | 'variadic' | 'optional-variadic'
85
86// `claude --help`의 Options 절에서 옵션 이름마다 값을 받는 방식을 읽는다. `-c, --continue`처럼 별명도 같은 방식이다.
87export function parseHelp(help: string): Map<string, Arity> {
88 const spec = new Map<string, Arity>()
89 const options = help.split(/\n(?=Commands:)/)[0] ?? ''
90 for (const line of options.split('\n')) {
91 const match = /^ {2}(-{1,2}[\w-]+(?:, -{1,2}[\w-]+)*)(?: (<[^>]*>|\[[^\]]*\]))?(?: {2,}|$)/.exec(line)
92 if (match === null) {
93 continue
94 }
95 const value = match[2] ?? ''
96 const isMany = value.endsWith('...>') || value.endsWith('...]')
97 const arity: Arity =
98 value === '' ? 'none' : value.startsWith('<') ? (isMany ? 'variadic' : 'required') : isMany ? 'optional-variadic' : 'optional'
99 for (const name of match[1]!.split(', ')) {
100 spec.set(name, arity)
101 }
102 }
103 return spec
104}
105
106// 다시 열 때 빼는 옵션. 세션을 고르는 옵션(새 --resume과 부딪힌다), 다른 화면에 여는 옵션, 한 번 출력하고 끝나는 옵션이다.
107// --name은 실행 중 /rename으로 바꾼 이름을 처음 이름으로 덮을 수 있어 뺀다.
108const DROPPED = new Set([
109 '-r', '--resume', '-c', '--continue', '--session-id', '--fork-session', '--from-pr', '--teleport', '-n', '--name',
110 '--bg', '--background', '--cloud', '--desktop', '--environment', '-p', '--print', '-h', '--help', '-v', '--version',
111])
112// 있으면 다시 열지 않는 옵션. 빼고 열면 처음과 다른 작업 폴더(워크트리)에서 열린다.
113const REFUSED = new Set(['-w', '--worktree', '--tmux'])
114
115export type Picked = { kept: string[] } | { refused: string }
116
117// 처음 실행한 인자(argv[0] 빼고)에서 다시 붙일 인자를 고른다. 위치 인자(처음 보낸 프롬프트)와 DROPPED 옵션은 값까지 뺀다.
118// 확신할 수 없으면 고르지 않고 이유를 낸다: 도움말에 없는 옵션, 워크트리 옵션, 값이 있어도 되고 없어도 되는 옵션 뒤에
119// 낱말이 따로 와서 그 옵션의 값인지 처음 프롬프트인지 알 수 없을 때.
120export function pickArgs(args: readonly string[], spec: ReadonlyMap<string, Arity>): Picked {
121 const kept: string[] = []
122 let i = 0
123 while (i < args.length) {
124 const arg = args[i]!
125 i += 1
126 if (arg === '--') {
127 break
128 }
129 if (!arg.startsWith('-') || arg === '-') {
130 continue
131 }
132 const isJoined = arg.includes('=')
133 const name = isJoined ? arg.slice(0, arg.indexOf('=')) : arg
134 const arity = spec.get(name)
135 if (arity === undefined) {
136 return { refused: `도움말에 없는 옵션 \`${name}\`` }
137 }
138 if (REFUSED.has(name)) {
139 return { refused: `\`${name}\`` }
140 }
141 const isDropped = DROPPED.has(name)
142 const values: string[] = []
143 const isValue = (next: string | undefined) => next !== undefined && (!next.startsWith('-') || next === '-')
144 if (isJoined) {
145 // 값이 =로 붙어 있다.
146 } else if (arity === 'required' && i < args.length) {
147 values.push(args[i]!)
148 i += 1
149 } else if (arity === 'variadic') {
150 while (isValue(args[i])) {
151 values.push(args[i]!)
152 i += 1
153 }
154 } else if ((arity === 'optional' || arity === 'optional-variadic') && isValue(args[i])) {
155 // 빼는 옵션이면 뒤 낱말이 값이든 프롬프트든 함께 빼면 된다. 남기는 옵션이면 어느 쪽인지 알 수 없다.
156 if (!isDropped) {
157 return { refused: `\`${name}\` 뒤의 \`${args[i]}\`` }
158 }
159 while (isValue(args[i])) {
160 i += 1
161 if (arity === 'optional') {
162 break
163 }
164 }
165 }
166 if (isDropped) {
167 continue
168 }
169 kept.push(arg, ...values)
170 }
171 return { kept }
172}
173
174// 도움말을 읽지 못했을 때 쓰는 권한 모드. 2.1.303의 `--permission-mode` choices다.
175const PERMISSION_MODES = ['acceptEdits', 'auto', 'bypassPermissions', 'manual', 'dontAsk', 'plan']
176
177// `claude --help`에서 `--permission-mode`가 받는 모드를 읽는다. 설명이 여러 줄로 접혀 있어 다음 옵션 전까지 이어 읽는다.
178export function parsePermissionModes(help: string): string[] {
179 const lines = help.split('\n')
180 const at = lines.findIndex(line => line.startsWith(' --permission-mode '))
181 if (at === -1) {
182 return PERMISSION_MODES
183 }
184 const end = lines.findIndex((line, i) => i > at && !line.startsWith(' '))
185 const text = lines.slice(at, end === -1 ? undefined : end).join(' ')
186 const choices = /\(choices: ([^)]*)\)/.exec(text)?.[1]
187 const modes = choices === undefined ? [] : Array.from(choices.matchAll(/"([^"]+)"/g), match => match[1]!)
188 return modes.length > 0 ? modes : PERMISSION_MODES
189}
190
191// 모드를 고르는 옵션. 긴 옵션(`--plan`)과 짧은 옵션(`-p`)이 짝이다. `a`는 acceptEdits와 auto에, `d`는 dontAsk와 default에
192// 다 맞아 하나로 정해 둔다.
193const MODE_OPTIONS: { long: string; short: string; mode: string }[] = [
194 { long: 'plan', short: 'p', mode: 'plan' },
195 { long: 'auto', short: 'a', mode: 'auto' },
196 { long: 'bypass', short: 'b', mode: 'bypassPermissions' },
197 { long: 'edit', short: 'e', mode: 'acceptEdits' },
198 { long: 'dontask', short: 'd', mode: 'dontAsk' },
199 { long: 'default', short: 'm', mode: 'manual' },
200]
201// 긴 옵션의 다른 이름. 앞부분을 찾을 때 함께 본다.
202const MODE_ALIASES: Record<string, string> = {
203 skip: 'bypassPermissions',
204 yolo: 'bypassPermissions',
205 accept: 'acceptEdits',
206 manual: 'manual',
207}
208const NOW = new Set(['now', '--now', '-n'])
209
210export type RelaunchArgs = { isForced: boolean; mode?: string } | { error: string }
211
212// `/relaunch [--모드 | -글자] [now]`의 인자를 읽는다. 순서는 상관없고 대소문자를 가리지 않는다.
213// `now`는 `--now`, `-n`으로도 받는다. 짧은 옵션은 한 글자만 받는다. 긴 옵션은 옵션 이름, 다른 이름, 엔진의 모드 이름에서
214// 같은 이름을, 없으면 앞부분이 맞는 이름을 찾는다. 한 모드로 정해지지 않으면 고르지 않는다.
215export function parseRelaunchArgs(args: string, modes: readonly string[]): RelaunchArgs {
216 const words = args.split(/\s+/).filter(word => word !== '')
217 const isForced = words.some(word => NOW.has(word.toLowerCase()))
218 const rest = words.filter(word => !NOW.has(word.toLowerCase()))
219 const options = MODE_OPTIONS.filter(option => modes.includes(option.mode))
220 const others = modes.filter(mode => !options.some(option => option.mode === mode))
221 const list = `고를 수 있는 모드: ${[...options.map(option => `\`--${option.long}\`(\`-${option.short}\`)`), ...others.map(mode => `\`--${mode.toLowerCase()}\``)].join(', ')}`
222 if (rest.length === 0) {
223 return { isForced }
224 }
225 if (rest.length > 1) {
226 return { error: `권한 모드는 하나만 고를 수 있어요(${rest.map(word => `\`${word}\``).join(' ')}). ${list}` }
227 }
228 const word = rest[0]!.toLowerCase()
229 let found: string[] = []
230 if (word.startsWith('--')) {
231 const name = word.slice(2)
232 const names: [string, string][] = [
233 ...options.map(option => [option.long, option.mode] as [string, string]),
234 ...Object.entries(MODE_ALIASES).filter(([, mode]) => modes.includes(mode)),
235 ...modes.map(mode => [mode.toLowerCase(), mode] as [string, string]),
236 ]
237 const exact = names.find(([long]) => long === name)?.[1]
238 found = exact !== undefined ? [exact] : name === '' ? [] : [...new Set(names.filter(([long]) => long.startsWith(name)).map(([, mode]) => mode))]
239 } else if (/^-[a-z]$/.test(word)) {
240 found = options.filter(option => option.short === word[1]).map(option => option.mode)
241 } else if (!word.startsWith('-')) {
242 return { error: `\`--${word}\`처럼 대시를 붙여 주세요. ${list}` }
243 }
244 if (found.length !== 1) {
245 return { error: `${found.length === 0 ? '알 수 없는' : '여러 모드에 맞는'} 권한 모드예요(\`${rest[0]}\`). ${list}` }
246 }
247 return { isForced, mode: found[0] }
248}
249
250// 처음 권한 옵션을 빼고 고른 권한 모드를 붙인다. `--allow-dangerously-skip-permissions`는 모드를 고르지 않으니 남긴다.
251export function withPermissionMode(kept: readonly string[], mode: string): string[] {
252 const args: string[] = []
253 for (let i = 0; i < kept.length; i++) {
254 const arg = kept[i]!
255 if (arg === '--permission-mode') {
256 i += 1
257 } else if (!arg.startsWith('--permission-mode=') && arg !== '--dangerously-skip-permissions') {
258 args.push(arg)
259 }
260 }
261 return [...args, '--permission-mode', mode]
262}
263
264// 셸에 그대로 칠 수 있게 감싼다. 안전한 글자만 있으면 그대로 둔다.
265export function shellQuote(arg: string): string {
266 return /^[A-Za-z0-9_@%+=:,.\/-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`
267}
268
269// 다시 열 인자. 처음 실행한 이름이 `claude`이거나 버전 폴더 안의 실행 파일이면, 이 claude의 PATH에서 찾은 `claude`(installed)로 연다.
270// 셸의 PATH가 달라도 업데이트를 확인하고 도움말을 읽은 바로 그 실행 파일이 열린다.
271export function resumeArgv(argv0: string, kept: readonly string[], sessionId: string | undefined, installed = 'claude'): string[] {
272 const isDefault = argv0 === '' || argv0 === 'claude' || argv0.includes('/versions/')
273 const resume = sessionId === undefined ? [] : ['--resume', sessionId]
274 return [isDefault ? installed : argv0, ...kept, ...resume]
275}
276
277// 다시 여는 스크립트. 옵션 값(비밀이 든 프롬프트일 수도 있다)을 셸에 직접 치면 화면과 셸 기록에 남으므로,
278// 본인만 읽을 수 있는 파일에 쓰고 셸에는 `/bin/sh <파일>`만 친다. 실행되면 먼저 자기 자신을 지우고, 지우지 못하면 실행하지 않는다.
279// 셸이 파일을 읽으니 줄바꿈이 든 값도 터미널을 거치지 않고, 별칭도 거치지 않는다(커널에서 읽은 인자에는 별칭이 붙인 옵션이 이미 들어 있다).
280export function launchScript(argv: readonly string[]): string {
281 return `#!/bin/sh\n/bin/rm -f -- "$0" || exit 1\nexec ${argv.map(shellQuote).join(' ')}\n`
282}
283
284// `hooks/argv.pl`이 낸 16진수 줄을 인자로 되돌린다. UTF-8로 읽을 수 없는 인자가 있으면 undefined다(깨진 글자로 바꿔 다시 붙이지 않는다).
285export function decodeArgs(stdout: string): string[] | undefined {
286 const decoder = new TextDecoder()
287 const encoder = new TextEncoder()
288 const args: string[] = []
289 for (const line of stdout.split('\n').slice(0, -1)) {
290 const hex = (line.match(/../g) ?? []).join('')
291 const text = decoder.decode(new Uint8Array((line.match(/../g) ?? []).map(byte => parseInt(byte, 16))))
292 // 다시 바이트로 바꿔 원본과 같아야 제대로 읽은 것이다.
293 const back = Array.from(encoder.encode(text), byte => byte.toString(16).padStart(2, '0')).join('')
294 if (back !== hex.toLowerCase()) {
295 return undefined
296 }
297 args.push(text)
298 }
299 return args
300}
301
302
303// 다시 여는 스크립트 이름의 앞부분. 실행되지 않고 남은 스크립트는 세션이 시작할 때 한 시간이 지난 것부터 지운다.
304const SCRIPT_PREFIX = 'claude-relaunch-script.'
305
306let isRelaunching = false
307
308// 지금 대화를 끝내고 같은 Orca 탭에서 다시 연다. 안전하게 할 수 없으면 아무것도 끝내지 않고 이유와 수동 명령을 낸다.
309// `args`는 `/relaunch` 뒤에 친 글이다. 권한 모드를 고르면 처음 권한 옵션 대신 그 모드로 연다.
310async function relaunch($: EngineInterface, args: string): Promise<string> {
311 if (isRelaunching) {
312 return '이미 다시 여는 중이에요.'
313 }
314 // 업데이트 확인과 같은 `claude`를 절대 경로로 찾아, 그 도움말을 읽고 그 경로로 다시 연다.
315 const which = await $.process.run(['/bin/sh', '-c', 'command -v claude'], { timeoutMs: 5_000 }).catch(() => undefined)
316 const installed = which?.exitCode === 0 && which.stdout.trim().startsWith('/') ? which.stdout.trim() : undefined
317 const help = installed === undefined ? undefined : await $.process.run([installed, '--help'], { timeoutMs: 10_000 }).catch(() => undefined)
318 const helpText = help?.exitCode === 0 ? help.stdout : ''
319 const parsed = parseRelaunchArgs(args, parsePermissionModes(helpText))
320 if ('error' in parsed) {
321 return parsed.error
322 }
323 const { isForced, mode } = parsed
324 // 에이전트 확인에서 다시 칠 명령에 사용자가 친 모드 옵션을 그대로 넣는다.
325 const modeArg = mode === undefined ? undefined : args.split(/\s+/).find(word => word.startsWith('-') && !NOW.has(word.toLowerCase()))
326
327 const sessionId = await $.session.id()
328 // 대화 기록이 없는 세션은 --resume으로 돌아올 수 없어 새로 연다.
329 const hasTranscript = (await $.session.messages()).length > 0
330 const resumeId = hasTranscript ? sessionId : undefined
331
332 // 처음 실행할 때 붙인 옵션을 그대로 다시 붙인다. 이 claude와 그 셸을 확인하고, 실행 인자를 읽어 옵션만 고른다.
333 const probe = await $.process.run(['/bin/sh', '-c', PROBE], { timeoutMs: 5_000 }).catch(() => undefined)
334 const claude = probe?.exitCode === 0 ? parseProbe(probe.stdout) : undefined
335 const argv =
336 claude === undefined
337 ? undefined
338 : await $.process
339 .run(['perl', `${$.plugin.root}/hooks/argv.pl`, claude.pid], { timeoutMs: 5_000 })
340 .then(run => (run.exitCode === 0 ? decodeArgs(run.stdout) : undefined))
341 .catch(() => undefined)
342 const spec = parseHelp(helpText)
343 // 도움말을 거의 못 읽었으면 어느 인자가 옵션 값인지 알 수 없다.
344 const isReadable = argv !== undefined && argv.length > 0 && spec.size >= 10
345 const picked = isReadable ? pickArgs(argv.slice(1), spec) : undefined
346 const kept = picked !== undefined && 'kept' in picked ? (mode === undefined ? picked.kept : withPermissionMode(picked.kept, mode)) : undefined
347
348 // 옵션을 고를 수 있으면 다시 여는 스크립트를 미리 써 둔다. 자동으로 열지 못해도 사람이 그 스크립트로 열 수 있다.
349 const tmp = ((await $.env.get('TMPDIR')) ?? '/tmp').replace(/\/$/, '')
350 let script: string | undefined
351 if (kept !== undefined && isSessionId(sessionId)) {
352 // mktemp로 본인만 읽을 수 있는 새 파일을 배타적으로 만든다. 있던 파일을 지우고 같은 이름으로 다시 만들지 않는다.
353 // 내용을 다 쓰지 못하면 만든 파일을 지운다.
354 const written = await $.process
355 .run(['/bin/sh', '-c', `f=$(mktemp "$1/${SCRIPT_PREFIX}XXXXXXXX") || exit 1; cat > "$f" && printf %s "$f" || { rm -f -- "$f"; exit 1; }`, 'sh', tmp], {
356 stdin: launchScript(resumeArgv(argv![0]!, kept, resumeId, installed)),
357 timeoutMs: 5_000,
358 })
359 .catch(() => undefined)
360 script = written?.exitCode === 0 && written.stdout.startsWith(`${tmp}/${SCRIPT_PREFIX}`) ? written.stdout : undefined
361 }
362 const command = script === undefined ? undefined : `/bin/sh ${shellQuote(script)}`
363 const bare = ['claude', ...(mode === undefined ? [] : ['--permission-mode', mode]), ...(resumeId === undefined ? [] : ['--resume', resumeId])].join(' ')
364 const manual =
365 command === undefined
366 ? `직접 /exit 한 뒤 처음 옵션을 붙여 \`${bare}\`처럼 다시 열어 주세요.`
367 : `직접 /exit 한 뒤 \`${command}\`로 다시 열어 주세요. ${mode === undefined ? '처음 옵션을 그대로 붙여' : `처음 권한 옵션 대신 \`${mode}\` 권한 모드로`} 엽니다. 이 파일은 한 시간이 지난 뒤 Claude Code 세션이 시작될 때 지워집니다.`
368
369 if (!isSessionId(sessionId)) {
370 return `세션 id를 알아보지 못했어요. ${manual}`
371 }
372 const handle = await $.env.get('ORCA_TERMINAL_HANDLE')
373 if (!handle) {
374 return `Orca 터미널 탭에서만 자동으로 다시 열 수 있어요. ${manual}`
375 }
376 const orca = await $.process.run(['orca', '--version'], { timeoutMs: 5_000 }).catch(() => undefined)
377 if (orca?.exitCode !== 0) {
378 return `orca 명령을 찾지 못했어요. ${manual}`
379 }
380 // Orca를 다시 시작하면 핸들이 바뀐다. 이 탭에 글자를 넣을 수 있는지 끝내기 전에 확인한다.
381 const tab = await $.process
382 .run(['orca', 'terminal', 'show', '--terminal', handle, '--json'], { timeoutMs: 5_000 })
383 .catch(() => undefined)
384 if (!isWritable(tab?.exitCode === 0 ? tab.stdout : '')) {
385 return `Orca가 이 탭을 찾지 못했어요. ${manual}`
386 }
387
388 // 서브에이전트나 팀원이 일하는 중이면 끊기니 먼저 묻는다. 백그라운드 셸 작업은 모드가 볼 수 없다.
389 const busy = (await $.agent.list()).filter(agent => ['pending', 'running', 'waiting'].includes(agent.status))
390 if (busy.length > 0 && !isForced) {
391 return `에이전트 ${busy.length}개가 작업 중이에요. 끝난 뒤 다시 하거나, 끊어도 되면 \`${['/relaunch', modeArg, 'now'].filter(Boolean).join(' ')}\`로 바로 다시 열어요.`
392 }
393
394 if (claude === undefined) {
395 return `이 claude를 띄운 셸을 확인하지 못했어요. ${manual}`
396 }
397 if (picked === undefined) {
398 return `처음 실행할 때 붙인 옵션을 읽지 못해 끝내지 않았어요. ${manual}`
399 }
400 if ('refused' in picked) {
401 return `처음 옵션을 그대로 다시 붙일 수 없어 끝내지 않았어요(${picked.refused}). ${manual}`
402 }
403 if (command === undefined) {
404 return `다시 여는 스크립트를 쓰지 못해 끝내지 않았어요. ${manual}`
405 }
406
407 const helper = await $.process
408 .run([
409 '/bin/sh',
410 '-c',
411 'nohup /bin/sh "$0" "$@" >/dev/null 2>&1 &',
412 `${$.plugin.root}/hooks/relaunch.sh`,
413 claude.pid,
414 claude.started,
415 claude.shell,
416 claude.shellStarted,
417 handle,
418 `${tmp}/claude-relaunch.${sessionId}`,
419 command,
420 script!,
421 ])
422 .catch(() => undefined)
423 if (helper?.exitCode !== 0) {
424 return `다시 여는 도우미를 띄우지 못했어요. ${manual}`
425 }
426
427 isRelaunching = true
428 const from = (await $.session.version()).version
429 await $.store.set(PENDING_KEY, { sessionId, handle, from, at: await $.clock.now() } satisfies Pending)
430 // 명령 훅 안에서 바로 끝내면 엔진이 거절한다(이 훅이 잡고 있는 턴을 기다리게 된다). 훅이 끝난 뒤에 끝낸다.
431 // 이 함수는 /relaunch로만 불려 세션이 쉬고 있을 때 돈다. 그 틈에 새 턴이 시작돼 exit이 밀려도 도우미는 기다린다.
432 $.clock.after(500, () => {
433 $.command.run({ command: 'exit' }).catch(() => {
434 // 엔진이 끝내기를 거절했으면 도우미는 10분 뒤 혼자 끝난다. 다시 시도할 수 있게 풀어 둔다.
435 isRelaunching = false
436 $.ui.toast('Claude Code를 끝내지 못했어요. 다시 시도하거나 직접 /exit 해 주세요')
437 })
438 })
439 if (mode !== undefined) {
440 return `Claude Code를 \`${mode}\` 권한 모드로 다시 열어요. 다시 열리지 않으면 \`${command}\`로 열어 주세요.`
441 }
442 // 처음 권한 옵션을 다시 걸면, 실행 중 낮춘 권한이 처음 값으로 돌아갈 수 있어 알린다.
443 const permission = picked.kept.filter(arg => /^--(permission-mode|dangerously-skip-permissions|allow-dangerously-skip-permissions)\b/.test(arg))
444 const note = permission.length > 0 ? ` 처음 권한 옵션(${permission.map(arg => `\`${arg.split('=')[0]}\``).join(', ')})도 다시 붙어요.` : ''
445 return `Claude Code를 다시 열어요.${note} 다시 열리지 않으면 \`${command}\`로 열어 주세요.`
446}
447
448let isChecking = false
449
450// 설치된 claude가 지금 돌고 있는 것보다 새 버전이면 띠에 알린다. 실패는 조용히 넘기고 다음 차례에 다시 본다.
451async function checkInstalled($: EngineInterface): Promise<void> {
452 if (isChecking) {
453 return
454 }
455 isChecking = true
456 try {
457 const running = parseVersion((await $.session.version()).version)
458 const { exitCode, stdout } = await $.process.run(['claude', '--version'], { timeoutMs: 5_000 })
459 const found = exitCode === 0 ? parseVersion(stdout) : undefined
460 if (running !== undefined && found !== undefined) {
461 const next: Installed = isNewer(found, running) ? { from: running, to: found } : null
462 await update($, installed, () => next)
463 }
464 } catch {
465 // 다음 차례에 다시 본다.
466 } finally {
467 isChecking = false
468 }
469}
470
471// 방금 다시 연 세션이면 어느 버전으로 열었는지 알린다. 같은 세션 id이거나, 새로 열었으면 같은 Orca 탭이어야 한다.
472async function reportRelaunched($: EngineInterface): Promise<void> {
473 const pending = (await $.store.get(PENDING_KEY)) as Pending | undefined
474 if (pending === undefined || pending === null) {
475 return
476 }
477 const isFresh = (await $.clock.now()) - pending.at < PENDING_TTL_MS
478 const isSame = pending.sessionId === (await $.session.id()) || pending.handle === (await $.env.get('ORCA_TERMINAL_HANDLE'))
479 if (isFresh && !isSame) {
480 // 다른 탭의 세션이다. 그 탭의 claude가 아직 열리는 중일 수 있어 기록을 남겨 둔다.
481 return
482 }
483 await $.store.delete(PENDING_KEY)
484 if (isFresh) {
485 const now = (await $.session.version()).version
486 $.ui.toast(
487 parseVersion(now) === parseVersion(pending.from)
488 ? `다시 열었지만 버전은 그대로예요 (${now})`
489 : `${pending.from} → ${now}로 다시 열었어요`,
490 )
491 }
492}
493
494export const register: Register = on => {
495 on('session.start', async ($, e, next) => {
496 const result = await next(e)
497 isRelaunching = false
498 await $.command.register({
499 name: 'relaunch',
500 description: '지금 대화를 새로 설치된 Claude Code로 다시 열기, 권한 모드를 바꿔 열 수도 있음 (Orca 탭)',
501 argumentHint: '[-p|-a|-b|-e|-d|-m] [now]',
502 })
503 void reportRelaunched($).catch(() => {})
504 void $.env
505 .get('TMPDIR')
506 .then(dir =>
507 $.process.run(
508 ['/usr/bin/find', (dir ?? '/tmp').replace(/\/$/, ''), '-maxdepth', '1', '-name', `${SCRIPT_PREFIX}*`, '-type', 'f', '-mmin', '+60', '-delete'],
509 { timeoutMs: 5_000 },
510 ),
511 )
512 .catch(() => {})
513 $.clock.after(FIRST_CHECK_MS, () => void checkInstalled($))
514 $.clock.every(CHECK_EVERY_MS, () => void checkInstalled($))
515 return result
516 })
517
518 on('command.run', { command: 'relaunch' }, async ($, e) => ({ text: await relaunch($, e.args) }))
519
520 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
521 if (e.props.hasSurvey || (e.surface !== 'terminal' && e.surface !== 'desktop')) {
522 return next(e)
523 }
524 const below = await next(e)
525 const shown = await read($, installed)
526 if (shown === null) {
527 return withBandSection(below, 'relaunch', ORDER, null)
528 }
529 const { Box, Button, Text } = $.ui.resolve(e)
530 const { isWorking } = e.props
531 return withBandSection(
532 below,
533 'relaunch',
534 ORDER,
535 // 오른쪽에 붙여, 번호가 붙은 제안·프리셋 줄과 구분되게 한다.
536 <Box key="relaunch" flexDirection="row" justifyContent="flex-end" columnGap={1} width="100%">
537 <Text dimColor>{`Claude Code 업데이트됨 ${shown.from} → ${shown.to}`}</Text>
538 <Button
539 key="relaunch:go"
540 label="다시 열기"
541 plain
542 hover={{ inverse: true }}
543 // /relaunch와 같은 확인을 거친다. 엔진은 모드가 $.command.run으로 부른 명령을 그 모드의 훅에 보내지 않아 직접 부른다.
544 // 턴이 도는 중에 끝내면 exit이 턴 뒤로 밀리므로, 쉬고 있을 때만 받는다.
545 onPress={async () =>
546 $.ui.toast(
547 isWorking
548 ? '지금 작업이 끝난 뒤 다시 눌러 주세요'
549 : await relaunch($, ''),
550 )
551 }
552 >
553 <Text color="suggestion">다시 열기</Text>
554 </Button>
555 </Box>,
556 )
557 })
558}
559hooks/band.ts 148 lines1// 입력창 위 띠(AbovePrompt)를 여러 모드가 나눠 쓰는 약속.
2// 이 파일은 quick-replies, next-prompts, relaunch에 똑같이 들어 있다. 고칠 때는 셋 다 고친다.
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// `$2-$4 `처럼 범위를 치면 그 번호들의 문구를 한 줄씩 넣는다(`typedRange`, `expandRanges`).
14// `$e`, `$0`처럼 `$` 뒤의 글자 하나는 모드가 정한 동작을 부른다(`typedAfterDollar`).
15//
16// 한 모드가 띠를 혼자 써야 할 때(편집 화면 등)는 `exclusiveBand`로 만든 틀을 돌려준다.
17// 다른 모드는 그 틀을 보면 자기 줄을 넣지 않고 그대로 넘긴다.
18import type { PromptEditInput, RenderElement, RenderNode } from 'claude-code'
19
20const BAND_KEY = 'oobg-band'
21const EXCLUSIVE_KEY = 'oobg-band-exclusive'
22const SECTION_PREFIX = `${BAND_KEY}:`
23
24function isElement(node: RenderNode | undefined | null): node is RenderElement {
25 return typeof node === 'object' && node !== null && 'type' in node
26}
27
28function keyOf(node: RenderNode): string | undefined {
29 if (!isElement(node) || node.type !== 'Box') {
30 return undefined
31 }
32 const key = node.props?.key
33 return typeof key === 'string' ? key : undefined
34}
35
36function orderOf(node: RenderNode): number {
37 return Number(keyOf(node)?.split(':')[1] ?? 0)
38}
39
40// 띠에 보이는 번호 표시.
41export const numberLabel = (n: number) => `$${n}:`
42
43// 방금 친 글자 하나가 `$` 바로 뒤이고 그 `$`가 글 처음이나 공백 뒤에 있으면 그 글자를 낸다.
44export function typedAfterDollar(e: PromptEditInput): string | undefined {
45 if (e.key === undefined || !/^[0-9a-z]$/.test(e.inputText) || e.start !== e.end || e.text[e.start - 1] !== '$') {
46 return undefined
47 }
48 const before = e.text[e.start - 2]
49 return before === undefined || /\s/.test(before) ? e.inputText : undefined
50}
51
52// `$12`처럼 번호를 친 뒤 스페이스를 치면 그 번호와 `$`의 자리를 낸다. 스페이스로 끝을 알려야 두 자리 번호도 고를 수 있다.
53// `$`는 글 처음이나 공백 뒤에 있어야 한다. 바꿀 때는 `next({ ...e, start: typed.start, inputText: 문구 + ' ' })`로 `$`부터 친 스페이스까지 덮고, 이어 쓰기 좋게 문구 뒤에 스페이스 하나를 붙인다.
54export function typedNumber(e: PromptEditInput): { n: number; start: number } | undefined {
55 if (e.key === undefined || e.inputText !== ' ' || e.start !== e.end) {
56 return undefined
57 }
58 const match = /(?:^|\s)\$([1-9][0-9]*)$/.exec(e.text.slice(0, e.start))
59 return match === null ? undefined : { n: Number(match[1]), start: e.start - match[1]!.length - 1 }
60}
61
62// 스페이스 없이 보낸 글에 남은 `$12` 같은 번호를 문구로 바꾼다. `$`는 글 처음이나 공백 뒤, 번호 뒤는 공백이나 글 끝이어야 한다.
63// `lookup`이 문구를 주지 않는 번호(다른 모드의 번호, 없는 번호)는 그대로 둔다.
64export function expandNumbers(text: string, lookup: (n: number) => string | undefined): string {
65 return text.replace(/(^|\s)\$([1-9][0-9]*)(?=\s|$)/g, (whole, before: string, digits: string) => {
66 const phrase = lookup(Number(digits))
67 return phrase === undefined ? whole : before + phrase
68 })
69}
70
71// `$2-$4`처럼 범위를 쳐서 여러 문구를 한 번에 넣는다. `$4-$2`처럼 거꾸로 치면 거꾸로 넣는다.
72// 문구마다 줄을 바꾸고, 글 맨 앞이 아니면 앞 글과 빈 줄 하나를 둔다. 앞 글 끝의 공백은 지운다.
73// 범위의 번호를 모두 `lookup`으로 찾을 수 있을 때만 바꾼다. 다음 제안과 프리셋이 섞인 범위는 둘 다 아는 quick-replies가 바꾼다.
74export type Range = { from: number; to: number; start: number }
75
76// 범위를 넣을 글. `start`는 범위가 덮는 자리의 처음이다. 찾을 수 없는 번호가 있으면 undefined.
77export function rangeText(range: Range, lookup: (n: number) => string | undefined): string | undefined {
78 const step = range.from <= range.to ? 1 : -1
79 let text = range.start === 0 ? '' : '\n\n'
80 for (let n = range.from; n !== range.to + step; n += step) {
81 const phrase = lookup(n)
82 if (phrase === undefined) {
83 return undefined
84 }
85 text += `${phrase}\n`
86 }
87 return text
88}
89
90// `$2-$4` 뒤에 스페이스를 치면 그 범위와 덮을 자리를 낸다. 자리는 앞 글 끝의 공백부터다.
91// 바꿀 때는 `next({ ...e, start: range.start, inputText: rangeText(range, lookup) })`로 친 스페이스까지 덮는다.
92export function typedRange(e: PromptEditInput): Range | undefined {
93 if (e.key === undefined || e.inputText !== ' ' || e.start !== e.end) {
94 return undefined
95 }
96 const match = /(?:^|\s)\$([1-9][0-9]*)-\$([1-9][0-9]*)$/.exec(e.text.slice(0, e.start))
97 if (match === null) {
98 return undefined
99 }
100 const start = e.text.slice(0, e.start - match[0].trimStart().length).trimEnd().length
101 return { from: Number(match[1]), to: Number(match[2]), start }
102}
103
104// 스페이스 없이 보낸 글에 남은 `$2-$4` 같은 범위를 문구들로 바꾼다. 바꾸는 규칙은 `typedRange`와 같다.
105export function expandRanges(text: string, lookup: (n: number) => string | undefined): string {
106 return text.replace(/(^|\s+)\$([1-9][0-9]*)-\$([1-9][0-9]*)(?=\s|$)/g, (whole, _before, from: string, to: string, start: number) => {
107 return rangeText({ from: Number(from), to: Number(to), start }, lookup) ?? whole
108 })
109}
110
111// 띠를 혼자 쓰는 틀. 다른 모드는 이 틀에 줄을 넣지 않는다.
112export function exclusiveBand(section: RenderNode): RenderElement {
113 return { type: 'Box', props: { key: EXCLUSIVE_KEY, flexDirection: 'column', marginTop: 1 }, children: [section] }
114}
115
116// `below`(next(e)가 돌려준 트리)에 이 모드의 줄을 넣은 띠 틀을 돌려준다.
117// `section`이 null이면 이 모드의 줄을 뺀다. 남은 줄이 하나도 없으면 `below`를 그대로 돌려준다.
118export function withBandSection(
119 below: RenderElement,
120 name: string,
121 order: number,
122 section: RenderNode | null,
123): RenderElement {
124 if (below.type === 'Box' && below.props?.key === EXCLUSIVE_KEY) {
125 return below
126 }
127 const ownKey = `${SECTION_PREFIX}${order}:${name}`
128 const isBand = below.type === 'Box' && below.props?.key === BAND_KEY
129 const others = isBand
130 ? (below.children ?? []).filter(child => {
131 const key = keyOf(child)
132 return key !== undefined && key.startsWith(SECTION_PREFIX) && key !== ownKey
133 })
134 : []
135 const mine: RenderNode[] =
136 section === null
137 ? []
138 : // 줄마다 위에 한 칸을 띄워 제안과 프리셋이 구분되어 읽히게 한다.
139 [{ type: 'Box', props: { key: ownKey, flexDirection: 'column', marginTop: 1 }, children: [section] }]
140 const sections = [...others, ...mine].sort((a, b) => orderOf(a) - orderOf(b))
141
142 if (sections.length === 0) {
143 return isBand ? { type: 'Box', props: { key: BAND_KEY }, children: [] } : below
144 }
145 // 띠와 입력창 사이의 빈 줄은 Claude Code가 늘 두는 간격이다. 음수 여백으로 당기면 띠가 아예 그려지지 않아 그대로 둔다.
146 return { type: 'Box', props: { key: BAND_KEY, flexDirection: 'column' }, children: sections }
147}
148types/index.d.ts 9 lines1/** 지금 돌고 있는 버전보다 새 버전이 설치돼 있으면 두 버전, 아니면 null. null이 아니면 띠에 알린다. */
2export type Installed = { readonly from: string; readonly to: string } | null
3
4declare module 'claude-code' {
5 interface PluginState {
6 relaunch: { installed: Installed }
7 }
8}
9