Draws the time under each of your messages and each text block of the model's replies, also for a resumed session whose transcript is 4 MiB or smaller. Display…

The transcript shows no time: when you scroll back through a long session you cannot tell when a message was sent or how long the model worked on it. This mod draws a time in a dim line under each of your messages and under each text block of the model's replies in the transcript. A resumed session shows the times of its old messages too, when its transcript file is 4 MiB or smaller.
A message or reply block from today shows the time alone. An older one shows the date too, in the local time zone:
❯ Say one word, then run date, then say one more word. 22:24 ⏺ Start. 22:24 ⏺ Bash(date) ⎿ Fri Sep 18 22:24:28 +03 2026 ⏺ Done. 22:24
❯ Summarize the log. 17.09.2026 21:58
The line is display only. The stored message does not change, and nothing reaches the model, so the mod costs no tokens and does not touch the prompt cache.
prompt.submit records the clock and the text of the prompt. The next new UserMessage row with the same text takes that time. A prompt another hook dropped is never drawn, so its time reaches no row.AssistantMessage block first drawn while a turn runs (between turn.start and turn.complete) takes the time of that draw. In a live check the draw came within 0.1 seconds of the timestamp the transcript stores for the block.classic.SessionStart reads the session transcript and maps the uuid of every user and assistant row to its timestamp. The engine's requestId for these rows is the same uuid (measured on 2.1.277). The rows drawn before the read are drawn again with $.ui.invalidate. A transcript over 4 MiB is not read, because $.fs.read refuses a file over 4 MiB and has no ranged read; the mod checks the size with $.fs.stat first and writes one line to the debug log. The rows of that session keep no time, also when a turn redraws them (a resize redraws every row), and new messages get their time as usual. A block of that session first drawn during a turn, as a fullscreen scroll draws it, still takes the turn's time: no prop and no $.session.messages() row ties a block to its turn./clear forgets the known times, because the cleared messages leave the screen.
claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install prompt-time@kilimcininkoroglu-mods
Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.
Load it from a local checkout for one session:
claude --plugin-dir plugins/prompt-time
Restart Claude Code. The mod reads the transcript at session start, so a session open during the install shows no time under its earlier messages.
Validated with claude plugin validate on Claude Code 2.1.283:
❯ ./register.tsx hooks: classic.SessionStart, prompt.submit, turn.start, turn.complete, ui.render{component=UserMessage}, ui.render{component=AssistantMessage} ❯ ./register.tsx calls: $.clock.now, $.fs.exists (via loadTranscript), $.fs.read (via loadTranscript), $.fs.stat (via loadTranscript), $.ui.invalidate (via loadTranscript), $.ui.log (via loadTranscript), $.ui.resolve (via stamped)
Reach L1, reads the transcript.
$.fs.read refuses the whole file, and $.session.messages() answers no timestamp. Of the transcripts checked in ~/.claude/projects, 78 were over 4 MiB.placeholder and then under its stored id, so every new row with the prompt's text takes the time until the next prompt. A notification row with a different text does not.turn.complete (seen on 2.1.277), so the first new block whose text equals the turn's final text takes the time the turn ended. Any other block first drawn after its turn has ended shows no time until the session is resumed.ui.render site for thinking, only for the text blocks of a reply (AssistantMessage).claude plugin test cannot raise classic.SessionStart. The transcript read is covered by unit tests of indexTranscript and by a live resume check.make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test
hooks/register.tsx 149 lines1import type { EngineInterface, Register, RenderElement, RenderInput } from 'claude-code'
2import { formatSent, indexTranscript } from './time.ts'
3
4interface State {
5 /** Time in epoch milliseconds, by message or reply block id (the transcript row's uuid). */
6 times: Map<string, number>
7 /** Every message and reply block id drawn so far; only a new one can take a time. */
8 seen: Set<string>
9 /**
10 * The last submitted prompt. The engine can draw one prompt under two ids
11 * (a row while it enters, then the stored message), so every new row with
12 * its text takes its time, until the next prompt.
13 */
14 pending: { at: number; text: string } | null
15 /** Turns running now; a reply block first drawn while one runs is being written. */
16 turns: Set<string>
17 /**
18 * The final text of the last main-loop turn and when it ended. The engine
19 * can draw that block for the first time just after `turn.complete`, so the
20 * first unknown block with this text takes this time.
21 */
22 ended: { at: number; text: string } | null
23}
24
25/** Sources whose transcript already holds the messages the session shows. */
26const LOAD_SOURCES = new Set(['startup', 'resume', 'fork'])
27
28function errorText(err: unknown): string {
29 return err instanceof Error ? err.message : String(err)
30}
31
32/** `$.fs.read` rejects a file over 4 MiB, and it has no ranged read. */
33const MAX_READ_BYTES = 4 * 1024 * 1024
34
35/**
36 * Reads the send times of a transcript's messages, for a resumed session. A
37 * transcript over the read limit is skipped: its rows stay without a time,
38 * and messages sent from now on still get one.
39 */
40async function loadTranscript($: EngineInterface, state: State, path: string): Promise<void> {
41 let text: string
42 try {
43 if (!(await $.fs.exists(path))) return
44 const { size } = await $.fs.stat(path)
45 if (size > MAX_READ_BYTES) {
46 $.ui.log(`earlier messages have no time: the transcript is ${Math.round(size / 1024 / 1024)} MiB, over the 4 MiB read limit: ${path}`, { to: 'debug' })
47 return
48 }
49 text = await $.fs.read(path)
50 } catch (err) {
51 $.ui.log(`cannot read the transcript ${path}: ${errorText(err)}`)
52 return
53 }
54 const index = indexTranscript(text)
55 for (const [id, at] of index.times) state.times.set(id, at)
56 // The resumed messages can be drawn before this hook ran.
57 if (index.times.size > 0) $.ui.invalidate('ui.render')
58 if (index.broken > 0) $.ui.log(`${index.broken} transcript lines are not JSON: ${path}`, { to: 'debug' })
59}
60
61/** The send time of a drawn message; a message first drawn with the pending prompt's text takes its time. */
62function sentAt(state: State, id: string, text: string): number | undefined {
63 const isNew = !state.seen.has(id)
64 state.seen.add(id)
65 const known = state.times.get(id)
66 if (known !== undefined || !isNew || state.pending?.text !== text.trim()) return known
67 state.times.set(id, state.pending.at)
68 return state.pending.at
69}
70
71/**
72 * The time of a reply block: a block first drawn while a turn runs is written
73 * now, and the turn's final block first drawn after it ended takes its end. A
74 * block drawn before without a time (a resumed block whose transcript was not
75 * read) keeps none, so a redraw during a later turn does not give it that turn's time.
76 */
77function writtenAt(state: State, id: string, text: string, now: number): number | undefined {
78 const known = state.times.get(id)
79 const isNew = !state.seen.has(id)
80 state.seen.add(id)
81 if (known !== undefined || !isNew) return known
82 let at: number | undefined
83 if (state.turns.size > 0) at = now
84 else if (state.ended !== null && state.ended.text === text.trim()) {
85 at = state.ended.at
86 state.ended = null
87 }
88 if (at !== undefined) state.times.set(id, at)
89 return at
90}
91
92/** The engine's drawing with the time in a dim line beneath it. */
93function stamped($: EngineInterface, e: RenderInput, drawn: RenderElement, at: number, now: number): RenderElement {
94 const { Box, Text } = $.ui.resolve(e)
95 return (
96 <Box flexDirection="column">
97 {drawn}
98 <Text dimColor>{formatSent(new Date(at), new Date(now))}</Text>
99 </Box>
100 )
101}
102
103export const register: Register = on => {
104 const state: State = { times: new Map(), seen: new Set(), pending: null, turns: new Set(), ended: null }
105
106 on('classic.SessionStart', async ($, e, next) => {
107 const r = await next(e)
108 if (e.agent_id !== undefined) return r
109 if (e.source === 'clear') state.times.clear()
110 else if (LOAD_SOURCES.has(e.source)) await loadTranscript($, state, e.transcript_path)
111 return r
112 })
113
114 on('prompt.submit', async ($, e, next) => {
115 const pending = { at: await $.clock.now(), text: e.text.trim() }
116 state.pending = pending
117 const r = await next(e)
118 // A prompt a hook dropped is never drawn, so its time must not reach a later row.
119 if (r.drop !== undefined && state.pending === pending) state.pending = null
120 return r
121 })
122
123 on('turn.start', async (_, e, next) => {
124 state.turns.add(e.turnId)
125 state.ended = null
126 return next(e)
127 })
128
129 on('turn.complete', async ($, e, next) => {
130 state.turns.delete(e.turnId)
131 // A subagent's final text is not a block of the main transcript.
132 if (e.agentId === undefined) state.ended = { at: await $.clock.now(), text: e.answer.trim() }
133 return next(e)
134 })
135
136 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
137 const drawn = await next(e)
138 const at = sentAt(state, e.requestId, e.props.text)
139 return at === undefined ? drawn : stamped($, e, drawn, at, await $.clock.now())
140 })
141
142 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
143 const drawn = await next(e)
144 const now = await $.clock.now()
145 const at = writtenAt(state, e.requestId, e.props.text, now)
146 return at === undefined ? drawn : stamped($, e, drawn, at, now)
147 })
148}
149hooks/time.ts 51 lines1function pad(n: number): string {
2 return String(n).padStart(2, '0')
3}
4
5function isSameDay(a: Date, b: Date): boolean {
6 return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
7}
8
9/** `21:58` for a message sent today, `17.09.2026 21:58` for an older one, in local time. */
10export function formatSent(at: Date, now: Date): string {
11 const clock = `${pad(at.getHours())}:${pad(at.getMinutes())}`
12 if (isSameDay(at, now)) return clock
13 return `${pad(at.getDate())}.${pad(at.getMonth() + 1)}.${at.getFullYear()} ${clock}`
14}
15
16export interface TranscriptIndex {
17 /** The time of each user and assistant row, in epoch milliseconds, by the row's uuid. */
18 times: Map<string, number>
19 /** Lines that are not JSON; a transcript being written can end in a partial one. */
20 broken: number
21}
22
23const MESSAGE_TYPES = new Set(['user', 'assistant'])
24
25function rowTime(row: unknown): [string, number] | null {
26 if (typeof row !== 'object' || row === null) return null
27 const { type, uuid, timestamp } = row as Record<string, unknown>
28 if (!MESSAGE_TYPES.has(String(type)) || typeof uuid !== 'string' || typeof timestamp !== 'string') return null
29 const at = Date.parse(timestamp)
30 return Number.isNaN(at) ? null : [uuid, at]
31}
32
33/** Maps the uuid of every user and assistant row of a session transcript (JSONL) to its timestamp. */
34export function indexTranscript(jsonl: string): TranscriptIndex {
35 const times = new Map<string, number>()
36 let broken = 0
37 for (const line of jsonl.split('\n')) {
38 if (!line.trim()) continue
39 let row: unknown
40 try {
41 row = JSON.parse(line)
42 } catch {
43 broken++
44 continue
45 }
46 const entry = rowTime(row)
47 if (entry) times.set(entry[0], entry[1])
48 }
49 return { times, broken }
50}
51