No description.

chezmoi で管理している。
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 = <メールアドレス>
chezmoi apply の直前に、上書き・削除されるファイルを退避する。 手で編集したファイルを、apply で誤って消す事故への備え。
.chezmoi.toml.tmpl の [hooks.apply.pre] から .chezmoi-backup.sh を呼ぶchezmoi init で作られるため、初めて使うマシンの最初の apply から効く~/.local/state/chezmoi-backups/<日時>/<ホームからの相対パス>status.txt に、退避したファイルの chezmoi status の行を残す復元するときは、退避先から 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)。
認証情報と、万が一露出して困るものは、このリポジトリに一切入れない。 暗号化していても入れない。
~/.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 を使う。
hooks/register.tsx 326 lines1// 選択範囲に注釈を貯めて、次の発言にまとめて添える
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} > {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}
326types/index.d.ts 16 lines1export 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