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

Плагин 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 .hooks/register.tsx 117 lines1import { 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}
117hooks/claude.ts 140 lines1import { 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, '&').replace(/</g, '<')
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}
140hooks/icons.ts 113 lines1import 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}
113hooks/limits.ts 46 lines1import 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}
46hooks/weather.ts 44 lines1import 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}
44types/index.d.ts 11 lines1export 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