SLOPSHOPPER

stock-alarm

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…

newpanebandcommandtoaststatus
v0.3.1MITupdated 2026-10-09shawn14/claude-stock-alarm/stock-alarm
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · stock-alarm
│ ┃ Stock Alarm ✕ › fix the failing auth test and add an audit log call │ ┃ Stock Alarm · 4 symbols · updated 3:34pm │ ┃ Add: symbols, e.g. AMD PLTR, then Enter ⏎ ad ⏺ Read(src/auth.ts) │ ┃ AAPL err open x ⎿ Read 6 lines │ ┃ NVDA err open x ⏺ Update(src/auth.ts) │ ┃ MSFT err open x ⎿ Added 2 lines, removed 1 line │ ┃ TSLA err open x ⏺ Bash(bun test) │ ┃ No alarms yet. Type /sa alert NVDA above 250 ⎿ 3 pass, 1 fail │ ┃ and press Enter │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ /sa add AMD · /sa rm TSLA · /sa open NVDA · │ ┃ /sa help ✻ Worked for 42s · done 4:20 PM │ ┃ /sa alert NVDA above 250 · below 200 · clear │ ┃ Source: Stock Alarm tickers feed › /sa │ │ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Stock Alarm · Watchlist (/sa) · refreshed 3:34pm │ │ SYMBOL PRICE CHANGE ALARM ACTIONS │ │ AAPL ? — open · x │ │ NVDA ? — open · x │ │ +2 more · /sa for the full list · /sa dock 4 to show all │ │ Add: symbol… ⏎ add /sa add · /sa rm · /sa alert NVDA above 250 · /sa open NVDA · /sa help │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Stock Alarm · Watchlist (/sa) · refreshed 3:34pm │ │ SYMBOL PRICE CHANGE ALARM ACTIONS │ │ AAPL ? — open · x │ │ NVDA ? — open · x │ │ +2 more · /sa for the full list · /sa dock 4 to show all │ │ Add: symbol… ⏎ add /sa add · /sa rm · /sa alert NVDA above 250 · /sa open NVDA · /sa help │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ⟨Claude Code's own drawing⟩
Pane · Stock Alarm
Stock Alarm · 4 symbols · updated 3:34pm Add: symbols, e.g. AMD PLTR, then Enter ⏎ add AAPL err open x NVDA err open x MSFT err open x TSLA err open x No alarms yet. Type /sa alert NVDA above 250 and press Enter /sa add AMD · /sa rm TSLA · /sa open NVDA · /sa help /sa alert NVDA above 250 · below 200 · clear Source: Stock Alarm tickers feed
README

Stock Alarm for Claude Code

Live stock prices in Claude Code. This mod (a Claude Code plugin with a hooks module) keeps your watchlist in view while you work:

  • Watchlist table docked above the prompt: a boxed SYMBOL / PRICE / CHANGE / ALARM / ACTIONS table (5 rows by default) with an "Add" field and a command footer. It stays on screen while you chat, and a triggered alarm is highlighted in the ALARM column.
  • One-line ticker strip when there's no room for the table: 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.
  • Local price alerts: type /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.

Stock Alarm 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.)

Requirements

  • Claude Code 2.1.287 or later (mods first shipped in 2.1.287). Check with claude --version.
  • No account, API key, or extra software.

Install

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

Quick start

  1. Restart Claude Code (type /restart) or run /reload-plugins after installing.
  2. Click into the prompt at the bottom of Claude Code (where the > is).
  3. Type /sa alert NVDA above 250 and press Enter.
  4. The alarm shows in the ALARM column of the watchlist table. When the price crosses 250, the row is highlighted and you get a notification.

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:

  • Alarms are local to Claude Code on this computer. They only check prices while Claude Code is open, and they don't go to your phone. For alerts on your phone, get the Stock Alarm app for iPhone or Android.
  • These commands don't use Claude tokens. They run inside the mod, not through Claude.

Usage

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.

CommandWhat it does
/saOpen the full watchlist pane (Esc closes it; type symbols in "Add" and press Enter)
/sa add AMD PLTR or /sa-add AMD PLTRAdd symbols (up to 25)
/sa rm TSLA or /sa-rm TSLARemove symbols
/sa listPrint the watchlist with fresh quotes
/sa open NVDAOpen 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 250Alarm when NVDA trades at or above 250
/sa alert NVDA below 200Alarm when NVDA trades at or below 200
/sa alert NVDA clearRemove the alarms on NVDA
/sa alert NVDA > 250Same as above. >= also means above, and < or <= mean below. A $ before the price is fine ($250)
/sa alert NVDA 250No 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 8Dock the watchlist table above the prompt (optionally with a row count, 1 to 25)
/sa undockJust the one-line ticker strip, no table
/sa hide / /sa showHide or show the ticker (table or strip)
/sa hints off / /sa hints onHide or show the · /sa help hint in the table footer and strip (for power users)
/sa refreshRefresh quotes now
/sa resetBack to the default watchlist, alarms cleared
/sa helpList the commands

The docked table

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
  • It shows up to 5 symbols (the 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.
  • It folds back to the one-line strip when the terminal is under 30 rows or the band is under 60 columns, and in a fullscreen layout it shrinks to the room Claude Code gives the band. It also steps aside while a Claude Code survey is showing.
  • To use the Add field or the 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.

Settings

Run /plugin configure stock-alarm@stock-alarm (or find the rows in /config):

SettingDefaultWhat it does
watchlistAAPL NVDA MSFT TSLADefault symbols, space- or comma-separated. Used until you change the list with /sa add / /sa rm, and after /sa reset.
refresh_seconds30How often quotes refresh, 10 to 600 seconds
displaypanelpanel: 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_rows5How 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.

Quote source

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.

Powered by Stock Alarm

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:

Disclaimer

Quotes may be delayed and are provided for information only. Nothing in this mod is investment advice.

Development

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.

License

MIT © Shawn Carpenter / Stock Alarm

Source 1 files
hooks/register.js 960 lines
1// 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