/btw で、作業を止めずに会話の続きとして脇道の質問をし、答えをペインに出す。答えは会話に残らない

English | 日本語
A marketplace of mods for Claude Code. A mod is a plugin that adds features to Claude Code through function hooks.
| Mod | What it does |
|---|---|
| branch-watch | Stops commits on a branch another session switched to, and lists each session's worktree and branch |
| push-gate | Holds git push and similar commands until a person approves |
| auto-worktree | Creates a worktree on the first prompt and starts the work there |
| session-diff | Shows the files this session edited and their diffs |
| pr-pane | Shows open pull requests and their CI results |
| side-question | /btw asks a side question without stopping the work |
| done-chime | Plays a sound when a long turn ends and when Claude waits for permission |
| ja-check | Tells you when a reply starts in English |
| usage-meter | Shows each turn's token counts and prompt cache rate |
claude plugin marketplace add yut0takagi/mods
claude plugin install branch-watch@yut0takagi-mods # one line per mod you want
Developed with Claude Code 2.1.289. Mods are an early access feature, so a Claude Code update may break them. The VS Code and Cursor extension draws no mod UI, so some mods are used differently there.
To change a mod, read CONTRIBUTING. Changes are listed in the CHANGELOG. Report security problems as described in SECURITY.
hooks/register.tsx 94 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { KEEP, outcomeOf, promptOf, replyOf, USAGE_TEXT } from './fork'
5import type { Outcome } from './fork'
6
7const PANE = 'side-question'
8const entries = atom({ plugin: 'side-question', key: 'entries' } as const, [])
9
10/** 会話の続きとして 1 問だけ聞き、答えをペインに出す。答えは会話には足さない。答えか理由を返す(空の質問なら undefined)。 */
11async function ask($: EngineInterface, question: string): Promise<Outcome | undefined> {
12 const text = question.trim()
13
14 if (text === '') {
15 return undefined
16 }
17
18 const id = await $.clock.now()
19 await update($, entries, list => [{ id, question: text }, ...list].slice(0, KEEP))
20
21 const result = await $.model.fork({ prompt: promptOf(text) })
22 const outcome = outcomeOf(result)
23 await update($, entries, list => list.map(entry => (entry.id === id ? { ...entry, ...outcome } : entry)))
24
25 return outcome
26}
27
28export const register: Register = on => {
29 on('session.start', async ($, e, next) => {
30 // 画面がない(VS Code の拡張機能など)と、答えは返答に出て会話に残る。説明もそれに合わせる
31 const isShown = (await $.session.surfaces().catch(() => undefined))?.length !== 0
32 await $.command.register({
33 name: 'btw',
34 description: isShown
35 ? '作業を止めずに脇道の質問をする。答えはペインに出て、会話には残らない'
36 : '作業を止めずに脇道の質問をする。答えは返答に出て、会話に残る',
37 argumentHint: '[質問]',
38 immediate: true,
39 })
40
41 return next(e)
42 })
43
44 on('command.run', { command: 'btw' }, async ($, e) => {
45 const question = e.args.trim()
46
47 // 画面がないセッション(VS Code・Cursor の拡張機能、-p)では、ペインが描かれない
48 // (ui.open は isPlaced: true を返すので、それでは見分けられない)。答えを待って返答の文で返す。
49 // 返答の文はモデルも読む行として会話に残る。人にだけ見せる道(ui.log、session.append の system)は
50 // 拡張機能では描かれないので使えない(2.1.289 の webview は ui_log を扱わず、プラグインの notice が
51 // なる level: info の informational 行も描かない。コードを読んだ結果で、実機では確かめていない)
52 if ((await $.session.surfaces()).length === 0) {
53 const outcome = await ask($, question)
54
55 return { text: outcome === undefined ? USAGE_TEXT : replyOf(question, outcome) }
56 }
57
58 await $.ui.open({ id: PANE, title: 'BTW', ...(question === '' && { focus: true as const }) })
59
60 if (question !== '') {
61 await ask($, question)
62 }
63
64 // 会話に何も残さないよう、出力の文は返さない
65 return {}
66 })
67
68 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
69 const table = $.ui.resolve(e)
70 const { Box, Markdown, Text } = table
71 const list = await read($, entries)
72
73 return (
74 <Box flexDirection="column">
75 {'Input' in table && (
76 <table.Input key="ask" placeholder="脇道の質問を入力して Enter" submitLabel="聞く" value="" autoFocus onSubmit={value => ask($, value)} />
77 )}
78 {list.length === 0 && <Text dimColor>答えはここに出ます。会話には残りません。</Text>}
79 {list.map(entry => (
80 <Box key={`q-${entry.id}`} flexDirection="column" marginTop={1}>
81 <Text bold>Q. {entry.question}</Text>
82 {entry.answer !== undefined && <Markdown text={entry.answer} />}
83 {entry.error !== undefined && <Text color="red">{entry.error}</Text>}
84 {entry.answer === undefined && entry.error === undefined && <Text dimColor>考え中…</Text>}
85 {entry.cacheRate !== undefined && (
86 <Text dimColor>キャッシュから読んだ入力 {Math.round(entry.cacheRate * 100)}%</Text>
87 )}
88 </Box>
89 ))}
90 </Box>
91 )
92 })
93}
94hooks/fork.ts 60 lines1/** 脇道の質問の組み立てと、fork の結果の読み方。$ に触れない部分。 */
2
3import type { ModelForkResult } from 'claude-code'
4
5import type { Entry } from '../types'
6
7// 質問の言語で答えてもらうため、前置きは英語にしておく
8const PREFIX = 'This is a side question, separate from the current task. Do not continue the task; answer only this question, briefly.'
9
10export const promptOf = (question: string) => `${PREFIX}\n\n${question}`
11
12/** ペインに残す数。古いものから消す。 */
13export const KEEP = 10
14
15/** Markdown に渡せる長さ。 */
16const MAX_TEXT = 10_000
17
18/** 1 問の結末。答えか、答えられなかった理由のどちらか。 */
19export type Outcome = Pick<Entry, 'answer' | 'error' | 'cacheRate'>
20
21/** fork の結果を、ペインに出す答えか理由にする。 */
22export function outcomeOf(result: ModelForkResult): Outcome {
23 if (result.isAnswered) {
24 const { input_tokens, cache_read_input_tokens, cache_creation_input_tokens } = result.usage
25 const total = input_tokens + cache_read_input_tokens + cache_creation_input_tokens
26
27 return {
28 answer: result.text.slice(0, MAX_TEXT),
29 ...(total > 0 && { cacheRate: cache_read_input_tokens / total }),
30 }
31 }
32
33 switch (result.reason) {
34 case 'nothing-to-fork':
35 return { error: 'まだ会話がありません。最初の返答のあとで聞けます。' }
36 case 'api-error':
37 return { error: `API エラーで答えられませんでした(${result.status ?? '?'})。` }
38 case 'empty-reply':
39 return { error: '答えが空でした。' }
40 default:
41 return { error: '中断されました。' }
42 }
43}
44
45/** 画面がないセッションで /btw に質問がなかったときの返答。入力欄のあるペインが出ないので、書き方を返す。 */
46export const USAGE_TEXT = '質問は /btw のあとに続けて書いてください。例: /btw なんでこの設計?'
47
48/** 画面がないセッションで、ペインの代わりにコマンドの返答として出す文。この文は会話に残り、モデルも読む。 */
49export function replyOf(question: string, outcome: Outcome): string {
50 const lines = [`Q. ${question}`, '', outcome.answer ?? outcome.error ?? '']
51
52 if (outcome.cacheRate !== undefined) {
53 lines.push('', `キャッシュから読んだ入力 ${Math.round(outcome.cacheRate * 100)}%`)
54 }
55
56 lines.push('', '(画面のないセッションなのでペインの代わりにここへ出しました。この答えは会話に残ります)')
57
58 return lines.join('\n')
59}
60types/index.d.ts 17 lines1/** 脇道の質問 1 つと、その答え。答えも理由もないあいだは考え中。 */
2export type Entry = {
3 id: number
4 question: string
5 answer?: string
6 /** 答えられなかった理由。 */
7 error?: string
8 /** プロンプトキャッシュから読んだ入力の割合(0〜1)。 */
9 cacheRate?: number
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 'side-question': { entries: Entry[] }
15 }
16}
17