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

<img src="../../brand/pixel-icons/svg/tape.svg" width="64" height="64" alt="">
A ticker tape above the prompt: live stock, index, gold, FX and crypto prices, keyless by default.
/plugin install tape --marketplace modmyclaude/claude-code-mods
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.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./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.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./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.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC set, tape makes no request at all and the band reads tape off: nonessential traffic disabled.Every setting has a default, so the notice at install about options not yet set is expected; change them in /config.
| Setting | Default | What it does |
|---|---|---|
enabled | true | Off draws nothing, runs no timer and fetches nothing. |
symbols | AAPL,NVDA,TSLA,AMZN,SP500,GOLD,BTC,ETH | The symbols, comma-separated, forty at most. Stocks, indices, gold and FX as Hyperliquid lists them; crypto by name. |
refresh | 10 | Seconds between fetches, at least 5. |
motion | scroll | scroll or rotate, the low-power choice. |
speed | 4 | Cells a second when motion is scroll, 1 to 10. |
rotateSeconds | 8 | How long each page shows when motion is rotate. |
alpacaKey | empty | Optional. Your Alpaca key id; stored as a secret. |
alpacaSecret | empty | Optional. Your Alpaca secret key; stored as a secret. |
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.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC.-- until that has arrived, about a minute after the band first draws.This mod was built from the tape prompt.
More mods: the full catalogue.
hooks/register.tsx 365 lines1// 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}
365hooks/tape.ts 270 lines1// 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}
270types/index.d.ts 59 lines1// 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