SLOPSHOPPER

side-chat

Claude の推論待ちのあいだ、Dots役(ChatGPT)と GrokBot役(Grok)が右のパネルで雑談する

newpaneguardcommandprocesstimer
★ 1v0.1.0MITupdated 2026-10-15marulimo/claude-code-mods/mods/side-chat
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · side-chat
│ ┃ 待ち時間の雑談 ✕ › fix the failing auth test and add an audit log call │ ┃ 待機中(Claude が考え始めたら話します) │ ┃ [ 止める ] [ 消す ] ⏺ 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 │ │ › /side-chat │ ⎿ side-chat: パネルを開きました。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 待ち時間の雑談
待機中(Claude が考え始めたら話します) [ 止める ] [ 消す ] まだ誰も話していません。
README

Claude Code Mods

Claude Code の Mod(10/1 公開の拡張機能)6本をまとめたリポジトリです。

インストール

ターミナルの Claude Code で、<mod> を下記の名前に置き換えて実行します。

/plugin install <mod> --marketplace marulimo/claude-code-mods

続けて y を入力し、インストール先のスコープを選びます。デスクトップアプリの Code タブではこのコマンドは使えないため、ターミナルでインストールしてください。user スコープに入れれば、デスクトップのセッションでも読み込まれます。

Mod の設定項目は各 mods/<mod>/.claude-plugin/plugin.json の userConfig に定義しています。実行コマンドは PATH 上で見つかる必要があります。別の場所にある場合は設定で実行ファイルのパスを指定してください。

ask-codex

右側のパネルから、Devin SWE-2 への相談と Codex への作業依頼を送ります。送った分は Devin と Codex の利用枠で動き、Claude の使用量は使いません。単独のリポジトリ marulimo/ask-codex でも同じ版を公開しています。

  • 使い方: パネル上部の「Devin(相談)」「Codex(作業)」で送り先を選び、入力欄に書いて Enter。「推論」で強さを選びます(送り先ごとに覚えます)。コマンドでも送れます: /devin <質問>、/codex <依頼>、/codex-panel。
  • 必要なもの: 使う送り先の CLI です(片方だけでも動きます)。Codex CLI はログイン済み、Devin CLI はログイン済みで SWE-2 が使えること。
  • 設定: Codex の権限(workspace-write)、Codex の推論強度(high)、Codex のモデル(空なら CLI の既定)、codex の場所(空なら自動で探す)、Devin の強さ(high)、devin の場所(空なら自動で探す)、Devin を動かすフォルダー(空なら ~/.ask-codex/devin-chat)です。
  • 注意: Codex の権限を danger-full-access にすると、確認もサンドボックスもなしで作業フォルダーを書き換えます。Devin は中身の空な専用フォルダーで、その1回だけフォルダーの信頼確認を省いて動かします。失敗の記録は ~/.ask-codex/errors.log に残ります。
  • 詳しくは mods/ask-codex/README.md を参照してください。

ai-usage-hub

Claude、Codex、Grok、Gemini、Devin の使用率とリセット時刻をパネルに表示します。Claude のセッション情報と外部 collector の結果を使います。

  • コマンド: /usage-hub。起動時にもパネルを開き、5分おきに取得します。
  • 必要なもの: Node.js と、各サービスの使用率を集める collector が別途必要です。collector とそのログイン処理はこのリポジトリには含まれません。
  • 設定: userConfig はありません。環境変数 AI_USAGE_NODE(既定 node)と AI_USAGE_DASH_DIR(既定は USERPROFILE または HOME の下の ai-usage-dashboard)を使います。
  • 呼び出し: collector のフォルダーを作業ディレクトリとして、次を実行します。終了コード 0 で、標準出力に JSON だけを返してください。
node <AI_USAGE_DASH_DIR>/server.mjs --json codex,grok,gemini,devin,claude

