SLOPSHOPPER

tape

A ticker tape above the prompt: live stock, index, gold, FX and crypto prices, keyless by default.

newbandcommandnetworktimer
★ 1v0.1.0MITupdated 2026-10-14modmyclaude/claude-code-mods/mods/tape
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tape
› fix the failing auth test and add an audit log call ● tape: tape: AAPL NVDA TSLA AMZN SP500 GOLD BTC ETH · hyperliquid perps · scroll ⏺ 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 › /tape ⟨Claude Code's own drawing⟩ tape · hyperliquid perps │ AAPL ? NVDA ? TSLA ? AMZN ? SP500 ? GOLD ? BTC ? ETH ? AA ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ tape · hyperliquid perps │ AAPL ? NVDA ? TSLA ? AMZN ? SP500 ? GOLD ? BTC ? ETH ? AA
README

<img src="../../brand/pixel-icons/svg/tape.svg" width="64" height="64" alt="">

Tape

A ticker tape above the prompt: live stock, index, gold, FX and crypto prices, keyless by default.

Install

/plugin install tape --marketplace modmyclaude/claude-code-mods

Use it

  • The band. One row above the prompt: tape · hyperliquid perps │ AAPL 346.16 +0.3% NVDA 225.55 -1.2% SP500 7,798 -0.1% BTC 83,508 +2.1%. Up in the theme's green, down in its red, unchanged dim; a sign and a colour, nothing else. The lead cell names the source, and adds · 2m old when the last fetch failed and the prices shown are the last good ones. The tape runs out under the divider after it, so the cell on its way out is cut there, as on any ticker.
  • Keyless by default. Prices come from Hyperliquid's public info API: stocks, indices, gold and FX from its xyz market (AAPL, NVDA, SP500, GOLD, EUR and about a hundred more), crypto from its main market (BTC, ETH, SOL and the rest). These are perpetual-contract mid prices, not exchange quotes: close to the market, not the market itself. The band says so in its lead cell. A symbol Hyperliquid does not list draws as SYM ?, with one line in the transcript.
  • Your own Alpaca key, if you have one. Put your Alpaca key id and secret in /config and stock symbols come as real-time IEX quotes from Alpaca's market data under your own account (the free plan is enough); crypto stays on Hyperliquid. The lead cell then reads alpaca iex. The keys are stored as secrets and never drawn or logged. If Alpaca refuses them, one transcript line says so and stocks go back to Hyperliquid until the keys change.
  • Motion. scroll, the default, is a marquee that moves four cells a second (speed, one to ten). Every step redraws the band: on one machine that cost about 5% of a core per session at four cells a second, 1.3% per cell a second, all day, idle or not. rotate shows pages of whole symbols that fit the band, flipped every eight seconds, with no redraw in between, and added half a percent. Running many sessions at once, set motion to rotate in /config. /tape scroll and /tape rotate switch for the session.
  • Commands. /tape writes the list and the source to the transcript. /tape add NVDA and /tape remove NVDA change the list for this session (the lasting list is the symbols setting). /tape off hides the band for the session and stops fetching; /tape on brings it back.
  • Failures. The last good prices stay on the band; nothing goes blank. After a failure or a rate limit the wait before the next fetch doubles, up to five minutes, and resets on the next success.
  • The quiet switch. With CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC set, tape makes no request at all and the band reads tape off: nonessential traffic disabled.
  • While a survey holds the band, tape steps aside; other mods' bands keep drawing above it.

Settings

Every setting has a default, so the notice at install about options not yet set is expected; change them in /config.

SettingDefaultWhat it does
enabledtrueOff draws nothing, runs no timer and fetches nothing.
symbolsAAPL,NVDA,TSLA,AMZN,SP500,GOLD,BTC,ETHThe symbols, comma-separated, forty at most. Stocks, indices, gold and FX as Hyperliquid lists them; crypto by name.
refresh10Seconds between fetches, at least 5.
motionscrollscroll or rotate, the low-power choice.
speed4Cells a second when motion is scroll, 1 to 10.
rotateSeconds8How long each page shows when motion is rotate.
alpacaKeyemptyOptional. Your Alpaca key id; stored as a secret.
alpacaSecretemptyOptional. Your Alpaca secret key; stored as a secret.

What it can touch

  • Network: one host by default, api.hyperliquid.xyz, two request kinds, each carrying nothing but the request type and the market name; a second host only when you add Alpaca keys, data.alpaca.markets, carrying your symbol list and your key. Never your prompts, code, project name or anything of the session. The mod ships no key of its own and relays nothing.
  • Environment: reads CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC.
  • Files: none. Processes: none. Model calls: none.
  • Memory: the last good prices and the band's state live in session state; your Alpaca keys live in the engine's secret settings. Nothing is written to disk by the mod.

