SLOPSHOPPER

nuet

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

newrowscommand
v0.1.2Apache-2.0updated 2026-10-11mmedum/nuet
A shopper browsing a rack in a slop shop
README

nuet

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

CI Release

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:

A Claude Code transcript with nuet, set to 24-hour time. A dim Sun, Oct 11 · 09:03 sits at the right end of the blank line above the first prompt; its answer came in the same minute and shows no time. The next prompt shows 09:04, and 09:05 sits above Claude's answer, which came in the next minute.

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.

Install

/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.

What it shows

  • Above each prompt: when you sent it. A prompt you type while Claude is busy shows when Claude Code took it in, at its next step. A prompt you send through Remote Control or from Slack, or that a plugin sends as your words, is yours too; what Claude Code delivers the same way from elsewhere, a background task's notification or a message from another session or a channel, shows none.
  • Above a reply: when it arrived, if that reads differently from the last time shown or falls on a new day. A reply in the same minute as your prompt shows none, so a quick answer adds nothing; a long one shows when it came.
  • The date: the first time shown, and the first of each new day, carry the date, as in 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.
  • In your format: Claude Code's own Time format setting (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.
  • Seconds, if you want them: turn on the Seconds option in /config or with /plugin configure nuet@nuet. A strftime format decides for itself.
  • In your theme's color: by default the times take Claude Code's own color for hints and timestamps, the 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.

Turning it off

/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.

How it works

Mods run with your permissions, so here is everything nuet touches. claude plugin validate lists the same.

What it reads

  • The kind of each row added to the conversation, who sent it, and its id: whether it's your prompt, typed or queued while Claude was busy, or a reply with text in it. nuet never reads what a message says.
  • The clock, when a prompt or a reply is added ($.clock.now).
  • Claude Code's settings, as it reads them ($.settings.read), for two of them only: timeFormat and timeZone.
  • The environment variables LC_ALL, LC_TIME and LANG, for the locale the times are written in.
  • What you type after /nuet, and the Seconds and Time color options.

What it runs and writes

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.

What its hooks change

  • Two kinds of transcript rows, your prompts and Claude's replies: the time is drawn over the blank line above the message.
  • Its hook on rows added to the conversation only notes the time, and hands every row on unchanged.
  • It adds the /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.

Privacy

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.

Contributing

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.

License

Apache-2.0 © 2026 Mark Medum Bundgaard

Source 4 files
hooks/register.tsx 207 lines
1// 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}
207
hooks/clock.ts 355 lines
1// 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}
355
hooks/paint.ts 111 lines
1// 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}
111
types/index.d.ts 34 lines
1// 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