hooks/register.tsx 253 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Todo, TodoWhen } from '../types'
5
6const PLUGIN = 'user-todos'
7const TOOL_ADD = 'mcp__user-todos__add'
8const TOOL_DONE = 'mcp__user-todos__done'
9const TOOL_MOVE = 'mcp__user-todos__move'
10const TOOL_LIST = 'mcp__user-todos__list'
11// 「あとで」は全セッション共通なので $.store に置き、帯の描画用に $.state へ写す
12const LATER_STORE_KEY = 'later'
13// 他のセッションが「あとで」を書き換えても帯に反映されるよう、定期的に読み直す
14const SYNC_MS = 30_000
15
16const now = atom({ plugin: 'user-todos', key: 'now' } as const, [] as Todo[])
17const later = atom({ plugin: 'user-todos', key: 'later' } as const, [] as Todo[])
18// ターミナルの配色(Catppuccin Mocha)に揃えたパステルの黄色
19const NOW_COLOR = '#f9e2af'
20const isLaterOpen = atom({ plugin: 'user-todos', key: 'isLaterOpen' } as const, false)
21
22const WHEN_SCHEMA = {
23 type: 'string',
24 enum: ['now', 'later'],
25 description: 'now: 今の作業を進めるのにユーザーの行動が要る / later: 今すぐではないが、あとでユーザーがやること',
26}
27
28const isTodo = (value: unknown): value is Todo =>
29 typeof value === 'object' &&
30 value !== null &&
31 typeof (value as Todo).id === 'string' &&
32 typeof (value as Todo).text === 'string'
33
34const loadLater = async ($: EngineInterface): Promise<Todo[]> => {
35 const stored = await $.store.get(LATER_STORE_KEY)
36 return Array.isArray(stored) ? stored.filter(isTodo) : []
37}
38
39// 他のセッションの書き込みを消さないよう、書き換えのたびに $.store から読み直してから書く
40const changeLater = async ($: EngineInterface, fn: (list: Todo[]) => Todo[]): Promise<Todo[]> => {
41 const next = fn(await loadLater($))
42 await $.store.set(LATER_STORE_KEY, next)
43 await update($, later, () => next)
44 return next
45}
46
47const syncLater = async ($: EngineInterface) => {
48 const stored = await loadLater($)
49 await update($, later, () => stored)
50}
51
52const repoName = async ($: EngineInterface): Promise<string> => {
53 const cwd = await $.session.cwd()
54 try {
55 const { exitCode, stdout } = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { timeoutMs: 5000 })
56 const root = exitCode === 0 ? stdout.trim() : ''
57 return (root || cwd).split('/').pop() ?? cwd
58 } catch {
59 return cwd.split('/').pop() ?? cwd
60 }
61}
62
63const formatList = (nowList: Todo[], laterList: Todo[]) => {
64 const lines = (list: Todo[]) => (list.length === 0 ? [' (なし)'] : list.map(todo => ` - ${todo.id}: ${todo.text}`))
65 return ['今やる:', ...lines(nowList), 'あとで:', ...lines(laterList)].join('\n')
66}
67
68const findIn = async ($: EngineInterface, id: string): Promise<TodoWhen | undefined> => {
69 if ((await read($, now)).some(todo => todo.id === id)) return 'now'
70 if ((await loadLater($)).some(todo => todo.id === id)) return 'later'
71 return undefined
72}
73
74const remove = async ($: EngineInterface, id: string) => {
75 await update($, now, list => list.filter(todo => todo.id !== id))
76 await changeLater($, list => list.filter(todo => todo.id !== id))
77}
78
79// 同じミリ秒に複数登録されても id が重ならないよう、連番を足す
80let sequence = 0
81const newId = async ($: EngineInterface) => {
82 sequence += 1
83 return `${(await $.clock.now()).toString(36)}${sequence.toString(36)}`
84}
85
86export const register: Register = on => {
87 on('session.start', async ($, e, next) => {
88 await syncLater($)
89 $.clock.every(SYNC_MS, () => void syncLater($))
90
91 await $.tool.register({
92 name: 'add',
93 description:
94 'ユーザーにやってもらうことを、プロンプト上の帯に登録して常に見えるようにする。' +
95 '確認・動作確認・判断・外部の連絡待ちなど、ユーザーが何かしないと作業が進まないときは、返答で頼むのと同時に必ず when=now で登録する。' +
96 '今すぐではないがあとでユーザーがやること(後回しにした作業、期日のある対応など)は when=later で登録する(全セッション・全リポジトリ共通で残る)。' +
97 'text は「誰が・何をしたら再開できるか」がわかる1文にする。機密情報(APIキー、顧客名、社内URL)は書かない。',
98 inputSchema: {
99 type: 'object',
100 properties: {
101 text: { type: 'string', description: 'ユーザーにやってもらうこと(1文)' },
102 when: WHEN_SCHEMA,
103 },
104 required: ['text', 'when'],
105 },
106 isDeferred: false,
107 })
108 await $.tool.register({
109 name: 'done',
110 description:
111 '帯に登録した項目を完了にして消す。ユーザーがやったと言った、または結果から済んだとわかったときに呼ぶ。' +
112 'ユーザーが返事をしただけで、頼んだことが済んでいなければ消さない。',
113 inputSchema: {
114 type: 'object',
115 properties: { id: { type: 'string', description: 'add / list が返した項目の id' } },
116 required: ['id'],
117 },
118 isDeferred: false,
119 })
120 await $.tool.register({
121 name: 'move',
122 description: '項目を「今やる」と「あとで」の間で移す。ユーザーが後回しにすると言ったときや、あとでの項目に今取りかかるときに呼ぶ。',
123 inputSchema: {
124 type: 'object',
125 properties: {
126 id: { type: 'string', description: 'add / list が返した項目の id' },
127 to: WHEN_SCHEMA,
128 },
129 required: ['id', 'to'],
130 },
131 isDeferred: false,
132 })
133 await $.tool.register({
134 name: 'list',
135 description: '帯に登録されている「今やる」と「あとで」の項目を id 付きで返す。',
136 inputSchema: { type: 'object', properties: {} },
137 isDeferred: false,
138 })
139
140 return next(e)
141 })
142
143 // ツールの引数は e.input ではなく e の直下に載る(tool / tool_use_id / agentId は予約キー)
144 on('tool.call', { tool: TOOL_ADD }, async ($, e) => {
145 const { text, when } = e as unknown as { text: string; when: TodoWhen }
146 const todo: Todo = {
147 id: await newId($),
148 text: text.trim(),
149 repo: await repoName($),
150 createdAt: new Date(await $.clock.now()).toISOString(),
151 }
152 if (when === 'later') {
153 await changeLater($, list => [...list, todo])
154 } else {
155 await update($, now, list => [...list, todo])
156 }
157 return { result: `${when === 'later' ? 'あとで' : '今やる'}に登録しました(id: ${todo.id})。` }
158 })
159
160 on('tool.call', { tool: TOOL_DONE }, async ($, e) => {
161 const { id } = e as unknown as { id: string }
162 if ((await findIn($, id)) === undefined) {
163 return { result: `id ${id} の項目はありません。\n${formatList(await read($, now), await loadLater($))}` }
164 }
165 await remove($, id)
166 return { result: `${id} を完了にしました。` }
167 })
168
169 on('tool.call', { tool: TOOL_MOVE }, async ($, e) => {
170 const { id, to } = e as unknown as { id: string; to: TodoWhen }
171 const from = await findIn($, id)
172 if (from === undefined) {
173 return { result: `id ${id} の項目はありません。\n${formatList(await read($, now), await loadLater($))}` }
174 }
175 if (from === to) {
176 return { result: `${id} はすでに${to === 'later' ? 'あとで' : '今やる'}にあります。` }
177 }
178 const todo = (from === 'now' ? await read($, now) : await loadLater($)).find(one => one.id === id)
179 if (todo === undefined) {
180 return { result: `id ${id} の項目はありません。` }
181 }
182 await remove($, id)
183 if (to === 'later') {
184 await changeLater($, list => [...list, todo])
185 } else {
186 await update($, now, list => [...list, todo])
187 }
188 return { result: `${id} を${to === 'later' ? 'あとで' : '今やる'}に移しました。` }
189 })
190
191 on('tool.call', { tool: TOOL_LIST }, async $ => {
192 await syncLater($)
193 return { result: formatList(await read($, now), await read($, later)) }
194 })
195
196 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
197 const nowList = await read($, now)
198 const laterList = await read($, later)
199 if (e.props.hasSurvey || (nowList.length === 0 && laterList.length === 0)) {
200 return next(e)
201 }
202
203 const { Box, Button, Text } = $.ui.resolve(e)
204 const isOpen = await read($, isLaterOpen)
205 const row = (todo: Todo, actions: 'now' | 'later') => (
206 <Box key={`row-${todo.id}`} flexDirection="row">
207 <Button key={`done-${todo.id}`} label="✓" onPress={() => remove($, todo.id)} />
208 <Text> </Text>
209 {actions === 'later' && (
210 <Button
211 key={`up-${todo.id}`}
212 label="今やる"
213 onPress={async () => {
214 await remove($, todo.id)
215 await update($, now, list => [...list, todo])
216 }}
217 />
218 )}
219 <Text wrap="truncate-end">
220 {actions === 'later' ? ' ' : ''}
221 {todo.text}
222 {actions === 'later' && <Text dimColor> {todo.repo}</Text>}
223 </Text>
224 </Box>
225 )
226
227 return (
228 <Box flexDirection="column">
229 {nowList.length > 0 && (
230 <Text bold color={NOW_COLOR}>
231 ▶ Your turn ({nowList.length})
232 </Text>
233 )}
234 {nowList.map(todo => row(todo, 'now'))}
235 {laterList.length > 0 && (
236 <Box flexDirection="row">
237 <Button
238 key="toggle-later"
239 plain
240 onPress={() => update($, isLaterOpen, open => !open)}
241 >
242 <Text dimColor>
243 {isOpen ? '▼' : '▶'} Later ({laterList.length})
244 </Text>
245 </Button>
246 </Box>
247 )}
248 {isOpen && laterList.map(todo => row(todo, 'later'))}
249 </Box>
250 )
251 })
252}
253