JSON は次の形です。groups は必須配列で、グループには文字列の name と配列の rows が必要です。行には文字列の label と有限の数値または null の used が必要です。used は使用率の百分率です。

{
  "groups": [
    {
      "name": "CODEX",
      "rows": [
        { "label": "5時間", "used": 25, "reset": "2026-10-16T00:00:00Z" },
        { "label": "週次", "used": null, "state": "データなし" }
      ],
      "stale": false,
      "note": null
    }
  ]
}

認識するグループ名は CLAUDE、CODEX、GROK、GEMINI、DEVIN です。省略可能な項目は、行の reset(日時文字列)、state(状態文字列)、star(真なら Claude の Fable 枠)、グループの stale(真なら古いデータ)、note(文字列)です。Claude の補完行は 5時間 と 週次 で始まるラベルを使います。取得に失敗したときは前回値を残すため、表示の失敗時刻も確認してください。

ai-router

入力を送信すると、依頼に適した AI の判定結果をトーストとステータス行に表示します。Claude への送信は判定の完了を待ちません。

  • コマンド: ありません。通常の入力送信時に動きます。スラッシュコマンド、空白を除いて8文字未満の入力などは判定しません。
  • 必要なもの: Python と外部の判定スクリプトです。スクリプト、利用先 API の設定や認証は同梱しません。
  • 設定: pythonPath(python)、routerScript(空)、backend(jev、または decisions)、privateFolders(空)、privateWords(空)です。
  • routerScript が空なら実行せず、「判定スクリプト未設定」と表示します。
  • privateFolders は除外する絶対パス、privateWords は除外する語句をカンマ区切りで指定します。フォルダー自身とその配下、語句の部分一致を大文字小文字を区別せず判定します。既定ではどちらも除外しません。
  • 注意: 設定すると入力全文を判定スクリプトの標準入力へ渡します。外部送信の有無はスクリプト側の実装にも依存します。

呼び出しは次の形です。入力全文を標準入力から読み、12秒以内に終了コード 0 と標準出力の JSON を返す必要があります。

python <routerScript> --backend jev

backend を decisions にすると末尾の引数も decisions になります。成功結果の例です。

{
  "ok": true,
  "label": "候補A",
  "entry": "作業の入口",
  "top": [{ "id": "candidate_a", "p": 0.82 }, { "id": "candidate_b", "p": 0.11 }]
}

成功時は ok: true、文字列の label と entry、1件以上の top が必要です。p は 0〜1 の有限数なら百分率で表示します。2件目の id と p が有効なら次点も表示します。失敗時は {"ok":false,"reason":"service_unavailable"} の形を返せます。reason は英字で始まる英数字・アンダースコア80文字以内だけを表示します。それ以外は一般的な失敗表示になります。

side-chat

Claude が考えている間、Dots 役(ChatGPT)と GrokBot 役(Grok)がパネルで交互に、いまの会話の話題について雑談します。直近の会話(最大8発言・1発言300字・合計1500字)、このターンの使用ツール名、直近の雑談を送り先に渡します。

  • 送らないもの: ツールの出力とコードブロックは渡しません。設定 privateWords の語句を含む発言は丸ごと省き、APIキー・トークン・メールアドレス・32文字以上の英数字列は伏せ字にします。
  • コマンド: /side-chat、/side-chat off、/side-chat on です。
  • 必要なもの: ログイン済みの Codex CLI と、hermes_cli を読み込める Python / Hermes Agent です。Grok は xai-oauth 経由で X Premium の枠を使います。
  • 設定: codexPath(codex)、gptModel(gpt-6.1-sol)、python(py)、pythonVersion(-3.13)、grokModel(grok-4.3)、startDelaySec(20秒)、gapSec(20秒)、maxLines(6)、privateFolders(空)、privateWords(空)です。Python ランチャーに版指定が不要なら pythonVersion を空にします。
  • privateFolders は除外する絶対パスをカンマ区切りで指定します。設定したフォルダーとその配下では送りません。既定では除外しません。
  • privateWords は雑談の材料に渡さない語句をカンマ区切りで指定します。大文字小文字を区別せず部分一致で判定します。既定では除外しません。
  • 注意: 雑談ごとに Codex / Grok の利用枠を使います。不要な場合は off で止めてください。

