SLOPSHOPPER

stock-ticker

/stock AAPL: a slim live row above the prompt with the price, the day's change and the last two hours' line, streamed from Yahoo Finance.

newbandcommandprocessnetworktimer
v0.3.0MITupdated 2026-10-14migmorales22/claude-stock-ticker
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · stock-ticker
› 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 › /stock ⎿ stock-ticker: Not watching a stock. Try /stock AAPL; /stock off hides the row. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

stock-ticker

A Claude Code mod that keeps one stock above your prompt: a slim row with the live price, the day's change, and a line of the last two hours.

MU in the row above the prompt: a pulsing dot, the price, the day's change in red, and a line of the last two hours

Install

In a terminal session of Claude Code:

/plugin install stock-ticker --marketplace migmorales22/claude-stock-ticker

Answer y to add the marketplace, then pick a scope (user scope loads it in every session, the desktop app's Code tab included). Needs a Claude Code version with mods enabled. The live price needs Node.js 22 or newer on the machine; without it the row still works, updating every 15 seconds.

Use

CommandWhat it does
/stock AAPLWatch a stock. Any Yahoo Finance symbol: TSLA, BRK-B, ^GSPC, BTC-USD, EURUSD=X.
/stockPrint the current quote in the chat.
/stock offStop watching and hide the row (the ✕ on the row does the same).
/stock debugWhat the row last drew and whether the live stream is on.

The symbol is remembered, so the row comes back in your next session.

How it works

  • The price is live. While the market trades, the mod runs stream/yahoo-stream.mjs, a small Node script that holds Yahoo Finance's price stream open and passes each new price on, about one a second for a busy stock. Each one moves the price and the end of the line. The script stops when the market closes, on /stock off, and with the session.
  • The line is the last 120 one-minute closes plus the live price: each new minute scrolls it left, and it scales to its own high and low so small moves show. Yesterday's close is dashed when the line reaches it. The color follows the day: green up, red down.
  • If the stream is down (no Node, or Yahoo changes it), the row reads the price from Yahoo's chart endpoint every 15 seconds instead, and says offline if that fails too. While the market is closed it checks every 10 minutes.
  • No tokens. It never calls Claude. The only thing that enters the conversation is the one-line reply to a /stock command you type.
  • The desktop app draws the row as a picture; a terminal shows one line with a block-character sparkline.

Data and privacy

Prices come from Yahoo Finance's public chart endpoint and price stream. Both are unofficial and undocumented: no account or key, but they can change or stop without notice. US stocks are real time; some other exchanges are delayed. The only thing sent is the ticker symbol.

For information only, not financial advice.

Develop

claude plugin validate .
claude plugin test .     # 6 tests, network and stream mocked

A note for anyone drawing SVG in a mod: the desktop app drew nothing at all, and logged nothing, for an early version whose SVG held a ▼ character, inline style= attributes and a <polyline>. Keeping the SVG plain ASCII (anything else as &#N;) and drawing glyphs as paths fixed it.

License

MIT

Source 2 files
hooks/register.tsx 442 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { MarketSession, Quote } from '../types'
5
6const quote = atom({ plugin: 'stock-ticker', key: 'quote' } as const, null)
7
8/** The watched symbol lives in the store, so the row comes back in the next session. */
9const SYMBOL_KEY = 'symbol'
10/** The chart's refresh while the stream is down, or not yet up. */
11const POLL_MS = 15_000
12/** While the stream carries the price, the chart is read only for the minute line. */
13const STREAMING_POLL_MS = 60_000
14/** A stream whose last price is older than this is not carrying the price. */
15const STREAM_FRESH_MS = 30_000
16/** After the stream could not start, the wait before trying again. */
17const STREAM_RETRY_MS = 5 * 60_000
18/** Outside pre-market to after-hours the price stands still: read it far less often. */
19const CLOSED_POLL_MS = 10 * 60_000
20/** Yahoo's symbols: AAPL, BRK-B, ^GSPC, BTC-USD, EURUSD=X, 7203.T. */
21const SYMBOL = /^\^?[A-Z0-9][A-Z0-9.=-]{0,14}$/
22const OFF = new Set(['OFF', 'STOP', 'HIDE', 'CLEAR'])
23/** As tall as savvy-progress's row, so the two sit together above the prompt. */
24const BAR_H = 22
25/** The line shows the last two hours, a point a minute: new minutes scroll it left, the live price moves its end. */
26const WINDOW = 120
27const UP = '#1f9d55'
28const DOWN = '#e5484d'
29const FLAT = '#8a8a86'
30const SPARK = '▁▂▃▄▅▆▇█'
31
32// The module's own: a reload drops it, and nothing is lost but a fetch in flight.
33let isFetching = false
34/** The last time the row above the prompt was drawn, for /stock debug. */
35const lastDraw = { count: 0, surface: '', hasSvg: false, hadQuote: false, error: '' }
36/** The live stream: its symbol, when its last price came, and whether it should keep running. */
37type Live = { symbol: string; isOn: boolean; lastAt: number }
38let live: Live | undefined
39/** When the stream last failed to start or ended by itself; 0 when it has not. */
40let liveFailedAt = 0
41
42const chartUrl = (symbol: string) =>
43  `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?interval=1m&range=1d`
44
45const clean = (s: unknown, max: number) =>
46  typeof s === 'string' ? s.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, max) : ''
47
48const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined)
49
50/** Escapes markup, and writes anything outside printable ASCII as a character reference: the SVG stays plain ASCII. */
51const xml = (s: string) => s.replace(/[&<>"']|[^\x20-\x7e]/gu, c => `&#${c.codePointAt(0)};`)
52
53/** Where the exchange is now, from the chart's trading periods (epoch seconds). */
54export function sessionAt(period: unknown, nowSec: number): MarketSession {
55  const p = period as Record<string, { start?: unknown; end?: unknown } | undefined> | undefined
56  const within = (k: string) => {
57    const start = num(p?.[k]?.start)
58    const end = num(p?.[k]?.end)
59    return start !== undefined && end !== undefined && nowSec >= start && nowSec < end
60  }
61  if (within('regular')) return 'open'
62  if (within('pre')) return 'pre'
63  if (within('post')) return 'post'
64  return 'closed'
65}
66
67/** A quote from Yahoo's chart answer, or why there is none. */
68export function parseChart(text: string, symbol: string, nowMs: number): Quote | string {
69  let data: unknown
70  try {
71    data = JSON.parse(text)
72  } catch {
73    return 'Yahoo Finance sent something that is not a quote'
74  }
75  const chart = (data as { chart?: { result?: unknown[] | null; error?: { description?: unknown } | null } }).chart
76  const r = chart?.result?.[0] as
77    | { meta?: Record<string, unknown>; indicators?: { quote?: { close?: unknown[] }[] } }
78    | undefined
79  if (r?.meta === undefined) {
80    return `no quote for ${symbol} on Yahoo Finance`
81  }
82  const m = r.meta
83  const price = num(m.regularMarketPrice)
84  const prevClose = num(m.chartPreviousClose) ?? num(m.previousClose)
85  if (price === undefined || prevClose === undefined || prevClose === 0) return `no price for ${symbol} right now`
86
87  // A minute with no trade comes back null: skip it.
88  const points = (r.indicators?.quote?.[0]?.close ?? []).map(num).filter((c): c is number => c !== undefined)
89  return {
90    symbol,
91    name: clean(m.shortName, 40) || clean(m.longName, 40) || symbol,
92    currency: clean(m.currency, 8) || 'USD',
93    price,
94    prevClose,
95    points: points.slice(-400),
96    session: sessionAt(m.currentTradingPeriod, Math.floor(nowMs / 1000)),
97    fetchedAt: nowMs,
98    isStale: false,
99  }
100}
101
102/** 1234.5 → "1,234.50"; under 1 keeps four decimals. */
103export function amount(n: number) {
104  const digits = Math.abs(n) < 1 ? 4 : 2
105  const [whole = '0', part = ''] = Math.abs(n).toFixed(digits).split('.')
106  return `${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}.${part}`
107}
108
109const PREFIX: Record<string, string> = { USD: '$', EUR: '€', GBP: '£', JPY: '¥', CNY: '¥', INR: '₹', KRW: '₩' }
110
111export function price(q: Quote) {
112  if (q.currency === 'GBp') return `${amount(q.price)}p`
113  const prefix = PREFIX[q.currency]
114  return prefix === undefined ? `${amount(q.price)} ${q.currency}` : `${prefix}${amount(q.price)}`
115}
116
117export function change(q: Quote) {
118  const diff = q.price - q.prevClose
119  const pct = (diff / q.prevClose) * 100
120  const dir: 'up' | 'down' | 'flat' = Math.abs(pct) < 0.005 ? 'flat' : diff > 0 ? 'up' : 'down'
121  const sign = dir === 'up' ? '+' : dir === 'down' ? '-' : ''
122  return {
123    dir,
124    color: dir === 'up' ? UP : dir === 'down' ? DOWN : FLAT,
125    arrow: dir === 'up' ? '▲' : dir === 'down' ? '▼' : '■',
126    text: `${sign}${amount(Math.abs(diff))} (${sign}${Math.abs(pct).toFixed(2)}%)`,
127  }
128}
129
130const SESSION_LABEL: Record<MarketSession, string> = { open: 'open', pre: 'pre-market', post: 'after hours', closed: 'closed' }
131
132/** One line saying it all: the row's alt text and the command's answer. */
133export function describe(q: Quote) {
134  const c = change(q)
135  return `${q.symbol} ${price(q)} ${c.arrow} ${c.text} today, market ${SESSION_LABEL[q.session]}${q.isStale ? ', not updating' : ''}`
136}
137
138/** The line in block characters, for the terminal. */
139export function sparkText(points: number[], width: number) {
140  if (points.length < 2 || width < 2) return ''
141  const step = points.length / width
142  const picked = points.length <= width ? points : Array.from({ length: width }, (_, i) => points[Math.floor(i * step)] ?? 0)
143  const lo = Math.min(...picked)
144  const hi = Math.max(...picked)
145  return picked.map(p => SPARK[hi === lo ? 3 : Math.round(((p - lo) / (hi - lo)) * (SPARK.length - 1))]).join('')
146}
147
148/** About how wide `s` draws at `size` px in the system font. */
149const textWidth = (s: string, size: number) => s.length * size * 0.62
150
151/** The last two hours and the live price: what the line draws. */
152export const recent = (q: Quote) => [...q.points.slice(-WINDOW), q.price]
153
154/** One slim row: a live dot, symbol, price, the day's change, the last two hours' line, and the market state. */
155export function barSvg(q: Quote, W: number) {
156  const c = change(q)
157  const isLive = !q.isStale && q.session !== 'closed'
158  const font = `font-family="-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"`
159  const mid = BAR_H / 2
160  const baseline = 15.5
161  const priceText = price(q)
162  const symbolX = 14
163  const priceX = symbolX + textWidth(q.symbol, 13) + 8
164  const arrowX = priceX + textWidth(priceText, 13) + 10
165  const changeX = arrowX + 10
166  const badge = q.isStale ? 'offline' : SESSION_LABEL[q.session]
167  const sparkLeft = changeX + textWidth(c.text, 12) + 14
168  const sparkRight = W - textWidth(badge, 11) - 14
169  // The day's direction as a small triangle beside the change.
170  const arrow =
171    c.dir === 'up'
172      ? `M${arrowX} ${mid + 3.5}L${arrowX + 7} ${mid + 3.5}L${arrowX + 3.5} ${mid - 3}Z`
173      : c.dir === 'down'
174        ? `M${arrowX} ${mid - 3}L${arrowX + 7} ${mid - 3}L${arrowX + 3.5} ${mid + 3.5}Z`
175        : `M${arrowX} ${mid - 1.25}L${arrowX + 7} ${mid - 1.25}L${arrowX + 7} ${mid + 1.25}L${arrowX} ${mid + 1.25}Z`
176
177  let spark = ''
178  const pts = recent(q)
179  if (pts.length > 1 && sparkRight - sparkLeft >= 40) {
180    const lo = Math.min(...pts)
181    const hi = Math.max(...pts)
182    const top = 3
183    const bottom = BAR_H - 3
184    const y = (v: number) => (hi === lo ? mid : bottom - ((v - lo) / (hi - lo)) * (bottom - top))
185    const x = (i: number) => sparkLeft + (i / (pts.length - 1)) * (sparkRight - sparkLeft)
186    const line = pts.map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)} ${y(v).toFixed(1)}`).join(' ')
187    // Yesterday's close, dashed, when the window reaches it.
188    const base =
189      q.prevClose >= lo && q.prevClose <= hi
190        ? `<line class="base" x1="${sparkLeft}" x2="${sparkRight.toFixed(1)}" y1="${y(q.prevClose).toFixed(1)}" y2="${y(q.prevClose).toFixed(1)}" stroke-width="1" stroke-dasharray="2 2"/>`
191        : ''
192    spark =
193      base +
194      `<path d="${line}" fill="none" stroke="${c.color}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>` +
195      `<circle cx="${sparkRight.toFixed(1)}" cy="${y(q.price).toFixed(1)}" r="2.2" fill="${c.color}"/>`
196  }
197
198  return [
199    `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${BAR_H}" viewBox="0 0 ${W} ${BAR_H}">`,
200    '<style>.t{fill:#1f1f1f}.s{fill:#6b6b68}.base{stroke:#b9b9b4}.n{font-variant-numeric:tabular-nums}',
201    '@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.base{stroke:#5a5a57}}',
202    '.live{animation:p 1.6s ease-in-out infinite}@keyframes p{50%{opacity:.3}}',
203    '@media (prefers-reduced-motion: reduce){.live{animation:none}}</style>',
204    `<circle${isLive ? ' class="live"' : ''} cx="5" cy="${mid}" r="3.5" fill="${isLive ? c.color : FLAT}"/>`,
205    `<text class="t" x="${symbolX}" y="${baseline}" ${font} font-size="13" font-weight="700">${xml(q.symbol)}</text>`,
206    `<text class="t n" x="${priceX.toFixed(1)}" y="${baseline}" ${font} font-size="13" font-weight="600">${xml(priceText)}</text>`,
207    `<path d="${arrow}" fill="${c.color}"/>`,
208    `<text class="n" x="${changeX.toFixed(1)}" y="${baseline}" fill="${c.color}" ${font} font-size="12" font-weight="600">${xml(c.text)}</text>`,
209    spark,
210    `<text class="s" x="${W - 2}" y="${baseline}" text-anchor="end" ${font} font-size="11">${badge}</text>`,
211    '</svg>',
212  ].join('')
213}
214
215async function watched($: EngineInterface): Promise<string | undefined> {
216  const got = await $.store.get(SYMBOL_KEY)
217  return typeof got === 'string' && SYMBOL.test(got) ? got : undefined
218}
219
220async function fetchQuote($: EngineInterface, symbol: string): Promise<Quote | string> {
221  try {
222    const r = await $.http.fetch(chartUrl(symbol), {
223      // Without a browser's User-Agent Yahoo answers 429 to every request.
224      headers: { 'User-Agent': 'Mozilla/5.0', Accept: 'application/json' },
225    })
226    if (r.status === 429) return 'Yahoo Finance is busy; trying again shortly'
227    return parseChart(r.text, symbol, await $.clock.now())
228  } catch {
229    return 'could not reach Yahoo Finance'
230  }
231}
232
233/** One refresh: the watched symbol, read again unless the market is closed and the last read is recent. */
234async function tick($: EngineInterface) {
235  if (isFetching) return
236  isFetching = true
237  try {
238    const symbol = await watched($)
239    const shown = await read($, quote)
240    if (symbol === undefined) {
241      stopStream()
242      if (shown !== null) await update($, quote, () => null)
243      return
244    }
245    const now = await $.clock.now()
246    const isStreaming = live?.symbol === symbol && now - live.lastAt < STREAM_FRESH_MS
247    const every = shown?.session === 'closed' ? CLOSED_POLL_MS : isStreaming ? STREAMING_POLL_MS : POLL_MS - 1000
248    const isFresh = shown?.symbol === symbol && !shown.isStale && now - shown.fetchedAt < every
249    if (!isFresh) {
250      const got = await fetchQuote($, symbol)
251      // A failed refresh keeps the last good read on the row, marked as not updating; while the
252      // stream runs, its price is newer than the chart's.
253      if (typeof got === 'string') {
254        if (!isStreaming) await update($, quote, q => (q?.symbol === symbol ? { ...q, isStale: true } : q))
255      } else {
256        await update($, quote, q => (isStreaming && q?.symbol === symbol ? { ...got, price: q.price } : got))
257      }
258    }
259    // The stream runs while the market does: a closed one sends nothing.
260    const q = await read($, quote)
261    if (q?.symbol === symbol && q.session !== 'closed') ensureStream($, symbol, now)
262    else stopStream()
263  } finally {
264    isFetching = false
265  }
266}
267
268/** The node binary: on PATH, or where Homebrew and the installers put it. */
269async function findNode($: EngineInterface) {
270  const path = (await $.env.get('PATH')) ?? ''
271  for (const dir of [...path.split(':'), '/opt/homebrew/bin', '/usr/local/bin']) {
272    if (dir.startsWith('/') && (await $.fs.exists(`${dir}/node`))) return `${dir}/node`
273  }
274  return undefined
275}
276
277/** One line from the stream: a price for the watched symbol moves the row; anything else is ignored. */
278async function onPrice($: EngineInterface, mine: Live, line: string) {
279  let got: unknown
280  try {
281    got = JSON.parse(line)
282  } catch {
283    return
284  }
285  const { id, price } = (got ?? {}) as { id?: unknown; price?: unknown }
286  const value = num(price)
287  if (id !== mine.symbol || value === undefined || value <= 0) return
288  mine.lastAt = await $.clock.now()
289  await update($, quote, q => (q?.symbol === mine.symbol ? { ...q, price: value, isStale: false } : q))
290}
291
292/**
293 * Runs stream/yahoo-stream.mjs until `mine` is switched off or the child ends. Its pings
294 * (every 5 s) wake the loop, so switching off ends the child within seconds even when nothing trades.
295 */
296async function runStream($: EngineInterface, mine: Live) {
297  try {
298    const node = await findNode($)
299    if (node !== undefined) {
300      let pending = ''
301      const child = $.process.spawn({ argv: [node, `${$.plugin.root}/stream/yahoo-stream.mjs`, mine.symbol] })
302      for await (const piece of child) {
303        if (!mine.isOn) break
304        if (piece.stream !== 'stdout') continue
305        const lines = (pending + piece.text).split('\n')
306        pending = lines.pop() ?? ''
307        for (const line of lines) await onPrice($, mine, line)
308      }
309    }
310  } catch {
311    // It could not start: the 15 s refresh carries the price.
312  }
313  // Ended without being switched off: no node, or the child stopped. Wait before trying again.
314  if (mine.isOn) liveFailedAt = await $.clock.now()
315  if (live === mine) live = undefined
316}
317
318function stopStream() {
319  if (live !== undefined) live.isOn = false
320  live = undefined
321}
322
323/** Starts the stream for `symbol` unless it runs already, or failed a moment ago. */
324function ensureStream($: EngineInterface, symbol: string, now: number) {
325  if (live?.symbol === symbol) return
326  stopStream()
327  if (now - liveFailedAt < STREAM_RETRY_MS) return
328  const mine: Live = { symbol, isOn: true, lastAt: 0 }
329  live = mine
330  void runStream($, mine).catch(() => undefined)
331}
332
333async function stop($: EngineInterface) {
334  stopStream()
335  await $.store.delete(SYMBOL_KEY)
336  await update($, quote, () => null)
337}
338
339export const register: Register = on => {
340  on('session.start', async ($, e, next) => {
341    const started = await next(e)
342    await $.command.register({
343      name: 'stock',
344      description: 'Watch a stock in a slim row above the prompt: /stock AAPL. /stock off hides it',
345    })
346    $.clock.after(0, () => void tick($).catch(() => undefined))
347    $.clock.every(POLL_MS, () => void tick($).catch(() => undefined))
348    return started
349  })
350
351  on('command.run', { command: 'stock' }, async ($, e) => {
352    const typed = e.args.trim()
353    const arg = typed.toUpperCase()
354    if (arg === '') {
355      const q = await read($, quote)
356      return { text: q === null ? 'Not watching a stock. Try /stock AAPL; /stock off hides the row.' : describe(q) }
357    }
358    if (arg === 'DEBUG') {
359      const q = await read($, quote)
360      const d = lastDraw
361      const now = await $.clock.now()
362      return {
363        text: [
364          `quote: ${q === null ? 'none' : describe(q)}`,
365          `drawn ${d.count} times; last on ${d.surface || '-'}, Svg ${d.hasSvg ? 'available' : 'not available'}, ${d.hadQuote ? 'with' : 'without'} a quote`,
366          `last error: ${d.error || 'none'}`,
367          `stream: ${live === undefined ? 'off' : `on for ${live.symbol}, last price ${live.lastAt === 0 ? 'not yet' : `${Math.round((now - live.lastAt) / 1000)} s ago`}`}${liveFailedAt === 0 ? '' : `; last failed ${Math.round((now - liveFailedAt) / 1000)} s ago`}`,
368        ].join('\n'),
369      }
370    }
371    if (OFF.has(arg)) {
372      await stop($)
373      return { text: 'Stock row off.' }
374    }
375    if (!SYMBOL.test(arg)) return { text: `"${clean(typed, 20)}" is not a ticker symbol. Try /stock AAPL` }
376    const got = await fetchQuote($, arg)
377    if (typeof got === 'string') {
378      // Yahoo writes share classes with a dash.
379      const hint = /^[A-Z]+\.[A-Z]$/.test(arg) ? ` (try ${arg.replace('.', '-')})` : ''
380      return { text: `${got.charAt(0).toUpperCase()}${got.slice(1)}${hint}.` }
381    }
382    await $.store.set(SYMBOL_KEY, arg)
383    await update($, quote, () => got)
384    if (got.session !== 'closed') ensureStream($, arg, await $.clock.now())
385    else stopStream()
386    return {
387      text: `Watching ${got.name}: ${describe(got)}. Prices stream live from Yahoo Finance while the market trades (every 15 s if the stream is down) and can be delayed. /stock off hides the row.`,
388    }
389  })
390
391  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
392    if (e.props.hasSurvey) return next(e)
393    const q = await read($, quote)
394    const ui = $.ui.resolve(e)
395    lastDraw.count += 1
396    lastDraw.surface = e.surface
397    lastDraw.hasSvg = 'Svg' in ui
398    lastDraw.hadQuote = q !== null
399    if (q === null) return next(e)
400    const { Box, Button, Text } = ui
401    const below = await next(e)
402    try {
403      const close = <Button key="stock-off" label="✕" plain role="dismiss" onPress={() => stop($)} />
404
405      if (e.surface === 'desktop' && 'Svg' in ui) {
406        const { Svg } = ui
407        // About 8 CSS px per reported column, less the dismiss and its gap; as savvy-progress sizes its row.
408        const width = Math.max(260, Math.min(720, (e.props.bodyColumns || 100) * 8 - 64))
409        return (
410          <Box flexDirection="column">
411            <Box flexDirection="row" alignItems="center" gap={1}>
412              <Svg source={barSvg(q, width)} alt={describe(q)} width={width} height={BAR_H} />
413              {close}
414            </Box>
415            {below}
416          </Box>
417        )
418      }
419
420      const c = change(q)
421      const spark = sparkText(recent(q), Math.max(0, Math.min(24, e.props.bodyColumns - 48)))
422      return (
423        <Box flexDirection="column">
424          <Box flexDirection="row" gap={1}>
425            <Text bold>{q.symbol}</Text>
426            <Text>{price(q)}</Text>
427            <Text color={c.color}>{`${c.arrow} ${c.text}`}</Text>
428            {spark !== '' && <Text color={c.color}>{spark}</Text>}
429            <Text dimColor>{q.isStale ? 'offline' : SESSION_LABEL[q.session]}</Text>
430            {close}
431          </Box>
432          {below}
433        </Box>
434      )
435    } catch (err) {
436      // A drawing that failed leaves the row to the plugins beneath; /stock debug says why.
437      lastDraw.error = String(err).slice(0, 300)
438      return below
439    }
440  })
441}
442
types/index.d.ts 31 lines
1/** Where the exchange is in its day when the quote was read. */
2export type MarketSession = 'pre' | 'open' | 'post' | 'closed'
3
4/** One read of a stock, as the tile draws it. */
5export type Quote = {
6  /** The symbol as watched, upper case (`AAPL`, `BRK-B`, `^GSPC`). */
7  symbol: string
8  /** The exchange's short name for it (`Apple Inc.`). */
9  name: string
10  currency: string
11  price: number
12  /** Yesterday's close: what the day's change is measured from. */
13  prevClose: number
14  /** Today's closes, a minute apart, oldest first. */
15  points: number[]
16  session: MarketSession
17  /** When the quote was read (epoch ms). */
18  fetchedAt: number
19  /** True once a refresh failed: the tile still shows the last good read. */
20  isStale: boolean
21}
22
23declare module 'claude-code' {
24  interface PluginState {
25    'stock-ticker': {
26      /** What the tile shows; null when nothing is watched. */
27      quote: Quote | null
28    }
29  }
30}
31