SLOPSHOPPER

reply-annotate

No description.

newpanebandcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · reply-annotate
│ ┃ reply-annotate ✕ › fix the failing auth test and add an audit log call │ ┃ 注釈はまだない │ ┃ 選択して /an コメント で追加 ⏺ 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 │ │ › /an │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · reply-annotate
注釈はまだない 選択して /an コメント で追加
README

Dotfiles

chezmoi で管理している。

QuickStart

brew install chezmoi age mise

age 秘密鍵を ~/.config/chezmoi/key.txt へ配置する。

chmod 600 ~/.config/chezmoi/key.txt
git clone git@github.com:TakeshiOnishi/dotfiles.git ~/dotfiles
chezmoi init --source ~/dotfiles
chezmoi apply

init 時にマシン種別(personal / work)を尋ねられる。

言語のバージョンは、マシンごとに mise で入れる。 バージョンは ~/.config/mise/config.toml に書かれ、dotfiles では管理しない。

mise use -g node@22

git のユーザー名とメールアドレスは、~/.gitconfig.local に手で書く。

[user]
  name = <名前>
  email = <メールアドレス>

apply 前のバックアップ

chezmoi apply の直前に、上書き・削除されるファイルを退避する。 手で編集したファイルを、apply で誤って消す事故への備え。

  • 仕組み
  • .chezmoi.toml.tmpl の [hooks.apply.pre] から .chezmoi-backup.sh を呼ぶ
  • 設定は chezmoi init で作られるため、初めて使うマシンの最初の apply から効く
  • 退避先
  • ~/.local/state/chezmoi-backups/<日時>/<ホームからの相対パス>
  • 各世代の status.txt に、退避したファイルの chezmoi status の行を残す
  • 保持
  • 30 日を過ぎた世代を、次の apply のときに消す
  • 期限に関係なく、最新 5 世代は残す
  • 退避に失敗したときは、apply を止める

復元するときは、退避先から cp で戻す。

ls ~/.local/state/chezmoi-backups/
cp -p ~/.local/state/chezmoi-backups/<日時>/.zshrc ~/.zshrc

構成

パス配置先
dot_*ホーム直下
dot_config/~/.config/
dot_claude/~/.claude/
.chezmoi*配置しない。chezmoi 自身の設定

exact_ の付いたディレクトリは、ソースにないファイルを apply のときに消す。 ホーム側に直接ファイルを置かない(例: ~/.config/nvim/lua/plugins)。

管理しないもの

