SLOPSHOPPER

chat-time

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

newrowspromptprocesstimer
v0.3.0no licenseupdated 2026-10-23kokotosu/claude-mods/chat-time
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · chat-time
⟨Claude Code's own drawing⟩ 10/9(木) 17:53 ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ⟨Claude Code's own drawing⟩ 10/9(木) 17:53 ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Your message
⟨Claude Code's own drawing⟩ 10/9(木) 17:53
Claude's reply
⟨Claude Code's own drawing⟩ 10/9(木) 17:53
README

claude-mods

kokot が作った Claude Code 用の mod 集です。

modできること
usage-band入力欄の上に、残りの使用量(5時間枠・週枠・コンテキスト)とリセットまでの時間を帯で表示する。7日間のトークンを何に使ったかの円グラフも出す。チャット名の色分けもする
chat-timeLINE のように、各メッセージの下に送った日時を表示する
ja-questionsClaude からの質問が英語なら、日本語に訳して表示する
push-to-talk決めたキーを押している間だけ録音して、話した内容を入力欄に入れる
shield危ない操作(フォルダごと削除・履歴の上書き・秘密ファイルの書き換えなど)は止めて確認し、Claude が書いたファイルは自動で構文チェックする。ほかの作業中のチャットも表示し、名前を押すとそのチャットへ飛べる。守るもの・守れないものは shield/SAFETY.md

別のパソコンに入れる方法

ログインはいりません。ほしい mod だけ選んで入れられます。

  1. ターミナルで、まず棚の登録を打つ(1回だけ)
  2. そのあと、ほしい 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
  1. Claude を開き直す

Claude に頼むなら「GitHub の kokotosu/claude-mods から chat-time だけ入れて」のように言えば入れてくれる。

更新したとき

claude plugin marketplace update kokot-claude-mods

そのあと claude plugin update <mod名>@kokot-claude-mods を打つ。

注意

  • chat-time と usage-band の円グラフは Node.js が要る
  • push-to-talk は Windows 専用
  • shield のチャットへ飛ぶボタンは、パソコンの Claude アプリで押したときだけ効く
  • チャット名の色分けを前のパソコンと同じにしたいときは、~/.claude/mods/chat-colors.json もコピーする
Source 2 files
hooks/register.tsx 174 lines
1import { 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}
174
types/index.d.ts 9 lines
1// 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