tag-feed

X のハッシュタグに合う投稿を探し、パネルに新着を表示します。タグを登録すると定期取得します。

  • コマンド: /tag-feed #タグ #別のタグ、/tag-feed です。パネルには即時取得と停止・再開のボタンがあります。
  • 必要なもの: hermes_cli を読み込める Python / Hermes Agent と、Grok の xai-oauth ログインです。
  • 設定: python(py)、pythonVersion(-3.13)、model(grok-4.3)、intervalMinutes(10分、最短2分)です。
  • 注意: X API は使いません。Grok の検索で投稿 URL を探し、X の埋め込み用の公開エンドポイントから本文を取得します。取得ごとに Grok の X Premium 枠を使います。検索結果や公開エンドポイントの状況により取得できない投稿もあります。

x-post

本文に登録タグを付け、ブラウザで X の投稿画面や返信画面を開きます。最後の「ポスト」は人が押します。この Mod が自分で投稿することはありません。

  • コマンド: /x 本文、/x-reply <投稿URL> 本文、/x-tag [add|rm|clear] [タグ]、/x-mode on|off、/x-account @名前、/x-pane です。
  • ペイン: 上にハッシュタグ(入力欄で追加、押すと削除)とメンション、下に投稿欄(返信先と本文。Enter で投稿画面を開く)を並べます。本文はハッシュタグ込みのプレビューと文字数の目安を出します。
  • 必要なもの: X にログインしたブラウザです。メンション検索には別途、PATH 上の Hermes Agent(hermes)と Grok のログインが必要です。hermes が無くても投稿はできます。
  • 設定: userConfig はありません。タグ、推論中だけ投稿可能にするモード、自分のアカウントを上記コマンドで保存します。アカウントの既定値は空で、未設定ならペインに /x-account @名前 で設定 と出ます。hermes のモデルとプロバイダーは環境変数 X_POST_HERMES_MODEL(既定 grok-4.3)と X_POST_HERMES_PROVIDER(既定 xai-oauth)で変えられます。
  • 上限: タグは10個、1つ50文字までです。本文がタグ込みで280を超えると投稿画面を開きません(X に合わせ、日本語などは1文字2、URL は23と数える目安です)。
  • 注意: X API キーは不要です。メンション検索は「更新」を押したときだけ動き、1回30〜40秒かかり Grok の枠を使います。検索なので取りこぼしがあり、X の通知画面の代わりにはなりません。ブラウザは Windows では rundll32、macOS では open、それ以外では xdg-open で開きます。開けなければコマンドが返すリンクから開いてください。デスクトップ版ではペインの幅を Mod から変えられないので、狭いときは境目をドラッグしてください。

動作環境とライセンス

作者は Windows 11 で動かしています。他 OS は未確認です。Mod 対応の Claude Code が必要です。

MIT ライセンスです。詳しくは LICENSE を参照してください。

