LINEのように、チャットの各メッセージ(自分とClaude)の下に送った日時を表示する

kokot が作った Claude Code 用の mod 集です。
| mod | できること |
|---|---|
| usage-band | 入力欄の上に、残りの使用量(5時間枠・週枠・コンテキスト)とリセットまでの時間を帯で表示する。7日間のトークンを何に使ったかの円グラフも出す。チャット名の色分けもする |
| chat-time | LINE のように、各メッセージの下に送った日時を表示する |
| ja-questions | Claude からの質問が英語なら、日本語に訳して表示する |
| push-to-talk | 決めたキーを押している間だけ録音して、話した内容を入力欄に入れる |
| shield | 危ない操作(フォルダごと削除・履歴の上書き・秘密ファイルの書き換えなど)は止めて確認し、Claude が書いたファイルは自動で構文チェックする。ほかの作業中のチャットも表示し、名前を押すとそのチャットへ飛べる。守るもの・守れないものは shield/SAFETY.md |
ログインはいりません。ほしい mod だけ選んで入れられます。
claude plugin marketplace add kokotosu/claude-mods
claude plugin install usage-band@kokot-claude-mods
claude plugin install chat-time@kokot-claude-mods
claude plugin install ja-questions@kokot-claude-mods
claude plugin install push-to-talk@kokot-claude-mods
claude plugin install shield@kokot-claude-mods
Claude に頼むなら「GitHub の kokotosu/claude-mods から chat-time だけ入れて」のように言えば入れてくれる。
claude plugin marketplace update kokot-claude-mods
そのあと claude plugin update <mod名>@kokot-claude-mods を打つ。
~/.claude/mods/chat-colors.json もコピーするhooks/register.tsx 174 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderInput } from 'claude-code'
3
4import type { Stamp } from '../types'
5
6// Bumped when a time turns up for a message drawn without one: it redraws.
7const found = atom({ plugin: 'chat-time', key: 'found' } as const, 0)
8
9const HEAD = 120
10// Within this long after a turn, a message with no time yet is taken as new.
11const LIVE_MS = 15_000
12// The transcript is read again at most this often while a message lacks a time.
13const REREAD_MS = 3_000
14// A soft blue-grey, apart from the text of either side on light and dark.
15const TIME_COLOR = '#7F95B5'
16const WEEK = ['日', '月', '火', '水', '木', '金', '土']
17
18// Spacing folded, the head only: how a drawn row and a stored one are matched.
19const flat = (text: string) => text.replace(/\s+/g, ' ').trim().slice(0, HEAD)
20
21// Like a chat app: `10/22(水) 14:03`, in Japan's time whatever the host's zone.
22export function when(at: number): string {
23 const d = new Date(at + 9 * 3_600_000)
24 const pad = (n: number) => String(n).padStart(2, '0')
25
26 return `${d.getUTCMonth() + 1}/${d.getUTCDate()}(${WEEK[d.getUTCDay()]}) ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`
27}
28
29const same = (stamp: Stamp, role: Stamp['r'], head: string) =>
30 stamp.r === role && (stamp.t === head || stamp.t.startsWith(head) || head.startsWith(stamp.t))
31
32// Module state, filled again after a reload.
33let stamps: Stamp[] = []
34let lastRead = 0
35let liveUntil = 0
36
37const storeKey = async ($: EngineInterface) => `stamps:${await $.session.id()}`
38
39// The session's transcript file, found once among the projects and remembered.
40async function transcriptPath($: EngineInterface): Promise<string | null> {
41 const id = await $.session.id()
42 const remembered = await $.store.get(`path:${id}`)
43 if (typeof remembered === 'string' && (await $.fs.exists(remembered))) return remembered
44
45 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
46 const root = `${home}/.claude/projects`
47 for (const project of await $.fs.list(root)) {
48 const file = `${root}/${project.name}/${id}.jsonl`
49 if (project.kind === 'dir' && (await $.fs.exists(file))) {
50 await $.store.set(`path:${id}`, file)
51
52 return file
53 }
54 }
55
56 return null
57}
58
59// Every time the transcript holds, read by a small script: the file is often
60// larger than one read of `$.fs` may be. The times this mod noted itself stay.
61async function reread($: EngineInterface) {
62 lastRead = await $.clock.now()
63 try {
64 const file = await transcriptPath($)
65 if (file === null) return
66 const { exitCode, stdout } = await $.process.run(['node', `${$.plugin.root}/hooks/stamps.mjs`, file], {
67 timeoutMs: 20_000,
68 })
69 if (exitCode !== 0) return
70 const fromFile = JSON.parse(stdout) as Stamp[]
71 const noted = await $.store.get(await storeKey($))
72 const mine = Array.isArray(noted) ? (noted as Stamp[]) : []
73 stamps = [...fromFile, ...mine.filter(n => !fromFile.some(f => same(f, n.r, n.t)))]
74 } catch {
75 // No node, no file: the rows go without times.
76 }
77}
78
79async function note($: EngineInterface, role: Stamp['r'], head: string, at: number) {
80 const stamp = { r: role, t: head, at }
81 stamps = [...stamps, stamp]
82 const key = await storeKey($)
83 const noted = await $.store.get(key)
84 const mine = Array.isArray(noted) ? (noted as Stamp[]) : []
85 // Only the newest few are kept: the transcript has the rest by then.
86 await $.store.set(key, [...mine, stamp].slice(-50))
87}
88
89// When the message was written, or null while nothing says.
90async function timeOf($: EngineInterface, role: Stamp['r'], text: string): Promise<number | null> {
91 const head = flat(text)
92 if (head === '') return null
93
94 // The latest match: a repeated message is most often the newest one.
95 const match = stamps.findLast(stamp => same(stamp, role, head))
96 if (match !== undefined) return match.at
97
98 const now = await $.clock.now()
99 if (now < liveUntil) {
100 await note($, role, head, now)
101
102 return now
103 }
104
105 if (now - lastRead > REREAD_MS) {
106 $.clock.after(0, async () => {
107 await reread($)
108 if (stamps.some(stamp => same(stamp, role, head))) await update($, found, n => n + 1)
109 })
110 }
111
112 return null
113}
114
115// The message as the engine draws it, and its time under it: smaller, and in
116// a color of its own so it never reads as part of the message.
117function stamped($: EngineInterface, e: RenderInput, own: RenderElement, at: number): RenderElement {
118 const { Box, Text } = $.ui.resolve(e)
119
120 return (
121 <Box flexDirection="column">
122 {own}
123 <Text size="footnote" color={TIME_COLOR}>
124 {when(at)}
125 </Text>
126 </Box>
127 )
128}
129
130export const register: Register = on => {
131 on('session.start', async ($, e, next) => {
132 const started = await next(e)
133 await reread($)
134 await update($, found, n => n + 1)
135
136 return started
137 })
138
139 on('prompt.submit', async ($, e, next) => {
140 const now = await $.clock.now()
141 liveUntil = Number.POSITIVE_INFINITY
142 const head = flat(e.text)
143 // Noting the time must never hold the prompt back.
144 if (head !== '') await note($, 'user', head, now).catch(() => undefined)
145
146 return next(e)
147 })
148
149 on('turn.complete', async ($, e, next) => {
150 if (e.agentId === undefined) liveUntil = (await $.clock.now()) + LIVE_MS
151
152 return next(e)
153 })
154
155 // The person's own messages: not a task's notice, not another party's.
156 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
157 await read($, found)
158 if (e.props.origin.kind === 'task-notification' || e.props.from !== undefined) return next(e)
159
160 const at = await timeOf($, 'user', e.props.text)
161
162 return at === null ? next(e) : stamped($, e, await next(e), at)
163 })
164
165 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
166 await read($, found)
167 if (e.props.isSummary === true) return next(e)
168
169 const at = await timeOf($, 'assistant', e.props.text)
170
171 return at === null ? next(e) : stamped($, e, await next(e), at)
172 })
173}
174types/index.d.ts 9 lines1// When one message was written: its role, the head of its text, the time.
2export type Stamp = { r: 'user' | 'assistant'; t: string; at: number }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'chat-time': { found: number }
7 }
8}
9