認証情報と、万が一露出して困るものは、このリポジトリに一切入れない。 暗号化していても入れない。

  • 対象の例
  • API トークン・アクセスキー・パスワード
  • 秘密鍵・証明書の秘密部分
  • 置き場所は ~/.zsh/rc/*_local.zsh にする
  • init.zsh が、各設定の最後に読み込む
  • .chezmoiignore に入れてあり、chezmoi の管理対象にならない
  • マシンごとに手で作る

暗号化の目的

暗号化は、個人の思考フローを公開しないためだけに使う。 秘密情報を守る手段としては使わない。 漏れても問題ない設計にする。

  • 対象は encrypted_* のファイル
  • マシン種別(personal / work)ごとのファイルは、接尾辞で分ける
  • *_personal.zsh:personal のマシンだけに置く
  • *_work.zsh:work のマシンだけに置く
  • *_local.zsh:そのマシンだけに置き、chezmoi で管理しない

日常の操作

<target> はホーム側の配置先パスを指す(例: ~/.zshrc)。 ソース側のパスを渡すと not managed になる。

chezmoi diff                   # 適用したら何が変わるかを見る
chezmoi apply                  # ソースの内容をホームへ反映する
chezmoi edit --apply <target>  # ソースを編集して即座に反映する
chezmoi merge <target>         # ホーム側の変更をソースへ取り込む

chezmoi re-add はテンプレートに効かない。 アプリが書き換えた設定を取り込むときは chezmoi merge を使う。

Source 2 files
hooks/register.tsx 326 lines
1// 選択範囲に注釈を貯めて、次の発言にまとめて添える
2//   /an <コメント> : 選択範囲とコメントを 1 件貯める(Claude には送らない)
3//   /an paste      : 貯めた注釈を入力欄へ入れて、貯めた分を空にする
4//   /an undo       : 最後の 1 件を消す
5//   /an clear      : 全件を消す
6//   /an            : ペインを開く
7//   選択なしで Enter : 貯めた注釈を発言に添えて送る
8// 選択中に herdr の ctrl+t → m を押すと、入力欄の上の帯にコメント欄を出す。m は目印の文字を送り、
9// 続けて ctrl+x tab で帯へ移る。Enter で 1 件貯める。プロンプトを送らないので、会話ログは最下部へ飛ばない
10import { atom, read, update } from 'claude-code'
11import type { EngineInterface, Register } from 'claude-code'
12
13import type { Annotation } from '../types'
14
15const PANE = 'reply-annotate'
16const COMMAND = 'an'
17// 入力欄が /an を書きかけているか
18const COMPOSING = new RegExp(`^/${COMMAND}(\\s|$)`)
19// herdr の m が送る目印の文字(私用領域)。入力欄には入れずに捨てる
20const MARK = '\uE000'
21// 選択の有無を見に行く間隔
22const POLL_MS = 300
23// ペインの幅。端末幅に対する割合と、読める下限の桁数
24const PANE_RATIO = 0.15
25const PANE_MIN_COLUMNS = 24
26// 端末幅が分からないとき(プラグインからの実行など)に使う幅
27const FALLBACK_COLUMNS = 80
28// 帯の右端にエンジンが取る桁数([-] の分)
29const BAND_MARK_COLUMNS = 5
30
31const annotations = atom({ plugin: 'reply-annotate', key: 'annotations' } as const, [])
32const selected = atom({ plugin: 'reply-annotate', key: 'selected' } as const, null)
33// 注釈に使った選択範囲。同じ選択で 2 件目を作らないよう覚えておく
34const used = atom({ plugin: 'reply-annotate', key: 'used' } as const, null)
35// 編集中の注釈の番号(0 始まり)
36const editing = atom({ plugin: 'reply-annotate', key: 'editing' } as const, null)
37// 入力欄が /an で始まっているか。選択中の帯は、このときだけ出す
38const composing = atom({ plugin: 'reply-annotate', key: 'composing' } as const, false)
39// 前回見た入力欄の文字。選択後に変わったら、コピー用の選択とみなして帯を消す
40const draft = atom({ plugin: 'reply-annotate', key: 'draft' } as const, '')
41// m で帯のコメント欄を出しているか。選択しただけでは出さない
42const writing = atom({ plugin: 'reply-annotate', key: 'writing' } as const, false)
43
44// 先頭に見出しを置き、「番号: 引用」の後に空行を挟んでコメントを書く。引用の 2 行目以降は字下げする
45const format = (list: readonly Annotation[]) =>
46  [
47    '引用して指摘・質問',
48    ...list.map((annotation, i) => {
49      const quote = annotation.quote.replace(/\n/g, '\n   ')
50      return `${i + 1}: ${quote}\n\n${annotation.comment}`
51    }),
52  ].join('\n\n')
53
54const firstLine = (text: string) => text.split('\n')[0] ?? ''
55
56const open = ($: EngineInterface, terminalColumns: number) =>
57  $.ui.open({
58    id: PANE,
59    title: 'reply-annotate',
60    columns: Math.max(PANE_MIN_COLUMNS, Math.round(terminalColumns * PANE_RATIO)),
61  })
62
63// ペインを閉じる。失敗しても、注釈の送信や消去は止めない
64const close = ($: EngineInterface) => $.ui.close({ id: PANE }).catch(() => undefined)
65
66// 注釈を空にして、ペインも閉じる
67const clear = async ($: EngineInterface) => {
68  await update($, annotations, () => [])
69  await update($, editing, () => null)
70  await close($)
71}
72
73// 選択範囲とコメントを 1 件貯める。選択がなければ toast で伝える
74const add = async ($: EngineInterface, comment: string, terminalColumns: number) => {
75  const quote = await read($, selected)
76  if (quote === null) {
77    $.ui.toast('選択範囲がない。注釈する箇所を選択してから実行する')
78    return
79  }
80  await update($, annotations, list => [...list, { quote, comment }])
81  await update($, used, () => quote)
82  await update($, selected, () => null)
83  await update($, writing, () => false)
84  await open($, terminalColumns)
85  const count = (await read($, annotations)).length
86  $.ui.toast(`注釈 ${count} 件目を追加した`)
87}
88
89// 1 件消す。0 件になったらペインを閉じる
90const remove = async ($: EngineInterface, index: number) => {
91  await update($, annotations, list => list.filter((_, i) => i !== index))
92  await update($, editing, () => null)
93  if ((await read($, annotations)).length === 0) {
94    await close($)
95  }
96}
97
98// コメントを入力欄に戻して、編集を始める
99const edit = async ($: EngineInterface, index: number) => {
100  const annotation = (await read($, annotations))[index]
101  if (annotation === undefined) {
102    return
103  }
104  await update($, editing, () => index)
105  await $.prompt.fill({ text: annotation.comment, mode: 'replace' })
106}
107
108export const register: Register = on => {
109  on('session.start', async ($, e, next) => {
110    await $.command.register({
111      name: COMMAND,
112      description: '選択範囲に注釈を貯める。paste で入力欄へ入れる、undo で最後の 1 件、clear で全件を消す',
113      argumentHint: '<コメント>|paste|undo|clear',
114    })
115
116    // 選択や入力欄が変わったら帯を描き直す。編集中に入力欄が空になったら、編集を取り消す
117    $.clock.every(POLL_MS, async () => {
118      const text = (await $.ui.selection())?.text.trim() || null
119      const fresh = text !== null && text !== (await read($, used)) ? text : null
120      if (fresh !== (await read($, selected))) {
121        await update($, selected, () => fresh)
122        await update($, writing, () => false)
123      }
124      const typed = (await $.prompt.read()).text
125      const isComposing = COMPOSING.test(typed)
126      if (isComposing !== (await read($, composing))) {
127        await update($, composing, () => isComposing)
128      }
129      // 選択したまま入力欄に打ち続けたら、注釈ではなくコピー用の選択だったとみなす。
130      // /an を打ちかけている途中(/ や /a)では消さない
131      if (typed !== (await read($, draft))) {
132        await update($, draft, () => typed)
133        const quote = await read($, selected)
134        if (quote !== null && !typed.startsWith('/')) {
135          await update($, used, () => quote)
136          await update($, selected, () => null)
137          await update($, writing, () => false)
138        }
139      }
140      if ((await read($, editing)) !== null && typed === '') {
141        await update($, editing, () => null)
142      }
143    })
144
145    return next(e)
146  })
147
148  // 結果は toast で伝え、command の出力行には何も出さない(Claude に読ませない)
149  on('command.run', { command: COMMAND }, async ($, e) => {
150    const args = e.args.trim()
151    switch (args) {
152      case '':
153        await open($, e.presentation?.columns ?? FALLBACK_COLUMNS)
154        return {}
155      case 'paste': {
156        const list = await read($, annotations)
157        if (list.length === 0) {
158          $.ui.toast('貯めた注釈がない')
159          return {}
160        }
161        await clear($)
162        await $.prompt.fill({ text: format(list), mode: 'insert' })
163        return {}
164      }
165      case 'undo':
166        await update($, annotations, list => list.slice(0, -1))
167        await update($, editing, () => null)
168        if ((await read($, annotations)).length === 0) {
169          await close($)
170        }
171        $.ui.toast('最後の注釈を消した')
172        return {}
173      case 'clear':
174        await clear($)
175        $.ui.toast('注釈を全件消した')
176        return {}
177    }
178
179    await add($, args, e.presentation?.columns ?? FALLBACK_COLUMNS)
180
181    return {}
182  })
183
184  on('prompt.submit', async ($, e, next) => {
185    // 自分の Enter だけを扱う。プラグインや通知からの発言は素通しする
186    if (e.origin.kind !== 'composer') {
187      return next(e)
188    }
189
190    const index = await read($, editing)
191    if (index !== null) {
192      const comment = e.text.trim()
193      await update($, annotations, list =>
194        list.map((annotation, i) => (i === index ? { ...annotation, comment } : annotation)),
195      )
196      await update($, editing, () => null)
197
198      return { drop: `注釈 ${index + 1} 件目を更新した` }
199    }
200
201    const list = await read($, annotations)
202    if (list.length === 0) {
203      return next(e)
204    }
205    await clear($)
206
207    return next({ ...e, text: `${e.text}\n\n${format(list)}` })
208  })
209
210  // m の目印を拾ったら、帯にコメント欄を出す。目印は入力欄に入れない
211  on('prompt.edit', async ($, e, next) => {
212    if (!e.inputText.includes(MARK)) {
213      return next(e)
214    }
215    if ((await read($, selected)) !== null) {
216      await update($, writing, () => true)
217    }
218    const inputText = e.inputText.split(MARK).join('')
219    if (inputText === '' && e.start === e.end) {
220      return { text: e.text, cursor: e.cursor }
221    }
222    return next({ ...e, inputText })
223  })
224
225  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
226    if (e.props.hasSurvey) {
227      return next(e)
228    }
229
230    const { Box, Input, Text } = $.ui.resolve(e)
231    const list = await read($, annotations)
232    const index = await read($, editing)
233
234    if (index !== null) {
235      return (
236        <Text color="yellow" wrap="truncate-end">
237          📝 {index + 1} 件目「{firstLine(list[index]?.quote ?? '')}」を編集中:Enter で更新 ・ 入力欄を空にすると取り消し
238        </Text>
239      )
240    }
241    const quote = await read($, selected)
242    if (quote !== null && (await read($, composing))) {
243      return (
244        <Text color="yellow" wrap="truncate-end">
245          📝 「{firstLine(quote)}」 → /an コメント で注釈に追加
246        </Text>
247      )
248    }
249    // 送信せずに貯めるコメント欄。m で出し、帯へ移ったらすぐ打てるよう autoFocus にする
250    if (quote !== null && (await read($, writing))) {
251      return (
252        <Box flexDirection="column">
253          <Text color="yellow" wrap="truncate-end">
254            📝 「{firstLine(quote)}」 にコメントを書き、Enter で注釈に追加
255          </Text>
256          <Input
257            key="comment"
258            label="注釈"
259            placeholder="コメント"
260            submitLabel="貯める"
261            autoFocus
262            onSubmit={async value => {
263              const comment = value.trim()
264              if (comment !== '') {
265                // 帯の幅に、エンジンが右端に取る 5 桁を足して端末幅とみなす
266                await add($, comment, e.props.bodyColumns + BAND_MARK_COLUMNS)
267              }
268            }}
269          />
270        </Box>
271      )
272    }
273    if (list.length > 0) {
274      return <Text dimColor>📝 注釈 {list.length} 件:次の発言に添えて送る</Text>
275    }
276
277    return next(e)
278  })
279
280  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
281    const { Box, Button, Text } = $.ui.resolve(e)
282    const list = await read($, annotations)
283
284    if (list.length === 0) {
285      return (
286        <Box flexDirection="column">
287          <Text dimColor>注釈はまだない</Text>
288          <Text dimColor>選択して /an コメント で追加</Text>
289        </Box>
290      )
291    }
292
293    return (
294      <Box flexDirection="column">
295        {list.map((annotation, i) => (
296          <Box key={`annotation-${i}`} flexDirection="column" marginBottom={1}>
297            <Text dimColor wrap="truncate-end">
298              {i + 1} &gt; {firstLine(annotation.quote)}
299            </Text>
300            <Text>  → {annotation.comment}</Text>
301            <Box>
302              <Text>  </Text>
303              <Button key={`edit-${i}`} label="編集" onPress={() => edit($, i)} />
304              <Text> </Text>
305              <Button key={`remove-${i}`} label="削除" onPress={() => remove($, i)} />
306            </Box>
307          </Box>
308        ))}
309        <Box>
310          <Button
311            key="send"
312            label="送信"
313            onPress={async () => {
314              const text = format(await read($, annotations))
315              await clear($)
316              await $.prompt.submit({ text })
317            }}
318          />
319          <Text> </Text>
320          <Button key="clear" label="全消去" onPress={() => clear($)} />
321        </Box>
322      </Box>
323    )
324  })
325}
326
types/index.d.ts 16 lines
1export type Annotation = { quote: string; comment: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'reply-annotate': {
6      annotations: Annotation[]
7      selected: string | null
8      used: string | null
9      editing: number | null
10      composing: boolean
11      draft: string
12      writing: boolean
13    }
14  }
15}
16