SLOPSHOPPER

cloaked-statusline

cloaked.agency status line v3: location, weather, model, cost, context breakdown, session and weekly limits, prompt cache and git, live above the prompt.

newbandguardcommandprocessnetwork
v0.1.0no licenseupdated 2026-10-06harideviitg/claude-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cloaked-statusline
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /cloaked ⎿ cloaked-statusline: Status band hidden. /cloaked show brings it back. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

cloaked.agency status line v3

A Claude Code mod that draws a live status band above the prompt, in the terminal and in the desktop app's Code tab.

preview

RowWhat it shows
header┃┃ cloaked.agency with a rule that pulses once a second
LOCyour location, local time with timezone, current weather (wttr.in)
ENVClaude Code version, model, session cost, session length
CONTEXTthe context window as one bar, each category its own colour, the autocompact buffer dashed at the end, fill % on the right
detailtooling (everything that is not the conversation) and each category: system, tools, mcp, agents, memory, skills, msgs
SESSION / WEEKLYyour 5 hour and 7 day plan limits on one bar that fills from the centre out, teal or blue under 50%, orange to 80%, red past it
CACHEthe prompt cache: live while a turn runs, then a bar that drains over the cache TTL with a countdown, cold once it expires
footerfolder, git branch, age of the last commit, number of changed files

Everything updates on its own: the clock and cache every second, git every 15 seconds and after edits, usage after every turn, weather every 20 minutes.

Install

In a Claude Code terminal session:

/plugin install cloaked-statusline --marketplace harideviitg/claude-mod

Answer y to add the marketplace, then pick the user scope. The band shows up right away.

Installed at the user scope, it also loads in the desktop app's Code tab (the /plugin command itself only runs in a terminal, so install from there once).

To run it straight from a clone instead:

claude --plugin-dir /path/to/claude-mod

For the desktop app, put the clone's path in CLAUDE_CODE_PLUGIN_DIRS under env in ~/.claude/settings.json.

Settings

Open /config and look for the cloaked-statusline rows:

SettingDefaultNotes
Brandcloaked.agencybefore the first dot is blue, the rest orange
Locationemptylike Glendale, AZ; empty looks it up from your IP
Unitsimperialor metric
Cache TTL (minutes)5set 60 if your account uses the 1 hour cache

Commands

  • /cloaked toggles the band
  • /cloaked hide, /cloaked show
  • /cloaked refresh re-reads everything now

Notes

  • SESSION and WEEKLY need a Claude subscription; on an API key they read --%.
  • The band gets compact when there is little room: it drops the category detail first, then the header and LOC.
  • ctrl+x ctrl+a (or the [-] mark) collapses the band like any other.

Develop

