// @generated by scripts/build-runtime.mjs; do not edit. // @ts-nocheck -- generated JavaScript uses a .ts extension for Pi's Jiti loader. import { createSettingsWriter, loadSettings, normalizeSymbols, parseSymbols } from "./chunks/chunk-U5NETXSC.js"; // src/ticker.ts import { join } from "node:path"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; import { sanitizeTerminalText } from "@narumitw/pi-tui-kit/terminal-text"; // src/quotes.ts var QUOTE_ENDPOINT = "https://query1.finance.yahoo.com/v8/finance/chart"; var QuoteRequestTimeoutError = class extends Error { constructor() { super("Quote request timed out."); this.name = "QuoteRequestTimeoutError"; } }; async function fetchQuotes(symbols, signal, fetchImplementation = fetch) { return Promise.all( symbols.map(async (symbol) => { try { const quote = await fetchQuote(symbol, signal, fetchImplementation); return { symbol, quote }; } catch (error) { if (signal.aborted) { if (!(signal.reason instanceof QuoteRequestTimeoutError)) throw error; return { symbol, error: signal.reason.message }; } return { symbol, error: errorMessage(error) }; } }) ); } async function fetchQuote(symbol, signal, fetchImplementation) { const url = `${QUOTE_ENDPOINT}/${encodeURIComponent(symbol)}?interval=1m&range=1d`; const response = await fetchImplementation(url, { headers: { "User-Agent": "pi-ticker/1.0" }, signal }); if (!response.ok) throw new Error(`Quote request failed with HTTP ${response.status}.`); return parseQuote(symbol, await response.json()); } function parseQuote(requestedSymbol, payload) { const chart = asRecord(payload)?.chart; const result = asRecord(chart)?.result; const first = Array.isArray(result) ? asRecord(result[0]) : void 0; const meta = asRecord(first?.meta); const price = finiteNumber(meta?.regularMarketPrice); const previousClose = finiteNumber(meta?.previousClose) ?? finiteNumber(meta?.chartPreviousClose); if (price === void 0 || previousClose === void 0 || previousClose === 0) { throw new Error("Quote response did not include a usable price and previous close."); } const change = price - previousClose; const currency = safeCurrency(meta?.currency); const marketTimeSeconds = finiteNumber(meta?.regularMarketTime); return { symbol: requestedSymbol, price, previousClose, change, changePercent: change / previousClose * 100, ...currency ? { currency } : {}, ...marketTimeSeconds === void 0 ? {} : { marketTime: marketTimeSeconds * 1e3 } }; } function asRecord(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : void 0; } function finiteNumber(value) { return typeof value === "number" && Number.isFinite(value) ? value : void 0; } function safeCurrency(value) { if (value === "GBp") return value; if (typeof value !== "string") return void 0; const normalized = value.toUpperCase(); return /^[A-Z]{3}$/.test(normalized) ? normalized : void 0; } function errorMessage(error) { return error instanceof Error ? error.message : String(error); } // src/render.ts import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui"; function renderTickerWidget(view, theme, width) { const renderWidth = Math.max(0, width); if (renderWidth === 0) return [""]; const heading = renderHeading(view, theme); const separator = theme.fg("dim", " \u2502 "); const quotes = packItems( view.items.map((item) => renderItem(item, theme)), separator, renderWidth ); return [ theme.fg("borderMuted", "\u2500".repeat(renderWidth)), ...fitLines(heading, renderWidth), ...quotes ]; } function formatTickerPlain(view) { const status = view.loading ? "Stocks \xB7 updating" : view.updatedAt ? `Stocks \xB7 updated ${new Date(view.updatedAt).toLocaleTimeString()}` : "Stocks"; return [status, ...view.items.map(formatItemPlain)]; } function mergeQuoteResults(current, results) { const previous = new Map(current.map((item) => [item.symbol, item])); return results.map((result) => { if (result.quote) return { symbol: result.symbol, quote: result.quote }; const existing = previous.get(result.symbol); return existing?.quote ? { symbol: result.symbol, quote: existing.quote, error: result.error, stale: true } : { symbol: result.symbol, error: result.error }; }); } function renderHeading(view, theme) { if (view.loading) return theme.fg("muted", "Stocks \xB7 updating\u2026"); if (view.updatedAt) { return theme.fg("muted", `Stocks \xB7 updated ${new Date(view.updatedAt).toLocaleTimeString()}`); } return theme.fg("muted", "Stocks"); } function renderItem(item, theme) { const symbol = theme.fg("accent", theme.bold(item.symbol)); if (!item.quote) return `${symbol} ${theme.fg("error", "unavailable")}`; const changeRole = item.quote.change >= 0 ? "success" : "error"; const content = `${symbol} ${theme.fg("text", formatPrice(item.quote))} ${theme.fg( changeRole, formatChange(item.quote) )}`; return item.stale ? `${content} ${theme.fg("warning", "stale")}` : content; } function formatItemPlain(item) { if (!item.quote) return `${item.symbol} unavailable`; const content = `${item.symbol} ${formatPrice(item.quote)} ${formatChange(item.quote)}`; return item.stale ? `${content} stale` : content; } function formatPrice(quote) { const value = formatMarketNumber(quote.price); if (quote.currency === "GBp") return `${value}p`; return `${currencyPrefix(quote.currency)}${value}`; } function formatChange(quote) { const sign = quote.change >= 0 ? "+" : ""; return `${sign}${formatMarketNumber(quote.change)} (${sign}${quote.changePercent.toFixed(2)}%)`; } function formatMarketNumber(value) { const absolute = Math.abs(value); if (absolute === 0) return "0.00"; const magnitude = Math.floor(Math.log10(absolute)); const significantDecimals = 6 - magnitude - 1; const formatted = significantDecimals > 8 ? value.toPrecision(6) : value.toFixed(Math.max(2, significantDecimals)); return trimNumericZeros(formatted); } function trimNumericZeros(value) { const [coefficient, exponent] = value.split("e"); const [integer = value, fraction = ""] = coefficient?.split(".") ?? [value, ""]; let trimmedFraction = fraction; const minimumFractionDigits = exponent === void 0 ? 2 : 0; while (trimmedFraction.length > minimumFractionDigits && trimmedFraction.endsWith("0")) { trimmedFraction = trimmedFraction.slice(0, -1); } const trimmed = trimmedFraction ? `${integer}.${trimmedFraction}` : integer; return exponent === void 0 ? trimmed : `${trimmed}e${exponent}`; } function currencyPrefix(currency) { switch (currency) { case "USD": return "$"; case "EUR": return "\u20AC"; case "GBP": return "\xA3"; case "JPY": return "\xA5"; case "CAD": return "C$"; case "HKD": return "HK$"; case "TWD": return "NT$"; default: return currency ? `${currency} ` : ""; } } function packItems(items, separator, width) { const rows = []; let current = ""; for (const item of items) { if (visibleWidth(item) > width) { if (current) rows.push(current); rows.push(...fitLines(item, width)); current = ""; continue; } const candidate = current ? `${current}${separator}${item}` : item; if (visibleWidth(candidate) <= width) { current = candidate; continue; } rows.push(current); current = item; } if (current) rows.push(current); return rows; } function fitLines(content, width) { return wrapTextWithAnsi(content, width).map((line) => truncateToWidth(line, width, "")); } // src/ticker.ts var WIDGET_KEY = "ticker"; var SETTINGS_FILE_NAME = "pi-ticker.json"; var POLL_INTERVAL_MS = 3e4; var REQUEST_TIMEOUT_MS = 1e4; var HELP = "Usage: /ticker [SYMBOL ...] | /ticker refresh | /ticker reset | /ticker help"; var COMPLETIONS = [ { value: "refresh", label: "refresh", description: "Refresh quotes now" }, { value: "reset", label: "reset", description: "Explain why no default reset is available" }, { value: "help", label: "help", description: "Show command usage" } ]; function stockTicker(pi, dependencies = {}) { let activeSession; let generation = 0; let cycleVersion = 0; let pollTimer; let requestController; let sessionController; const activeMenus = /* @__PURE__ */ new Set(); let symbols = []; let widgetEnabled = true; let view = createInitialView(symbols); const settingsWriter = (dependencies.createSettingsWriter ?? createSettingsWriter)(); const readSettings = dependencies.loadSettings ?? loadSettings; const ownsSession = (ctx, expectedGeneration = generation) => ctx.sessionManager === activeSession && expectedGeneration === generation; const publish = (ctx) => { if (!ctx.hasUI || !ownsSession(ctx)) return; if (!widgetEnabled || view.items.length === 0) { ctx.ui.setWidget(WIDGET_KEY, void 0); return; } const snapshot = cloneView(view); if (ctx.mode === "tui") { ctx.ui.setWidget( WIDGET_KEY, (_tui, theme) => ({ render: (width) => renderTickerWidget(snapshot, theme, width), invalidate: () => { } }), { placement: "aboveEditor" } ); return; } ctx.ui.setWidget(WIDGET_KEY, formatTickerPlain(snapshot), { placement: "aboveEditor" }); }; const stopPolling = () => { cycleVersion += 1; if (pollTimer) clearTimeout(pollTimer); pollTimer = void 0; requestController?.abort(); requestController = void 0; }; const clearWidget = (ctx) => { if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, void 0); }; const runCycle = async (ctx, expectedGeneration, expectedCycle) => { if (!ownsSession(ctx, expectedGeneration) || expectedCycle !== cycleVersion) return; view = { ...view, loading: true }; publish(ctx); const controller = new AbortController(); requestController = controller; const timeout = setTimeout(() => { controller.abort(new QuoteRequestTimeoutError()); }, REQUEST_TIMEOUT_MS); try { const results = await fetchQuotes(symbols, controller.signal); if (!ownsSession(ctx, expectedGeneration) || expectedCycle !== cycleVersion) return; view = { items: mergeQuoteResults(view.items, results), loading: false, updatedAt: results.some((result) => result.quote) ? Date.now() : view.updatedAt }; publish(ctx); } catch { if (!controller.signal.aborted && ownsSession(ctx, expectedGeneration) && expectedCycle === cycleVersion) { view = { items: view.items.map((item) => ({ ...item, stale: Boolean(item.quote), error: "unavailable" })), loading: false, updatedAt: view.updatedAt }; publish(ctx); } } finally { clearTimeout(timeout); if (requestController === controller) requestController = void 0; } }; const startCycle = (ctx, expectedGeneration = generation) => { if (!ownsSession(ctx, expectedGeneration)) return; if (!widgetEnabled || symbols.length === 0) { stopPolling(); publish(ctx); return; } if (pollTimer) clearTimeout(pollTimer); requestController?.abort(); const expectedCycle = ++cycleVersion; void runCycle(ctx, expectedGeneration, expectedCycle).finally(() => { if (!ownsSession(ctx, expectedGeneration) || expectedCycle !== cycleVersion) return; pollTimer = setTimeout(() => startCycle(ctx, expectedGeneration), POLL_INTERVAL_MS); }); }; const openMenu = async (ctx) => { const controller = sessionController; const expectedGeneration = generation; if (!controller || controller.signal.aborted || !ownsSession(ctx, expectedGeneration)) return; const running = (async () => { const { showTickerMenu } = await import("./chunks/menu-WR7QV3P4.js"); if (controller.signal.aborted || !ownsSession(ctx, expectedGeneration)) return; await showTickerMenu(ctx, { signal: controller.signal, isCurrent: () => !controller.signal.aborted && ownsSession(ctx, expectedGeneration), getSymbols: () => [...symbols], applySymbols: async (nextSymbols, signal) => { if (signal.aborted || !ownsSession(ctx, expectedGeneration)) return; const normalized = normalizeSymbols(nextSymbols, true); await settingsWriter.save(settingsPath(), normalized); if (signal.aborted || !ownsSession(ctx, expectedGeneration)) return; symbols = normalized; view = createInitialView(symbols); publish(ctx); startCycle(ctx, expectedGeneration); }, getWidgetEnabled: () => widgetEnabled, applyWidgetEnabled: async (nextWidgetEnabled, signal) => { if (signal.aborted || !ownsSession(ctx, expectedGeneration)) return; await settingsWriter.saveWidgetEnabled(settingsPath(), nextWidgetEnabled); if (signal.aborted || !ownsSession(ctx, expectedGeneration)) return; widgetEnabled = nextWidgetEnabled; if (!widgetEnabled) stopPolling(); publish(ctx); if (widgetEnabled) startCycle(ctx, expectedGeneration); }, refresh: () => { if (!ownsSession(ctx, expectedGeneration)) return; if (!widgetEnabled) { notify(ctx, "Ticker widget is disabled. Enable it before refreshing.", "warning"); return; } startCycle(ctx, expectedGeneration); } }); })(); activeMenus.add(running); try { await running; } finally { activeMenus.delete(running); } }; pi.on("session_start", async (_event, ctx) => { sessionController?.abort(); stopPolling(); activeSession = ctx.sessionManager; sessionController = new AbortController(); const expectedGeneration = ++generation; symbols = []; widgetEnabled = true; view = createInitialView(symbols); publish(ctx); await settingsWriter.flush(); if (!ownsSession(ctx, expectedGeneration)) return; const loaded = await readSettings(settingsPath()); if (!ownsSession(ctx, expectedGeneration)) return; symbols = loaded.settings.symbols; widgetEnabled = loaded.settings.widgetEnabled; view = createInitialView(symbols); publish(ctx); if (loaded.warning) notify(ctx, loaded.warning, "warning"); if (ctx.hasUI && widgetEnabled && symbols.length > 0) startCycle(ctx, expectedGeneration); }); pi.on("session_shutdown", async (_event, ctx) => { if (!ownsSession(ctx)) return; sessionController?.abort(); stopPolling(); clearWidget(ctx); activeSession = void 0; sessionController = void 0; generation += 1; await Promise.allSettled([...activeMenus]); await settingsWriter.flush(); }); pi.registerCommand("ticker", { description: "Show or change the stock ticker symbols", getArgumentCompletions: completeArguments, handler: async (argumentsText, ctx) => { if (!ctx.hasUI) { throw new Error("The /ticker command requires TUI or RPC mode."); } const input = argumentsText.trim(); if (!input) { await openMenu(ctx); return; } const words = input.split(/\s+/); const route = words[0]?.toLowerCase(); if (["help", "refresh", "reset"].includes(route ?? "") && words.length > 1) { notify(ctx, `${route} does not accept trailing arguments.`, "error"); return; } if (route === "help") { notify(ctx, HELP); return; } if (route === "refresh") { if (!widgetEnabled) { notify( ctx, "Ticker widget is disabled. Enable it with /ticker before refreshing.", "warning" ); return; } if (ownsSession(ctx)) startCycle(ctx); notify(ctx, "Refreshing stock quotes."); return; } if (route === "reset") { notify( ctx, "No default ticker list is configured. Use /ticker SYMBOL ... instead.", "error" ); return; } let nextSymbols; try { nextSymbols = parseSymbols([input]); } catch (error) { notify(ctx, errorMessage2(error), "error"); return; } try { await settingsWriter.save(settingsPath(), nextSymbols); } catch (error) { notify(ctx, `Could not save stock ticker settings: ${errorMessage2(error)}`, "error"); return; } if (!ownsSession(ctx)) return; symbols = nextSymbols; view = createInitialView(symbols); publish(ctx); startCycle(ctx); notify(ctx, `Stock ticker symbols: ${symbols.join(" ")}`); } }); } function settingsPath() { return join(getAgentDir(), SETTINGS_FILE_NAME); } function completeArguments(prefix) { if (/\s/.test(prefix)) return null; const normalized = prefix.toLowerCase(); const matches = COMPLETIONS.filter((item) => item.value.toLowerCase().startsWith(normalized)); return matches.length > 0 ? matches : null; } function createInitialView(symbols) { return { items: symbols.map((symbol) => ({ symbol })), loading: true }; } function cloneView(view) { return { ...view, items: view.items.map((item) => ({ ...item, quote: item.quote && { ...item.quote } })) }; } function notify(ctx, message, type = "info") { if (ctx.hasUI) ctx.ui.notify(sanitizeTerminalText(message), type); } function errorMessage2(error) { return error instanceof Error ? error.message : String(error); } export { stockTicker as default }; //# sourceMappingURL=index.ts.map