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

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

| Row | What it shows |
|---|---|
| header | ┃┃ cloaked.agency with a rule that pulses once a second |
LOC | your location, local time with timezone, current weather (wttr.in) |
ENV | Claude Code version, model, session cost, session length |
CONTEXT | the context window as one bar, each category its own colour, the autocompact buffer dashed at the end, fill % on the right |
| detail | tooling (everything that is not the conversation) and each category: system, tools, mcp, agents, memory, skills, msgs |
SESSION / WEEKLY | your 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 |
CACHE | the prompt cache: live while a turn runs, then a bar that drains over the cache TTL with a countdown, cold once it expires |
| footer | folder, 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.
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.
Open /config and look for the cloaked-statusline rows:
| Setting | Default | Notes |
|---|---|---|
| Brand | cloaked.agency | before the first dot is blue, the rest orange |
| Location | empty | like Glendale, AZ; empty looks it up from your IP |
| Units | imperial | or metric |
| Cache TTL (minutes) | 5 | set 60 if your account uses the 1 hour cache |
/cloaked toggles the band/cloaked hide, /cloaked show/cloaked refresh re-reads everything nowSESSION and WEEKLY need a Claude subscription; on an API key they read --%.LOC.ctrl+x ctrl+a (or the [-] mark) collapses the band like any other.claude plugin validate .
claude plugin test .hooks/register.tsx 419 lines1import { 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}
419hooks/format.ts 263 lines1import 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}
263types/index.d.ts 45 lines1export 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