SLOPSHOPPER

prompt-time

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…

newrowsprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-time
⟨Claude Code's own drawing⟩ 01: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⟩ 01:53 ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Your message
⟨Claude Code's own drawing⟩ 01:53
Claude's reply
⟨Claude Code's own drawing⟩ 01:53
README

prompt-time

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.

What it shows

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.

How it knows the time

  • Your message. 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.
  • A reply block. A new 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.
  • A resumed session. At startup, resume and fork, 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.

Install

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

After installing

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.

What it can reach

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.

  1. Reads: the size of the session's own transcript file, then the file once at startup, resume and fork when it is 4 MiB or smaller, and only the type, uuid and timestamp of its rows; the text of each submitted prompt and of each drawn user row, to match the two; the clock
  2. Runs: nothing; no process, no fork, no timer
  3. Sends: nothing; no network call and nothing to the model
  4. Persists: nothing; the times live in memory and end with the session
  5. Hostile input: a transcript line that is not JSON is counted and skipped, a row without a string uuid or a parseable timestamp is skipped; the drawn label is built from numbers only

Limits

  • A resumed session whose transcript is over 4 MiB shows no time under its earlier messages. A transcript row holds its timestamp, but $.fs.read refuses the whole file, and $.session.messages() answers no timestamp. Of the transcripts checked in ~/.claude/projects, 78 were over 4 MiB.
  • A mod loaded in the middle of a session shows no time under the messages that were already on the screen, because it reads the transcript only at session start.
  • A user row takes the prompt's time by matching text. The engine draws a new prompt twice, first as 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.
  • A reply block takes its time when it is first drawn during a turn. The engine can draw the last block of a turn for the first time a few milliseconds after 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.
  • A thinking block shows no time. Claude Code 2.1.277 has no ui.render site for thinking, only for the text blocks of a reply (AssistantMessage).
  • The time is the local time of the machine that runs Claude Code.
  • The test engine of claude plugin test cannot raise classic.SessionStart. The transcript read is covered by unit tests of indexTranscript and by a live resume check.

Development

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

Source 2 files
hooks/register.tsx 149 lines
1import 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}
149
hooks/time.ts 51 lines
1function 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