SLOPSHOPPER

context-weather

Прогноз погоды над строкой ввода: чем полнее контекстное окно, тем хуже погода

newband
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-weather
› 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 ⛅ █████░░░░░ 49% 🙂31% 5ч ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⛅ █████░░░░░ 49% 🙂31% 5ч
README

context-weather

Плагин Claude Code: над строкой ввода показывает заполнение контекстного окна — слева анимированная 2D-иконка погоды, прогресс-бар и проценты, справа два маленьких круглых Claude: один на лимит 5 часов, другой на неделю.

ЗаполненоИконка
ещё не измеренорассвет
< 20%солнце
20–40%солнце за облаком
40–60%облачно
60–75%пасмурно
75–85%дождь
85–95%гроза
95%+ураган

Иконки — SVG с CSS-анимацией (hooks/icons.ts, hooks/claude.ts), рисуются в десктопе, VS Code и мобильном приложении через Svg isInteractive: только в этом режиме десктоп крутит анимацию. В SVG прописан color-scheme: light dark, иначе изолированный фрейм внутри тёмного приложения заливается белым. В терминале SVG нет, там эмодзи. При prefers-reduced-motion анимация выключается.

Каждого Claude (hooks/claude.ts) окружает кольцо, которое заполняется по израсходованной доле лимита. Весь персонаж, тело и кольцо, одного цвета по статусу и прыгает целиком, вместе с кольцом:

ИзрасходованоЦветЛицо
0–25%зелёныйрадуется, со щеками, подпрыгивает
25–50%зелёныйдоволен, покачивается
50–75%жёлтыйгрустит
75–90%жёлтыйплачет
90–99%красныйплачет
100%красныйбез сил, глаза крестиками

Рядом подпись «5ч» или «7д», точные проценты и время до сброса во всплывающей подсказке. Персонажи по 20 px и влезают в полосу любой ширины. Это собственный рисунок в цвете Claude, не официальный логотип.

Лимиты (hooks/limits.ts) приходят из того же замера (rateLimits) и видны только на подписке. В терминале вместо персонажа эмодзи и процент.

Что рисуют в полосе другие плагины (кнопки chat-memory), встаёт в конец правой группы. Служебный элемент движка { type: 'engine' }, который приходит из next(e), когда под плагином никто не рисует, отбрасывается: вложенный в чужое дерево, он гасит всю полосу.

Цифры берутся из события session.measure — те же, что в статус-строке, обновляются после каждого ответа модели.

Установка

claude plugin marketplace add ~/work/ai-settings/plugins
claude plugin install context-weather@popovs-plugins

Нужен Claude Code 2.1.286+.

Разработка

  • hooks/weather.ts — шкала погоды, правится здесь.
  • hooks/icons.ts — SVG-иконки погоды и их анимация.
  • hooks/limits.ts — лимиты на 5 часов и неделю.
  • hooks/claude.ts — персонаж с кольцом, цвета и настроения.
  • hooks/register.tsx — хуки и отрисовка полосы.
  • Проверка: claude plugin validate . и claude plugin test .