Limits

  • Hyperliquid prices are perpetual-contract mids, the midpoint of a derivatives market that tracks the asset, not the exchange's last trade. They can sit a little off the quote, and outside market hours they can keep moving while the exchange is closed. The choice of source is yours: Alpaca's IEX quotes are exchange prices, under your own account.
  • Hyperliquid's own terms apply to its API; the mod uses the public, keyless endpoints and sends nothing of yours. Whether the service is available where you are is between you and Hyperliquid.
  • Alpaca's free plan gives IEX-venue prices in real time and 200 requests a minute; tape makes one request per refresh for all your stock symbols. SIP (full-market) data is not on the free plan.
  • The 24 h change is read from each source's own earlier price and shows -- until that has arrived, about a minute after the band first draws.
  • The desktop app draws text in a proportional font, narrower than the cells the band counts, so there the band packs about a quarter more than the count says. A page can still cut its last price at the right edge when the symbols run long.
  • A symbol list is capped at forty, and each symbol at sixteen characters of letters, digits, colon, dot and dash; anything else is dropped before it is drawn.

Built from a prompt

This mod was built from the tape prompt.

More mods: the full catalogue.

Source 3 files
hooks/register.tsx 365 lines
1// tape: a ticker band above the prompt. Prices come from Hyperliquid's public
2// info API with no key (perpetual-contract mids for stocks, indices, gold, FX
3// and crypto), or, for stocks, from Alpaca's market data under the person's
4// own keys. The last good prices live in $.state, so a failed fetch never
5// blanks the band; a marquee or rotating pages show them.
6//
7// What leaves this machine: to api.hyperliquid.xyz, the request type and the
8// dex name, nothing else; to data.alpaca.markets, only when both keys are
9// set, the symbol list and the keys. Never a prompt, a file, a project name
10// or anything of the session. The keys are never drawn, logged or stored
11// anywhere but the engine's secret settings.
12
13import { atom, read, update } from 'claude-code'
14import type { EngineInterface, Register, Timer } from 'claude-code'
15
16import type { TapeMeta, TapeQuotes, TapeView } from '../types'
17import {
18  ageOf, backoff, cellsOf, clampSpeed, DEFAULT_MOTION, DEFAULT_SPEED, DEFAULT_SYMBOLS, DIVIDER, frameMsOf, isMotion, isQuiet, leadOf, pagesOf,
19  parseAssetCtxs, parseMids, parseSnapshots, parseSymbols, ring, roomOf, runsOfPage, windowOf,
20} from './tape'
21import type { Motion, Run } from './tape'
22
23const HL_URL = 'https://api.hyperliquid.xyz/info'
24const ALPACA_URL = 'https://data.alpaca.markets/v2/stocks/snapshots'
25/** The previous day's prices are refreshed every this many successful fetches (about a minute at the default refresh). */
26const CONTEXT_EVERY = 6
27const MIN_REFRESH_S = 5
28const MAX_REFRESH_S = 300
29
30const quotes = atom({ plugin: 'tape', key: 'quotes' } as const, {} as TapeQuotes)
31const meta = atom({ plugin: 'tape', key: 'meta' } as const, { lastOk: null, lastErr: null, intervalMs: 10_000 } as TapeMeta)
32const view = atom({ plugin: 'tape', key: 'view' } as const, { on: true } as TapeView)
33
34// The settings, as the manifest's fields hold them; config.set keeps them current.
35let enabled = true
36let symbolsSetting = DEFAULT_SYMBOLS
37let refreshS = 10
38let motionSetting: Motion = DEFAULT_MOTION
39let speedSetting = DEFAULT_SPEED
40let rotateS = 8
41let alpacaKey = ''
42let alpacaSecret = ''
43/** The nonessential-traffic switch, read at session start: no request leaves while it is set. */
44let quiet = false
45
46// The loops. Module values, so a code reload drops them: session.start starts them again.
47let fetchTimer: Timer | undefined
48let frameTimer: Timer | undefined
49let pageTimer: Timer | undefined
50let okTicks = 0
51
52function cancelLoops() {
53  fetchTimer?.cancel()
54  frameTimer?.cancel()
55  pageTimer?.cancel()
56  fetchTimer = frameTimer = pageTimer = undefined
57}
58
59function clampRefresh(value: unknown): number {
60  const n = typeof value === 'number' && Number.isFinite(value) ? value : 10
61  return Math.min(MAX_REFRESH_S, Math.max(MIN_REFRESH_S, Math.round(n)))
62}
63
64function clampRotate(value: unknown): number {
65  const n = typeof value === 'number' && Number.isFinite(value) ? value : 8
66  return Math.min(60, Math.max(2, Math.round(n)))
67}
68
69/** Starts the fetch loop and the motion for the current settings; nothing while off, hidden or quiet. */
70async function startLoops($: EngineInterface) {
71  cancelLoops()
72  const v = await read($, view)
73  if (!enabled || !v.on || quiet) return
74  scheduleFetch($, 0)
75  await startMotion($)
76}
77
78/** Restarts the frame or page timer for the current motion, speed and page time; the fetch loop is left alone. */
79async function startMotion($: EngineInterface) {
80  frameTimer?.cancel()
81  pageTimer?.cancel()
82  frameTimer = pageTimer = undefined
83  const v = await read($, view)
84  if (!enabled || !v.on || quiet) return
85  const motion = v.motion ?? motionSetting
86  if (motion === 'scroll') {
87    frameTimer = $.clock.every(frameMsOf(speedSetting), () => void update($, view, x => ({ ...x, offset: (x.offset ?? 0) + 1 })))
88  } else {
89    pageTimer = $.clock.every(rotateS * 1000, () => void update($, view, x => ({ ...x, page: (x.page ?? 0) + 1 })))
90  }
91}
92
93/** One fetch timer at a time: a new schedule replaces the old. */
94function scheduleFetch($: EngineInterface, ms: number) {
95  fetchTimer?.cancel()
96  fetchTimer = $.clock.after(ms, () => {
97    fetchTimer = undefined
98    void tick($)
99  })
100}
101
102/** One Hyperliquid info request: the type and the dex, nothing else. */
103async function hyperliquid($: EngineInterface, body: { type: string; dex?: string }): Promise<string> {
104  const r = await $.http.fetch(HL_URL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
105  if (!r.ok) throw new Error(`hyperliquid answered ${r.status}`)
106  return r.text
107}
108
109async function currentSymbols($: EngineInterface): Promise<string[]> {
110  const v = await read($, view)
111  return parseSymbols(v.symbols ?? symbolsSetting)
112}
113
114/** One round of fetches, then the next is scheduled: sooner after a success, later after each failure. */
115async function tick($: EngineInterface) {
116  if (!enabled || quiet) return
117  const v = await read($, view)
118  if (!v.on) return
119  const list = parseSymbols(v.symbols ?? symbolsSetting)
120  const now = await $.clock.now()
121  const m = await read($, meta)
122  try {
123    const [xyzText, mainText] = await Promise.all([hyperliquid($, { type: 'allMids', dex: 'xyz' }), hyperliquid($, { type: 'allMids' })])
124    const xyzMids = parseMids(xyzText)
125    const mainMids = parseMids(mainText)
126    const previous = await read($, quotes)
127    // The previous day's prices, once a minute or so, for the change; a crypto symbol needs the main dex's too.
128    let prevDays: Record<string, number> = {}
129    if (okTicks % CONTEXT_EVERY === 0) {
130      prevDays = parseAssetCtxs(await hyperliquid($, { type: 'metaAndAssetCtxs', dex: 'xyz' }))
131      if (list.some(s => mainMids[s] !== undefined)) Object.assign(prevDays, parseAssetCtxs(await hyperliquid($, { type: 'metaAndAssetCtxs' })))
132    }
133    // Stocks from Alpaca when the person's keys are set and were not refused: one request for all of them.
134    let alpaca: Record<string, { price: number; prevDay?: number }> = {}
135    if (alpacaKey !== '' && alpacaSecret !== '' && m.alpacaRefused !== true) {
136      const stocks = list.filter(s => mainMids[s] === undefined)
137      if (stocks.length > 0) {
138        const r = await $.http.fetch(`${ALPACA_URL}?symbols=${stocks.join(',')}&feed=iex`, {
139          headers: { 'APCA-API-KEY-ID': alpacaKey, 'APCA-API-SECRET-KEY': alpacaSecret },
140        })
141        if (r.status === 401 || r.status === 403) {
142          await update($, meta, x => ({ ...x, alpacaRefused: true }))
143          $.ui.log('tape: alpaca refused the key; stocks stay on hyperliquid until the keys change')
144        } else if (r.ok) {
145          alpaca = parseSnapshots(r.text)
146        }
147      }
148    }
149    const next: TapeQuotes = {}
150    const unknown: string[] = []
151    for (const s of list) {
152      const a = alpaca[s]
153      const x = xyzMids[`xyz:${s}`]
154      const c = mainMids[s]
155      const kept = previous[s]?.prevDay
156      if (a !== undefined) {
157        next[s] = { price: a.price, ...(a.prevDay !== undefined ? { prevDay: a.prevDay } : {}), source: 'alpaca', at: now }
158      } else if (x !== undefined) {
159        const prevDay = prevDays[`xyz:${s}`] ?? (previous[s]?.source === 'hyperliquid' ? kept : undefined)
160        next[s] = { price: x, ...(prevDay !== undefined ? { prevDay } : {}), source: 'hyperliquid', at: now }
161      } else if (c !== undefined) {
162        const prevDay = prevDays[s] ?? (previous[s]?.source === 'hyperliquid' ? kept : undefined)
163        next[s] = { price: c, ...(prevDay !== undefined ? { prevDay } : {}), source: 'hyperliquid', at: now }
164      } else {
165        unknown.push(s)
166      }
167    }
168    const logged = m.unknown ?? []
169    for (const s of unknown) if (!logged.includes(s)) $.ui.log(`tape: ${s} is not on hyperliquid`)
170    await update($, quotes, () => next)
171    await update($, meta, x => ({ ...x, lastOk: now, intervalMs: refreshS * 1000, unknown: [...new Set([...logged, ...unknown])] }))
172    okTicks += 1
173    scheduleFetch($, refreshS * 1000)
174  } catch {
175    const wait = backoff(m.intervalMs)
176    await update($, meta, x => ({ ...x, lastErr: now, intervalMs: wait }))
177    scheduleFetch($, wait)
178  }
179}
180
181function paintOf(tone: Run['tone']): { color?: 'success' | 'error'; dimColor?: boolean } {
182  if (tone === 'up') return { color: 'success' }
183  if (tone === 'down') return { color: 'error' }
184  if (tone === 'name') return {}
185  return { dimColor: true }
186}
187
188export const register: Register = (on, options) => {
189  enabled = options.enabled !== false
190  symbolsSetting = typeof options.symbols === 'string' && options.symbols.trim() !== '' ? options.symbols : DEFAULT_SYMBOLS
191  refreshS = clampRefresh(options.refresh)
192  motionSetting = isMotion(options.motion) ? options.motion : DEFAULT_MOTION
193  speedSetting = clampSpeed(options.speed)
194  rotateS = clampRotate(options.rotateSeconds)
195  alpacaKey = typeof options.alpacaKey === 'string' ? options.alpacaKey.trim() : ''
196  alpacaSecret = typeof options.alpacaSecret === 'string' ? options.alpacaSecret.trim() : ''
197
198  on('session.start', async ($, e, next) => {
199    quiet = isQuiet(await $.env.get('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC'))
200    okTicks = 0
201    await $.command.register({ name: 'tape', description: 'The ticker tape: /tape, /tape add SYM, /tape remove SYM, /tape on, /tape off, /tape scroll, /tape rotate' })
202    await startLoops($)
203    return next(e)
204  })
205
206  // The settings as they change in /config.
207  on('config.set', { key: 'tape.enabled' }, async ($, e, next) => {
208    const r = await next(e)
209    enabled = e.value === true
210    await startLoops($)
211    $.ui.invalidate('ui.render')
212    return r
213  })
214  on('config.set', { key: 'tape.symbols' }, async ($, e, next) => {
215    const r = await next(e)
216    symbolsSetting = typeof e.value === 'string' && e.value.trim() !== '' ? e.value : DEFAULT_SYMBOLS
217    await update($, view, x => {
218      const { symbols: _, ...rest } = x
219      return rest
220    })
221    if (enabled && !quiet) scheduleFetch($, 0)
222    $.ui.invalidate('ui.render')
223    return r
224  })
225  on('config.set', { key: 'tape.refresh' }, async ($, e, next) => {
226    const r = await next(e)
227    refreshS = clampRefresh(e.value)
228    return r
229  })
230  on('config.set', { key: 'tape.motion' }, async ($, e, next) => {
231    const r = await next(e)
232    motionSetting = isMotion(e.value) ? e.value : DEFAULT_MOTION
233    await update($, view, x => {
234      const { motion: _, ...rest } = x
235      return rest
236    })
237    await startMotion($)
238    $.ui.invalidate('ui.render')
239    return r
240  })
241  on('config.set', { key: 'tape.speed' }, async ($, e, next) => {
242    const r = await next(e)
243    speedSetting = clampSpeed(e.value)
244    await startMotion($)
245    return r
246  })
247  on('config.set', { key: 'tape.rotateSeconds' }, async ($, e, next) => {
248    const r = await next(e)
249    rotateS = clampRotate(e.value)
250    await startMotion($)
251    return r
252  })
253  for (const key of ['tape.alpacaKey', 'tape.alpacaSecret'] as const) {
254    on('config.set', { key }, async ($, e, next) => {
255      const r = await next(e)
256      const value = typeof e.value === 'string' ? e.value.trim() : ''
257      if (key === 'tape.alpacaKey') alpacaKey = value
258      else alpacaSecret = value
259      await update($, meta, x => ({ ...x, alpacaRefused: false }))
260      if (enabled && !quiet) scheduleFetch($, 0)
261      return r
262    })
263  }
264
265  on('command.run', { command: 'tape' }, async ($, e) => {
266    const [verb = '', arg = ''] = e.args.trim().split(/\s+/)
267    const v = await read($, view)
268    const list = parseSymbols(v.symbols ?? symbolsSetting)
269    const usage = 'tape: /tape, /tape add SYM, /tape remove SYM, /tape on, /tape off, /tape scroll, /tape rotate; the lasting list is in /config'
270    switch (verb.toLowerCase()) {
271      case '': {
272        const q = await read($, quotes)
273        const source = Object.values(q).some(x => x.source === 'alpaca') ? 'alpaca iex for stocks, hyperliquid perps for crypto' : 'hyperliquid perps'
274        $.ui.log(`tape: ${list.length > 0 ? list.join(' ') : 'no symbols'} · ${source} · ${!enabled ? 'off in /config' : v.on ? (v.motion ?? motionSetting) : 'off this session'}`)
275        break
276      }
277      case 'add': {
278        const s = parseSymbols(arg)[0]
279        if (s === undefined) {
280          $.ui.log(usage)
281          break
282        }
283        await update($, view, x => ({ ...x, symbols: parseSymbols([...list, s].join(',')).join(',') }))
284        if (enabled && !quiet && v.on) scheduleFetch($, 0)
285        break
286      }
287      case 'remove': {
288        const s = parseSymbols(arg)[0]
289        if (s === undefined) {
290          $.ui.log(usage)
291          break
292        }
293        await update($, view, x => ({ ...x, symbols: list.filter(k => k !== s).join(',') }))
294        break
295      }
296      case 'on':
297        await update($, view, x => ({ ...x, on: true }))
298        await startLoops($)
299        break
300      case 'off':
301        await update($, view, x => ({ ...x, on: false }))
302        cancelLoops()
303        break
304      case 'scroll':
305      case 'rotate':
306        await update($, view, x => ({ ...x, motion: verb.toLowerCase() as Motion }))
307        await startMotion($)
308        break
309      default:
310        $.ui.log(usage)
311    }
312    return {}
313  })
314
315  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
316    if (e.props.hasSurvey) return next(e)
317    const v = await read($, view)
318    if (!enabled || !v.on) return next(e)
319    const { Box, Text } = $.ui.resolve(e)
320    const below = await next(e)
321    const width = Math.max(1, e.props.bodyColumns)
322    if (quiet) {
323      return (
324        <Box flexDirection="column">
325          {below}
326          <Box key="tape" minWidth={0}>
327            <Text dimColor wrap="truncate-end">tape off: nonessential traffic disabled</Text>
328          </Box>
329        </Box>
330      )
331    }
332    const q = await read($, quotes)
333    const m = await read($, meta)
334    const now = await $.clock.now()
335    const cells = cellsOf(parseSymbols(v.symbols ?? symbolsSetting), q)
336    const source = Object.values(q).some(x => x.source === 'alpaca') ? 'alpaca' : 'hyperliquid'
337    const stale = m.lastErr !== null && (m.lastOk === null || m.lastErr > m.lastOk)
338    const lead = leadOf(source, stale ? ageOf(now, m.lastOk) : undefined)
339    const room = roomOf(width, lead, e.surface)
340    const motion = v.motion ?? motionSetting
341    let runs: Run[]
342    if (motion === 'scroll') {
343      runs = windowOf(ring(cells), v.offset ?? 0, room)
344    } else {
345      const pages = pagesOf(cells, room)
346      runs = pages.length === 0 ? [{ text: 'no symbols', tone: 'dim' }] : runsOfPage(pages[((v.page ?? 0) % pages.length + pages.length) % pages.length]!)
347    }
348    return (
349      <Box flexDirection="column">
350        {below}
351        <Box key="tape" flexDirection="row" width={width} minWidth={0}>
352          <Box flexShrink={0}>
353            <Text dimColor>{`${lead}${DIVIDER}`}</Text>
354          </Box>
355          <Box flexDirection="row" flexShrink={1} minWidth={0}>
356            {runs.map(run => (
357              <Text {...paintOf(run.tone)} wrap="truncate-end">{run.text}</Text>
358            ))}
359          </Box>
360        </Box>
361      </Box>
362    )
363  })
364}
365
hooks/tape.ts 270 lines
1// What the tape shows and how it moves, as pure functions over plain values:
2// the symbol list, the sources' answers, the cells of a row, the marquee's
3// window and the pages of the rotate mode. Nothing here reaches the engine,
4// so every rule is testable on its own.
5
6import type { TapeQuotes } from '../types'
7
8export const DEFAULT_SYMBOLS = 'AAPL,NVDA,TSLA,AMZN,SP500,GOLD,BTC,ETH'
9/** A symbol as this mod will draw it: letters, digits, a colon, a dot or a dash, sixteen at most. */
10export const SYMBOL_RE = /^[A-Z0-9][A-Z0-9:.-]{0,15}$/
11/** Symbols at most; the rest of a long setting are dropped. */
12export const SYMBOLS_MAX = 40
13/** The marquee's pace in cells a second: four reads at a glance; ten is the engine's own ceiling for redraws. */
14export const DEFAULT_SPEED = 4
15export const SPEED_MIN = 1
16export const SPEED_MAX = 10
17/** Between the lead cell and the moving part: where the tape runs out, so the cell on its way out is cut there. */
18export const DIVIDER = ' │ '
19/** The desktop app draws text in a proportional font, narrower than the cells the band counts: it holds about a quarter more. */
20export const DESKTOP_FILL = 1.25
21
22/** The cells beside the lead: the band's width less the lead and the divider, a quarter more on the desktop. */
23export function roomOf(width: number, lead: string, surface: string): number {
24  const fill = surface === 'desktop' ? DESKTOP_FILL : 1
25  return Math.max(0, Math.floor(width * fill) - [...lead].length - [...DIVIDER].length)
26}
27/** The longest wait between fetches after failures. */
28export const MAX_INTERVAL_MS = 300_000
29/** The gap between two symbols on the tape. */
30export const GAP = '   '
31
32export type Tone = 'up' | 'down' | 'flat' | 'dim' | 'name'
33export type Motion = 'scroll' | 'rotate'
34/** The marquee by default, the person's call; pages (`rotate`) redraw once each and are the low-power choice. */
35export const DEFAULT_MOTION: Motion = 'scroll'
36
37/** The symbols of a setting or a command, upper-cased, cleaned, deduplicated, capped. */
38export function parseSymbols(text: string): string[] {
39  const out: string[] = []
40  for (const raw of text.split(/[\s,;]+/)) {
41    const s = raw.trim().toUpperCase()
42    if (s === '' || !SYMBOL_RE.test(s) || out.includes(s)) continue
43    out.push(s)
44    if (out.length >= SYMBOLS_MAX) break
45  }
46  return out
47}
48
49export function isMotion(value: unknown): value is Motion {
50  return value === 'scroll' || value === 'rotate'
51}
52
53/** The speed setting as whole cells a second within the range; anything else is the default. */
54export function clampSpeed(value: unknown): number {
55  const n = typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : DEFAULT_SPEED
56  return Math.min(SPEED_MAX, Math.max(SPEED_MIN, n))
57}
58
59/** The wait between two frames of the marquee at a speed, in milliseconds. */
60export function frameMsOf(speed: number): number {
61  return Math.round(1000 / clampSpeed(speed))
62}
63
64/** A finite positive number from what a source wrote, or undefined. */
65function numberOf(value: unknown): number | undefined {
66  const n = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : Number.NaN
67  return Number.isFinite(n) && n > 0 ? n : undefined
68}
69
70/** Hyperliquid `allMids`: `{ "BTC": "83508.0", "xyz:AAPL": "346.16" }` to prices, keys cleaned, values checked. */
71export function parseMids(text: string): Record<string, number> {
72  const out: Record<string, number> = {}
73  let data: unknown
74  try {
75    data = JSON.parse(text)
76  } catch {
77    return out
78  }
79  if (data === null || typeof data !== 'object' || Array.isArray(data)) return out
80  for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
81    const n = numberOf(value)
82    if (n !== undefined && /^[A-Za-z0-9:.-]{1,24}$/.test(key)) out[key] = n
83  }
84  return out
85}
86
87/** Hyperliquid `metaAndAssetCtxs`: `[{ universe: [{ name }] }, [{ midPx, prevDayPx }]]` to the previous day's price by name. */
88export function parseAssetCtxs(text: string): Record<string, number> {
89  const out: Record<string, number> = {}
90  let data: unknown
91  try {
92    data = JSON.parse(text)
93  } catch {
94    return out
95  }
96  if (!Array.isArray(data) || data.length < 2) return out
97  const meta = data[0] as { universe?: unknown } | null
98  const ctxs = data[1]
99  const universe = meta !== null && typeof meta === 'object' && Array.isArray(meta.universe) ? meta.universe : []
100  if (!Array.isArray(ctxs)) return out
101  universe.forEach((asset, i) => {
102    const name = asset !== null && typeof asset === 'object' ? (asset as { name?: unknown }).name : undefined
103    const ctx = ctxs[i]
104    if (typeof name !== 'string' || !/^[A-Za-z0-9:.-]{1,24}$/.test(name) || ctx === null || typeof ctx !== 'object') return
105    const prev = numberOf((ctx as { prevDayPx?: unknown }).prevDayPx)
106    if (prev !== undefined) out[name] = prev
107  })
108  return out
109}
110
111/** Alpaca `stocks/snapshots`: `{ "AAPL": { latestTrade: { p }, prevDailyBar: { c } } }` to prices by symbol. */
112export function parseSnapshots(text: string): Record<string, { price: number; prevDay?: number }> {
113  const out: Record<string, { price: number; prevDay?: number }> = {}
114  let data: unknown
115  try {
116    data = JSON.parse(text)
117  } catch {
118    return out
119  }
120  if (data === null || typeof data !== 'object' || Array.isArray(data)) return out
121  for (const [symbol, snap] of Object.entries(data as Record<string, unknown>)) {
122    if (!SYMBOL_RE.test(symbol) || snap === null || typeof snap !== 'object') continue
123    const s = snap as { latestTrade?: { p?: unknown }; dailyBar?: { c?: unknown }; prevDailyBar?: { c?: unknown } }
124    const price = numberOf(s.latestTrade?.p) ?? numberOf(s.dailyBar?.c)
125    if (price === undefined) continue
126    const prevDay = numberOf(s.prevDailyBar?.c)
127    out[symbol] = prevDay !== undefined ? { price, prevDay } : { price }
128  }
129  return out
130}
131
132/** A price as the tape writes it: whole with commas from a thousand up, two decimals below. */
133export function fmtPrice(n: number): string {
134  if (!Number.isFinite(n)) return '?'
135  if (n >= 1000) return Math.round(n).toLocaleString('en-US')
136  if (n >= 1) return n.toFixed(2)
137  return n.toPrecision(3)
138}
139
140/** The day's change as the tape writes it, with its tone; `--` dim where the earlier price is unknown; never NaN. */
141export function fmtChange(price: number, prevDay: number | undefined): { text: string; tone: Tone } {
142  if (prevDay === undefined || !Number.isFinite(prevDay) || prevDay <= 0 || !Number.isFinite(price)) return { text: '--', tone: 'dim' }
143  const pct = ((price - prevDay) / prevDay) * 100
144  const rounded = Math.round(pct * 10) / 10
145  if (rounded === 0) return { text: '0.0%', tone: 'flat' }
146  return { text: `${rounded > 0 ? '+' : '-'}${Math.abs(rounded).toFixed(1)}%`, tone: rounded > 0 ? 'up' : 'down' }
147}
148
149/** One symbol's cell on the tape. */
150export type Cell = { symbol: string; price: string; change: string; tone: Tone; known: boolean }
151
152export function cellsOf(symbols: readonly string[], quotes: TapeQuotes): Cell[] {
153  return symbols.map(symbol => {
154    const q = quotes[symbol]
155    if (q === undefined) return { symbol, price: '?', change: '', tone: 'dim', known: false }
156    const change = fmtChange(q.price, q.prevDay)
157    return { symbol, price: fmtPrice(q.price), change: change.text, tone: change.tone, known: true }
158  })
159}
160
161/** A run of text drawn in one tone. */
162export type Run = { text: string; tone: Tone }
163
164/** The runs of one cell: the name, the price, the change. */
165export function runsOfCell(cell: Cell): Run[] {
166  const runs: Run[] = [{ text: cell.symbol, tone: cell.known ? 'name' : 'dim' }, { text: ` ${cell.price}`, tone: cell.known ? 'name' : 'dim' }]
167  if (cell.change !== '') runs.push({ text: ` ${cell.change}`, tone: cell.tone })
168  return runs
169}
170
171export function cellWidth(cell: Cell): number {
172  return runsOfCell(cell).reduce((n, r) => n + [...r.text].length, 0)
173}
174
175/** The whole tape as one ring of runs: every cell, a gap after each, so the marquee loops with a gap. */
176export function ring(cells: readonly Cell[]): Run[] {
177  if (cells.length === 0) return [{ text: `no symbols${GAP}`, tone: 'dim' }]
178  const runs: Run[] = []
179  for (const cell of cells) {
180    runs.push(...runsOfCell(cell))
181    runs.push({ text: GAP, tone: 'dim' })
182  }
183  return runs
184}
185
186export function ringLength(runs: readonly Run[]): number {
187  return runs.reduce((n, r) => n + [...r.text].length, 0)
188}
189
190/** The `width` cells of the ring from `offset`, wrapping, as runs cut at the window's edges. */
191export function windowOf(runs: readonly Run[], offset: number, width: number): Run[] {
192  const total = ringLength(runs)
193  if (total === 0 || width <= 0 || runs.length === 0) return []
194  let start = ((Math.floor(offset) % total) + total) % total
195  let i = 0
196  while (i < runs.length && start >= [...runs[i]!.text].length) {
197    start -= [...runs[i]!.text].length
198    i += 1
199  }
200  const out: Run[] = []
201  let need = width
202  while (need > 0) {
203    const run = runs[i % runs.length]!
204    const chars = [...run.text]
205    const take = chars.slice(start, start + need)
206    if (take.length > 0) out.push({ text: take.join(''), tone: run.tone })
207    need -= take.length
208    start = 0
209    i += 1
210  }
211  return out
212}
213
214/** Whole cells packed into pages of at most `width` cells, a gap between cells; a cell wider than the page goes alone. */
215export function pagesOf(cells: readonly Cell[], width: number): Cell[][] {
216  const pages: Cell[][] = []
217  let page: Cell[] = []
218  let used = 0
219  for (const cell of cells) {
220    const w = cellWidth(cell)
221    const needed = page.length === 0 ? w : used + GAP.length + w
222    if (page.length > 0 && needed > width) {
223      pages.push(page)
224      page = []
225      used = 0
226    }
227    page.push(cell)
228    used = page.length === 1 ? w : used + GAP.length + w
229  }
230  if (page.length > 0) pages.push(page)
231  return pages
232}
233
234/** The runs of one page: its cells with gaps between, no gap after the last. */
235export function runsOfPage(page: readonly Cell[]): Run[] {
236  const runs: Run[] = []
237  page.forEach((cell, i) => {
238    if (i > 0) runs.push({ text: GAP, tone: 'dim' })
239    runs.push(...runsOfCell(cell))
240  })
241  return runs
242}
243
244/** The wait after one more failure: double, capped. */
245export function backoff(intervalMs: number): number {
246  return Math.min(MAX_INTERVAL_MS, Math.max(1000, intervalMs) * 2)
247}
248
249/** How old the last good prices are, in words. */
250export function ageOf(now: number, lastOk: number | null): string {
251  if (lastOk === null) return 'no prices yet'
252  const s = Math.max(0, Math.round((now - lastOk) / 1000))
253  if (s < 60) return `${s}s old`
254  if (s < 3600) return `${Math.round(s / 60)}m old`
255  return `${Math.round(s / 3600)}h old`
256}
257
258/** The nonessential-traffic switch counts as set unless empty, `0` or `false`. */
259export function isQuiet(value: string | undefined): boolean {
260  if (value === undefined) return false
261  const v = value.trim().toLowerCase()
262  return v !== '' && v !== '0' && v !== 'false'
263}
264
265/** The lead cell: the mod's name and its source, and the age of the prices when the last fetch failed. */
266export function leadOf(source: 'hyperliquid' | 'alpaca', staleAge: string | undefined): string {
267  const name = source === 'alpaca' ? 'alpaca iex' : 'hyperliquid perps'
268  return staleAge === undefined ? `tape · ${name}` : `tape · ${name} · ${staleAge}`
269}
270
types/index.d.ts 59 lines
1// The values tape keeps in $.state for the session: the last good prices,
2// how the fetch loop is doing, and how the band is shown. Plain JSON data:
3// the host holds them across a hot reload, and the band is drawn again when
4// one changes. Every field added later is optional, and absent reads as its
5// default, so a value kept by an older version still loads.
6
7/** One symbol's last good price. */
8export type TapeQuote = {
9  /** The price as the source gave it. */
10  price: number
11  /** The price a day earlier, for the change; absent where the source gave none yet. */
12  prevDay?: number
13  /** Where the price came from. */
14  source: 'hyperliquid' | 'alpaca'
15  /** `$.clock.now()` milliseconds when it was fetched. */
16  at: number
17}
18
19/** The last good price of every symbol that resolved, by symbol. */
20export type TapeQuotes = Record<string, TapeQuote>
21
22/** How the fetch loop is doing. */
23export type TapeMeta = {
24  /** When the last fetch succeeded, or null before the first. */
25  lastOk: number | null
26  /** When the last fetch failed, or null if none has. */
27  lastErr: number | null
28  /** The wait before the next fetch: the refresh setting, doubled after each failure up to five minutes. */
29  intervalMs: number
30  /** Alpaca refused the keys: stocks stay on Hyperliquid until the keys change. */
31  alpacaRefused?: boolean
32  /** Symbols no source knows, each logged once. */
33  unknown?: string[]
34}
35
36/** How the band is shown this session. */
37export type TapeView = {
38  /** `/tape off` hides the band for the session; `/tape on` brings it back. */
39  on: boolean
40  /** A session override of the motion setting, from `/tape scroll` or `/tape rotate`. */
41  motion?: 'scroll' | 'rotate'
42  /** The marquee's position, in cells; wraps at the tape's length. */
43  offset?: number
44  /** The page shown while rotating; wraps at the page count. */
45  page?: number
46  /** A session override of the symbols setting, from `/tape add` and `/tape remove`. */
47  symbols?: string
48}
49
50declare module 'claude-code' {
51  interface PluginState {
52    tape: {
53      quotes: TapeQuotes
54      meta: TapeMeta
55      view: TapeView
56    }
57  }
58}
59