A dim time above each prompt and each reply in the Claude Code transcript, written the way your own clock setting writes it

A dim time above each prompt and each reply in the Claude Code transcript, written the way your own clock setting writes it.
nuet is Danish for "the now", as in leve i nuet, to live in the moment. Each time nuet writes is the now of one message: when you sent it, or when Claude's answer came. Its icon keeps nuet's own now: 21:30, the minute of its first commit.
Claude Code says when a turn finished, done 9:10 PM, but not when you asked or when Claude answered. In a long session that's what you need to line the conversation up with your logs, or to see how long Claude worked on something. nuet puts the time on the blank line Claude Code already leaves above each message, at its right end:

Nothing else changes. The messages keep their width and their words, and /nuet off hides the times whenever you want Claude Code's own view.
/plugin install nuet --marketplace mmedum/nuet
Tested with Claude Code 2.1.296 in a terminal. Mods don't draw in the VS Code panel, which has message times of its own, or with -p.
Third-party marketplaces don't update on their own. To update, run claude plugin update nuet@nuet and restart Claude Code. To remove nuet, run claude plugin uninstall nuet@nuet.
Sun, Oct 11 · 8:00 AM, so every stretch of a long session can be placed. On a line with room only for the time, the date is left out, so a new day goes unmarked there.timeFormat: auto, 12-hour, 24-hour, 24-hour UTC or a strftime pattern) and timeZone, the ones its done time follows, so both agree. Set it in /config (Time format) or settings.json. Times are written in your locale, as Claude Code writes them: the first of LC_ALL, LC_TIME and LANG that is set, or the runtime's default for none or C. Auto takes the locale's own form, so en_US gives 12-hour times and most other locales 24-hour; 12-hour and 24-hour change only the hour cycle, so a Danish 24-hour time is 21.10. A strftime pattern takes every field Claude Code's does, with days and months named in your locale and glibc's %- for no padding (%-I:%M %p); a field it doesn't know is written as it is. As Claude Code does, nuet takes terminal escapes and control and invisible characters out of a pattern, and cuts the pattern, and what it writes, at 100 characters. A strftime pattern that already says which day it is, with the day of the month or year, the weekday or the whole date, gets no second date from nuet. A changed setting shows from the next message on, and every time above redraws in it./config or with /plugin configure nuet@nuet. A strftime format decides for itself.inactive role, so they follow /theme, light or dark. The Time color option takes another role, such as claude or success, or a color: #7aa2f7, rgb(122,162,247), ansi256(110) or ansi:cyan. A value nuet can't read leaves the default. To restyle quiet text all over Claude Code, override inactive in a custom theme instead; a hex or rgb() color doesn't follow light or dark themes, while color names, ansi: names and ansi256() from 0 to 15 follow your terminal's own palette.The times cover the messages added since nuet loaded. After /resume, older messages show none. In ctrl+o's transcript viewer, Claude Code times the replies itself and nuet times the prompts. A subagent's messages, tool calls and their results get no time.
/nuet says whether nuet is on
/nuet off hides nuet's times
/nuet on shows them again
/nuet off hides the times on screen, those already drawn too, and /nuet on brings them back. Messages added while it's off are still timed, so their times show once it's on. The switch lasts for the session, through /clear, and a new session starts with nuet on. To turn nuet off for good, run claude plugin disable nuet@nuet.
/nuet answers with a dim line that Claude doesn't read, and works while Claude is busy.
Mods run with your permissions, so here is everything nuet touches. claude plugin validate lists the same.
$.clock.now).$.settings.read), for two of them only: timeFormat and timeZone.LC_ALL, LC_TIME and LANG, for the locale the times are written in./nuet, and the Seconds and Time color options.Nothing. nuet runs no programs, reads no files, writes no files, makes no network requests and uses no plugin store. Each message's time, and whether you turned nuet off, live in the session's own state and go with it.
/nuet command, which answers with a dim line in the transcript. Its hook on a session restarting (/clear, /resume, /branch) only puts the switch back as you left it.nuet collects no data about you and sends nothing anywhere. It runs only on your machine, inside Claude Code, and keeps nothing after the session ends.
Questions and bugs: open an issue. For a pull request, open an issue first. CONTRIBUTING.md says how to run, test and release nuet. To report a security problem, see SECURITY.md.
Apache-2.0 © 2026 Mark Medum Bundgaard
hooks/register.tsx 207 lines1// nuet puts a dim time over the blank line above each prompt and reply in
2// the transcript: when Claude Code added the row, written the way its own
3// clock writes a turn's "done 9:10 PM".
4
5import type { EngineInterface, Register, RenderElement, RenderInput, SessionAppendInput } from 'claude-code'
6import { atom, memberOf, read, update } from 'claude-code'
7import type { Clock, Stamp } from '../types'
8import { DEFAULT_CLOCK, cells, clockOf, isSameDay, labelOf, localeOf, timeOf } from './clock'
9import { colorOf } from './paint'
10
11const stamps = atom({ plugin: 'nuet', key: 'stamps' } as const, null)
12const last = atom({ plugin: 'nuet', key: 'last' } as const, null)
13const clock = atom({ plugin: 'nuet', key: 'clock' } as const, DEFAULT_CLOCK)
14const isShown = atom({ plugin: 'nuet', key: 'isShown' } as const, true)
15
16// Whether the person last asked for the times on or off, for state that
17// starts over at /clear; the Seconds option; and the Time color option, by
18// default Claude Code's own color for hints and timestamps.
19let isShownNow = true
20let hasSeconds = false
21let timeColor = 'inactive'
22
23// What /nuet answers, from what was asked and whether the times showed.
24// Claude Code puts "nuet: " before it.
25function answerTo(asked: string, was: boolean): string {
26 switch (asked) {
27 case '':
28 return was
29 ? "on. The times above your prompts and Claude's replies are nuet's. /nuet off hides them."
30 : 'off for this session. /nuet on shows its times again.'
31 case 'on':
32 return was ? 'already on. /nuet off hides its times.' : 'on: its times show again. /nuet off hides them.'
33 case 'off':
34 return was ? 'off for this session: its times are hidden. /nuet on shows them again.' : 'already off. /nuet on shows its times again.'
35 default:
36 return "/nuet takes on or off, or nothing to say whether it's on. To turn nuet off for good: claude plugin disable nuet@nuet"
37 }
38}
39
40// The rows drawn as messages: the person's prompt, typed at the prompt or
41// while Claude was busy (Claude Code delivers that one as a queued command),
42// and a reply that says something (not one that only thinks or calls a tool).
43function isPrompt(e: SessionAppendInput): boolean {
44 if (!isPersons(e.origin)) {
45 return false
46 }
47 if (e.message.type === 'attachment') {
48 return e.message.name === 'queued_command'
49 }
50
51 return e.message.type === 'user' && e.door === 'prompt' && e.message.isMeta !== true
52}
53function isReply(e: SessionAppendInput): boolean {
54 return e.message.type === 'assistant' && e.door === 'response' && e.message.content.some(block => block.type === 'text')
55}
56
57// The locale every format is written in, read when the session starts or starts over:
58// the environment doesn't change under it.
59async function readLocale($: EngineInterface) {
60 const locale = localeOf(await $.env.get('LC_ALL'), await $.env.get('LC_TIME'), await $.env.get('LANG'))
61 await update($, clock, now => ({ ...now, locale }))
62}
63
64// Claude Code's own time settings, written only when they changed, so the
65// times already drawn redraw only then. A read that fails keeps the ones
66// last read.
67async function readSettings($: EngineInterface) {
68 const settings = await $.settings.read().catch(() => null)
69 const was = await read($, clock)
70 const fresh = settings === null ? { ...was, hasSeconds } : clockOf(settings, was.locale, hasSeconds)
71 if (fresh.format !== was.format || fresh.zone !== was.zone || fresh.hasSeconds !== was.hasSeconds) {
72 await update($, clock, now => ({ ...now, format: fresh.format, zone: fresh.zone, hasSeconds: fresh.hasSeconds }))
73 }
74}
75
76// Whether a prompt is the person's own words, as Claude Code's origins say
77// so: typed, sent through Remote Control or from Slack, or sent by a plugin
78// as theirs. Claude Code queues a background task's notification, another
79// session's or a channel's message, and a schedule's prompt the same way.
80function isPersons(origin: SessionAppendInput['origin']): boolean {
81 switch (origin.kind) {
82 case 'composer':
83 case 'bridge':
84 case 'slack-ping':
85 return true
86 case 'plugin':
87 return 'asUser' in origin && origin.asUser
88 default:
89 return false
90 }
91}
92
93// Whether a reply's time is news after the last time shown: later, and
94// reading differently or on another day. A reply that finishes after a
95// later row has had its time shown is not.
96function isNew(before: number | null, at: number, now: Clock): boolean {
97 return before === null || (at > before && (!isSameDay(before, at, now) || timeOf(before, now) !== timeOf(at, now)))
98}
99
100// The part of a row's id that a stamp is kept under. Claude Code draws a
101// message under its id, or, seen with typed prompts and with replies that
102// think first, one row per part under the id with its last twelve digits
103// replaced by the part's number (`…000000000000` the first); a queued prompt
104// kept its full id. The first twenty digits are the same either way, and
105// unique in practice.
106const rowKey = (id: string) => id.slice(0, 23)
107
108// Whether a row is a later part of a message drawn in parts, which shows no
109// time: the first part, or a row under the full id, shows the message's.
110const isLaterPart = (id: string) => /^-0{9}[0-9a-f]{3}$/.test(id.slice(23)) && !id.endsWith('-000000000000')
111
112// A message row as Claude Code drew it and, over the blank line it leaves
113// above the message, the row's time at its end; the time without its date
114// when there is no room for both, and the row alone when it shows no time
115// or has no room even for the time.
116async function withTime($: EngineInterface, e: RenderInput<'UserMessage' | 'AssistantMessage'>, drawn: RenderElement): Promise<RenderElement> {
117 const stamp = await read($, memberOf(stamps, { requestId: rowKey(e.requestId) }))
118 if (stamp === null || isLaterPart(e.requestId) || !(await read($, isShown))) {
119 return drawn
120 }
121 const { time, dated } = labelOf(stamp, await read($, clock))
122 const columns = e.viewport?.columns ?? 80
123 const label = (dated === null ? [time] : [dated, time]).find(one => cells(one) < columns)
124 if (label === undefined) {
125 return drawn
126 }
127
128 const { Box, Text } = $.ui.resolve(e)
129 return (
130 <Box flexDirection="column">
131 {drawn}
132 <Box position="absolute" top={0} left={columns - cells(label)}>
133 <Text color={timeColor}>{label}</Text>
134 </Box>
135 </Box>
136 )
137}
138
139export const register: Register = (on, options) => {
140 hasSeconds = options.seconds === true
141 timeColor = colorOf(options.timeColor, 'inactive')
142
143 on('session.start', async ($, e, next) => {
144 await $.command.register({
145 name: 'nuet',
146 description: "Show whether nuet's times are on, or turn them on or off for this session",
147 argumentHint: '[on|off]',
148 immediate: true,
149 })
150 await readLocale($)
151 return next(e)
152 })
153
154 // /nuet says whether nuet is on; /nuet on and /nuet off switch it,
155 // and say how it stands after. The answer is a dim line Claude doesn't read.
156 on('command.run', { command: 'nuet' }, async ($, e) => {
157 const asked = e.args.trim().toLowerCase()
158 const was = await read($, isShown)
159 if (asked === 'on' || asked === 'off') {
160 isShownNow = asked === 'on'
161 await update($, isShown, () => isShownNow)
162 }
163 $.ui.log(answerTo(asked, was))
164 return {}
165 })
166
167 // /clear, /resume and /branch put nuet's state back to how it starts:
168 // the person's switch outlives them, and the locale is read again.
169 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
170 await update($, isShown, () => isShownNow)
171 await readLocale($)
172 return next(e)
173 })
174
175 // Each prompt and reply of the main conversation is timed as Claude Code
176 // adds it, and the row passes on unchanged before the rest is worked out.
177 // A prompt always shows its time; a reply only when it reads differently
178 // from the last time shown, a new minute or a new day. The settings are
179 // read again first: Claude Code takes a changed one in only after its hooks
180 // on the change have run, and every time drawn follows it. The last time
181 // shown is weighed and moved in one write, so rows added at once each see
182 // the one before, and it never moves back.
183 on('session.append', async ($, e, next) => {
184 const isTimed = e.agentId === undefined && (isPrompt(e) || isReply(e))
185 const at = isTimed ? await $.clock.now() : 0
186 const added = await next(e)
187 if (isTimed) {
188 await readSettings($)
189 const now = await read($, clock)
190 let stamp = null as Stamp | null
191 await update($, last, before => {
192 stamp = isPrompt(e) || isNew(before, at, now) ? { at, previous: before } : null
193 return stamp === null ? before : Math.max(before ?? at, at)
194 })
195 // The row's id is the requestId it is drawn with (see rowKey).
196 if (stamp !== null) {
197 await update($, memberOf(stamps, { requestId: rowKey(e.uuid) }), () => stamp)
198 }
199 }
200
201 return added
202 })
203
204 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => withTime($, e, await next(e)))
205 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => withTime($, e, await next(e)))
206}
207hooks/clock.ts 355 lines1// Times as Claude Code writes its own clock, the "done 9:10 PM" on a turn's
2// closing line: by its timeFormat and timeZone settings, in the locale.
3
4// A string is measured by its code points, so it is spread into them here.
5/* oxlint-disable typescript/no-misused-spread */
6
7import type { Clock, Stamp } from '../types'
8
9export const DEFAULT_CLOCK: Clock = { format: 'auto', zone: null, locale: null, hasSeconds: false }
10
11// The time formats Claude Code writes through Intl, by their exact names.
12const PRESETS = ['auto', '12-hour', '24-hour', '24-hour-utc']
13
14// The clock's own settings, as Claude Code reads them; a value it couldn't
15// use is left for its default.
16export function clockOf(settings: Readonly<Record<string, unknown>>, locale: string | null, hasSeconds: boolean): Clock {
17 const zone = typeof settings.timeZone === 'string' && isZone(settings.timeZone) ? settings.timeZone : null
18 return { format: formatOf(settings.timeFormat), zone, locale, hasSeconds }
19}
20
21// A time format as Claude Code takes one: a preset by its name, else a
22// strftime pattern, cleaned, or auto when no % is left in it.
23function formatOf(value: unknown): string {
24 if (typeof value !== 'string') {
25 return 'auto'
26 }
27 if (PRESETS.includes(value)) {
28 return value
29 }
30 const pattern = cleaned(value)
31 return pattern.includes('%') ? pattern : 'auto'
32}
33
34function isZone(zone: string): boolean {
35 try {
36 formatter('zone', 'en-US', zone)
37 return true
38 } catch {
39 return false
40 }
41}
42
43// What Claude Code takes out of a strftime pattern, so the time stays one
44// line: unpaired surrogates, terminal escape sequences, then control,
45// format and invisible characters. It takes the last out in two passes, the
46// second a superset of the first, so one pass with the superset does the same.
47const UNPAIRED = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/g
48const ESCAPES =
49 // oxlint-disable-next-line no-control-regex -- escape sequences start with one
50 /[\u001b\u009b][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?(?:\u0007|\u001b\u005c|\u009c))|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g
51const INVISIBLE = /[\p{Cc}\p{Cf}\p{Cs}\p{Default_Ignorable_Code_Point}\u2028\u2029]/gu
52
53function cleaned(pattern: string): string {
54 return cut(pattern.replace(UNPAIRED, '').replace(ESCAPES, '').replace(INVISIBLE, ''))
55}
56
57// Claude Code keeps a strftime pattern, and what it writes, to 100 UTF-16
58// units, with no half of a surrogate pair at the end.
59const MOST = 100
60
61function cut(text: string): string {
62 if (text.length <= MOST) {
63 return text
64 }
65 const kept = text.slice(0, MOST)
66 const last = kept.charCodeAt(MOST - 1)
67
68 return last >= 0xd800 && last <= 0xdbff ? kept.slice(0, -1) : kept
69}
70
71// The locale as Claude Code reads it from a POSIX environment: the first of
72// LC_ALL, LC_TIME and LANG that is set, up to its . and its @ ("da_DK.UTF-8"
73// is da-DK). Null for C, POSIX or none, or a tag Intl refuses: Intl then
74// writes in its own default locale.
75export function localeOf(...values: readonly (string | undefined)[]): string | null {
76 const value = values.find(one => one !== undefined && one !== '')
77 if (value === undefined || value === 'C' || value === 'POSIX') {
78 return null
79 }
80 const tag = (value.split('.')[0] ?? '').split('@')[0]?.replaceAll('_', '-') ?? ''
81 if (tag === '') {
82 return null
83 }
84 try {
85 formatter('zone', tag, undefined)
86 return tag
87 } catch {
88 return null
89 }
90}
91
92const TIME = { hour: 'numeric', minute: '2-digit' } as const
93const SECONDS = { ...TIME, second: '2-digit' } as const
94
95// What nuet asks Intl for, each by a fixed name: a time's fields for
96// strftime and to tell days apart, as Claude Code reads them; a preset's
97// time, with seconds or without, in its hour cycle (auto keeps the
98// locale's); the date before a time; strftime's names; and none, to try a
99// zone or a locale.
100const OPTIONS = {
101 fields: {
102 year: 'numeric',
103 month: '2-digit',
104 day: '2-digit',
105 hour: '2-digit',
106 minute: '2-digit',
107 second: '2-digit',
108 hourCycle: 'h23',
109 timeZoneName: 'short',
110 },
111 auto: TIME,
112 'auto seconds': SECONDS,
113 '12-hour': { ...TIME, hourCycle: 'h12' },
114 '12-hour seconds': { ...SECONDS, hourCycle: 'h12' },
115 '24-hour': { ...TIME, hourCycle: 'h23' },
116 '24-hour seconds': { ...SECONDS, hourCycle: 'h23' },
117 date: { weekday: 'short', month: 'short', day: 'numeric', calendar: 'gregory' },
118 a: { weekday: 'short', calendar: 'gregory' },
119 A: { weekday: 'long', calendar: 'gregory' },
120 b: { month: 'short', calendar: 'gregory' },
121 B: { month: 'long', calendar: 'gregory' },
122 zone: {},
123} satisfies Record<string, Intl.DateTimeFormatOptions>
124
125type Options = keyof typeof OPTIONS
126
127// Each Intl formatter made once, by its options' name, locale and zone, as
128// Claude Code keeps its own: every row writes its time again on each render.
129// Undefined is the default locale, or the system's zone.
130const formatters = new Map<Options, Map<string | undefined, Map<string | undefined, Intl.DateTimeFormat>>>()
131
132function formatter(options: Options, locale: string | undefined, zone: string | undefined): Intl.DateTimeFormat {
133 let byLocale = formatters.get(options)
134 if (byLocale === undefined) {
135 byLocale = new Map()
136 formatters.set(options, byLocale)
137 }
138 let byZone = byLocale.get(locale)
139 if (byZone === undefined) {
140 byZone = new Map()
141 byLocale.set(locale, byZone)
142 }
143 let made = byZone.get(zone)
144 if (made === undefined) {
145 made = new Intl.DateTimeFormat(locale, zone === undefined ? OPTIONS[options] : { ...OPTIONS[options], timeZone: zone })
146 byZone.set(zone, made)
147 }
148
149 return made
150}
151
152// The time zone a clock writes in: UTC for "24-hour-utc", else its own.
153function zoneOf(clock: Clock): string | undefined {
154 return clock.format === '24-hour-utc' ? 'UTC' : (clock.zone ?? undefined)
155}
156
157function isPattern(format: string): boolean {
158 return !PRESETS.includes(format) && format.includes('%')
159}
160
161function fieldsOf(at: number, zone: string | undefined): Partial<Record<Intl.DateTimeFormatPartTypes, string>> {
162 return Object.fromEntries(
163 formatter('fields', 'en-US', zone)
164 .formatToParts(at)
165 .map(part => [part.type, part.value]),
166 )
167}
168
169// A preset's options: "24-hour-utc" is "24-hour" in UTC.
170function presetOptions(clock: Clock): Options {
171 switch (clock.format) {
172 case '12-hour':
173 return clock.hasSeconds ? '12-hour seconds' : '12-hour'
174 case '24-hour':
175 case '24-hour-utc':
176 return clock.hasSeconds ? '24-hour seconds' : '24-hour'
177 default:
178 return clock.hasSeconds ? 'auto seconds' : 'auto'
179 }
180}
181
182// A preset written as Claude Code writes it: the locale's own form with
183// only the hour cycle set, a Z after "24-hour-utc"'s minutes or seconds.
184// ICU's narrow space before AM or PM becomes a plain one, as a terminal
185// draws it.
186function presetOf(at: number, clock: Clock): string {
187 const made = formatter(presetOptions(clock), clock.locale ?? undefined, zoneOf(clock))
188 if (clock.format !== '24-hour-utc') {
189 return plain(made.format(at))
190 }
191 const parts = made.formatToParts(at)
192 const zulu = parts.findLastIndex(part => part.type === 'minute' || part.type === 'second')
193
194 return plain(parts.map((part, i) => (i === zulu ? `${part.value}Z` : part.value)).join(''))
195}
196
197function plain(text: string): string {
198 return text.replace(/[\u00a0\u202f]/g, ' ')
199}
200
201// A time as the clock writes it.
202export function timeOf(at: number, clock: Clock): string {
203 return isPattern(clock.format) ? cut(strftime(clock.format, at, zoneOf(clock), clock.locale ?? undefined)) : presetOf(at, clock)
204}
205
206// A strftime field, with glibc's - before its name to drop the padding.
207const FIELD = /%(-?)([\s\S])/g
208
209const zero = (n: number, bare: boolean, width = 2) => (bare ? String(n) : String(n).padStart(width, '0'))
210const space = (n: number, bare: boolean) => (bare ? String(n) : String(n).padStart(2, ' '))
211
212// The fields Claude Code's strftime knows, written as it writes them, the
213// names in the clock's locale; one it doesn't know is written as it is.
214function strftime(pattern: string, at: number, zone: string | undefined, locale: string | undefined): string {
215 const f = fieldsOf(at, zone)
216 const year = Number(f.year)
217 const month = Number(f.month)
218 const day = Number(f.day)
219 const hour = Number(f.hour)
220 const minute = f.minute ?? ''
221 const second = f.second ?? ''
222 const hour12 = ((hour + 11) % 12) + 1
223 const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay()
224 const meridiem = hour < 12 ? 'AM' : 'PM'
225
226 return pattern.replace(FIELD, (whole, flag: string, name: string) => {
227 const bare = flag === '-'
228 switch (name) {
229 case 'Y':
230 return String(year)
231 case 'y':
232 return zero(year % 100, bare)
233 case 'C':
234 return zero(Math.floor(year / 100), bare)
235 case 'm':
236 return zero(month, bare)
237 case 'd':
238 return zero(day, bare)
239 case 'e':
240 return space(day, bare)
241 case 'j':
242 return zero((Date.UTC(year, month - 1, day) - Date.UTC(year, 0, 1)) / 86_400_000 + 1, bare, 3)
243 case 'u':
244 return String(weekday === 0 ? 7 : weekday)
245 case 'w':
246 return String(weekday)
247 case 'H':
248 return zero(hour, bare)
249 case 'k':
250 return space(hour, bare)
251 case 'I':
252 return zero(hour12, bare)
253 case 'l':
254 return space(hour12, bare)
255 case 'M':
256 return zero(Number(minute), bare)
257 case 'S':
258 return zero(Number(second), bare)
259 case 'p':
260 return meridiem
261 case 'P':
262 return meridiem.toLowerCase()
263 case 's':
264 return String(Math.floor(at / 1000))
265 case 'a':
266 return formatter('a', locale, zone).format(at)
267 case 'A':
268 return formatter('A', locale, zone).format(at)
269 case 'b':
270 case 'h':
271 return formatter('b', locale, zone).format(at)
272 case 'B':
273 return formatter('B', locale, zone).format(at)
274 case 'Z':
275 return f.timeZoneName ?? ''
276 case 'z':
277 return offsetOf(at, Date.UTC(year, month - 1, day, hour, Number(minute), Number(second)))
278 case 'F':
279 return `${year}-${zero(month, false)}-${zero(day, false)}`
280 case 'D':
281 return `${zero(month, false)}/${zero(day, false)}/${zero(year % 100, false)}`
282 case 'T':
283 return `${zero(hour, false)}:${minute}:${second}`
284 case 'R':
285 return `${zero(hour, false)}:${minute}`
286 case 'r':
287 return `${zero(hour12, false)}:${minute}:${second} ${meridiem}`
288 case '%':
289 return '%'
290 default:
291 return whole
292 }
293 })
294}
295
296// The zone's offset from UTC, as +0200: how far its wall clock is ahead.
297function offsetOf(at: number, wall: number): string {
298 const minutes = Math.round((wall - Math.floor(at / 1000) * 1000) / 60_000)
299 const away = Math.abs(minutes)
300 return `${minutes < 0 ? '-' : '+'}${zero(Math.floor(away / 60), false)}${zero(away % 60, false)}`
301}
302
303// Whether two times fall on the same day in the clock's zone.
304export function isSameDay(one: number, other: number, clock: Clock): boolean {
305 const a = fieldsOf(one, zoneOf(clock))
306 const b = fieldsOf(other, zoneOf(clock))
307 return a.year === b.year && a.month === b.month && a.day === b.day
308}
309
310// The strftime fields that change every day: the day of the month or of the
311// year, the weekday by name or number, and the whole date.
312const DAY_FIELDS = new Set(['a', 'A', 'd', 'D', 'e', 'F', 'j', 'u', 'w'])
313
314// Whether a strftime time format already writes which day it is, so a date
315// before it would say it twice.
316function namesTheDay(format: string): boolean {
317 return isPattern(format) && [...format.matchAll(FIELD)].some(([, , name]) => DAY_FIELDS.has(name ?? ''))
318}
319
320// A row's time as drawn: the time, and the time led by the date on the first
321// time shown and on the first of each new day ("Sat, Oct 10 · 9:10 PM"), so
322// every stretch of a long session can be placed; no dated form where no date
323// is due. The date in the clock's locale; both in the clock now in force.
324export function labelOf(stamp: Stamp, clock: Clock): { time: string; dated: string | null } {
325 const time = timeOf(stamp.at, clock)
326 if (namesTheDay(clock.format) || (stamp.previous !== null && isSameDay(stamp.previous, stamp.at, clock))) {
327 return { time, dated: null }
328 }
329 const date = formatter('date', clock.locale ?? undefined, zoneOf(clock)).format(stamp.at)
330
331 return { time, dated: `${plain(date)} · ${time}` }
332}
333
334// Marks drawn on the character before them, and the joiners and selectors
335// that shape a character without a cell of their own.
336const NO_CELL = /^(?:\p{Mn}|\p{Me}|[\u200b-\u200f]|[\ufe00-\ufe0f])$/u
337
338// Characters a terminal draws two cells wide: Chinese, Japanese and Korean
339// script, their punctuation and full-width forms, and emoji drawn as emoji.
340const TWO_CELLS =
341 /^[\p{Emoji_Presentation}\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\u3000-\u303f\uff01-\uff60\uffe0-\uffe6]$/u
342
343function cellsOf(char: string): number {
344 if (NO_CELL.test(char)) {
345 return 0
346 }
347
348 return TWO_CELLS.test(char) ? 2 : 1
349}
350
351// How many cells a label takes on the terminal.
352export function cells(text: string): number {
353 return [...text].reduce((sum, char) => sum + cellsOf(char), 0)
354}
355hooks/paint.ts 111 lines1// A color option, as Claude Code's custom themes spell a color, turned into
2// one a Text paints: a theme role by name, hex, or one of the terminal's 256
3// colors by number.
4
5// Claude Code's theme roles, which follow the person's theme: every token its
6// custom theme reference lists.
7const ROLES = new Set([
8 'text',
9 'inverseText',
10 'inactive',
11 'subtle',
12 'suggestion',
13 'remember',
14 'success',
15 'error',
16 'warning',
17 'merged',
18 'claude',
19 'permission',
20 'planMode',
21 'autoAccept',
22 'promptBorder',
23 'bashBorder',
24 'ide',
25 'diffAdded',
26 'diffRemoved',
27 'diffAddedDimmed',
28 'diffRemovedDimmed',
29 'diffAddedWord',
30 'diffRemovedWord',
31 'fastMode',
32 'effortUltra',
33 'userMessageBackground',
34 'userMessageBackgroundHover',
35 'bashMessageBackgroundColor',
36 'memoryBackgroundColor',
37 'selectionBg',
38 'rate_limit_fill',
39 'rate_limit_empty',
40 'briefLabelYou',
41 'briefLabelClaude',
42 ...['claude', 'warning', 'permission', 'promptBorder', 'inactive', 'fastMode'].map(role => `${role}Shimmer`),
43 ...['red', 'blue', 'green', 'yellow', 'purple', 'orange', 'pink', 'cyan'].map(color => `${color}_FOR_SUBAGENTS_ONLY`),
44 ...['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet'].flatMap(color => [`rainbow_${color}`, `rainbow_${color}_shimmer`]),
45])
46
47// The terminal's sixteen colors as Text names them, in ANSI order, which
48// numbers them for `ansi256(n)`; gray, in either spelling, is blackBright.
49const ANSI = [
50 'black',
51 'red',
52 'green',
53 'yellow',
54 'blue',
55 'magenta',
56 'cyan',
57 'white',
58 'blackBright',
59 'redBright',
60 'greenBright',
61 'yellowBright',
62 'blueBright',
63 'magentaBright',
64 'cyanBright',
65 'whiteBright',
66]
67const NUMBERS = new Map<string, number>([...ANSI.map((name, n) => [name, n] as const), ['gray', 8], ['grey', 8]])
68
69const hex = (...channels: number[]) => `#${channels.map(channel => channel.toString(16).padStart(2, '0')).join('')}`
70
71// The option's color, or `fallback` for one it can't read (undefined for no
72// color at all): a theme role, a color name, `#rgb` or `#rrggbb`,
73// `rgb(r, g, b)`, `ansi256(n)` or `ansi:<name>`, as a custom theme takes
74// them. A color name, bare or after `ansi:`, comes back by its number as
75// `ansi256(n)`, which Claude Code draws in the terminal's own palette: it
76// draws a bare name as a fixed hex, and refuses a colon in a Text color. Hex,
77// short or not, and `rgb()` come back as `#rrggbb`, so nothing rests on how
78// Claude Code reads `#rgb`.
79export function colorOf<T>(value: unknown, fallback: T): string | T {
80 if (typeof value !== 'string') {
81 return fallback
82 }
83 const color = value.trim()
84 if (ROLES.has(color)) {
85 return color
86 }
87 const named = NUMBERS.get(color.startsWith('ansi:') ? color.slice('ansi:'.length) : color)
88 if (named !== undefined) {
89 return `ansi256(${named})`
90 }
91 if (/^#[0-9a-f]{6}$/i.test(color)) {
92 return color.toLowerCase()
93 }
94 const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i.exec(color)
95 if (short !== null) {
96 return hex(...short.slice(1).map(digit => Number.parseInt(digit.repeat(2), 16)))
97 }
98 const rgb = /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/.exec(color)
99 if (rgb !== null) {
100 const channels = rgb.slice(1).map(Number)
101 return channels.every(channel => channel <= 255) ? hex(...channels) : fallback
102 }
103 const indexed = /^ansi256\(\s*(\d{1,3})\s*\)$/.exec(color)
104 if (indexed !== null) {
105 const n = Number(indexed[1])
106 return n <= 255 ? `ansi256(${n})` : fallback
107 }
108
109 return fallback
110}
111types/index.d.ts 34 lines1// How nuet writes a time: Claude Code's own timeFormat and timeZone
2// settings ("auto", "12-hour", "24-hour", "24-hour-utc" or a strftime
3// pattern; an IANA zone, or null for the system's), the locale the times
4// are written in (null for Intl's own default), and whether times carry
5// seconds.
6export type Clock = {
7 format: string
8 zone: string | null
9 locale: string | null
10 hasSeconds: boolean
11}
12
13// The time of a row that shows one: when Claude Code added it, and the time
14// shown before it, if any, which tells whether a new day began between.
15export type Stamp = {
16 at: number
17 previous: number | null
18}
19
20declare module 'claude-code' {
21 interface PluginState {
22 nuet: {
23 // Each timed row's time, by the row's id; null for a row that shows none.
24 stamps: StateFamily<Stamp | null>
25 // When the last time shown was, to tell a new minute or day from the
26 // same one.
27 last: number | null
28 clock: Clock
29 // Whether nuet draws its times; /nuet off hides them for the session.
30 isShown: boolean
31 }
32 }
33}
34