Source 6 files
hooks/register.tsx 117 lines
1import { atom, read, update } from 'claude-code'
2import type { Register, RenderElement } from 'claude-code'
3
4import type { ContextReading } from '../types'
5import { limitBuddySvg, moodFor } from './claude'
6import { iconSvg } from './icons'
7import { limitViews } from './limits'
8import { bar, forecast } from './weather'
9
10const reading = atom({ plugin: 'context-weather', key: 'reading' } as const, null)
11const limits = atom({ plugin: 'context-weather', key: 'limits' } as const, [])
12
13// CSS pixels; about one line of the prompt's text.
14const ICON_SIZE = 20
15const BAR_CELLS = 10
16
17// `next(e)` answers `{ type: 'engine' }` when nothing beneath drew: the engine's
18// own band, which may only stand alone, never inside another tree.
19function drawnBeneath(rest: RenderElement): RenderElement | null {
20  return rest.type === 'engine' ? null : rest
21}
22
23export const register: Register = on => {
24  on('session.start', async ($, e, next) => {
25    const usage = await $.session.usage()
26    const snapshot: ContextReading = { ...usage.context }
27    await update($, reading, () => snapshot)
28    await update($, limits, () => [...usage.rateLimits])
29
30    return next(e)
31  })
32
33  on('session.measure', async ($, e, next) => {
34    if (e.changed.includes('context')) {
35      const snapshot: ContextReading = { ...e.context }
36      await update($, reading, () => snapshot)
37    }
38    if (e.changed.includes('rateLimits')) {
39      await update($, limits, () => [...e.rateLimits])
40    }
41
42    return next(e)
43  })
44
45  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
46    // What other plugins draw in the band (chat-memory's buttons) goes at the right end.
47    const rest = await next(e)
48    const current = await read($, reading)
49
50    if (e.props.hasSurvey || current === null) {
51      return rest
52    }
53
54    const { Box, Text } = $.ui.resolve(e)
55    const weather = forecast(current.percent)
56    const percent = current.percent === undefined ? '—' : `${current.percent}%`
57
58    // The terminal draws no Svg: there the emoji stands in for the icon.
59    let icon = <Text>{weather.emoji}</Text>
60    if (e.surface !== 'terminal') {
61      const { Svg } = $.ui.resolve(e)
62      icon = (
63        <Svg
64          source={iconSvg(weather.sky)}
65          alt={weather.alt}
66          width={ICON_SIZE}
67          height={ICON_SIZE}
68          isInteractive
69        />
70      )
71    }
72
73    // One tiny Claude per rate-limit window: its ring fills, it yellows, reddens and saddens.
74    // Small enough for any band width; the exact figures are in its tooltip.
75    const windows = limitViews(await read($, limits), await $.clock.now())
76    const buddies = windows.map(w => {
77      const tooltip = `${w.title}: ${w.percent}%${w.resetsIn === undefined ? '' : `, сброс через ${w.resetsIn}`}`
78      let face = (
79        <Text color={w.color}>
80          {moodFor(w.percent).emoji}
81          {w.percent}%
82        </Text>
83      )
84      if (e.surface !== 'terminal') {
85        const { Svg } = $.ui.resolve(e)
86        face = (
87          <Svg source={limitBuddySvg(w.percent, tooltip)} alt={tooltip} width={ICON_SIZE} height={ICON_SIZE} isInteractive />
88        )
89      }
90      return (
91        <Box key={w.label} flexDirection="row" alignItems="center" gap={1}>
92          {face}
93          <Text dimColor>{w.label}</Text>
94        </Box>
95      )
96    })
97
98    return (
99      <Box flexDirection="row" justifyContent="space-between" alignItems="center" width="100%">
100        <Box flexDirection="row" alignItems="center" gap={1} flexShrink={1} minWidth={0}>
101          {icon}
102          {current.percent === undefined ? null : (
103            <Text color={weather.color} wrap="truncate-end">
104              {bar(current.percent, BAR_CELLS)}
105            </Text>
106          )}
107          <Text color={weather.color}>{percent}</Text>
108        </Box>
109        <Box flexDirection="row" alignItems="center" gap={2} flexShrink={0}>
110          {buddies}
111          {drawnBeneath(rest)}
112        </Box>
113      </Box>
114    )
115  })
116}
117
hooks/claude.ts 140 lines
1import { FRAME_STYLE } from './icons'
2
3// A tiny round Claude per rate-limit window, inside a ring that fills with the
4// share used: the less limit is left, the yellower, redder and sadder it gets.
5// 32x32, animated with CSS keyframes like the weather.
6
7export type Mood = 'joy' | 'calm' | 'sad' | 'crying' | 'drained'
8
9export type MoodView = { mood: Mood; emoji: string; alt: string }
10
11const FACE = '#3A2620'
12const CHEEK = '#F2A48A'
13const TEAR = '#7CC4FF'
14
15// Upper bound of percent used (exclusive) -> mood. Last entry catches the rest.
16const MOODS: ReadonlyArray<readonly [number, MoodView]> = [
17  [25, { mood: 'joy', emoji: '😄', alt: 'Claude радуется: лимита много' }],
18  [50, { mood: 'calm', emoji: '🙂', alt: 'Claude доволен' }],
19  [75, { mood: 'sad', emoji: '😢', alt: 'Claude грустит: половина лимита позади' }],
20  [100, { mood: 'crying', emoji: '😭', alt: 'Claude плачет: лимит на исходе' }],
21  [Infinity, { mood: 'drained', emoji: '😵', alt: 'Claude без сил: лимит исчерпан' }],
22]
23
24export function moodFor(percentUsed: number): MoodView {
25  const match = MOODS.find(([upper]) => percentUsed < upper)
26  return (match ?? MOODS[MOODS.length - 1]!)[1]
27}
28
29const STYLE = `
30.hop{animation:hop .8s ease-in-out infinite}
31.sway{animation:sway 1.6s ease-in-out infinite}
32.blink{transform-box:fill-box;transform-origin:center;animation:blink 3.2s infinite}
33.tear{animation:tear 1.2s ease-in infinite}
34@keyframes hop{0%,100%{transform:translateY(0)}50%{transform:translateY(-1px)}}
35@keyframes sway{0%,100%{transform:translateY(0)}50%{transform:translateY(-0.5px)}}
36@keyframes blink{0%,93%,100%{transform:scaleY(1)}96%{transform:scaleY(.15)}}
37@keyframes tear{0%{transform:translateY(0);opacity:1}80%{opacity:1}100%{transform:translateY(7px);opacity:0}}
38@media (prefers-reduced-motion:reduce){*{animation:none!important}}
39`
40
41const n = (value: number) => Number(value.toFixed(2))
42
43export type Level = 'ok' | 'warn' | 'alarm'
44
45// One color for body and ring, by how much of the window is used.
46const LEVELS: Record<Level, string> = {
47  ok: '#3FB950',
48  warn: '#E8B931',
49  alarm: '#E5484D',
50}
51
52// Green below half, yellow to 90%, red from there.
53export function levelFor(percentUsed: number): Level {
54  if (percentUsed >= 90) return 'alarm'
55  if (percentUsed >= 50) return 'warn'
56  return 'ok'
57}
58
59// Room above and below the ring for the whole character to hop.
60const RING_R = 13.5
61const RING_LENGTH = 2 * Math.PI * RING_R
62
63// The share used as an arc from the top, clockwise, over a faint track.
64function ring(percentUsed: number, color: string): string {
65  const filled = n((Math.max(0, Math.min(100, percentUsed)) / 100) * RING_LENGTH)
66  return (
67    `<circle cx="16" cy="16" r="${RING_R}" fill="none" stroke="#8884" stroke-width="3"/>` +
68    `<circle cx="16" cy="16" r="${RING_R}" fill="none" stroke="${color}" stroke-width="3" stroke-linecap="round" ` +
69    `stroke-dasharray="${filled} ${n(RING_LENGTH)}" transform="rotate(-90 16 16)"/>`
70  )
71}
72
73function eyes(droop: number): string {
74  const y = n(15.5 + droop)
75  return (
76    `<g class="blink"><ellipse cx="12" cy="${y}" rx="1.6" ry="2" fill="${FACE}"/>` +
77    `<ellipse cx="20" cy="${y}" rx="1.6" ry="2" fill="${FACE}"/></g>`
78  )
79}
80
81// Inner ends up: sad and crying brows.
82function brows(lift: number): string {
83  return (
84    `<g stroke="${FACE}" stroke-width="1.4" stroke-linecap="round">` +
85    `<path d="M9.5 ${n(12 + lift)}L13.5 10.5"/><path d="M22.5 ${n(12 + lift)}L18.5 10.5"/></g>`
86  )
87}
88
89function mouth(d: string): string {
90  return `<path d="${d}" fill="none" stroke="${FACE}" stroke-width="1.6" stroke-linecap="round"/>`
91}
92
93function tear(x: number, delay: number): string {
94  return (
95    `<g class="tear" style="animation-delay:-${delay}s">` +
96    `<path d="M${x} 18q-1.3 2 0 2.6q1.3-.6 0-2.6z" fill="${TEAR}"/></g>`
97  )
98}
99
100const FACES: Record<Mood, string> = {
101  joy:
102    eyes(0) +
103    `<circle cx="9.5" cy="19.5" r="1.6" fill="${CHEEK}"/><circle cx="22.5" cy="19.5" r="1.6" fill="${CHEEK}"/>` +
104    `<path d="M12 20q4 4.5 8 0z" fill="${FACE}"/>`,
105  calm: eyes(0) + mouth('M12.5 20.5q3.5 2.5 7 0'),
106  sad: brows(1.5) + eyes(1) + mouth('M12.5 23q3.5-2.8 7 0'),
107  crying: brows(1.5) + eyes(1) + mouth('M12 23.5q4-3.2 8 0') + tear(11, 0) + tear(21, 0.6),
108  drained:
109    `<g stroke="${FACE}" stroke-width="1.5" stroke-linecap="round">` +
110    '<path d="M10.5 14l3 3M13.5 14l-3 3M18.5 14l3 3M21.5 14l-3 3"/></g>' +
111    mouth('M12.5 22.5h7'),
112}
113
114// Happy moods hop, sad ones slump a pixel lower and keep still.
115const POSE: Record<Mood, { dy: number; motion: string }> = {
116  joy: { dy: 0, motion: 'hop' },
117  calm: { dy: 0, motion: 'sway' },
118  sad: { dy: 1, motion: '' },
119  crying: { dy: 1, motion: '' },
120  drained: { dy: 1, motion: '' },
121}
122
123// `title` is the tooltip the desktop shows over the interactive frame.
124export function limitBuddySvg(percentUsed: number, title: string): string {
125  const { mood } = moodFor(percentUsed)
126  const color = LEVELS[levelFor(percentUsed)]
127  const { dy, motion } = POSE[mood]
128  const safeTitle = title.replace(/&/g, '&amp;').replace(/</g, '&lt;')
129  // The face was drawn for a body of r=11 at (16,17); here the body is r=10 at (16,16).
130  const face = `<g transform="translate(16 16) scale(0.82) translate(-16 -17)">${FACES[mood]}</g>`
131  return (
132    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">' +
133    `<title>${safeTitle}</title><style>${FRAME_STYLE}${STYLE}</style>` +
134    // The ring moves with the body: the whole character hops or slumps.
135    `<g transform="translate(0 ${dy * 0.5})"><g class="${motion}">` +
136    ring(percentUsed, color) +
137    `<circle cx="16" cy="16" r="10" fill="${color}"/>${face}</g></g></svg>`
138  )
139}
140
hooks/icons.ts 113 lines
1import type { Sky } from './weather'
2
3// Flat 2D weather icons, 32x32, animated with CSS keyframes. The band draws
4// them with Svg isInteractive, in a frame where the keyframes run.
5// Every animated <g> sits inside a positioned <g>: a CSS transform replaces
6// the element's transform attribute, so the two never share an element.
7
8const SUN = '#FFC531'
9const RAY = '#FFA41B'
10const CLOUD_LIGHT = '#EEF2F6'
11const CLOUD_EDGE = '#C3CCD6'
12const CLOUD_GREY = '#A3AEBA'
13const CLOUD_DARK = '#6E7987'
14const CLOUD_STORM = '#4E5866'
15const DROP = '#4DA3FF'
16const BOLT = '#FFD43B'
17const SWIRL = '#E5484D'
18
19// Without color-scheme the frame stays light inside the dark app and Chrome
20// paints it opaque white; with both schemes it takes the app's and stays clear.
21export const FRAME_STYLE = ':root{color-scheme:light dark;background:transparent}'
22
23const STYLE = `
24.spin{transform-box:fill-box;transform-origin:center;animation:spin 12s linear infinite}
25.spin-fast{transform-box:fill-box;transform-origin:center;animation:spin 1.4s linear infinite}
26.drift{animation:drift 4s ease-in-out infinite}
27.drift-back{animation:drift 5s ease-in-out infinite reverse}
28.bob{animation:bob 3s ease-in-out infinite}
29.rise{animation:rise 4s ease-in-out infinite}
30.pulse{animation:pulse 2s ease-in-out infinite}
31.drop{animation:fall 1s linear infinite}
32.flash{animation:flash 2.4s steps(1) infinite}
33@keyframes spin{to{transform:rotate(360deg)}}
34@keyframes drift{0%,100%{transform:translateX(-1.2px)}50%{transform:translateX(1.2px)}}
35@keyframes bob{0%,100%{transform:translateY(0)}50%{transform:translateY(1px)}}
36@keyframes rise{0%,100%{transform:translateY(1.5px)}50%{transform:translateY(-0.5px)}}
37@keyframes pulse{0%,100%{opacity:.45}50%{opacity:1}}
38@keyframes fall{0%{transform:translateY(-2px);opacity:0}20%{opacity:1}100%{transform:translateY(6px);opacity:0}}
39@keyframes flash{0%,70%{opacity:1}74%{opacity:.15}78%{opacity:1}82%,92%{opacity:.15}}
40@media (prefers-reduced-motion:reduce){*{animation:none!important}}
41`
42
43function rays(cx: number, cy: number, inner: number, outer: number, width: number): string {
44  const lines = Array.from({ length: 8 }, (_, i) => {
45    const angle = (i * Math.PI) / 4
46    const point = (r: number) =>
47      `${(cx + r * Math.cos(angle)).toFixed(2)} ${(cy + r * Math.sin(angle)).toFixed(2)}`
48    return `<path d="M${point(inner)}L${point(outer)}"/>`
49  })
50  return `<g stroke="${RAY}" stroke-width="${width}" stroke-linecap="round">${lines.join('')}</g>`
51}
52
53function sun(cx: number, cy: number, r: number): string {
54  const spinning = `<g class="spin">${rays(cx, cy, r + 2.6, r + 5.4, r / 2.8)}</g>`
55  return `${spinning}<circle cx="${cx}" cy="${cy}" r="${r}" fill="${SUN}"/>`
56}
57
58// A cloud 24 wide and 14 tall, its top-left corner at (x, y).
59function cloud(x: number, y: number, scale: number, fill: string, motion: string, edge?: string): string {
60  const stroke = edge ? ` stroke="${edge}" stroke-width="1"` : ''
61  const shape =
62    `<g fill="${fill}"${stroke}>` +
63    '<circle cx="6" cy="9" r="5"/><circle cx="12.5" cy="6" r="6"/><circle cx="18.5" cy="9" r="5"/>' +
64    '<rect x="1" y="8.5" width="22.5" height="5.5" rx="2.75"/></g>' +
65    // Same shape without the stroke on top, so the inner seams of the circles disappear.
66    (edge
67      ? `<g fill="${fill}"><circle cx="6" cy="9" r="4.5"/><circle cx="12.5" cy="6" r="5.5"/>` +
68        '<circle cx="18.5" cy="9" r="4.5"/><rect x="1.5" y="9" width="21.5" height="4.5" rx="2.25"/></g>'
69      : '')
70  return `<g transform="translate(${x} ${y}) scale(${scale})"><g class="${motion}">${shape}</g></g>`
71}
72
73function drops(xs: number[], top: number): string {
74  return xs
75    .map(
76      (x, i) =>
77        `<g class="drop" style="animation-delay:-${(i * 0.33).toFixed(2)}s">` +
78        `<path d="M${x} ${top}l-1 3" stroke="${DROP}" stroke-width="2" stroke-linecap="round"/></g>`,
79    )
80    .join('')
81}
82
83const BODIES: Record<Sky, string> = {
84  dawn:
85    `<defs><clipPath id="sky"><rect width="32" height="23"/></clipPath></defs>` +
86    `<g clip-path="url(#sky)"><g class="rise">` +
87    `<g class="pulse">${rays(16, 22, 9, 12, 2.2)}</g>` +
88    `<circle cx="16" cy="22" r="7" fill="${SUN}"/></g></g>` +
89    `<path d="M4 23.5H28" stroke="${RAY}" stroke-width="2" stroke-linecap="round"/>`,
90  clear: sun(16, 16, 6.5),
91  mostlyClear: sun(12, 12, 5) + cloud(9, 15, 0.85, CLOUD_LIGHT, 'drift', CLOUD_EDGE),
92  cloudy:
93    cloud(9, 6, 0.8, CLOUD_GREY, 'drift-back') + cloud(3, 13, 0.95, CLOUD_LIGHT, 'drift', CLOUD_EDGE),
94  overcast: cloud(8, 5, 0.85, CLOUD_DARK, 'drift-back') + cloud(3, 12, 1, CLOUD_GREY, 'bob'),
95  rain: cloud(4, 4, 1, CLOUD_GREY, 'bob') + drops([10, 16, 22], 21),
96  storm:
97    cloud(4, 3, 1, CLOUD_STORM, 'bob') +
98    `<g class="flash"><path d="M17 15l-4 7h3.5l-2 6 6-8.5h-3.6l2.1-4.5z" fill="${BOLT}"/></g>` +
99    drops([9, 24], 20),
100  hurricane:
101    `<g class="spin-fast">` +
102    `<path d="M16 12C16 6.5 20.5 4 26 4.5C22 6 20 9 20 12.5Z" fill="${SWIRL}"/>` +
103    `<path d="M16 20C16 25.5 11.5 28 6 27.5C10 26 12 23 12 19.5Z" fill="${SWIRL}"/>` +
104    `<circle cx="16" cy="16" r="4.2" fill="none" stroke="${SWIRL}" stroke-width="2.6"/></g>`,
105}
106
107export function iconSvg(sky: Sky): string {
108  return (
109    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">' +
110    `<style>${FRAME_STYLE}${STYLE}</style>${BODIES[sky]}</svg>`
111  )
112}
113
hooks/limits.ts 46 lines
1import type { ThemeKey } from 'claude-code'
2
3import type { RateLimitReading } from '../types'
4
5export type LimitView = { label: string; title: string; percent: number; resetsIn?: string; color: ThemeKey }
6
7// kind, the short label beside the character, the name in its tooltip.
8const LABELS: ReadonlyArray<readonly [string, string, string]> = [
9  ['five_hour', '5ч', 'Лимит на 5 часов'],
10  ['seven_day', '7д', 'Лимит на неделю'],
11]
12
13const HOUR = 60 * 60 * 1000
14const DAY = 24 * HOUR
15
16export function formatDuration(ms: number): string {
17  if (ms <= 0) return 'сейчас'
18  if (ms >= DAY) return `${Math.floor(ms / DAY)}д ${Math.floor((ms % DAY) / HOUR)}ч`
19  if (ms >= HOUR) return `${Math.floor(ms / HOUR)}ч ${Math.floor((ms % HOUR) / 60_000)}м`
20  return `${Math.max(1, Math.floor(ms / 60_000))}м`
21}
22
23// The same thresholds as the character's color (levelFor in claude.ts).
24function colorFor(percent: number): ThemeKey {
25  if (percent >= 90) return 'error'
26  if (percent >= 50) return 'warning'
27  return 'success'
28}
29
30// The 5-hour and weekly windows in that order; the ones with no reading are left out.
31export function limitViews(limits: readonly RateLimitReading[], now: number): LimitView[] {
32  return LABELS.flatMap(([kind, label, title]) => {
33    const limit = limits.find(l => l.kind === kind)
34    if (!limit) return []
35    const resetsAt = limit.resetsAt ? Date.parse(limit.resetsAt) : NaN
36    const view: LimitView = {
37      label,
38      title,
39      percent: Math.round(limit.percentUsed),
40      color: colorFor(limit.percentUsed),
41    }
42    if (!Number.isNaN(resetsAt)) view.resetsIn = formatDuration(resetsAt - now)
43    return [view]
44  })
45}
46
hooks/weather.ts 44 lines
1import type { ThemeKey } from 'claude-code'
2
3export type Sky =
4  | 'dawn'
5  | 'clear'
6  | 'mostlyClear'
7  | 'cloudy'
8  | 'overcast'
9  | 'rain'
10  | 'storm'
11  | 'hurricane'
12
13export type Forecast = {
14  sky: Sky
15  // Fallback for surfaces without Svg (the terminal), and the icon's alt text.
16  emoji: string
17  alt: string
18  color: ThemeKey
19}
20
21// Upper bound of percent used (exclusive) -> forecast. Last entry catches the rest.
22const SCALE: ReadonlyArray<readonly [number, Forecast]> = [
23  [20, { sky: 'clear', emoji: '☀️', alt: 'Ясно', color: 'success' }],
24  [40, { sky: 'mostlyClear', emoji: '🌤', alt: 'Малооблачно', color: 'success' }],
25  [60, { sky: 'cloudy', emoji: '⛅', alt: 'Облачно', color: 'text' }],
26  [75, { sky: 'overcast', emoji: '🌥', alt: 'Пасмурно', color: 'warning' }],
27  [85, { sky: 'rain', emoji: '🌧', alt: 'Дождь', color: 'warning' }],
28  [95, { sky: 'storm', emoji: '⛈', alt: 'Гроза', color: 'error' }],
29  [Infinity, { sky: 'hurricane', emoji: '🌪', alt: 'Ураган', color: 'error' }],
30]
31
32export const DAWN: Forecast = { sky: 'dawn', emoji: '🌅', alt: 'Рассвет', color: 'subtle' }
33
34export function forecast(percent: number | undefined): Forecast {
35  if (percent === undefined) return DAWN
36  const match = SCALE.find(([upper]) => percent < upper)
37  return (match ?? SCALE[SCALE.length - 1]!)[1]
38}
39
40export function bar(percent: number, cells: number): string {
41  const filled = Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)))
42  return '█'.repeat(filled) + '░'.repeat(cells - filled)
43}
44
types/index.d.ts 11 lines
1export type ContextReading = { tokens?: number; window: number; percent?: number }
2
3// The fields of SessionRateLimit the band reads.
4export type RateLimitReading = { kind: string; percentUsed: number; resetsAt?: string }
5
6declare module 'claude-code' {
7  interface PluginState {
8    'context-weather': { reading: ContextReading | null; limits: RateLimitReading[] }
9  }
10}
11