claude plugin validate .
claude plugin test .
Source 3 files
hooks/register.tsx 419 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CloakedCategory, CloakedGit, CloakedLimit, CloakedUsage } from '../types'
5import {
6  C,
7  PULSE,
8  ageColor,
9  basename,
10  cacheBar,
11  categoryColor,
12  categoryLabel,
13  clockText,
14  compact,
15  contextBar,
16  duration,
17  parseTz,
18  parseWeather,
19  prettyModel,
20  shortPlace,
21  splitBar,
22  tierColor,
23  tooling,
24} from './format'
25import type { Seg } from './format'
26
27const now = atom({ plugin: 'cloaked-statusline', key: 'now' } as const, 0)
28const env = atom({ plugin: 'cloaked-statusline', key: 'env' } as const, null)
29const usage = atom({ plugin: 'cloaked-statusline', key: 'usage' } as const, null)
30const git = atom({ plugin: 'cloaked-statusline', key: 'git' } as const, null)
31const weather = atom({ plugin: 'cloaked-statusline', key: 'weather' } as const, null)
32const lastResponseAt = atom({ plugin: 'cloaked-statusline', key: 'lastResponseAt' } as const, null)
33const isHidden = atom({ plugin: 'cloaked-statusline', key: 'isHidden' } as const, false)
34
35const LABEL = 8
36const EDITS = new Set(['Bash', 'Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
37
38type Config = { brand: string; location: string; units: 'u' | 'm'; ttlMs: number }
39
40let config: Config = { brand: 'cloaked.agency', location: '', units: 'u', ttlMs: 5 * 60_000 }
41let isRunning = false
42let isUsageBusy = false
43let isGitBusy = false
44let gitSoon: { cancel: () => void } | undefined
45
46function quiet($: EngineInterface, what: string, work: Promise<unknown>): void {
47  work.catch((error: unknown) => $.ui.log(`${what}: ${String(error)}`, { to: 'debug' }))
48}
49
50async function refreshTime($: EngineInterface): Promise<void> {
51  const t = await $.clock.now()
52  await update($, now, () => t)
53}
54
55async function refreshEnv($: EngineInterface): Promise<void> {
56  const [version, model, measured] = await Promise.all([
57    $.session.version(),
58    $.session.model(),
59    $.session.usage(),
60  ])
61  let tz = { offsetMin: 0, name: 'UTC' }
62  try {
63    const out = await $.process.run(['date', '+%z %Z'], { timeoutMs: 3000 })
64    tz = parseTz(out.stdout) ?? tz
65  } catch {
66    // No `date` here (Windows): the clock reads UTC.
67  }
68  const value = {
69    version: version.base ?? version.version,
70    model: prettyModel(model, measured.context.window),
71    tzOffsetMin: tz.offsetMin,
72    tzName: tz.name,
73  }
74  await update($, env, () => value)
75}
76
77async function refreshUsage($: EngineInterface): Promise<void> {
78  if (isUsageBusy) return
79  isUsageBusy = true
80  try {
81    const u = await $.session.usage({ breakdown: 'summary' })
82    const b = u.context.breakdown
83    const used: CloakedCategory[] = []
84    let bufferTokens = 0
85    for (const cat of b?.categories ?? []) {
86      if (cat.kind === 'buffer') bufferTokens += cat.tokens
87      if (cat.kind !== 'used') continue
88      const label = categoryLabel(cat.name)
89      const same = used.find(c => c.label === label)
90      if (same) same.tokens += cat.tokens
91      else used.push({ label, tokens: cat.tokens })
92    }
93    const limit = (kind: string): CloakedLimit | null => {
94      const found = u.rateLimits.find(r => r.kind === kind)
95      if (found === undefined) return null
96      return found.resetsAt === undefined
97        ? { percent: found.percentUsed }
98        : { percent: found.percentUsed, resetsAt: found.resetsAt }
99    }
100    const value: CloakedUsage = {
101      startedAt: u.startedAt,
102      percent: b?.percentage ?? u.context.percent ?? null,
103      maxTokens: b?.rawMaxTokens ?? u.context.window,
104      used,
105      bufferTokens,
106      costUsd: u.cost?.usd ?? null,
107      session: limit('five_hour'),
108      weekly: limit('seven_day'),
109    }
110    await update($, usage, () => value)
111  } finally {
112    isUsageBusy = false
113  }
114}
115
116async function runGit($: EngineInterface, args: string[]) {
117  return $.process.run(['git', ...args], { timeoutMs: 5000 })
118}
119
120async function refreshGit($: EngineInterface): Promise<void> {
121  if (isGitBusy) return
122  isGitBusy = true
123  try {
124    const dir = await $.session.cwd()
125    const inside = await runGit($, ['rev-parse', '--is-inside-work-tree'])
126    if (inside.exitCode !== 0) {
127      await update($, git, () => ({ dir, branch: null, lastCommitAt: null, modified: 0 }))
128      return
129    }
130    const [symbolic, status, log] = await Promise.all([
131      runGit($, ['symbolic-ref', '--short', '-q', 'HEAD']),
132      runGit($, ['status', '--porcelain']),
133      runGit($, ['log', '-1', '--format=%ct']),
134    ])
135    let branch = symbolic.stdout.trim()
136    if (symbolic.exitCode !== 0 || branch === '') {
137      branch = (await runGit($, ['rev-parse', '--short', 'HEAD'])).stdout.trim() || 'detached'
138    }
139    const seconds = Number(log.stdout.trim())
140    const value: CloakedGit = {
141      dir,
142      branch,
143      lastCommitAt: log.exitCode === 0 && seconds > 0 ? seconds * 1000 : null,
144      modified: status.stdout.split('\n').filter(line => line.trim() !== '').length,
145    }
146    await update($, git, () => value)
147  } finally {
148    isGitBusy = false
149  }
150}
151
152async function refreshWeather($: EngineInterface): Promise<void> {
153  const where = config.location === '' ? '' : encodeURIComponent(config.location)
154  const reply = await $.http.fetch(`https://wttr.in/${where}?${config.units}&format=%25t%7C%25C%7C%25l`, {
155    headers: { 'User-Agent': 'curl/8.4.0' },
156  })
157  const parsed = reply.ok ? parseWeather(reply.text) : null
158  if (parsed === null) return
159  const value = {
160    place: config.location === '' ? shortPlace(parsed.place) : config.location,
161    temp: parsed.temp,
162    condition: parsed.condition,
163  }
164  await update($, weather, () => value)
165}
166
167function refreshAll($: EngineInterface): Promise<unknown> {
168  return Promise.allSettled([
169    refreshTime($),
170    refreshEnv($),
171    refreshUsage($),
172    refreshGit($),
173    refreshWeather($),
174  ])
175}
176
177function start($: EngineInterface): void {
178  if (isRunning) return
179  isRunning = true
180  quiet($, 'refresh', refreshAll($))
181  $.clock.every(1000, () => quiet($, 'clock', refreshTime($)))
182  $.clock.every(15_000, () => quiet($, 'git', refreshGit($)))
183  $.clock.every(20 * 60_000, () => quiet($, 'weather', refreshWeather($)))
184  $.clock.every(30 * 60_000, () => quiet($, 'env', refreshEnv($)))
185}
186
187function refreshGitSoon($: EngineInterface): void {
188  gitSoon?.cancel()
189  gitSoon = $.clock.after(1200, () => quiet($, 'git', refreshGit($)))
190}
191
192async function markResponse($: EngineInterface): Promise<void> {
193  const t = await $.clock.now()
194  await update($, lastResponseAt, () => t)
195}
196
197export const register: Register = (on, options) => {
198  isRunning = false
199  isUsageBusy = false
200  isGitBusy = false
201  config = {
202    brand: String(options.brand ?? '').trim() || 'cloaked.agency',
203    location: String(options.location ?? '').trim(),
204    units: options.units === 'metric' ? 'm' : 'u',
205    ttlMs: Math.max(1, Number(options.cacheTtlMinutes ?? 5) || 5) * 60_000,
206  }
207  const { brand, location, ttlMs } = config
208
209  on('session.start', async ($, e, next) => {
210    const started = await next(e)
211    if (e.isInteractive || e.surface !== null) start($)
212    await $.command.register({
213      name: 'cloaked',
214      description: 'cloaked.agency status band: toggle it, or pass hide, show or refresh',
215      argumentHint: '[hide|show|refresh]',
216    })
217    return started
218  })
219
220  on('session.attach', ($, e, next) => {
221    start($)
222    return next(e)
223  })
224
225  on('session.measure', ($, e, next) => {
226    if (e.changed.includes('context')) quiet($, 'cache', markResponse($))
227    quiet($, 'usage', refreshUsage($))
228    return next(e)
229  })
230
231  on('turn.complete', ($, e, next) => {
232    if (e.agentId === undefined) {
233      quiet($, 'cache', markResponse($))
234      quiet($, 'env', refreshEnv($))
235    }
236    return next(e)
237  })
238
239  on('tool.call', async ($, e, next) => {
240    const ran = await next(e)
241    if (isRunning && EDITS.has(String(e.tool))) refreshGitSoon($)
242    return ran
243  }).catch(($, e, next) => next(e))
244
245  on('command.run', { command: 'cloaked' }, async ($, e) => {
246    const arg = e.args.trim().toLowerCase()
247    if (arg === 'refresh') {
248      start($)
249      await refreshAll($)
250      return { text: 'cloaked.agency status band refreshed.' }
251    }
252    const hide = arg === 'hide' ? true : arg === 'show' ? false : !(await read($, isHidden))
253    await update($, isHidden, () => hide)
254    return { text: hide ? 'Status band hidden. /cloaked show brings it back.' : 'Status band shown.' }
255  })
256
257  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
258    if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
259
260    const [t, en, us, gi, we, last] = await Promise.all([
261      read($, now),
262      read($, env),
263      read($, usage),
264      read($, git),
265      read($, weather),
266      read($, lastResponseAt),
267    ])
268    const { Box, Text } = $.ui.resolve(e)
269    const width = Math.max(24, e.props.bodyColumns)
270    const rows = e.props.maxRows
271
272    const paint = (segs: Seg[]) => segs.map(s => <Text color={s.color} bold={s.bold}>{s.text}</Text>)
273    const label = (text: string) => <Text color={C.label}>{text}</Text>
274    const sep = () => <Text color={C.sep}> │ </Text>
275    const pad = (text: string) => text.padEnd(LABEL)
276
277    // Header: the brand, and a rule that pulses once a second.
278    const dot = brand.indexOf('.')
279    const head = dot > 0 ? brand.slice(0, dot) : brand
280    const tail = dot > 0 ? brand.slice(dot) : ''
281    const pulse = PULSE[Math.floor(t / 1000) % PULSE.length] ?? PULSE[0]
282    const header = (
283      <Box>
284        <Text color={pulse}>── </Text>
285        <Text color={C.blue} bold>┃</Text>
286        <Text color={C.orange} bold>┃ </Text>
287        <Text color={C.blue} bold>{head}</Text>
288        <Text color={C.orange} bold>{tail}</Text>
289        <Text color={pulse}> {'─'.repeat(Math.max(0, width - brand.length - 7))}</Text>
290      </Box>
291    )
292
293    // LOC: place, local time, weather.
294    const clock = en === null ? '--:--' : `${clockText(t, en.tzOffsetMin)} ${en.tzName}`.trim()
295    const loc = (
296      <Box>
297        {label('LOC ')}
298        <Text>{we?.place || location || '--'}</Text>
299        {sep()}
300        <Text color={C.blue}>{clock}</Text>
301        {sep()}
302        <Text>{we === null ? '--' : `${we.temp} ${we.condition}`}</Text>
303      </Box>
304    )
305
306    // ENV: version, model, cost, session length.
307    const envRow = (
308      <Box>
309        {label('ENV ')}
310        <Text>CC {en?.version ?? '--'}</Text>
311        {sep()}
312        <Text color={C.blue} bold>{en?.model ?? '--'}</Text>
313        {sep()}
314        <Text color={C.cost}>{us?.costUsd == null ? '$--' : `$${us.costUsd.toFixed(2)}`}</Text>
315        {sep()}
316        <Text color={C.blue}>{us === null ? '--' : duration(t - us.startedAt)}</Text>
317      </Box>
318    )
319
320    // CONTEXT: the window by category.
321    const ctxPct = us?.percent == null ? '--%' : `${Math.round(us.percent)}%`
322    const ctxRight = ` ${ctxPct.padStart(4)}`
323    const context = (
324      <Box>
325        {label(pad('CONTEXT'))}
326        {paint(contextBar(us, width - LABEL - ctxRight.length))}
327        <Text color={C.blue} bold>{ctxRight}</Text>
328      </Box>
329    )
330
331    // The categories beneath it.
332    const used = us?.used ?? []
333    const tool = tooling(used)
334    const toolPct = us === null || us.maxTokens <= 0 ? 0 : Math.round((tool / us.maxTokens) * 100)
335    const detail = (
336      <Box paddingLeft={LABEL} flexWrap="wrap" columnGap={2}>
337        <Box>
338          {label('tooling ')}
339          <Text color={C.blue} bold>{compact(tool)}</Text>
340          <Text color={C.label}> · </Text>
341          <Text color={C.blue}>{toolPct}%</Text>
342          <Text color={C.sep}> │</Text>
343        </Box>
344        {used.map(cat => (
345          <Box>
346            <Text color={categoryColor(cat.label)}>━ </Text>
347            {label(`${cat.label} `)}
348            <Text>{compact(cat.tokens)}</Text>
349          </Box>
350        ))}
351      </Box>
352    )
353
354    // SESSION and WEEKLY: one bar filling from the centre out.
355    const sPct = us?.session?.percent ?? null
356    const wPct = us?.weekly?.percent ?? null
357    const sText = sPct === null ? '--%' : `${Math.round(sPct)}%`
358    const wText = wPct === null ? '--%' : `${Math.round(wPct)}%`
359    const sessionLeft = `${sText.padEnd(4)} `
360    const sessionRight = ` ${wText.padStart(4)} `
361    const sessionBar = width - LABEL - sessionLeft.length - sessionRight.length - 'WEEKLY'.length
362    const session = (
363      <Box>
364        {label(pad('SESSION'))}
365        <Text color={sPct === null ? C.label : tierColor(sPct, C.green)} bold>{sessionLeft}</Text>
366        {paint(splitBar(sPct, wPct, sessionBar))}
367        <Text color={wPct === null ? C.label : tierColor(wPct, C.weekly)} bold>{sessionRight}</Text>
368        {label('WEEKLY')}
369      </Box>
370    )
371
372    // CACHE: drains over its TTL after the last response.
373    const cacheRight = 11
374    const cache = cacheBar(last, t, ttlMs, e.props.isWorking, width - LABEL - cacheRight)
375    const cacheRow = (
376      <Box>
377        {label(pad('CACHE'))}
378        {paint(cache.bar)}
379        <Text color={cache.color} bold>{` ${cache.label}`.padStart(cacheRight)}</Text>
380      </Box>
381    )
382
383    // Footer: folder, branch, last commit age, changed files.
384    const age = gi?.lastCommitAt == null ? null : t - gi.lastCommitAt
385    const footer = (
386      <Box>
387        <Text color={C.blue}>◆ </Text>
388        {label('PWD ')}
389        <Text bold>{gi === null ? '--' : basename(gi.dir)}</Text>
390        {sep()}
391        <Text color={C.blue}>⎇ {gi?.branch ?? 'no git'}</Text>
392        {sep()}
393        {label('Age ')}
394        <Text color={age === null ? C.label : ageColor(age)}>{age === null ? '--' : duration(age)}</Text>
395        {sep()}
396        {label('Mod ')}
397        <Text>{gi?.modified ?? 0}</Text>
398      </Box>
399    )
400
401    // Fewer rows to spare: drop the detail first, then the header and LOC.
402    const isRoomy = rows >= 9
403    const isTight = rows < 7
404
405    return (
406      <Box flexDirection="column" width={width}>
407        {!isTight && header}
408        {!isTight && loc}
409        {envRow}
410        {context}
411        {isRoomy && detail}
412        {session}
413        {cacheRow}
414        {footer}
415      </Box>
416    )
417  })
418}
419
hooks/format.ts 263 lines
1import type { CloakedCategory, CloakedUsage } from '../types'
2
3export const C = {
4  blue: '#4fa3d1',
5  orange: '#f0743c',
6  label: '#8b949e',
7  sep: '#484f58',
8  track: '#30363d',
9  buffer: '#5a636e',
10  cost: '#e3a84b',
11  green: '#3fb68b',
12  yellow: '#e6c34a',
13  red: '#f0563c',
14  weekly: '#5aa9d6',
15} as const
16
17const CATEGORY_COLORS: Record<string, string> = {
18  system: '#9aa4b2',
19  tools: '#d0d7de',
20  mcp: '#3fa7c9',
21  'mcp-instr': '#3fb68b',
22  agents: '#8fb8e8',
23  memory: '#f07b52',
24  skills: '#e6c34a',
25  cmds: '#7ee0c3',
26  msgs: '#b48ae0',
27}
28
29export const PULSE = ['#2f3542', '#46505f', '#5e6b7d', '#46505f'] as const
30
31/** One run of same-styled cells in a bar or a row. */
32export type Seg = { text: string; color?: string; bold?: boolean }
33
34export function categoryLabel(name: string): string {
35  const n = name.toLowerCase()
36  if (n.includes('mcp') && n.includes('instr')) return 'mcp-instr'
37  if (n.includes('mcp')) return 'mcp'
38  if (n.includes('system prompt')) return 'system'
39  if (n.includes('tool')) return 'tools'
40  if (n.includes('agent')) return 'agents'
41  if (n.includes('memory')) return 'memory'
42  if (n.includes('skill')) return 'skills'
43  if (n.includes('slash') || n.includes('command')) return 'cmds'
44  if (n.includes('message')) return 'msgs'
45  return n.split(/\s+/)[0] ?? n
46}
47
48export function categoryColor(label: string): string {
49  return CATEGORY_COLORS[label] ?? '#9aa4b2'
50}
51
52function oneDecimal(x: number): string {
53  return String(Math.round(x * 10) / 10)
54}
55
56/** 2400 is 2.4k, 15000 is 15k, 338000 is 338k, 1200000 is 1.2M. */
57export function compact(n: number): string {
58  if (n >= 1_000_000) return `${oneDecimal(n / 1_000_000)}M`
59  if (n >= 1_000) {
60    const k = n / 1_000
61    return `${k >= 10 ? Math.round(k) : oneDecimal(k)}k`
62  }
63  return String(Math.round(n))
64}
65
66/** 45s, 56m, 2h 05m, 3d 4h. */
67export function duration(ms: number): string {
68  const s = Math.max(0, Math.floor(ms / 1000))
69  if (s < 60) return `${s}s`
70  const m = Math.floor(s / 60)
71  if (m < 60) return `${m}m`
72  const h = Math.floor(m / 60)
73  if (h < 24) return `${h}h ${String(m % 60).padStart(2, '0')}m`
74  return `${Math.floor(h / 24)}d ${h % 24}h`
75}
76
77/** m:ss, for the cache countdown. */
78export function countdown(ms: number): string {
79  const s = Math.max(0, Math.ceil(ms / 1000))
80  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
81}
82
83/** Wall clock HH:MM at a UTC offset, from epoch milliseconds. */
84export function clockText(now: number, offsetMin: number): string {
85  const total = Math.floor(now / 60_000) + offsetMin
86  const m = ((total % 1440) + 1440) % 1440
87  return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`
88}
89
90/** Parses `date +'%z %Z'` output ("-0700 MST"). */
91export function parseTz(text: string): { offsetMin: number; name: string } | null {
92  const m = /([+-])(\d{2})(\d{2})\s*(\S*)/.exec(text.trim())
93  if (!m) return null
94  const sign = m[1] === '-' ? -1 : 1
95  return { offsetMin: sign * (Number(m[2]) * 60 + Number(m[3])), name: m[4] ?? '' }
96}
97
98function capitalize(s: string): string {
99  return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()
100}
101
102/** "claude-opus-5-5[1m]" or "Opus 5.5 (1M context)" both read "Opus 5.5 (1M)". */
103export function prettyModel(raw: string, window: number): string {
104  let s = raw.trim()
105  const id = /claude-(opus|sonnet|haiku|fable)-(\d+)(?:-(\d{1,2})(?!\d))?(?:-\d{8})?(\[1m\])?/i.exec(s)
106  if (id) {
107    s = `${capitalize(id[1] ?? '')} ${id[2]}${id[3] ? `.${id[3]}` : ''}${id[4] ? ' (1M)' : ''}`
108  } else {
109    s = s
110      .replace(/^default\s*\((.*)\)$/i, '$1')
111      .replace(/\s*context\s*\)/i, ')')
112      .replace(/\s*\(\s*\)/, '')
113  }
114  if (window >= 1_000_000 && !/\(1M\)/i.test(s)) s += ' (1M)'
115  return s
116}
117
118const US_STATES: Record<string, string> = {
119  alabama: 'AL', alaska: 'AK', arizona: 'AZ', arkansas: 'AR', california: 'CA',
120  colorado: 'CO', connecticut: 'CT', delaware: 'DE', florida: 'FL', georgia: 'GA',
121  hawaii: 'HI', idaho: 'ID', illinois: 'IL', indiana: 'IN', iowa: 'IA', kansas: 'KS',
122  kentucky: 'KY', louisiana: 'LA', maine: 'ME', maryland: 'MD', massachusetts: 'MA',
123  michigan: 'MI', minnesota: 'MN', mississippi: 'MS', missouri: 'MO', montana: 'MT',
124  nebraska: 'NE', nevada: 'NV', 'new hampshire': 'NH', 'new jersey': 'NJ',
125  'new mexico': 'NM', 'new york': 'NY', 'north carolina': 'NC', 'north dakota': 'ND',
126  ohio: 'OH', oklahoma: 'OK', oregon: 'OR', pennsylvania: 'PA', 'rhode island': 'RI',
127  'south carolina': 'SC', 'south dakota': 'SD', tennessee: 'TN', texas: 'TX', utah: 'UT',
128  vermont: 'VT', virginia: 'VA', washington: 'WA', 'west virginia': 'WV',
129  wisconsin: 'WI', wyoming: 'WY', 'district of columbia': 'DC',
130}
131
132/** "Glendale, Arizona, United States" reads "Glendale, AZ". */
133export function shortPlace(raw: string): string {
134  const parts = raw.split(',').map(p => p.trim()).filter(Boolean)
135  if (parts.length === 0) return ''
136  const city = parts[0]!
137  const region = parts[1]
138  if (region === undefined) return capitalize(city)
139  const state = US_STATES[region.toLowerCase()]
140  return `${city}, ${state ?? region}`
141}
142
143/** Parses wttr.in's `format=%t|%C|%l` reply. */
144export function parseWeather(text: string): { temp: string; condition: string; place: string } | null {
145  const [temp, condition, place] = text.trim().split('|')
146  if (temp === undefined || condition === undefined || !/°/.test(temp)) return null
147  return { temp: temp.replace(/^\+/, '').trim(), condition: condition.trim(), place: (place ?? '').trim() }
148}
149
150/** Merges runs of the same color so a bar is a handful of Text elements. */
151function merge(cells: Seg[]): Seg[] {
152  const out: Seg[] = []
153  for (const cell of cells) {
154    const last = out.at(-1)
155    if (last !== undefined && last.color === cell.color && last.bold === cell.bold) last.text += cell.text
156    else out.push({ ...cell })
157  }
158  return out
159}
160
161function repeat(char: string, n: number, color: string): Seg[] {
162  return n > 0 ? [{ text: char.repeat(n), color }] : []
163}
164
165/**
166 * The context window as one bar: each category its own color, the free
167 * window as a dim track, the autocompact buffer dashed at the end.
168 */
169export function contextBar(usage: CloakedUsage | null, width: number): Seg[] {
170  if (width <= 0) return []
171  if (usage === null) return repeat('─', width, C.track)
172  const total = usage.maxTokens
173  if (total <= 0 || usage.used.length === 0) {
174    const filled = Math.round(((usage.percent ?? 0) / 100) * width)
175    return merge([...repeat('━', Math.min(width, filled), C.blue), ...repeat('─', width - filled, C.track)])
176  }
177  const bufferCells = Math.min(width, Math.round((usage.bufferTokens / total) * width))
178  let room = width - bufferCells
179  const cells: Seg[] = []
180  for (const cat of usage.used) {
181    if (room <= 0) break
182    const n = Math.min(room, Math.max(cat.tokens > 0 ? 1 : 0, Math.round((cat.tokens / total) * width)))
183    cells.push(...repeat('━', n, categoryColor(cat.label)))
184    room -= n
185  }
186  cells.push(...repeat('─', room, C.track))
187  cells.push(...repeat('╌', bufferCells, C.buffer))
188  return merge(cells)
189}
190
191/** Teal or blue to half, orange to 80, red past it. */
192export function tierColor(percent: number, base: string): string {
193  if (percent >= 80) return C.red
194  if (percent >= 50) return C.orange
195  return base
196}
197
198function half(percent: number | null, cells: number, base: string): Seg[] {
199  const filled = percent === null ? 0 : Math.min(cells, Math.round((percent / 100) * cells))
200  const out: Seg[] = []
201  for (let i = 0; i < filled; i++) out.push({ text: '━', color: tierColor(((i + 1) / cells) * 100, base) })
202  return out
203}
204
205/**
206 * Session (five hour) and weekly limits on one bar that fills from the centre
207 * out: the session to the left, the week to the right, a diamond between.
208 */
209export function splitBar(session: number | null, weekly: number | null, width: number): Seg[] {
210  const inner = Math.max(0, width - 1)
211  const leftCells = Math.floor(inner / 2)
212  const rightCells = inner - leftCells
213  const left = half(session, leftCells, C.green).reverse()
214  const right = half(weekly, rightCells, C.weekly)
215  return merge([
216    ...repeat(' ', leftCells - left.length, C.track),
217    ...left,
218    { text: '◆', color: C.orange },
219    ...right,
220    ...repeat(' ', rightCells - right.length, C.track),
221  ])
222}
223
224export type CacheState = { bar: Seg[]; label: string; color: string }
225
226/** The prompt cache: live while a turn runs, draining after, cold past its TTL. */
227export function cacheBar(
228  lastResponseAt: number | null,
229  now: number,
230  ttlMs: number,
231  isWorking: boolean,
232  width: number,
233): CacheState {
234  if (isWorking) return { bar: repeat('━', width, C.green), label: 'live', color: C.green }
235  if (lastResponseAt === null) return { bar: repeat('─', width, C.track), label: 'idle', color: C.label }
236  const left = ttlMs - (now - lastResponseAt)
237  if (left <= 0) return { bar: repeat('─', width, C.red), label: 'cold', color: C.red }
238  const frac = left / ttlMs
239  const color = frac < 0.25 ? C.yellow : C.green
240  const filled = Math.min(width, Math.ceil(frac * width))
241  return {
242    bar: merge([...repeat('━', filled, color), ...repeat('─', width - filled, C.track)]),
243    label: `warm ${countdown(left)}`,
244    color,
245  }
246}
247
248/** Tokens spent on everything but the conversation itself. */
249export function tooling(used: readonly CloakedCategory[]): number {
250  return used.filter(c => c.label !== 'msgs').reduce((sum, c) => sum + c.tokens, 0)
251}
252
253export function ageColor(ms: number): string {
254  if (ms < 3_600_000) return C.green
255  if (ms < 86_400_000) return C.yellow
256  return C.label
257}
258
259export function basename(path: string): string {
260  const parts = path.split(/[\\/]/).filter(Boolean)
261  return parts.at(-1) ?? path
262}
263
types/index.d.ts 45 lines
1export type CloakedCategory = { label: string; tokens: number }
2
3export type CloakedLimit = { percent: number; resetsAt?: string }
4
5export type CloakedUsage = {
6  startedAt: number
7  percent: number | null
8  maxTokens: number
9  used: CloakedCategory[]
10  bufferTokens: number
11  costUsd: number | null
12  session: CloakedLimit | null
13  weekly: CloakedLimit | null
14}
15
16export type CloakedEnv = {
17  version: string
18  model: string
19  tzOffsetMin: number
20  tzName: string
21}
22
23export type CloakedGit = {
24  dir: string
25  branch: string | null
26  lastCommitAt: number | null
27  modified: number
28}
29
30export type CloakedWeather = { place: string; temp: string; condition: string }
31
32declare module 'claude-code' {
33  interface PluginState {
34    'cloaked-statusline': {
35      now: number
36      env: CloakedEnv | null
37      usage: CloakedUsage | null
38      git: CloakedGit | null
39      weather: CloakedWeather | null
40      lastResponseAt: number | null
41      isHidden: boolean
42    }
43  }
44}
45