Stock Alarm (SA) mod: a boxed watchlist table docked above the prompt (or a live one-line ticker strip), a /sa watchlist pane, local price alerts, and /sa…

Live stock prices in Claude Code. This mod (a Claude Code plugin with a hooks module) keeps your watchlist in view while you work:
SA AAPL 340.42 ▲0.72% NVDA 230.48 ▼0.03% MSFT …, green when a stock is up, red when it's down, and closed when the US market is shut. It replaces the table in a short or narrow terminal, with the band setting, or after /sa undock. While the table is showing, the strip is not drawn./sa watchlist pane: the full watchlist with price, change, alerts, an "Add" field, an open button per row (opens the symbol's quote page on Stock Alarm Pro, e.g. https://pro.stockalarm.io/quote/NVDA), and an x to remove it./sa alert NVDA above 250 and press Enter. When the price crosses, NVDA is highlighted in the table, strip and pane, and a notification pops up in Claude Code.
Mockup: the docked watchlist table with a triggered MSFT alert during a normal Claude Code session. (Drawn before 0.3.1; the ticker strip under the table is no longer shown while the table is docked.)
claude --version.claude plugin marketplace add shawn14/claude-stock-alarm
claude plugin install stock-alarm@stock-alarm
Or, inside a Claude Code session: /plugin marketplace add shawn14/claude-stock-alarm, then /plugin install stock-alarm@stock-alarm.
Restart Claude Code (/restart) or run /reload-plugins in an open session. The watchlist table appears above the prompt within a few seconds.
Update: claude plugin marketplace update stock-alarm && claude plugin update stock-alarm@stock-alarm Turn it off: claude plugin disable stock-alarm@stock-alarm (or /plugin → Installed → stock-alarm) Remove it: claude plugin uninstall stock-alarm@stock-alarm && claude plugin marketplace remove stock-alarm
/restart) or run /reload-plugins after installing.> is)./sa alert NVDA above 250 and press Enter.More things to try:
below works too: /sa alert NVDA below 200. Use any symbol and any price./sa alert NVDA clear removes the alarms on NVDA./sa add AMD adds a symbol to your watchlist./sa on its own opens the full watchlist./sa help lists every command.Good to know:
Type these in the prompt and press Enter. Not sure what to type? Run /sa help. The docked table's footer ends with a dim · /sa help hint, and the ticker strip ends with one when there's room for it (it's the first thing dropped in a narrow terminal, so it never pushes a quote off). The first time the mod runs it shows a one-time tip: Stock Alarm: to set an alarm, type /sa alert NVDA above 250 and press Enter · /sa opens your watchlist · /sa help for more.
| Command | What it does |
|---|---|
/sa | Open the full watchlist pane (Esc closes it; type symbols in "Add" and press Enter) |
/sa add AMD PLTR or /sa-add AMD PLTR | Add symbols (up to 25) |
/sa rm TSLA or /sa-rm TSLA | Remove symbols |
/sa list | Print the watchlist with fresh quotes |
/sa open NVDA | Open NVDA's Stock Alarm Pro quote page (https://pro.stockalarm.io/quote/NVDA) in your browser. Symbols are upper-cased and share classes use a dot (/sa open brk-b opens /quote/BRK.B) |
/sa alert NVDA above 250 | Alarm when NVDA trades at or above 250 |
/sa alert NVDA below 200 | Alarm when NVDA trades at or below 200 |
/sa alert NVDA clear | Remove the alarms on NVDA |
/sa alert NVDA > 250 | Same as above. >= also means above, and < or <= mean below. A $ before the price is fine ($250) |
/sa alert NVDA 250 | No above or below: the mod compares 250 with NVDA's current price, picks above (250 is higher) or below (250 is lower), and tells you which it chose |
/sa dock / /sa dock 8 | Dock the watchlist table above the prompt (optionally with a row count, 1 to 25) |
/sa undock | Just the one-line ticker strip, no table |
/sa hide / /sa show | Hide or show the ticker (table or strip) |
/sa hints off / /sa hints on | Hide or show the · /sa help hint in the table footer and strip (for power users) |
/sa refresh | Refresh quotes now |
/sa reset | Back to the default watchlist, alarms cleared |
/sa help | List the commands |
The table lives in Claude Code's band above the prompt, so it stays visible while you chat and while Claude works:
╭──────────────────────────────────────────────────────────────────────────╮
│ Stock Alarm · Watchlist (/sa) · refreshed 7:12am · market closed │
│ SYMBOL PRICE CHANGE ALARM ACTIONS │
│ AAPL 340.42 ▼0.00% — open · x │
│ NVDA 230.48 ▼0.03% above 250.00 open · x │
│ MSFT 522.61 ▼0.02% below 525.00 · TRIGGERED open · x │
│ TSLA 375.00 ▼0.01% — open · x │
│ Add: symbol… ⏎ add /sa add · /sa rm · /sa alert NVDA… · /sa help │
╰──────────────────────────────────────────────────────────────────────────╯
The table is drawn on its own. When it folds away, this one-line strip takes its place:
SA AAPL 340.42 ▼0.00% NVDA 230.48 ▼0.03% ! MSFT 522.61 ▼0.02% TSLA 375.00 ▼0.01% closed · /sa help
panel_rows setting, or /sa dock N). With more on the list, triggered alarms are shown first and a +N more row points to /sa for the full list.open / x buttons, focus the band with Ctrl+X then Tab (or click it); Esc returns to the prompt. The table never takes keystrokes while you type./sa dock and /sa undock are saved and override the display setting.Until you set an alarm, the docked table and the /sa pane show No alarms yet. Type /sa alert NVDA above 250 and press Enter. The /sa pane also lists the key commands at the bottom. If you type /sa alert without a symbol, direction or price, the mod replies with an example instead of an error.
All /sa commands run immediately, even while Claude is in the middle of a turn, and they don't use Claude tokens. Your watchlist, alarms, dock and hint settings are saved locally and shared by every Claude Code session on your machine.
Alerts in this mod are local to Claude Code on this computer: they only fire while a Claude Code session is open. For alerts on your phone, see below.
Run /plugin configure stock-alarm@stock-alarm (or find the rows in /config):
| Setting | Default | What it does |
|---|---|---|
watchlist | AAPL NVDA MSFT TSLA | Default symbols, space- or comma-separated. Used until you change the list with /sa add / /sa rm, and after /sa reset. |
refresh_seconds | 30 | How often quotes refresh, 10 to 600 seconds |
display | panel | panel: the docked watchlist table with the ticker strip under it (folds to the strip below 30 rows or 60 columns). band: just the one-line strip above the prompt. status: one line under the prompt. off: only the /sa pane. /sa dock / /sa undock override this. |
panel_rows | 5 | How many symbols the docked table shows, 1 to 25 |
quote_endpoint | (empty) | Optional custom quote URL (see below). Empty uses Stock Alarm's public feed. |
By default, quotes come from Stock Alarm's public, read-only tickers feed (no key needed), one small request per symbol per refresh:
https://stockalarm-8b019.firebaseio.com/tickers/<SYMBOL>.json
To use a different source, set the quote_endpoint setting or the SA_QUOTE_ENDPOINT environment variable (the variable wins) to a URL template:
{symbol}: one request per symbol; the response is one quote object{symbols}: one request with a comma-separated list; the response is an array, { "quotes": [...] }, or { "quotes": { "SYM": {...} } }Recognized fields: symbol, latestPrice / price / close, previousClose, change, changePercent (fraction), changesPercentage (percent), isUSMarketOpen, companyName / name.
Unknown symbols show as ?. Symbols containing a dot, such as BRK.B, may not resolve on the default feed.
This mod is powered by Stock Alarm. For real alerts on your phone (push notifications, email, or a phone call) on price, percent change, moving averages, RSI, earnings, and more, get the app:
Quotes may be delayed and are provided for information only. Nothing in this mod is investment advice.
git clone https://github.com/shawn14/claude-stock-alarm
cd claude-stock-alarm
claude --plugin-dir ./stock-alarm # try it in one session, with hot reload
claude plugin validate ./stock-alarm --strict # static checks
cd stock-alarm && claude plugin test # unit tests (no network)
Layout:
.claude-plugin/marketplace.json # the "stock-alarm" marketplace
stock-alarm/ # the mod
├── .claude-plugin/plugin.json # manifest and settings
├── hooks/hooks.json # { "modules": ["./register.js"] }
├── hooks/register.js # the hooks module
└── tests/stock-alarm.test.ts # tests for `claude plugin test`
The mods API can change between Claude Code releases. This version was tested with Claude Code 2.1.295.
MIT © Shawn Carpenter / Stock Alarm
hooks/register.js 960 lines1// Stock Alarm (SA) mod for Claude Code.
2//
3// Shows live quotes for a small watchlist while you code: a boxed watchlist
4// table docked in the band above the prompt (or a one-line ticker strip, or a
5// status line), a /sa pane with the full table, and
6// price alarms that highlight a symbol and pop a toast when it crosses a threshold.
7//
8// The docked table lives in the AbovePrompt render site (the band): it stays on
9// screen while you chat, and the band shows a tree of up to e.props.maxRows rows
10// whole. The table folds back to the strip in short or narrow terminals.
11//
12// Data source: Stock Alarm's own public tickers feed (Firebase RTDB
13// tickers/<SYMBOL>.json, read-only, no key). Swap it with the
14// SA_QUOTE_ENDPOINT env var or the quote_endpoint option. See README.md.
15
16const PANE = 'stock-alarm'
17const DEFAULT_ENDPOINT = 'https://stockalarm-8b019.firebaseio.com/tickers/{symbol}.json'
18const DEFAULT_WATCHLIST = ['AAPL', 'NVDA', 'MSFT', 'TSLA']
19// Stock Alarm Pro quote page: https://pro.stockalarm.io/quote/<SYMBOL>
20const QUOTE_PAGE = 'https://pro.stockalarm.io/quote/'
21const MAX_SYMBOLS = 25
22// Dim hint at the end of the ticker strip and the docked table's footer; the
23// first thing dropped from the strip when space is short
24export const BAND_HINT = '· /sa help'
25// Shown once per user (flag kept in $.store), a moment after the first session starts
26export const FIRST_RUN_TIP = 'Stock Alarm: to set an alarm, type /sa alert NVDA above 250 and press Enter · /sa opens your watchlist · /sa help for more'
27const FIRST_RUN_TIP_KEY = 'firstRunTipShown'
28const FIRST_RUN_TIP_DELAY_MS = 1500
29// Shown in the docked table and the /sa pane until an alarm is set
30export const EMPTY_ALARMS = 'No alarms yet. Type /sa alert NVDA above 250 and press Enter'
31// Friendly reply to /sa alert with missing or unreadable arguments
32export function alertUsage(sym) {
33 const s = sym || 'NVDA'
34 return [
35 'To set an alarm, type the symbol, above or below, and a price, then press Enter:',
36 ' /sa alert ' + s + ' above 250',
37 ' /sa alert ' + s + ' below 200',
38 'To remove alarms on ' + s + ': /sa alert ' + s + ' clear',
39 ].join('\n')
40}
41// The docked table (display "panel", /sa dock)
42export const DEFAULT_PANEL_ROWS = 5
43// Below this many terminal rows, or this many band columns, the table folds to the strip
44export const PANEL_MIN_TERMINAL_ROWS = 30
45export const PANEL_MIN_COLUMNS = 60
46// Command footer on the docked table's last row, beside its Add field
47export const PANEL_FOOTER = '/sa add · /sa rm · /sa alert NVDA above 250 · /sa open NVDA'
48const DISPLAYS = ['panel', 'band', 'status', 'off']
49
50// ---------- module state (rebuilt on reload; durable bits live in $.store) ----------
51let opts = {}
52let watchlist = DEFAULT_WATCHLIST.slice()
53let alerts = {} // { SYM: { above?: number, below?: number } }
54let bandHidden = false
55let hintsHidden = false // /sa hints off: no "· /sa help" in the table, strip or status line
56let dockOverride // /sa dock: true, /sa undock: false, unset: follow the display option
57let panelRowsSaved // /sa dock N: rows of the docked table; unset: the panel_rows option
58let quotes = {} // { SYM: normalized quote }
59let errors = {} // { SYM: message }
60let lastUpdated = 0
61let inflight = false
62let firing = {} // { SYM: 'above' | 'below' } alarms currently triggered
63let endpoint = DEFAULT_ENDPOINT
64let sourceLabel = 'Stock Alarm tickers feed'
65
66// ---------- pure helpers ----------
67export function parseSymbols(text) {
68 return String(text || '')
69 .toUpperCase()
70 .split(/[\s,;]+/)
71 .map((s) => s.replace(/^\$/, '').trim())
72 .filter((s) => /^[A-Z0-9][A-Z0-9.\-^=]{0,14}$/.test(s))
73}
74
75// Stock Alarm Pro quote page for a symbol. The route only accepts upper case
76// (lower case 404s) and uses dots for share classes (BRK.B), so a class-share
77// hyphen like BRK-B or BF-B becomes a dot.
78export function quoteUrl(symbol) {
79 let s = String(symbol || '').trim().replace(/^\$/, '').toUpperCase()
80 if (/^[A-Z]{1,5}-[A-Z]$/.test(s)) s = s.replace('-', '.')
81 return QUOTE_PAGE + encodeURIComponent(s)
82}
83
84function num(v) {
85 const n = typeof v === 'string' ? Number(v) : v
86 return typeof n === 'number' && Number.isFinite(n) ? n : undefined
87}
88
89// Accepts a Stock Alarm tickers node or a typical quote object and returns
90// { symbol, price, change, pct, isOpen, name }.
91export function normalizeQuote(raw, fallbackSymbol) {
92 if (!raw || typeof raw !== 'object') return undefined
93 const symbol = String(raw.symbol || raw.fullSymbol || raw.firebaseKey || fallbackSymbol || '').toUpperCase()
94 const price = num(raw.latestPrice) ?? num(raw.price) ?? num(raw.close)
95 if (price === undefined) return undefined
96 const prev = num(raw.previousClose)
97 let change = num(raw.change)
98 let pct
99 if (prev !== undefined && prev > 0) {
100 change = price - prev
101 pct = (change / prev) * 100
102 } else if (num(raw.changePercent) !== undefined) {
103 pct = num(raw.changePercent) * 100
104 } else if (num(raw.changesPercentage) !== undefined) {
105 pct = num(raw.changesPercentage)
106 }
107 return {
108 symbol,
109 price,
110 change,
111 pct,
112 isOpen: typeof raw.isUSMarketOpen === 'boolean' ? raw.isUSMarketOpen : undefined,
113 name: raw.companyName || raw.name || undefined,
114 }
115}
116
117// Pulls quotes out of a bulk response: an array, { quotes: [...] | {...} }, or a map by symbol.
118export function quotesFromBulk(body) {
119 const out = {}
120 let list = body
121 if (body && typeof body === 'object' && !Array.isArray(body) && body.quotes) list = body.quotes
122 if (Array.isArray(list)) {
123 for (const r of list) {
124 const q = normalizeQuote(r)
125 if (q && q.symbol) out[q.symbol] = q
126 }
127 } else if (list && typeof list === 'object') {
128 for (const k of Object.keys(list)) {
129 const q = normalizeQuote(list[k], k)
130 if (q) out[q.symbol || k.toUpperCase()] = q
131 }
132 }
133 return out
134}
135
136export function fmtPrice(p) {
137 if (p === undefined) return '—'
138 if (p >= 1000) return p.toFixed(0)
139 if (p >= 1) return p.toFixed(2)
140 return p.toPrecision(3)
141}
142
143export function fmtPct(pct) {
144 if (pct === undefined) return ''
145 const arrow = pct > 0.0049 ? '▲' : pct < -0.0049 ? '▼' : '•'
146 return arrow + Math.abs(pct).toFixed(2) + '%'
147}
148
149function colorFor(pct) {
150 if (pct === undefined) return undefined
151 if (pct > 0.0049) return 'green'
152 if (pct < -0.0049) return 'red'
153 return undefined
154}
155
156// Which side of an alarm, if any, a price has crossed.
157export function alarmState(alert, price) {
158 if (!alert || price === undefined) return undefined
159 if (alert.above !== undefined && price >= alert.above) return 'above'
160 if (alert.below !== undefined && price <= alert.below) return 'below'
161 return undefined
162}
163
164function describeAlert(a) {
165 if (!a) return ''
166 const parts = []
167 if (a.above !== undefined) parts.push('≥' + fmtPrice(a.above))
168 if (a.below !== undefined) parts.push('≤' + fmtPrice(a.below))
169 return parts.join(' ')
170}
171
172// An alarm in words for the docked table: "above 250.00", "below 200.00", or both
173export function alarmWords(a) {
174 if (!a) return ''
175 const parts = []
176 if (a.above !== undefined) parts.push('above ' + fmtPrice(a.above))
177 if (a.below !== undefined) parts.push('below ' + fmtPrice(a.below))
178 return parts.join(' · ')
179}
180
181const ABOVE_WORDS = ['above', 'over', '>', '>=', '=>', '≥']
182const BELOW_WORDS = ['below', 'under', '<', '<=', '=<', '≤']
183const CLEAR_WORDS = ['clear', 'off', 'none', 'remove', 'delete', 'rm']
184
185// A price as typed: 250, $250, 1,250.50. Returns undefined when it isn't one.
186export function parsePrice(text) {
187 const t = String(text || '').trim().replace(/^\$/, '').replace(/,/g, '')
188 if (!/^\d*\.?\d+$/.test(t)) return undefined
189 const n = Number(t)
190 return Number.isFinite(n) && n > 0 ? n : undefined
191}
192
193// Reads what follows "/sa alert", forgivingly:
194// NVDA above 250 | NVDA below 200 | nvda > 250 | NVDA >= $250 | NVDA<=200 | NVDA 250 | NVDA clear
195// Returns { kind: 'set', sym, side, level } (side undefined: infer it from the price),
196// { kind: 'clear', sym }, or { kind: 'usage', sym? } when it can't tell what was meant.
197export function parseAlertArgs(text) {
198 const words = String(text || '')
199 .replace(/(>=|<=|=>|=<|≥|≤|>|<)/g, ' $1 ')
200 .trim()
201 .split(/\s+/)
202 .filter(Boolean)
203 if (!words.length) return { kind: 'usage' }
204 const first = words[0].toLowerCase()
205 // No symbol: "/sa alert above 250" or "/sa alert 250"
206 if (ABOVE_WORDS.includes(first) || BELOW_WORDS.includes(first) || CLEAR_WORDS.includes(first) || parsePrice(first) !== undefined) {
207 return { kind: 'usage' }
208 }
209 const sym = parseSymbols(words[0])[0]
210 if (!sym) return { kind: 'usage' }
211 const rest = words.slice(1)
212 if (rest.length === 1 && CLEAR_WORDS.includes(rest[0].toLowerCase())) return { kind: 'clear', sym }
213 let side
214 let priceWord
215 if (rest.length === 2) {
216 const d = rest[0].toLowerCase()
217 side = ABOVE_WORDS.includes(d) ? 'above' : BELOW_WORDS.includes(d) ? 'below' : undefined
218 if (!side) return { kind: 'usage', sym }
219 priceWord = rest[1]
220 } else if (rest.length === 1) {
221 priceWord = rest[0]
222 } else {
223 return { kind: 'usage', sym }
224 }
225 const level = parsePrice(priceWord)
226 if (level === undefined) return { kind: 'usage', sym }
227 return { kind: 'set', sym, side, level }
228}
229
230// "/sa alert NVDA 250": above when the target is over the current price, below
231// when it's under (a target equal to the price counts as above)
232export function inferSide(level, price) {
233 if (price === undefined || level === undefined) return undefined
234 return level >= price ? 'above' : 'below'
235}
236
237// The band's layout for a display option and the /sa dock | undock override:
238// 'panel' (docked table, or the strip when it folds), 'band' (strip only), 'status' or 'off'
239export function displayMode(display, docked) {
240 if (docked === true) return 'panel'
241 if (docked === false) return 'band'
242 if (display === undefined || display === '') return 'panel'
243 return DISPLAYS.includes(display) ? display : 'panel'
244}
245
246// Rows the docked table takes: its border (2), title, column header, one per
247// symbol, a "+N more" row when the list is longer, the "No alarms yet" row
248// when it shows, and the Add/commands row
249export function panelHeight(symbolRows, hasMore, hasEmptyAlarms = false) {
250 return 2 + 1 + 1 + symbolRows + (hasMore ? 1 : 0) + (hasEmptyAlarms ? 1 : 0) + 1
251}
252
253// How many symbol rows the docked table draws, or 0 to fold to the one-line strip.
254// Folds when the terminal is under PANEL_MIN_TERMINAL_ROWS rows or the band under
255// PANEL_MIN_COLUMNS columns; otherwise shrinks to fit maxRows. The strip isn't drawn
256// under the table, so the table gets every row of the band.
257export function fitPanel({ want, total, terminalRows, maxRows, columns }) {
258 if (!total || columns < PANEL_MIN_COLUMNS) return 0
259 if (terminalRows !== undefined && terminalRows < PANEL_MIN_TERMINAL_ROWS) return 0
260 const budget = maxRows
261 let n = Math.max(1, Math.min(want, total))
262 while (n > 0 && panelHeight(n, total > n) > budget) n -= 1
263 if (n < Math.min(2, total)) return 0
264 return n
265}
266
267// The symbols the docked table shows when it can't show them all: triggered
268// alarms first, then the watchlist's order, drawn in watchlist order
269export function panelSymbols(list, firingMap, n) {
270 if (list.length <= n) return list.slice()
271 const pick = new Set(list.filter((s) => firingMap && firingMap[s]).slice(0, n))
272 for (const s of list) {
273 if (pick.size >= n) break
274 pick.add(s)
275 }
276 return list.filter((s) => pick.has(s))
277}
278
279function mode() {
280 return displayMode(opts.display, dockOverride)
281}
282
283function panelRowsWanted() {
284 const n = Number(panelRowsSaved ?? opts.panel_rows ?? DEFAULT_PANEL_ROWS)
285 return Number.isFinite(n) ? Math.min(MAX_SYMBOLS, Math.max(1, Math.round(n))) : DEFAULT_PANEL_ROWS
286}
287
288function timeOf(ms) {
289 if (!ms) return 'never'
290 const d = new Date(ms)
291 const h = d.getHours()
292 const m = String(d.getMinutes()).padStart(2, '0')
293 return (h % 12 || 12) + ':' + m + (h < 12 ? 'am' : 'pm')
294}
295
296export function tickerLine() {
297 const parts = []
298 for (const s of watchlist) {
299 const q = quotes[s]
300 const tag = firing[s] ? '!' : ''
301 parts.push(tag + s + ' ' + (q ? fmtPrice(q.price) + ' ' + fmtPct(q.pct) : errors[s] ? '?' : '…'))
302 }
303 return 'SA ' + parts.join(' ')
304}
305
306function anyOpen() {
307 const vals = Object.values(quotes).filter((q) => q.isOpen !== undefined)
308 if (!vals.length) return undefined
309 return vals.some((q) => q.isOpen)
310}
311
312function listText() {
313 const lines = ['Stock Alarm watchlist · ' + sourceLabel + ' · updated ' + timeOf(lastUpdated)]
314 for (const s of watchlist) {
315 const q = quotes[s]
316 const a = describeAlert(alerts[s])
317 const row =
318 s.padEnd(7) +
319 (q ? fmtPrice(q.price).padStart(10) + ' ' + fmtPct(q.pct).padEnd(8) : (errors[s] ? 'error: ' + errors[s] : 'loading').padStart(10)) +
320 (a ? ' alarm ' + a : '') +
321 (firing[s] ? ' << ALARM ' + firing[s].toUpperCase() : '')
322 lines.push(row)
323 }
324 return lines.join('\n')
325}
326
327const HELP = [
328 'Stock Alarm commands (type one in the prompt and press Enter)',
329 '',
330 'Set an alarm',
331 ' /sa alert NVDA above 250 alarm when NVDA trades at or above 250',
332 ' /sa alert NVDA below 200 alarm when NVDA trades at or below 200',
333 ' /sa alert NVDA clear remove the alarms on NVDA',
334 ' Also works: /sa alert nvda > 250, /sa alert NVDA >= $250, and /sa alert NVDA 250',
335 ' (with no above or below, it picks one from the current price and tells you which)',
336 '',
337 'Watchlist',
338 ' /sa open the full watchlist pane (Esc closes it)',
339 ' /sa add AMD PLTR add symbols (also /sa-add)',
340 ' /sa rm TSLA remove symbols (also /sa-rm)',
341 ' /sa list print the watchlist with quotes',
342 ' /sa open NVDA open NVDA on Stock Alarm Pro',
343 '',
344 'Display',
345 ' /sa dock [rows] dock the watchlist table above the prompt (default 5 rows)',
346 ' /sa undock just the one-line ticker strip',
347 ' /sa hide | /sa show hide or show the ticker (table or strip)',
348 ' /sa hints off | on hide or show the "/sa help" hint',
349 ' /sa refresh refresh quotes now',
350 ' /sa reset default watchlist, all alarms cleared',
351 ' /sa help this list',
352 '',
353 'The docked table folds to the strip when the terminal is under 30 rows or 60 columns.',
354 'Alarms are local to Claude Code on this computer: they fire while a session is open.',
355 'These commands run in the mod and use no Claude tokens.',
356].join('\n')
357
358// Key commands, shown at the bottom of the /sa pane
359export const PANE_FOOTER = [
360 '/sa add AMD · /sa rm TSLA · /sa open NVDA · /sa help',
361 '/sa alert NVDA above 250 · below 200 · clear',
362]
363
364// How many symbols on the watchlist have an alarm set
365export function alarmCount(list, alertMap) {
366 return list.filter((s) => {
367 const a = alertMap && alertMap[s]
368 return !!a && (a.above !== undefined || a.below !== undefined)
369 }).length
370}
371
372// ---------- mods API helpers (top-level so validation can see the calls) ----------
373async function loadSaved($) {
374 const savedList = await $.store.get('watchlist')
375 const savedAlerts = await $.store.get('alerts')
376 const savedHidden = await $.store.get('bandHidden')
377 const savedHints = await $.store.get('hintsHidden')
378 const savedDocked = await $.store.get('docked')
379 const savedRows = await $.store.get('panelRows')
380 if (Array.isArray(savedList) && savedList.length) watchlist = parseSymbols(savedList.join(' ')).slice(0, MAX_SYMBOLS)
381 else {
382 const fromOpts = parseSymbols(opts.watchlist)
383 watchlist = (fromOpts.length ? fromOpts : DEFAULT_WATCHLIST).slice(0, MAX_SYMBOLS)
384 }
385 alerts = savedAlerts && typeof savedAlerts === 'object' ? { ...savedAlerts } : {}
386 bandHidden = savedHidden === true
387 hintsHidden = savedHints === true
388 dockOverride = typeof savedDocked === 'boolean' ? savedDocked : undefined
389 panelRowsSaved = typeof savedRows === 'number' && Number.isFinite(savedRows) ? savedRows : undefined
390}
391
392// The one-time first-run tip. Returns true when it was shown.
393async function showFirstRunTip($) {
394 const seen = await $.store.get(FIRST_RUN_TIP_KEY)
395 if (seen === true) return false
396 await $.store.set(FIRST_RUN_TIP_KEY, true)
397 $.ui.toast(FIRST_RUN_TIP, { timeoutMs: 15000 })
398 return true
399}
400
401async function saveWatchlist($) {
402 await $.store.set('watchlist', watchlist)
403}
404
405async function saveAlerts($) {
406 await $.store.set('alerts', alerts)
407}
408
409async function getJson($, url) {
410 const res = await $.http.fetch(url, { headers: { accept: 'application/json' } })
411 if (!res.ok) throw new Error('HTTP ' + res.status)
412 return JSON.parse(res.text)
413}
414
415async function refresh($) {
416 if (inflight || !watchlist.length) return
417 inflight = true
418 try {
419 const symbols = watchlist.slice()
420 if (endpoint.includes('{symbols}')) {
421 try {
422 const body = await getJson($, endpoint.replace('{symbols}', encodeURIComponent(symbols.join(','))))
423 const got = quotesFromBulk(body)
424 for (const s of symbols) {
425 if (got[s]) {
426 quotes[s] = got[s]
427 delete errors[s]
428 } else errors[s] = 'no quote'
429 }
430 } catch (err) {
431 for (const s of symbols) errors[s] = String((err && err.message) || err)
432 }
433 } else {
434 const results = await Promise.all(
435 symbols.map((s) =>
436 getJson($, endpoint.replace('{symbol}', encodeURIComponent(s)))
437 .then((body) => ({ s, q: normalizeQuote(body, s) }))
438 .catch((err) => ({ s, err: String((err && err.message) || err) })),
439 ),
440 )
441 for (const r of results) {
442 if (r.q) {
443 quotes[r.s] = r.q
444 delete errors[r.s]
445 } else errors[r.s] = r.err || 'unknown symbol'
446 }
447 }
448 lastUpdated = Date.now()
449 await checkAlarms($)
450 await showStatus($)
451 } finally {
452 inflight = false
453 $.ui.invalidate('ui.render')
454 }
455}
456
457// A fresh quote for one symbol (on the watchlist or not), or undefined
458async function fetchQuote($, sym) {
459 try {
460 if (endpoint.includes('{symbols}')) {
461 const got = quotesFromBulk(await getJson($, endpoint.replace('{symbols}', encodeURIComponent(sym))))
462 if (got[sym]) quotes[sym] = got[sym]
463 } else {
464 const q = normalizeQuote(await getJson($, endpoint.replace('{symbol}', encodeURIComponent(sym))), sym)
465 if (q) quotes[sym] = q
466 }
467 } catch {
468 // Handled by the caller: no quote
469 }
470 return quotes[sym]
471}
472
473async function checkAlarms($) {
474 for (const s of watchlist) {
475 const q = quotes[s]
476 const state = alarmState(alerts[s], q && q.price)
477 if (state && firing[s] !== state) {
478 const level = state === 'above' ? alerts[s].above : alerts[s].below
479 $.ui.toast('ALARM ' + s + ' ' + fmtPrice(q.price) + (state === 'above' ? ' crossed above ' : ' fell below ') + fmtPrice(level), { timeoutMs: 10000 })
480 }
481 if (state) firing[s] = state
482 else delete firing[s]
483 }
484}
485
486async function showStatus($) {
487 if (mode() !== 'status' || bandHidden) return
488 $.ui.status(tickerLine() + (hintsHidden ? '' : ' ' + BAND_HINT))
489}
490
491async function openSymbol($, symbol) {
492 const url = quoteUrl(symbol)
493 try {
494 const r = await $.process.run(['open', url], { timeoutMs: 10000 })
495 if (r.exitCode !== 0) throw new Error(r.stderr || 'open failed')
496 } catch {
497 try {
498 await $.process.run(['xdg-open', url], { timeoutMs: 10000 })
499 } catch {
500 // Nothing to open with; the URL is printed below
501 }
502 }
503 return url
504}
505
506async function addSymbols($, list) {
507 const added = []
508 for (const s of list) {
509 if (!watchlist.includes(s) && watchlist.length < MAX_SYMBOLS) {
510 watchlist.push(s)
511 added.push(s)
512 }
513 }
514 if (added.length) {
515 await saveWatchlist($)
516 await refresh($)
517 }
518 return added
519}
520
521async function removeSymbols($, list) {
522 const removed = list.filter((s) => watchlist.includes(s))
523 if (removed.length) {
524 watchlist = watchlist.filter((s) => !removed.includes(s))
525 for (const s of removed) {
526 delete quotes[s]
527 delete errors[s]
528 delete firing[s]
529 }
530 await saveWatchlist($)
531 $.ui.invalidate('ui.render')
532 }
533 return removed
534}
535
536async function handleCommand($, argText) {
537 const words = String(argText || '').trim().split(/\s+/).filter(Boolean)
538 const sub = (words[0] || '').toLowerCase()
539 const rest = words.slice(1).join(' ')
540
541 if (!sub || sub === 'pane' || sub === 'show-pane') {
542 await $.ui.open({ id: PANE, title: 'Stock Alarm', focus: true, closeOnEscape: true })
543 await refresh($)
544 return {}
545 }
546 if (sub === 'help' || sub === '?' || sub === '-h' || sub === '--help') return { text: HELP }
547 if (sub === 'hints' || sub === 'hint') {
548 const arg = (words[1] || '').toLowerCase()
549 if (arg !== 'on' && arg !== 'off') return { text: 'Usage: /sa hints off | /sa hints on. Hints are ' + (hintsHidden ? 'off' : 'on') + '.' }
550 hintsHidden = arg === 'off'
551 await $.store.set('hintsHidden', hintsHidden)
552 // Someone turning hints off doesn't need the first-run tip either
553 if (hintsHidden) await $.store.set(FIRST_RUN_TIP_KEY, true)
554 await showStatus($)
555 $.ui.invalidate('ui.render')
556 return { text: hintsHidden ? 'Hints off. /sa hints on brings them back.' : 'Hints on.' }
557 }
558 if (sub === 'list' || sub === 'ls') {
559 await refresh($)
560 return { text: listText() }
561 }
562 if (sub === 'add') {
563 const syms = parseSymbols(rest)
564 if (!syms.length) return { text: 'Usage: /sa add NVDA AMD' }
565 const added = await addSymbols($, syms)
566 return { text: added.length ? 'Added ' + added.join(', ') + '. Watchlist: ' + watchlist.join(' ') : 'Nothing added (already listed or the list is full at ' + MAX_SYMBOLS + ').' }
567 }
568 if (sub === 'rm' || sub === 'remove' || sub === 'del') {
569 const syms = parseSymbols(rest)
570 const removed = await removeSymbols($, syms)
571 return { text: removed.length ? 'Removed ' + removed.join(', ') + '. Watchlist: ' + (watchlist.join(' ') || '(empty)') : 'None of those are on the watchlist.' }
572 }
573 if (sub === 'alert' || sub === 'alarm') {
574 const parsed = parseAlertArgs(String(argText || '').trim().replace(/^\S+\s*/, ''))
575 if (parsed.kind === 'usage') return { text: alertUsage(parsed.sym) }
576 const sym = parsed.sym
577 if (parsed.kind === 'clear') {
578 const had = !!alerts[sym]
579 delete alerts[sym]
580 delete firing[sym]
581 await saveAlerts($)
582 $.ui.invalidate('ui.render')
583 return { text: had ? 'Cleared alarms on ' + sym + '.' : 'No alarms on ' + sym + ' to clear.' }
584 }
585 const level = parsed.level
586 let side = parsed.side
587 let note = ''
588 if (!side) {
589 const q = quotes[sym] || (await fetchQuote($, sym))
590 side = inferSide(level, q && q.price)
591 if (!side) {
592 return {
593 text:
594 "Couldn't get " + sym + "'s current price to tell if " + fmtPrice(level) + ' is above or below it. Type one of these and press Enter:\n' +
595 ' /sa alert ' + sym + ' above ' + fmtPrice(level) + '\n' +
596 ' /sa alert ' + sym + ' below ' + fmtPrice(level),
597 }
598 }
599 const other = side === 'above' ? 'below' : 'above'
600 note =
601 ' ' + sym + ' is at ' + fmtPrice(q.price) + ', so I chose ' + side + '. If you meant ' + other + ', type /sa alert ' + sym + ' ' + other + ' ' + fmtPrice(level) + '.'
602 }
603 alerts = { ...alerts, [sym]: { ...(alerts[sym] || {}), [side]: level } }
604 await saveAlerts($)
605 if (!watchlist.includes(sym)) await addSymbols($, [sym])
606 else await refresh($)
607 const current = quotes[sym] ? ' Current: ' + fmtPrice(quotes[sym].price) + '.' : ''
608 return {
609 text:
610 'Alarm set: ' + sym + ' ' + side + ' ' + fmtPrice(level) + '.' + (note || current) +
611 ' It shows in the ALARM column, and you get a notification in Claude Code when the price crosses.',
612 }
613 }
614 if (sub === 'open') {
615 const sym = parseSymbols(rest)[0]
616 if (!sym) return { text: 'Usage: /sa open NVDA' }
617 const url = await openSymbol($, sym)
618 return { text: 'Opened ' + url }
619 }
620 if (sub === 'refresh') {
621 await refresh($)
622 return { text: tickerLine() }
623 }
624 if (sub === 'dock' || sub === 'panel') {
625 const arg = words[1]
626 if (arg !== undefined) {
627 const n = Number(arg)
628 if (!Number.isInteger(n) || n < 1 || n > MAX_SYMBOLS) return { text: 'Usage: /sa dock [rows], rows from 1 to ' + MAX_SYMBOLS + ' (default ' + DEFAULT_PANEL_ROWS + ').' }
629 panelRowsSaved = n
630 await $.store.set('panelRows', n)
631 }
632 const wasStatus = mode() === 'status'
633 dockOverride = true
634 bandHidden = false
635 await $.store.set('docked', true)
636 await $.store.set('bandHidden', false)
637 if (wasStatus) $.ui.status(undefined)
638 $.ui.invalidate('ui.render')
639 return {
640 text:
641 'Watchlist docked above the prompt (' + panelRowsWanted() + ' rows). It folds to the one-line strip when the terminal is under ' +
642 PANEL_MIN_TERMINAL_ROWS + ' rows or ' + PANEL_MIN_COLUMNS + ' columns. /sa undock for the strip only.',
643 }
644 }
645 if (sub === 'undock' || sub === 'strip') {
646 const wasStatus = mode() === 'status'
647 dockOverride = false
648 bandHidden = false
649 await $.store.set('docked', false)
650 await $.store.set('bandHidden', false)
651 if (wasStatus) $.ui.status(undefined)
652 $.ui.invalidate('ui.render')
653 return { text: 'Undocked: just the one-line ticker strip. /sa dock brings the table back.' }
654 }
655 if (sub === 'hide') {
656 bandHidden = true
657 await $.store.set('bandHidden', true)
658 if (mode() === 'status') $.ui.status(undefined)
659 $.ui.invalidate('ui.render')
660 return { text: 'Ticker hidden. /sa show brings it back.' }
661 }
662 if (sub === 'show') {
663 bandHidden = false
664 await $.store.set('bandHidden', false)
665 await showStatus($)
666 $.ui.invalidate('ui.render')
667 return { text: 'Ticker shown.' }
668 }
669 if (sub === 'reset') {
670 const fromOpts = parseSymbols(opts.watchlist)
671 watchlist = (fromOpts.length ? fromOpts : DEFAULT_WATCHLIST).slice(0, MAX_SYMBOLS)
672 alerts = {}
673 firing = {}
674 quotes = {}
675 errors = {}
676 await saveWatchlist($)
677 await saveAlerts($)
678 await refresh($)
679 return { text: 'Reset. Watchlist: ' + watchlist.join(' ') }
680 }
681 // "/sa NVDA AMD" with no subcommand: treat as add
682 const syms = parseSymbols(argText)
683 if (syms.length) {
684 const added = await addSymbols($, syms)
685 return { text: added.length ? 'Added ' + added.join(', ') + '.' : 'Already on the watchlist.' }
686 }
687 return { text: HELP }
688}
689
690async function registerCommands($) {
691 const specs = [
692 { name: 'sa', description: 'Stock Alarm: open the watchlist pane, or add | rm | alert | open | dock | undock | list | help', argumentHint: '[add|rm|alert|open|list|dock|undock|refresh|hide|show|hints|reset|help] [args]', immediate: true },
693 { name: 'sa-add', description: 'Stock Alarm: add symbols to the watchlist', argumentHint: '<SYMBOL ...>', immediate: true },
694 { name: 'sa-rm', description: 'Stock Alarm: remove symbols from the watchlist', argumentHint: '<SYMBOL ...>', immediate: true },
695 ]
696 for (const spec of specs) {
697 try {
698 await $.command.register(spec)
699 } catch (err) {
700 $.ui.log('could not add /' + spec.name + ': ' + String((err && err.message) || err))
701 }
702 }
703}
704
705// A failed /sa command prints a short error instead of nothing
706function failed($, e, next) {
707 return { text: 'Stock Alarm: ' + ((next.error && next.error.message) || 'command failed') }
708}
709
710// ---------- band drawing ----------
711
712// The one-line ticker strip: SA AAPL 340.42 ▲0.72% NVDA ... · /sa help
713function stripRow(ui, width) {
714 const { Box, Text } = ui
715 const items = [Text({ bold: true, color: 'yellow', children: ['SA'] })]
716 let used = 3
717 let shown = 0
718 let truncated = false
719 for (const s of watchlist) {
720 const q = quotes[s]
721 const label = s + ' ' + (q ? fmtPrice(q.price) + ' ' + fmtPct(q.pct) : errors[s] ? '?' : '…')
722 const firingSide = firing[s]
723 const cell = (firingSide ? '! ' : '') + label
724 if (used + cell.length + 2 > width - 4 && shown > 0) {
725 items.push(Text({ dimColor: true, children: ['+' + (watchlist.length - shown)] }))
726 truncated = true
727 break
728 }
729 used += cell.length + 2
730 shown += 1
731 if (firingSide) {
732 items.push(Text({ bold: true, inverse: true, color: firingSide === 'above' ? 'green' : 'red', children: [cell] }))
733 } else {
734 const c = colorFor(q && q.pct)
735 items.push(Text(c ? { color: c, children: [label] } : { children: [label] }))
736 }
737 }
738 const open = anyOpen()
739 if (open === false && used + 8 < width) {
740 items.push(Text({ dimColor: true, children: ['closed'] }))
741 used += 8
742 }
743 // The hint goes last and only into room that's left, so it never pushes a quote off
744 if (!hintsHidden && !truncated && used + BAND_HINT.length + 2 <= width - 4) {
745 items.push(Text({ dimColor: true, children: [BAND_HINT] }))
746 }
747 return Box({ flexDirection: 'row', columnGap: 2, children: items })
748}
749
750// The docked watchlist table: SYMBOL / PRICE / CHANGE / ALARM / ACTIONS, an Add
751// field and the command footer, in a rounded box `rows` symbols tall
752function panelBox($, ui, width, rows, showEmptyAlarms = false) {
753 const { Box, Text, Button, Input } = ui
754 const inner = Math.max(20, width - 4) // border and one column of padding each side
755 const W = { sym: 8, price: 10, change: 10, actions: 10 }
756 W.alarm = Math.max(12, inner - W.sym - W.price - W.change - W.actions)
757 const cell = (w, text) => Box({ width: w, flexShrink: 0, children: [text] })
758 const open = anyOpen()
759 const syms = panelSymbols(watchlist, firing, rows)
760 const more = watchlist.length - syms.length
761
762 const title = Box({
763 flexDirection: 'row',
764 children: [
765 Text({ bold: true, children: ['Stock Alarm · Watchlist'] }),
766 Text({
767 dimColor: true,
768 wrap: 'truncate',
769 children: [' (/sa) · refreshed ' + timeOf(lastUpdated) + (open === undefined ? '' : open ? ' · market open' : ' · market closed')],
770 }),
771 ],
772 })
773 const head = (w, label) => cell(w, Text({ bold: true, dimColor: true, wrap: 'truncate', children: [label] }))
774 const header = Box({
775 flexDirection: 'row',
776 children: [head(W.sym, 'SYMBOL'), head(W.price, 'PRICE'), head(W.change, 'CHANGE'), head(W.alarm, 'ALARM'), head(W.actions, 'ACTIONS')],
777 })
778 const body = syms.map((s) => {
779 const q = quotes[s]
780 const side = firing[s]
781 const words = alarmWords(alerts[s])
782 const c = colorFor(q && q.pct)
783 const tint = (w, text) => cell(w, Text(c ? { color: c, wrap: 'truncate', children: [text] } : { wrap: 'truncate', children: [text] }))
784 const alarm = side
785 ? Text({ bold: true, inverse: true, color: 'yellow', wrap: 'truncate', children: [(words || side) + ' · TRIGGERED'] })
786 : words
787 ? Text({ color: 'yellow', wrap: 'truncate', children: [words] })
788 : Text({ dimColor: true, children: ['—'] })
789 return Box({
790 key: 'dock-row-' + s,
791 flexDirection: 'row',
792 children: [
793 cell(W.sym, Text(side ? { bold: true, color: side === 'above' ? 'green' : 'red', children: [s] } : { bold: true, wrap: 'truncate', children: [s] })),
794 cell(W.price, Text({ wrap: 'truncate', children: [q ? fmtPrice(q.price) : errors[s] ? '?' : '…'] })),
795 tint(W.change, q ? fmtPct(q.pct) || '—' : ''),
796 cell(W.alarm, alarm),
797 Box({
798 width: W.actions,
799 flexShrink: 0,
800 flexDirection: 'row',
801 children: [
802 Button({ key: 'dock-open-' + s, label: 'open', plain: true, onPress: async () => { await openSymbol($, s) } }),
803 Text({ dimColor: true, children: [' · '] }),
804 Button({ key: 'dock-rm-' + s, label: 'x', plain: true, onPress: async () => { await removeSymbols($, [s]) } }),
805 ],
806 }),
807 ],
808 })
809 })
810 const footer = Box({
811 flexDirection: 'row',
812 columnGap: 2,
813 children: [
814 Box({
815 width: 22,
816 flexShrink: 0,
817 children: [
818 Input({
819 key: 'dock-add',
820 label: 'Add',
821 placeholder: 'symbol…',
822 value: '',
823 submitLabel: 'add',
824 onSubmit: async (value) => {
825 const list = parseSymbols(value)
826 if (list.length) await addSymbols($, list)
827 },
828 }),
829 ],
830 }),
831 Box({ flexGrow: 1, flexShrink: 1, children: [Text({ dimColor: true, wrap: 'truncate', children: [PANEL_FOOTER] })] }),
832 ...(hintsHidden ? [] : [Box({ flexShrink: 0, children: [Text({ dimColor: true, children: [BAND_HINT] })] })]),
833 ],
834 })
835 return Box({
836 flexDirection: 'column',
837 borderStyle: 'round',
838 borderColor: Object.keys(firing).length ? 'red' : 'yellow',
839 paddingX: 1,
840 children: [
841 title,
842 header,
843 ...body,
844 ...(more > 0 ? [Text({ dimColor: true, children: ['+' + more + ' more · /sa for the full list · /sa dock ' + Math.min(MAX_SYMBOLS, watchlist.length) + ' to show all'] })] : []),
845 ...(showEmptyAlarms ? [Text({ color: 'yellow', wrap: 'truncate', children: [EMPTY_ALARMS] })] : []),
846 footer,
847 ],
848 })
849}
850
851// ---------- hooks ----------
852export function register(on, options) {
853 opts = options || {}
854
855 on('session.start', async ($, e, next) => {
856 const envEndpoint = await $.env.get('SA_QUOTE_ENDPOINT')
857 const configured = (envEndpoint || opts.quote_endpoint || '').trim()
858 endpoint = configured && /^https?:\/\//.test(configured) ? configured : DEFAULT_ENDPOINT
859 sourceLabel = endpoint === DEFAULT_ENDPOINT ? 'Stock Alarm tickers feed' : 'custom endpoint'
860 await loadSaved($)
861 const seconds = Math.min(600, Math.max(10, Number(opts.refresh_seconds) || 30))
862 $.clock.every(seconds * 1000, () => refresh($))
863 // First fetch right away, without holding up the session start
864 $.clock.after(0, () => refresh($))
865 // One-time tip so new users find /sa; a short delay lets the interface settle
866 $.clock.after(FIRST_RUN_TIP_DELAY_MS, () => {
867 showFirstRunTip($).catch((err) => $.ui.log('first-run tip: ' + String((err && err.message) || err), { to: 'debug' }))
868 })
869 await registerCommands($)
870 return next(e)
871 })
872
873 on('command.run', { command: 'sa' }, async ($, e) => handleCommand($, e.args)).catch(failed)
874 on('command.run', { command: 'sa-add' }, async ($, e) => handleCommand($, 'add ' + (e.args || ''))).catch(failed)
875 on('command.run', { command: 'sa-rm' }, async ($, e) => handleCommand($, 'rm ' + (e.args || ''))).catch(failed)
876
877 // The band above the prompt: the docked watchlist table on its own, or the
878 // one-line strip when the table folds (short or narrow terminal), in band
879 // mode, or after /sa undock
880 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
881 const m = mode()
882 if (m !== 'panel' && m !== 'band') return next(e)
883 if (bandHidden || !watchlist.length) return next(e)
884 const ui = $.ui.resolve(e)
885 const { Box } = ui
886 const props = e.props || {}
887 const width = Math.max(20, props.bodyColumns || 80)
888 let rows = 0
889 let showEmpty = false
890 // A survey holds the band first: fold to the strip while one shows
891 if (m === 'panel' && !props.hasSurvey) {
892 const maxRows = typeof props.maxRows === 'number' ? props.maxRows : 40
893 const terminalRows = e.viewport && typeof e.viewport.rows === 'number' ? e.viewport.rows : maxRows
894 rows = fitPanel({ want: panelRowsWanted(), total: watchlist.length, terminalRows, maxRows, columns: width })
895 // "No alarms yet" gets its own row when there's room for it beside the symbols
896 showEmpty = rows > 0 && alarmCount(watchlist, alerts) === 0 && panelHeight(rows, watchlist.length > rows, true) <= maxRows
897 }
898 const mine = rows > 0 ? panelBox($, ui, width, rows, showEmpty) : stripRow(ui, width)
899 const theirs = await next(e)
900 return theirs ? Box({ flexDirection: 'column', children: [mine, theirs] }) : mine
901 })
902
903 // The /sa pane
904 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
905 if (e.requestId !== PANE) return next(e)
906 const { Box, Text, Button, Input } = $.ui.resolve(e)
907 const open = anyOpen()
908 const header = Text({
909 bold: true,
910 children: ['Stock Alarm · ' + watchlist.length + ' symbols · ' + (open === undefined ? '' : open ? 'market open · ' : 'market closed · ') + 'updated ' + timeOf(lastUpdated)],
911 })
912 const rows = watchlist.map((s) => {
913 const q = quotes[s]
914 const side = firing[s]
915 const a = describeAlert(alerts[s])
916 const priceText = q ? fmtPrice(q.price).padStart(9) + ' ' + fmtPct(q.pct).padEnd(8) : (errors[s] ? 'err' : '…').padStart(9) + ' '.repeat(9)
917 const c = side ? (side === 'above' ? 'green' : 'red') : colorFor(q && q.pct)
918 const symText = side
919 ? Text({ bold: true, inverse: true, color: c, children: [s.padEnd(6)] })
920 : Text({ bold: true, children: [s.padEnd(6)] })
921 return Box({
922 key: 'row-' + s,
923 flexDirection: 'row',
924 columnGap: 1,
925 children: [
926 symText,
927 Text(c ? { color: c, children: [priceText] } : { children: [priceText] }),
928 Text(side ? { bold: true, children: [a ? 'ALARM ' + a : ''] } : { dimColor: true, children: [a ? 'alarm ' + a : ''] }),
929 Button({ key: 'open-' + s, label: 'open', plain: true, onPress: async () => { await openSymbol($, s) } }),
930 Button({ key: 'rm-' + s, label: 'x', plain: true, onPress: async () => { await removeSymbols($, [s]) } }),
931 ],
932 })
933 })
934 return Box({
935 flexDirection: 'column',
936 children: [
937 header,
938 Input({
939 key: 'add',
940 label: 'Add',
941 placeholder: 'symbols, e.g. AMD PLTR, then Enter',
942 value: '',
943 submitLabel: 'add',
944 autoFocus: true,
945 onSubmit: async (value) => {
946 const syms = parseSymbols(value)
947 if (syms.length) await addSymbols($, syms)
948 },
949 }),
950 ...rows,
951 ...(watchlist.length === 0 ? [Text({ dimColor: true, children: ['Watchlist empty. Type symbols in Add above, or /sa add AMD'] })] : []),
952 ...(alarmCount(watchlist, alerts) === 0 ? [Text({ dimColor: true, children: [EMPTY_ALARMS] })] : []),
953 Text({ children: [' '] }),
954 ...PANE_FOOTER.map((line) => Text({ dimColor: true, children: [line] })),
955 Text({ dimColor: true, children: ['Source: ' + sourceLabel] }),
956 ],
957 })
958 })
959}
960