Source 2 files
hooks/register.tsx 339 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Chat, Line, Speaker } from '../types'
5
6const PANE = 'side-chat'
7const DOCK_COLUMNS = 56
8const TICK_MS = 4_000
9const MAX_KEEP = 60
10
11const lines = atom({ plugin: 'side-chat', key: 'lines' } as const, [] as Line[])
12const chat = atom({ plugin: 'side-chat', key: 'chat' } as const, { isEnabled: true, isBlocked: false } as Chat)
13
14export type Config = {
15  codexPath: string
16  gptModel: string
17  python: string
18  pythonVersion: string
19  grokModel: string
20  startDelayMs: number
21  gapMs: number
22  maxLines: number
23  privateWords: string[]
24}
25
26const NAME: Record<Speaker, string> = { dots: 'Dots', grok: 'GrokBot' }
27
28// 外へ送るのは依頼文の先頭とツール名だけ。モジュール変数なので再読込で消えてよい
29let promptHead = ''
30let toolsSeen: string[] = []
31
32// 設定したフォルダーとその配下では外部に送らない
33export function isBlockedPath(cwd: string, folders: string[]): boolean {
34  const normalize = (path: string) => path.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
35  const path = normalize(cwd)
36  return folders.some(folder => path === normalize(folder) || path.startsWith(`${normalize(folder)}/`))
37}
38
39const SECRET_RES = [
40  /\b(?:sk|pk|rk)-[A-Za-z0-9_-]{10,}/g,
41  /\b(?:ghp|gho|ghs|github_pat|xox[abprs]|AIza|ya29)[A-Za-z0-9_.-]{10,}/g,
42  /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g,
43  /[A-Za-z0-9+/_=-]{32,}/g,
44]
45
46// 設定した語句を含む発言は丸ごと送らない
47export function sanitize(text: string, privateWords: readonly string[] = []): string | undefined {
48  let s = text
49    .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
50    .replace(/<[^>\n]{1,80}>/g, '')
51    .replace(/```[\s\S]*?```/g, '(コード省略)')
52    .replace(/\s+/g, ' ')
53    .trim()
54  if (!s) return undefined
55  const lower = s.toLowerCase()
56  if (privateWords.some(word => lower.includes(word.toLowerCase()))) return '(除外した話題なので省略)'
57  for (const re of SECRET_RES) s = s.replace(re, '[伏せ字]')
58  return s
59}
60
61const RECENT_MAX = 1500
62const MESSAGE_MAX = 300
63
64// 直近の会話を、新しい方から上限まで拾って古い順に並べる
65export function recentTalk(messages: readonly { role: string; text: string }[], privateWords: readonly string[] = []): string {
66  const out: string[] = []
67  let used = 0
68  for (let i = messages.length - 1; i >= 0 && out.length < 8; i--) {
69    const m = messages[i]!
70    const s = sanitize(m.text, privateWords)
71    if (!s) continue
72    const cut = s.length > MESSAGE_MAX ? `${s.slice(0, MESSAGE_MAX)}…` : s
73    const line = `${m.role === 'user' ? 'ユーザー' : 'Claude'}: ${cut}`
74    if (used + line.length > RECENT_MAX && out.length) break
75    out.unshift(line)
76    used += line.length
77  }
78  return out.join('\n')
79}
80
81export function nextSpeaker(chatNow: Chat, turnLines: Line[], now: number, cfg: Config): Speaker | undefined {
82  if (!chatNow.isEnabled || chatNow.isBlocked || !chatNow.turnId || chatNow.busy) return undefined
83  if (now - (chatNow.turnStartedAt ?? now) < cfg.startDelayMs) return undefined
84  if (turnLines.length >= cfg.maxLines) return undefined
85  const last = turnLines[turnLines.length - 1]
86  if (last && now - last.at < cfg.gapMs) return undefined
87  // 交互に話す。ターンの最初は前ターンの最後と逆側から
88  if (last) return last.who === 'dots' ? 'grok' : 'dots'
89  return turnLines.length % 2 === 0 ? 'dots' : 'grok'
90}
91
92const PERSONA: Record<Speaker, string> = {
93  dots:
94    'あなたは「Dots」。ユーザーの日常を支える穏やかな相棒(ChatGPT)。生活や体調、息抜きに目を向ける。',
95  grok:
96    'あなたは「GrokBot」。Xをよく見ている、ちょっと茶目っ気のある相棒(Grok)。軽いツッコミや小ネタが得意。',
97}
98
99export function buildPrompt(who: Speaker, talk: string, tools: string[], recent: Line[]): string {
100  const other = who === 'dots' ? 'GrokBot' : 'Dots'
101  const log = recent.map(l => `${NAME[l.who]}: ${l.text}`).join('\n')
102  return [
103    'ツール禁止・ファイル読取禁止・検索禁止。考えたことだけで答える。',
104    PERSONA[who],
105    `いま、ユーザーは Claude Code と作業の話をしていて、Claude の返事を待っている。あなたは右側の小窓で ${other} と雑談している。`,
106    talk ? `ユーザーと Claude の最近の会話(抜粋):\n${talk}` :'ユーザーと Claude の会話の中身は分からない。',
107    `Claude がこのターンで使った道具: ${tools.length ? tools.join('、') : 'まだなし'}`,
108    log ? `ここまでの雑談:\n${log}` : 'まだ誰も話していない。あなたから話し始める。',
109    `次のあなたの一言を書く。40字以内、1文か2文。会話の話題に触れた感想や軽いツッコミにする。${other}の発言があれば受けて返してよい。`,
110    `作業をしているのは Claude で、あなたと ${other} は横で見ているだけ。自分たちが作業したように言わない。`,
111    'ここまでの雑談と同じ小ネタや言い回しを繰り返さない。雑談の流れより、ユーザーと Claude の会話の話題を優先する。',
112    '会話に書かれていないことは決めつけない。説教や助言の押しつけはしない。名前の前置きや引用符は付けない。',
113  ].join('\n')
114}
115
116export function cleanReply(raw: string): string {
117  const one = raw
118    .split(/\r?\n/)
119    .map(s => s.trim())
120    .filter(Boolean)
121    .join(' ')
122    .replace(/^(Dots|GrokBot|Grok)\s*[::]\s*/i, '')
123    .replace(/^[「"']+|[」"']+$/g, '')
124    .trim()
125  return one.length > 90 ? `${one.slice(0, 89)}…` : one
126}
127
128async function say($: EngineInterface, cfg: Config, who: Speaker, prompt: string): Promise<string> {
129  const env = { PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' }
130  if (who === 'dots') {
131    const r = await $.process.run(
132      [
133        cfg.codexPath, 'exec', '--skip-git-repo-check',
134        '-c', `model="${cfg.gptModel}"`, '-c', 'model_reasoning_effort=low',
135        '-s', 'read-only', '-',
136      ],
137      { stdin: prompt, timeoutMs: 90_000, env },
138    )
139    if (r.exitCode !== 0) throw new Error(`codex exit ${r.exitCode}: ${r.stderr.slice(-300)}`)
140    return r.stdout
141  }
142  const code =
143    'import sys\n' +
144    'from hermes_cli.main import main\n' +
145    `sys.argv=['hermes','-z',sys.argv[1],'-m',sys.argv[2],'--provider','xai-oauth']\n` +
146    'main()\n'
147  const r = await $.process.run(
148    [cfg.python, ...(cfg.pythonVersion ? [cfg.pythonVersion] : []), '-c', code, prompt, cfg.grokModel],
149    { timeoutMs: 90_000, env },
150  )
151  if (r.exitCode !== 0) throw new Error(`hermes exit ${r.exitCode}: ${r.stderr.slice(-300)}`)
152  return r.stdout
153}
154
155export async function tick($: EngineInterface, cfg: Config) {
156  const c = await read($, chat)
157  const all = await read($, lines)
158  const turnLines = all.filter(l => l.turnId === c.turnId)
159  const now = await $.clock.now()
160  const who = nextSpeaker(c, turnLines, now, cfg)
161  if (!who) return
162  const turnId = c.turnId!
163  await update($, chat, x => ({ ...x, busy: who, error: undefined }))
164  try {
165    let talk = ''
166    try {
167      talk = recentTalk(await $.session.messages(), cfg.privateWords)
168    } catch {
169      // 履歴が読めないときは依頼文の冒頭だけで話す
170    }
171    if (!talk && promptHead) talk = recentTalk([{ role: 'user', text: promptHead }], cfg.privateWords)
172    // 前のターンの雑談は2発言だけ持ち込む。多いと同じ小ネタを引きずる
173    const recent = [...all.filter(l => l.turnId !== turnId).slice(-2), ...turnLines].slice(-6)
174    const text = cleanReply(await say($, cfg, who, buildPrompt(who, talk, toolsSeen, recent)))
175    if (text) {
176      const at = await $.clock.now()
177      await update($, lines, list => [...list, { id: `${turnId}-${at}-${who}`, who, text, at, turnId }].slice(-MAX_KEEP))
178    }
179  } catch (err) {
180    await update($, chat, x => ({ ...x, error: `${NAME[who]}: ${String(err).slice(0, 200)}` }))
181  } finally {
182    await update($, chat, x => ({ ...x, busy: undefined }))
183  }
184}
185
186function openPane($: EngineInterface, focus?: boolean) {
187  return $.ui.open({ id: PANE, title: '待ち時間の雑談', columns: DOCK_COLUMNS, ...(focus ? { focus } : {}) })
188}
189
190async function setEnabled($: EngineInterface, on: boolean) {
191  await update($, chat, x => ({ ...x, isEnabled: on }))
192  await $.store.set('enabled', on)
193}
194
195function fmtWait(ms: number): string {
196  const s = Math.max(0, Math.floor(ms / 1000))
197  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
198}
199
200let timer: Timer | undefined
201
202export const register: Register = (on, options) => {
203  const cfg: Config = {
204    codexPath: String(options.codexPath || 'codex'),
205    gptModel: String(options.gptModel || 'gpt-6.1-sol'),
206    python: String(options.python || 'py'),
207    pythonVersion: String(options.pythonVersion ?? ''),
208    grokModel: String(options.grokModel || 'grok-4.3'),
209    startDelayMs: Math.max(5, Number(options.startDelaySec) || 20) * 1000,
210    gapMs: Math.max(5, Number(options.gapSec) || 20) * 1000,
211    maxLines: Math.max(1, Number(options.maxLines) || 6),
212    privateWords: String(options.privateWords || '').split(',').map(s => s.trim()).filter(Boolean),
213  }
214
215  const privateFolders = String(options.privateFolders || '').split(',').map(s => s.trim()).filter(Boolean)
216
217  on('session.start', async ($, e, next) => {
218    const res = await next(e)
219    await $.command.register({
220      name: 'side-chat',
221      description: '推論待ちの雑談パネル。on / off で切り替え、引数なしでパネルを開く',
222      argumentHint: '[on|off]',
223    })
224    const stored = await $.store.get('enabled')
225    const cwd = String(e.cwd ?? (await $.session.cwd()) ?? '')
226    await update($, chat, x => ({
227      ...x,
228      isEnabled: stored !== false,
229      isBlocked: isBlockedPath(cwd, privateFolders),
230      turnId: undefined,
231      busy: undefined,
232    }))
233    timer?.cancel()
234    timer = $.clock.every(TICK_MS, () => void tick($, cfg))
235    void openPane($)
236    return res
237  })
238
239  on('session.end', async ($, e, next) => {
240    timer?.cancel()
241    return next(e)
242  })
243
244  on('turn.start', async ($, e, next) => {
245    const c = await read($, chat)
246    // 実行中のターンの内側で始まったもの(サブエージェント等)は数えない
247    if (!c.turnId) {
248      promptHead = e.text.replace(/\s+/g, ' ').trim().slice(0, 200)
249      toolsSeen = []
250      const now = await $.clock.now()
251      await update($, chat, x => ({ ...x, turnId: e.turnId, turnStartedAt: now }))
252    }
253    return next(e)
254  })
255
256  on('turn.complete', async ($, e, next) => {
257    const c = await read($, chat)
258    if (c.turnId === e.turnId) await update($, chat, x => ({ ...x, turnId: undefined, turnStartedAt: undefined }))
259    return next(e)
260  })
261
262  on('tool.call', async ($, e, next) => {
263    try {
264      const name = String(e.tool)
265      if (!toolsSeen.includes(name)) toolsSeen = [...toolsSeen, name].slice(-10)
266    } catch {
267      // 記録に失敗してもツール呼び出しは止めない
268    }
269    return next(e)
270  })
271
272  on('command.run', { command: 'side-chat' }, async ($, e) => {
273    const arg = e.args.trim().toLowerCase()
274    if (arg === 'off') {
275      await setEnabled($, false)
276      return { text: '雑談を止めました。/side-chat on で再開します。' }
277    }
278    if (arg === 'on') await setEnabled($, true)
279    await openPane($, true)
280    const c = await read($, chat)
281    if (c.isBlocked) return { text: '除外設定されたフォルダーの作業なので、このセッションでは雑談を送りません。' }
282    return { text: arg === 'on' ? '雑談を再開しました。' : 'パネルを開きました。' }
283  })
284
285  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
286    const { Box, Text, Button } = $.ui.resolve(e) as any
287    const c = await read($, chat)
288    const all = await read($, lines)
289    const now = await $.clock.now()
290    const room = e.surface === 'terminal' ? Math.max(4, Math.floor(((e.viewport?.rows ?? 30) - 6) / 2)) : 40
291    const shown = all.slice(-room)
292
293    const state = c.isBlocked
294      ? '除外設定されたフォルダーの作業なので送らない'
295      : !c.isEnabled
296        ? '停止中'
297        : c.busy
298          ? `${NAME[c.busy]} が考え中…`
299          : c.turnId
300            ? `Claude 考え中 ${fmtWait(now - (c.turnStartedAt ?? now))}`
301            : '待機中(Claude が考え始めたら話します)'
302
303    let lastTurn = ''
304    return (
305      <Box flexDirection="column" gap={1}>
306        <Box flexDirection="column">
307          <Text dimColor>{state}</Text>
308          {c.error ? <Text color="red">{c.error}</Text> : null}
309          <Box flexDirection="row" gap={1}>
310            <Button
311              key="toggle"
312              label={c.isEnabled ? '止める' : '再開'}
313              onPress={() => void setEnabled($, !c.isEnabled)}
314            />
315            <Button key="clear" label="消す" onPress={() => void update($, lines, () => [])} />
316          </Box>
317        </Box>
318        {shown.length === 0 ? <Text dimColor>まだ誰も話していません。</Text> : null}
319        <Box flexDirection="column">
320          {shown.map(l => {
321            const sep = lastTurn && l.turnId !== lastTurn
322            lastTurn = l.turnId
323            return (
324              <Box key={l.id} flexDirection="column" marginTop={sep ? 1 : 0}>
325                <Text>
326                  <Text bold color={l.who === 'dots' ? 'cyan' : 'magenta'}>
327                    {NAME[l.who]}
328                  </Text>
329                  <Text> {l.text}</Text>
330                </Text>
331              </Box>
332            )
333          })}
334        </Box>
335      </Box>
336    )
337  })
338}
339
types/index.d.ts 25 lines
1export type Speaker = 'dots' | 'grok'
2
3export type Line = {
4  id: string
5  who: Speaker
6  text: string
7  at: number
8  turnId: string
9}
10
11export type Chat = {
12  isEnabled: boolean
13  isBlocked: boolean
14  turnId?: string
15  turnStartedAt?: number
16  busy?: Speaker
17  error?: string
18}
19
20declare module 'claude-code' {
21  interface PluginState {
22    'side-chat': { lines: Line[]; chat: Chat }
23  }
24}
25