SLOPSHOPPER

relaunch

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

bandcommandtoastprocesstimer
★ 1v0.3.0MITupdated 2026-10-21oobg/claude-code-mods/mods/relaunch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · relaunch
› 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 › /relaunch ⎿ relaunch: 세션 id를 알아보지 못했어요. 직접 /exit 한 뒤 처음 옵션을 붙여 `claude --resume preview-session`처럼 다시 열어 주세요. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
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.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> &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의 입력창과 도구 결과에 기능을 추가하는 모드 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처럼 두 자리 번호도 쓸 수 있습니다.
  • 프리셋도 입력창에서 편집합니다. 편집 화면에서 프리셋을 추가하거나 수정할 때는 입력창에 내용을 쓰고 Enter를 누르면 됩니다.
  • 상태 표시줄 대신 입력창 아래에 대시보드를 표시합니다. 컨텍스트 사용량, 5시간과 7일 한도, Fable 한도, 모델, 브랜치를 두 줄로 확인할 수 있습니다.
  • 파일을 경로 멘션으로 붙여넣습니다. Finder에서 복사한 파일을 붙여넣으면 파일 이름 대신 @경로 멘션이 입력됩니다.
  • 넓은 터미널에서는 diff를 좌우로 나눠 표시합니다. 원래 줄과 바뀐 줄이 같은 높이에 나란히 표시됩니다.
  • 업데이트 후 기존 대화를 이어서 엽니다. 새 버전이 설치되면 입력창 위에 알립니다. Orca 탭에서는 /relaunch로 다시 시작해 기존 대화를 이어서 엽니다.
  • 따로 설치할 프로그램이나 직접 고칠 설정 파일이 없습니다. 마켓플레이스에서 필요한 모드만 골라 설치합니다.

설치

요구 사항

  • Claude Code 2.1.296 이상이 필요합니다. 기존 모드는 2.1.296~2.1.303, relaunch는 2.1.303에서 테스트했습니다. 모드 기능은 아직 공개 초기 단계라 이후 버전에서는 동작이 달라질 수 있습니다.
  • key-assist는 macOS 전용입니다. Cmd+Z를 사용하려면 Command 키 입력을 Claude Code에 전달할 수 있는 터미널이 필요합니다. Orca, Ghostty, kitty, WezTerm처럼 Kitty 키보드 프로토콜을 지원하는 터미널이 해당합니다.
  • relaunch가 자동으로 다시 열려면 Orca 터미널 탭과 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 라이선스에 따라 배포됩니다.

Source 3 files
hooks/register.tsx 559 lines
1import { 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}
559
hooks/band.ts 148 lines
1// 입력창 위 띠(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}
148
types/index.d.ts 9 lines
1/** 지금 돌고 있는 버전보다 새 버전이 설치돼 있으면 두 버전, 아니면 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