Shows when you sent each prompt, and when each turn started and ended, in the transcript.

Shows when you sent each request.
❯ 18:15:12 · run the tests and fix what fails
✻ Cogitated for 24s · 18:15:12 → 18:15:35
Only the drawing changes: the model never sees the times, so the prompt cache is untouched.
claude plugin install prompt-clock@vampik-plugins
session.append stamps each typed prompt by its row id; ui.render on UserMessage puts the time in front.turn.complete records each turn's end; ui.render on TurnDuration draws the line with start → end.Oct 4 23:10:05.| Option | Default | |
|---|---|---|
seconds | true | 16:42:07 instead of 16:42 |
turnRange | true | start → end on the turn's line |
Needs function hooks (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1).
hooks/register.tsx 107 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import type { PromptClockTimes } from "../types";
5import {
6 cap,
7 emptyTimes,
8 fmtDuration,
9 parseTranscript,
10 promptText,
11 stampText,
12 turnEnd,
13 turnRange,
14} from "./core.ts";
15
16const times = atom(
17 { plugin: "prompt-clock", key: "times" } as const,
18 emptyTimes(),
19);
20
21/** Folds the transcript file's times under what the session already stamped. */
22async function loadTranscript($: EngineInterface, path: string): Promise<void> {
23 let jsonl: string;
24 try {
25 jsonl = await $.fs.read(path);
26 } catch {
27 return; // a new session has no file yet
28 }
29 const fromFile = parseTranscript(jsonl);
30 await update($, times, (t) => ({
31 sent: cap({ ...fromFile.sent, ...t.sent }),
32 byText: cap({ ...fromFile.byText, ...t.byText }),
33 turns: cap({ ...fromFile.turns, ...t.turns }),
34 }));
35}
36
37export const register: Register = (on, options) => {
38 const seconds = options.seconds !== false;
39 const showRange = options.turnRange !== false;
40
41 // a resumed session: earlier prompts' times come from its own transcript
42 on("classic.SessionStart", async ($, e, next) => {
43 const r = await next(e);
44 if (e.source === "resume" || e.source === "startup")
45 await loadTranscript($, e.transcript_path);
46 return r;
47 });
48
49 on("session.end", async ($, e, next) => {
50 if (e.reason === "clear") await update($, times, () => emptyTimes());
51 return next(e);
52 });
53
54 // a typed prompt becomes a row: stamp it by the row's id
55 on("session.append", { door: "prompt" }, async ($, e, next) => {
56 const r = await next(e);
57 if (e.agentId || e.origin.kind !== "composer" || "deny" in r) return r;
58 const text = promptText(e.message.content);
59 if (text === undefined) return r;
60 const at = Date.now();
61 await update($, times, (t: PromptClockTimes) => ({
62 ...t,
63 sent: cap({ ...t.sent, [r.uuid]: at }),
64 byText: cap({ ...t.byText, [text]: at }),
65 }));
66 return r;
67 });
68
69 on("turn.complete", async ($, e, next) => {
70 const r = await next(e);
71 if (!e.agentId) {
72 const end = Date.now();
73 await update($, times, (t) => ({
74 ...t,
75 turns: cap({ ...t.turns, [String(e.durationMs)]: end }),
76 }));
77 }
78 return r;
79 });
80
81 on("ui.render", { component: "UserMessage" }, async ($, e, next) => {
82 if (e.props.origin.kind !== "composer") return next(e);
83 const t = await read($, times);
84 const at =
85 (e.requestId ? t.sent[e.requestId] : undefined) ?? t.byText[e.props.text];
86 if (at === undefined) return next(e);
87 return next({
88 ...e,
89 props: {
90 ...e.props,
91 text: stampText(e.props.text, at, Date.now(), seconds),
92 },
93 });
94 });
95
96 on("ui.render", { component: "TurnDuration" }, async ($, e, next) => {
97 if (!showRange) return next(e);
98 const t = await read($, times);
99 const end = turnEnd(t.turns, e.props.durationMs);
100 if (end === undefined) return next(e);
101 // the engine's line is opaque and takes the row, so the mod draws the whole line
102 const { Text } = $.ui.resolve(e);
103 const range = turnRange(end, e.props.durationMs, Date.now(), seconds);
104 return <Text dimColor>{`✻ ${e.props.word} for ${fmtDuration(e.props.durationMs)} · ${range}`}</Text>;
105 });
106};
107hooks/core.ts 154 lines1/**
2 * core.ts — the pure half of prompt-clock: formatting and reading the
3 * transcript file's own timestamps. No `$`, no engine.
4 */
5
6import type { PromptClockTimes } from "../types";
7
8/** Most rows kept per map, newest win; a long session's oldest fall off. */
9export const KEEP = 2000;
10
11const MONTHS = [
12 "Jan",
13 "Feb",
14 "Mar",
15 "Apr",
16 "May",
17 "Jun",
18 "Jul",
19 "Aug",
20 "Sep",
21 "Oct",
22 "Nov",
23 "Dec",
24];
25
26const pad = (n: number) => String(n).padStart(2, "0");
27
28/** 16:42:07 (or 16:42), with "Oct 4 " in front when it is not today. */
29export function fmtTime(at: number, now: number, seconds: boolean): string {
30 const d = new Date(at);
31 const clock = `${pad(d.getHours())}:${pad(d.getMinutes())}${seconds ? `:${pad(d.getSeconds())}` : ""}`;
32 return d.toDateString() === new Date(now).toDateString()
33 ? clock
34 : `${MONTHS[d.getMonth()]} ${d.getDate()} ${clock}`;
35}
36
37/** A turn's length as the engine's line writes it: 3s, 1m 4s, 1h 2m. */
38export function fmtDuration(ms: number): string {
39 const total = Math.round(ms / 1000);
40 const h = Math.floor(total / 3600);
41 const m = Math.floor((total % 3600) / 60);
42 const s = total % 60;
43 if (h > 0) return `${h}h ${m}m`;
44 return m > 0 ? `${m}m ${s}s` : `${s}s`;
45}
46
47/** The row's text with its send time in front. */
48export const stampText = (
49 text: string,
50 at: number,
51 now: number,
52 seconds: boolean,
53) => `${fmtTime(at, now, seconds)} · ${text}`;
54
55/** "16:42:07 → 16:45:11" for a turn that ended at `endedAt` after `durationMs`. */
56export const turnRange = (
57 endedAt: number,
58 durationMs: number,
59 now: number,
60 seconds: boolean,
61) =>
62 `${fmtTime(endedAt - durationMs, now, seconds)} → ${fmtTime(endedAt, now, seconds)}`;
63
64/** Keeps the newest `keep` entries of a map whose values are times. */
65export function cap(
66 record: Record<string, number>,
67 keep = KEEP,
68): Record<string, number> {
69 const entries = Object.entries(record);
70 if (entries.length <= keep) return record;
71 // slice(-keep) would keep everything at keep = 0
72 return Object.fromEntries(
73 entries.sort((a, b) => a[1] - b[1]).slice(entries.length - keep),
74 );
75}
76
77export const emptyTimes = (): PromptClockTimes => ({
78 sent: {},
79 byText: {},
80 turns: {},
81});
82
83/** The typed text of a user row's content, or undefined for a tool result or a meta row. */
84export function promptText(content: unknown): string | undefined {
85 if (typeof content === "string") return content;
86 if (!Array.isArray(content)) return undefined;
87 if (content.some((b) => b?.type === "tool_result")) return undefined;
88 const text = content
89 .filter((b) => b?.type === "text" && typeof b.text === "string")
90 .map((b) => b.text as string)
91 .join("\n");
92 return text || undefined;
93}
94
95/**
96 * Times from a transcript file (JSONL): each typed prompt's row by uuid and
97 * text, and each turn's end by its duration.
98 */
99export function parseTranscript(jsonl: string): PromptClockTimes {
100 const times = emptyTimes();
101 for (const line of jsonl.split("\n")) {
102 if (!line.includes('"timestamp"')) continue;
103 let row: any;
104 try {
105 row = JSON.parse(line);
106 } catch {
107 continue;
108 }
109 const at = Date.parse(row.timestamp);
110 if (!Number.isFinite(at)) continue;
111 if (
112 row.type === "system" &&
113 row.subtype === "turn_duration" &&
114 typeof row.durationMs === "number"
115 ) {
116 times.turns[String(row.durationMs)] = at;
117 } else if (
118 row.type === "user" &&
119 !row.isMeta &&
120 !row.isSidechain &&
121 typeof row.uuid === "string"
122 ) {
123 const text = promptText(row.message?.content);
124 if (text === undefined) continue;
125 times.sent[row.uuid] = at;
126 times.byText[text] = at;
127 }
128 }
129 return {
130 sent: cap(times.sent),
131 byText: cap(times.byText),
132 turns: cap(times.turns),
133 };
134}
135
136/** The end time of a turn drawn with `durationMs`: exact, else the closest within a second. */
137export function turnEnd(
138 turns: Record<string, number>,
139 durationMs: number,
140): number | undefined {
141 const exact = turns[String(durationMs)];
142 if (exact !== undefined) return exact;
143 let best: number | undefined;
144 let gap = 1000;
145 for (const [ms, at] of Object.entries(turns)) {
146 const d = Math.abs(Number(ms) - durationMs);
147 if (d <= gap) {
148 gap = d;
149 best = at;
150 }
151 }
152 return best;
153}
154types/index.d.ts 16 lines1/** When each typed prompt was sent, by its transcript row id; and turn ends by duration. */
2export type PromptClockTimes = {
3 /** row uuid -> ms since the epoch */
4 sent: Record<string, number>;
5 /** prompt text -> ms, for a row whose id the render does not carry */
6 byText: Record<string, number>;
7 /** a turn's durationMs -> when it ended, ms since the epoch */
8 turns: Record<string, number>;
9};
10
11declare module 'claude-code' {
12 interface PluginState {
13 'prompt-clock': { times: PromptClockTimes };
14 }
15}
16