horizon-code 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "horizon-code",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "AI-powered trading strategy terminal for Polymarket",
5
5
  "type": "module",
6
6
  "bin": {
package/src/ai/client.ts CHANGED
@@ -152,6 +152,7 @@ import { zodSchema } from "@ai-sdk/provider-utils";
152
152
 
153
153
  function toolsToServerFormat(tools: Record<string, any>): Record<string, any> {
154
154
  const result: Record<string, any> = {};
155
+ let failures = 0;
155
156
  for (const [name, tool] of Object.entries(tools)) {
156
157
  if (!tool) continue;
157
158
  let params: any = { type: "object", properties: {} };
@@ -184,17 +185,26 @@ async function callServer(opts: {
184
185
  const config = loadConfig();
185
186
  if (!config.api_key) throw new Error("Not authenticated. Type /login.");
186
187
 
188
+ const toolPayload = toolsToServerFormat(opts.tools);
189
+ const body = JSON.stringify({
190
+ messages: opts.messages,
191
+ system: opts.system,
192
+ model_power: opts.modelPower,
193
+ tools: toolPayload,
194
+ response_format: opts.responseFormat,
195
+ });
196
+
197
+ // Use the caller's abort signal, with a fallback 2-min timeout
198
+ const timeoutSignal = AbortSignal.timeout(120000);
199
+ const combinedSignal = opts.signal
200
+ ? AbortSignal.any([opts.signal, timeoutSignal])
201
+ : timeoutSignal;
202
+
187
203
  const res = await fetch(`${getAuthUrl()}/api/v1/chat`, {
188
204
  method: "POST",
189
- signal: opts.signal ?? AbortSignal.timeout(120000), // 2 min max
205
+ signal: combinedSignal,
190
206
  headers: { "Authorization": `Bearer ${config.api_key}`, "Content-Type": "application/json" },
191
- body: JSON.stringify({
192
- messages: opts.messages,
193
- system: opts.system,
194
- model_power: opts.modelPower,
195
- tools: toolsToServerFormat(opts.tools),
196
- response_format: opts.responseFormat,
197
- }),
207
+ body,
198
208
  });
199
209
 
200
210
  if (!res.ok) {
@@ -248,7 +258,12 @@ export async function* chat(
248
258
  signal,
249
259
  });
250
260
  } catch (err: any) {
261
+ if (signal.aborted) {
262
+ yield { type: "finish" };
263
+ return;
264
+ }
251
265
  yield { type: "error", message: err.message };
266
+ yield { type: "finish" };
252
267
  return;
253
268
  }
254
269
 
@@ -262,6 +277,7 @@ export async function* chat(
262
277
  let eventCount = 0;
263
278
 
264
279
  for await (const event of consumeSSE(res)) {
280
+ if (signal.aborted) break;
265
281
  eventCount++;
266
282
  if (event.type === "meta") {
267
283
  yield {
@@ -323,6 +339,9 @@ export async function* chat(
323
339
  if (!hasToolCalls || pendingToolCalls.length === 0) {
324
340
  if (eventCount === 0) {
325
341
  yield { type: "error", message: "No response from server. Check your connection or try again." };
342
+ } else if (!hasToolCalls && jsonBuf === "" && emittedResponseLen === 0) {
343
+ // Server sent events (meta, usage) but no actual content
344
+ yield { type: "text-delta", textDelta: "*(Server returned no content. This may happen if the model is overloaded. Try again.)*" };
326
345
  }
327
346
  yield { type: "finish" };
328
347
  return;
@@ -351,6 +370,12 @@ export async function* chat(
351
370
  }
352
371
  }
353
372
 
373
+ // If aborted during tool execution, stop
374
+ if (signal.aborted) {
375
+ yield { type: "finish" };
376
+ return;
377
+ }
378
+
354
379
  // Add as assistant message — the LLM sees it already ran the tools
355
380
  serverMessages.push({ role: "assistant", content: assistantParts.join("\n\n") });
356
381
  // Prompt the LLM to continue with its response based on the tool results
@@ -15,7 +15,7 @@ const MODE_PROMPTS: Record<Mode, string> = {
15
15
 
16
16
  ## Mode: Research
17
17
 
18
- You have 13 research tools that fetch LIVE data from Polymarket, Kalshi, and the web. Use them.
18
+ You have 29 research tools that fetch LIVE data from Polymarket, Kalshi, stocks, and the web. Use them.
19
19
 
20
20
  ### CRITICAL RULE
21
21
  When the user mentions ANY topic, event, market, or asks about what's happening — ALWAYS call a tool to search for real data. Never answer from your training data alone. Prediction markets change by the minute.
@@ -33,24 +33,66 @@ When the user mentions ANY topic, event, market, or asks about what's happening
33
33
  - kalshiEvents(query) — search Kalshi markets
34
34
  - webSearch(query) — search the internet for news, facts, context
35
35
  - calaKnowledge(query) — deep research on companies, people, industries
36
+ - liquidityScanner(query, limit) — scan top markets ranked by liquidity, spread, volume
36
37
 
37
- **Analyze a specific market (requires a slug from polymarketEvents):**
38
+ **Analyze a specific Polymarket (requires a slug from polymarketEvents):**
38
39
  - polymarketEventDetail(slug) — full details, sub-markets, price history, spreads
39
40
  - polymarketPriceHistory(slug, interval) — price chart (1h/6h/1d/1w/1m/max)
40
41
  - polymarketOrderBook(slug) — bid/ask depth
41
42
  - whaleTracker(slug) — large trades, smart money flow
42
43
  - newsSentiment(slug) — news sentiment score + articles
43
- - historicalVolatility(slug, interval) — realized vol, regime detection
44
+ - historicalVolatility(slug, interval) — multi-estimator vol (close-to-close, Parkinson, Garman-Klass, EWMA), regime detection
45
+ - marketMicrostructure(slug) — depth imbalance, effective spread, Kyle's lambda, liquidity score
46
+
47
+ **Analyze a specific Kalshi market (requires a ticker from kalshiEvents):**
48
+ - kalshiEventDetail(ticker) — full details, all sub-markets, spreads, volumes
49
+ - kalshiOrderBook(ticker) — bid/ask depth for a Kalshi market
50
+ - kalshiPriceHistory(ticker, period) — price chart for a Kalshi market
51
+
52
+ **Whale & wallet analysis (requires wallet address from whaleTracker results):**
53
+ - walletProfiler(address) — score a wallet: win rate, PnL, Sharpe, edge category (smart_money/neutral/weak_hand/bot)
54
+ - botDetector(slug) — detect bots in a market: classify top traders, identify strategy types (market_maker, grid_bot, momentum, sniper)
55
+ - marketFlow(slug, limit) — deep flow analysis: buy/sell volume, wallet concentration (Herfindahl), top movers
44
56
 
45
57
  **Quantitative analysis:**
46
58
  - evCalculator(slug, side, estimatedEdge, bankroll) — expected value + Kelly sizing
47
59
  - probabilityCalculator(slugA, slugB) — joint/conditional probability
48
60
  - compareMarkets(topic) — cross-platform price comparison (Polymarket vs Kalshi)
61
+ - correlationAnalysis(slugA, slugB, interval) — price correlation, beta, dual sparklines between two markets
62
+ - riskMetrics(slug, interval, confidenceLevel) — VaR, CVaR, max drawdown, Sharpe, Sortino, Calmar, skewness, kurtosis
63
+ - marketRegime(slug, interval) — Hurst exponent, variance ratio, entropy — detect if market is trending, mean-reverting, or efficient
49
64
 
50
- ### Tool Chaining
51
- When the user refers to a market from a previous result, reuse the SLUG from that result. Don't call polymarketEvents again — go straight to the detail tool. Look at conversation history for slugs.
65
+ **Stock market (Yahoo Finance — free, no API key needed):**
66
+ - stockQuote(symbols) real-time quotes: price, change, volume, market cap, P/E, 52-week range, moving averages. Comma-separated: "AAPL,MSFT,TSLA"
67
+ - stockChart(symbol, range, interval) — price chart with sparkline. Ranges: 1d, 5d, 1mo, 3mo, 6mo, 1y, 5y, max
68
+ - stockSearch(query) — search tickers by name/keyword: "electric vehicles", "gold ETF"
69
+ - stockScreener(symbols) — compare stocks side-by-side: P/E, market cap, 52-week, moving averages
52
70
 
53
- Use your intelligence to understand what the user wants. If they say "dig deeper into the first one" — that means polymarketEventDetail with the first slug. If they say "what do whales think" — that means whaleTracker. You're smart enough to figure this out without a flowchart.`,
71
+ **Visualization:**
72
+ - openChart(source, identifier, range) — open interactive Chart.js chart in browser. source: "polymarket" or "stock". Great for detailed analysis.
73
+
74
+ ### Tool Chaining
75
+ When the user refers to a market from a previous result, reuse the SLUG (Polymarket) or TICKER (Kalshi) from that result. Don't search again — go straight to the detail tool.
76
+
77
+ Use your intelligence to understand what the user wants:
78
+ - "dig deeper" → polymarketEventDetail or kalshiEventDetail
79
+ - "what do whales think" → whaleTracker
80
+ - "show me the order book" → polymarketOrderBook or kalshiOrderBook
81
+ - "how liquid is this" → marketMicrostructure
82
+ - "do these markets correlate" → correlationAnalysis
83
+ - "what's the best market to trade" → liquidityScanner
84
+ - "compare Polymarket and Kalshi" → compareMarkets
85
+ - "what's the volatility" → historicalVolatility (now shows 4 estimators)
86
+ - "show me the Kalshi chart" → kalshiPriceHistory
87
+ - "who are the bots" → botDetector
88
+ - "analyze this wallet" → walletProfiler (needs 0x address from whaleTracker)
89
+ - "where is the money flowing" → marketFlow
90
+ - "what's the risk" → riskMetrics (VaR, CVaR, Sharpe, drawdown)
91
+ - "is this market trending" → marketRegime (Hurst exponent, variance ratio)
92
+ - "show me AAPL" → stockQuote("AAPL")
93
+ - "compare big tech" → stockScreener("AAPL,MSFT,GOOG,AMZN,META")
94
+ - "chart this" → openChart (opens interactive chart in browser)
95
+ - "search for AI stocks" → stockSearch("artificial intelligence")`,
54
96
 
55
97
  strategy: "", // dynamically generated
56
98
 
package/src/app.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { BoxRenderable, ScrollBoxRenderable, type CliRenderer } from "@opentui/core";
1
+ import { BoxRenderable, TextRenderable, ScrollBoxRenderable, type CliRenderer } from "@opentui/core";
2
2
  import { COLORS, setTheme, ULTRA_THEMES } from "./theme/colors.ts";
3
3
  import { Footer } from "./components/footer.ts";
4
4
  import { Splash } from "./components/splash.ts";
@@ -13,6 +13,8 @@ import { ModeBar } from "./components/mode-bar.ts";
13
13
  import { TabBar } from "./components/tab-bar.ts";
14
14
  import { InputBar } from "./components/input-bar.ts";
15
15
  import { ChatRenderer } from "./chat/renderer.ts";
16
+ import { renderToolWidget } from "./research/widgets.ts";
17
+ import { renderStrategyWidget } from "./strategy/widgets.ts";
16
18
  import { KeyHandler } from "./keys/handler.ts";
17
19
  import { store } from "./state/store.ts";
18
20
  import { createUserMessage } from "./chat/messages.ts";
@@ -26,6 +28,7 @@ import { autoFixStrategyCode } from "./strategy/validator.ts";
26
28
  import { CodeFenceDetector, finalizeStrategy, isStrategyCode, extractStrategyName } from "./strategy/code-stream.ts";
27
29
  import { loginWithBrowser, loginWithPassword, signOut, isLoggedIn, getUser } from "./platform/supabase.ts";
28
30
  import { loadConfig, saveConfig, setEncryptedEnv, removeEncryptedEnv, listEncryptedEnvNames } from "./platform/config.ts";
31
+ import { EXCHANGE_PROFILES, getExchangeStatuses, getExchangeStatus } from "./platform/exchanges.ts";
29
32
  import { getTreeSitterClient, destroyTreeSitterClient } from "./syntax/setup.ts";
30
33
  import type { Message } from "./chat/types.ts";
31
34
 
@@ -33,7 +36,11 @@ import type { Message } from "./chat/types.ts";
33
36
  const WIDGET_TOOLS = new Set([
34
37
  "polymarketEvents", "polymarketEventDetail", "polymarketPriceHistory",
35
38
  "polymarketOrderBook", "whaleTracker", "newsSentiment", "evCalculator",
36
- "kalshiEvents", "compareMarkets", "historicalVolatility",
39
+ "kalshiEvents", "kalshiEventDetail", "kalshiOrderBook", "kalshiPriceHistory",
40
+ "compareMarkets", "historicalVolatility",
41
+ "marketMicrostructure", "liquidityScanner", "correlationAnalysis",
42
+ "walletProfiler", "botDetector", "marketFlow", "riskMetrics", "marketRegime",
43
+ "stockQuote", "stockChart", "stockSearch", "stockScreener",
37
44
  "webSearch", "calaKnowledge", "probabilityCalculator",
38
45
  "edit_strategy", "validate_strategy",
39
46
  "backtest_strategy", "polymarket_data",
@@ -65,6 +72,8 @@ const SLASH_COMMANDS: Record<string, { description: string; usage: string }> = {
65
72
  "/strategies": { description: "List saved strategies", usage: "/strategies" },
66
73
  "/load": { description: "Load a saved strategy", usage: "/load <name>" },
67
74
  "/env": { description: "Manage encrypted API keys", usage: "/env [list|add|remove]" },
75
+ "/connect": { description: "Connect an exchange", usage: "/connect [polymarket|kalshi|alpaca|...]" },
76
+ "/exchanges": { description: "Show exchange connection status", usage: "/exchanges" },
68
77
  "/quit": { description: "Exit Horizon", usage: "/quit" },
69
78
  "/exit": { description: "Exit Horizon", usage: "/exit" },
70
79
  };
@@ -769,6 +778,50 @@ export class App {
769
778
  }
770
779
  break;
771
780
  }
781
+ case "/connect": {
782
+ const exchangeId = arg.trim().toLowerCase();
783
+ if (!exchangeId) {
784
+ const statuses = getExchangeStatuses();
785
+ const lines = statuses.map(s => {
786
+ const icon = s.status === "connected" ? "●" : s.status === "partial" ? "◐" : "○";
787
+ const color = s.status === "connected" ? "green" : s.status === "partial" ? "yellow" : "dim";
788
+ return ` ${icon} ${s.profile.name.padEnd(20)} ${s.status.padEnd(14)} ${s.profile.category}`;
789
+ });
790
+ this.showSystemMsg(`Exchange Connections:\n${lines.join("\n")}\n\nUse /connect <exchange> to set up keys.`);
791
+ } else {
792
+ const profile = EXCHANGE_PROFILES.find(p => p.id === exchangeId || p.name.toLowerCase() === exchangeId);
793
+ if (!profile) {
794
+ const ids = EXCHANGE_PROFILES.map(p => p.id).join(", ");
795
+ this.showSystemMsg(`Unknown exchange: ${exchangeId}. Available: ${ids}`);
796
+ } else {
797
+ const status = getExchangeStatus(profile.id);
798
+ const lines = profile.keys.map(k => {
799
+ const isSet = status?.keysSet.includes(k.env);
800
+ return ` ${isSet ? "●" : "○"} ${k.label.padEnd(18)} ${k.env.padEnd(28)} ${isSet ? "set" : k.required ? "REQUIRED" : "optional"}`;
801
+ });
802
+ this.showSystemMsg(
803
+ `${profile.name} — ${profile.description}\n${lines.join("\n")}\n\n` +
804
+ `Set keys with: /env add <ENV_NAME> <value>\n` +
805
+ `Example: /env add ${profile.keys[0].env} <your-key>`
806
+ );
807
+ }
808
+ }
809
+ break;
810
+ }
811
+ case "/exchanges": {
812
+ const statuses = getExchangeStatuses();
813
+ const connected = statuses.filter(s => s.status === "connected");
814
+ const partial = statuses.filter(s => s.status === "partial");
815
+ const disconnected = statuses.filter(s => s.status === "disconnected");
816
+
817
+ let msg = "Exchange Status:\n";
818
+ if (connected.length > 0) msg += `\nConnected:\n${connected.map(s => ` ● ${s.profile.name} (${s.profile.category})`).join("\n")}`;
819
+ if (partial.length > 0) msg += `\nPartial:\n${partial.map(s => ` ◐ ${s.profile.name} — missing: ${s.keysMissing.join(", ")}`).join("\n")}`;
820
+ if (disconnected.length > 0) msg += `\nNot Connected:\n${disconnected.map(s => ` ○ ${s.profile.name}`).join("\n")}`;
821
+ msg += "\n\nUse /connect <exchange> to set up.";
822
+ this.showSystemMsg(msg);
823
+ break;
824
+ }
772
825
  case "/quit":
773
826
  case "/exit":
774
827
  this.quit();
@@ -1274,7 +1327,33 @@ export class App {
1274
1327
  }
1275
1328
 
1276
1329
  if (WIDGET_TOOLS.has(part.toolName)) {
1277
- currentBlocks.push({ type: "tool-widget", toolName: part.toolName, widgetData: part.result });
1330
+ const { showWidgets, widgetsInTab } = this.settingsPanel.settings;
1331
+ if (showWidgets && !widgetsInTab) {
1332
+ // Render inline in chat (default)
1333
+ currentBlocks.push({ type: "tool-widget", toolName: part.toolName, widgetData: part.result });
1334
+ } else if (showWidgets && widgetsInTab) {
1335
+ // Render in the widgets tab
1336
+ try {
1337
+ const result = part.result;
1338
+ // Skip error results — they'd render as empty widgets
1339
+ if (result && typeof result === "object" && "error" in (result as any)) {
1340
+ const errBox = new BoxRenderable(this.renderer, { id: `wt-err-${Date.now()}`, flexDirection: "column" });
1341
+ errBox.add(new TextRenderable(this.renderer, { id: `wt-err-t-${Date.now()}`, content: `Error: ${(result as any).error}`, fg: COLORS.error }));
1342
+ this.codePanel.addWidget(part.toolName, errBox);
1343
+ } else {
1344
+ const widget = renderToolWidget(part.toolName, result, this.renderer)
1345
+ ?? renderStrategyWidget(part.toolName, result, this.renderer);
1346
+ if (widget) {
1347
+ this.codePanel.addWidget(part.toolName, widget);
1348
+ }
1349
+ }
1350
+ if (!this.codePanel.visible) this.codePanel.show();
1351
+ this.codePanel.setTab("widgets");
1352
+ } catch {
1353
+ // Widget rendering failed silently — don't break the stream
1354
+ }
1355
+ }
1356
+ // else showWidgets=false: skip rendering, LLM still got the data
1278
1357
  }
1279
1358
 
1280
1359
  // Update code panel from edit_strategy/load_saved_strategy results
@@ -59,7 +59,7 @@ const syntaxStyle = SyntaxStyle.fromStyles({
59
59
  "@punctuation.special": { fg: h("#D7BA7D") }, // gold
60
60
  });
61
61
 
62
- type PanelTab = "code" | "logs" | "dashboard";
62
+ type PanelTab = "code" | "logs" | "dashboard" | "widgets";
63
63
 
64
64
  export class CodePanel {
65
65
  readonly container: BoxRenderable;
@@ -74,6 +74,8 @@ export class CodePanel {
74
74
  private logsMd: MarkdownRenderable;
75
75
  private dashScroll: ScrollBoxRenderable;
76
76
  private dashMd: MarkdownRenderable;
77
+ private widgetsScroll: ScrollBoxRenderable;
78
+ private widgetsList: BoxRenderable;
77
79
 
78
80
  private footerText: TextRenderable;
79
81
  private _visible = false;
@@ -128,6 +130,7 @@ export class CodePanel {
128
130
  { id: "code", label: "Code" },
129
131
  { id: "logs", label: "Logs" },
130
132
  { id: "dashboard", label: "Metrics" },
133
+ { id: "widgets", label: "Widgets" },
131
134
  ];
132
135
  for (const tab of tabs) {
133
136
  const text = new TextRenderable(renderer, {
@@ -195,10 +198,34 @@ export class CodePanel {
195
198
  this.dashScroll.add(this.dashMd);
196
199
  this.container.add(this.dashScroll);
197
200
 
201
+ // ── Widgets tab content ──
202
+ this.widgetsScroll = new ScrollBoxRenderable(renderer, {
203
+ id: "widgets-scroll",
204
+ flexGrow: 1,
205
+ paddingLeft: 1,
206
+ paddingRight: 1,
207
+ paddingTop: 1,
208
+ stickyScroll: true,
209
+ stickyStart: "bottom",
210
+ });
211
+ this.widgetsScroll.visible = false;
212
+ this.widgetsList = new BoxRenderable(renderer, {
213
+ id: "widgets-list",
214
+ flexDirection: "column",
215
+ width: "100%",
216
+ });
217
+ this.widgetsList.add(new TextRenderable(renderer, {
218
+ id: "widgets-empty",
219
+ content: "*no widgets yet*\n\n*Tool results will appear here when \"Widgets in Tab\" is enabled in settings.*",
220
+ fg: COLORS.textMuted,
221
+ }));
222
+ this.widgetsScroll.add(this.widgetsList);
223
+ this.container.add(this.widgetsScroll);
224
+
198
225
  // ── Footer ──
199
226
  this.footerText = new TextRenderable(renderer, {
200
227
  id: "code-footer",
201
- content: " ^G close | Tab cycle | 1 code 2 logs 3 dash",
228
+ content: " ^G close | Tab cycle | 1 code 2 logs 3 dash 4 widgets",
202
229
  fg: COLORS.borderDim,
203
230
  });
204
231
  this.container.add(this.footerText);
@@ -224,20 +251,72 @@ export class CodePanel {
224
251
  this.codeScroll.visible = tab === "code";
225
252
  this.logsScroll.visible = tab === "logs";
226
253
  this.dashScroll.visible = tab === "dashboard";
254
+ this.widgetsScroll.visible = tab === "widgets";
227
255
  // Refresh content for the active tab
228
256
  if (tab === "code") this.updateCodeContent();
229
257
  else if (tab === "logs") this.updateLogsContent();
230
258
  else if (tab === "dashboard") this.updateDashContent();
259
+ // Widgets tab: force scroll to bottom to show latest
260
+ if (tab === "widgets") this.widgetsScroll.scrollToBottom?.();
231
261
  this.updateTabBar();
232
262
  this.renderer.requestRender();
233
263
  }
234
264
 
235
265
  cycleTab(): void {
236
- const order: PanelTab[] = ["code", "logs", "dashboard"];
266
+ const order: PanelTab[] = ["code", "logs", "dashboard", "widgets"];
237
267
  const idx = order.indexOf(this._activeTab);
238
268
  this.setTab(order[(idx + 1) % order.length]!);
239
269
  }
240
270
 
271
+ private _widgetCount = 0;
272
+
273
+ /** Add a widget renderable to the widgets tab */
274
+ addWidget(toolName: string, widget: BoxRenderable): void {
275
+ // Remove placeholder on first widget
276
+ if (this._widgetCount === 0) {
277
+ const children = this.widgetsList.getChildren();
278
+ for (const child of [...children]) {
279
+ this.widgetsList.remove(child);
280
+ }
281
+ }
282
+ this._widgetCount++;
283
+
284
+ // Separator between widgets
285
+ if (this._widgetCount > 1) {
286
+ this.widgetsList.add(new TextRenderable(this.renderer, {
287
+ id: `wt-sep-${Date.now()}-${this._widgetCount}`,
288
+ content: "",
289
+ fg: COLORS.borderDim,
290
+ }));
291
+ }
292
+
293
+ // Tool name header
294
+ this.widgetsList.add(new TextRenderable(this.renderer, {
295
+ id: `wt-hdr-${Date.now()}-${this._widgetCount}`,
296
+ content: `── ${toolName.replace(/_/g, " ")} ${"─".repeat(40)}`,
297
+ fg: COLORS.borderDim,
298
+ }));
299
+
300
+ // The widget itself
301
+ this.widgetsList.add(widget);
302
+
303
+ this.renderer.requestRender();
304
+ }
305
+
306
+ /** Clear all widgets from the tab */
307
+ clearWidgets(): void {
308
+ const children = this.widgetsList.getChildren();
309
+ for (const child of [...children]) {
310
+ this.widgetsList.remove(child);
311
+ }
312
+ this._widgetCount = 0;
313
+ this.widgetsList.add(new TextRenderable(this.renderer, {
314
+ id: `widgets-empty-${Date.now()}`,
315
+ content: "*no widgets yet*\n\n*Enable \"Widgets in Tab\" in /settings.*",
316
+ fg: COLORS.textMuted,
317
+ }));
318
+ }
319
+
241
320
  // ── Content setters ──
242
321
 
243
322
  setCode(code: string, status: "pending" | "valid" | "invalid" | "none" = "none"): void {
@@ -47,6 +47,9 @@ export class Footer {
47
47
  this.streamingText.content = `${BRAILLE[this.spinnerFrame]} generating`;
48
48
  this.streamingText.fg = COLORS.accent;
49
49
  this.renderer.requestRender();
50
+ } else if (this.streamingText.content !== "") {
51
+ this.streamingText.content = "";
52
+ this.renderer.requestRender();
50
53
  }
51
54
  }, 60);
52
55
  }
@@ -12,6 +12,8 @@ export interface HorizonSettings {
12
12
  autoCompact: boolean;
13
13
  compactThreshold: number;
14
14
  showToolCalls: boolean;
15
+ showWidgets: boolean;
16
+ widgetsInTab: boolean;
15
17
  soundEnabled: boolean;
16
18
  theme: ThemeName;
17
19
  payAsYouGo: boolean;
@@ -25,6 +27,8 @@ const DEFAULT_SETTINGS: HorizonSettings = {
25
27
  autoCompact: true,
26
28
  compactThreshold: 80,
27
29
  showToolCalls: true,
30
+ showWidgets: true,
31
+ widgetsInTab: false,
28
32
  soundEnabled: false,
29
33
  theme: "dark",
30
34
  payAsYouGo: false,
@@ -72,6 +76,16 @@ const SETTINGS_DEFS: SettingDef[] = [
72
76
  description: "Display tool call indicators in chat",
73
77
  type: "toggle",
74
78
  },
79
+ {
80
+ key: "showWidgets", label: "Show Widgets",
81
+ description: "Render tool results as visual widgets (off = text only, LLM still gets data)",
82
+ type: "toggle",
83
+ },
84
+ {
85
+ key: "widgetsInTab", label: "Widgets in Tab",
86
+ description: "Render widgets in a side tab instead of inline in chat",
87
+ type: "toggle",
88
+ },
75
89
  {
76
90
  key: "soundEnabled", label: "Sound",
77
91
  description: "Play terminal bell when generation completes",
@@ -0,0 +1,154 @@
1
+ // Exchange credential profiles — defines what keys each exchange needs
2
+ // Keys are stored encrypted via /env system
3
+
4
+ import { listEncryptedEnvNames, getDecryptedEnv, setEncryptedEnv, removeEncryptedEnv } from "./config.ts";
5
+
6
+ export interface ExchangeProfile {
7
+ id: string;
8
+ name: string;
9
+ category: "prediction" | "stock" | "crypto";
10
+ description: string;
11
+ keys: { env: string; label: string; hint: string; required: boolean }[];
12
+ testUrl?: string;
13
+ }
14
+
15
+ export const EXCHANGE_PROFILES: ExchangeProfile[] = [
16
+ {
17
+ id: "polymarket",
18
+ name: "Polymarket",
19
+ category: "prediction",
20
+ description: "Prediction market on Polygon. Requires a funded wallet private key.",
21
+ keys: [
22
+ { env: "POLYMARKET_PRIVATE_KEY", label: "Private Key", hint: "Ethereum private key (0x...)", required: true },
23
+ { env: "CLOB_API_KEY", label: "CLOB API Key", hint: "From Polymarket developer portal (optional)", required: false },
24
+ { env: "CLOB_SECRET", label: "CLOB Secret", hint: "CLOB API secret", required: false },
25
+ { env: "CLOB_PASSPHRASE", label: "CLOB Passphrase", hint: "CLOB API passphrase", required: false },
26
+ ],
27
+ },
28
+ {
29
+ id: "kalshi",
30
+ name: "Kalshi",
31
+ category: "prediction",
32
+ description: "Regulated US prediction market. API key from kalshi.com/settings.",
33
+ keys: [
34
+ { env: "KALSHI_API_KEY", label: "API Key", hint: "From Kalshi account settings", required: true },
35
+ { env: "KALSHI_API_SECRET", label: "API Secret", hint: "Secret paired with API key", required: false },
36
+ ],
37
+ },
38
+ {
39
+ id: "limitless",
40
+ name: "Limitless",
41
+ category: "prediction",
42
+ description: "Emerging prediction market platform.",
43
+ keys: [
44
+ { env: "LIMITLESS_API_KEY", label: "API Key", hint: "From Limitless developer portal", required: true },
45
+ ],
46
+ },
47
+ {
48
+ id: "alpaca",
49
+ name: "Alpaca",
50
+ category: "stock",
51
+ description: "Commission-free stock/ETF trading. Paper + live. alpaca.markets",
52
+ keys: [
53
+ { env: "ALPACA_API_KEY", label: "API Key", hint: "From Alpaca dashboard (paper or live)", required: true },
54
+ { env: "ALPACA_SECRET_KEY", label: "Secret Key", hint: "Secret paired with API key", required: true },
55
+ { env: "ALPACA_PAPER", label: "Paper Mode", hint: "Set to 'true' for paper trading (default: true)", required: false },
56
+ ],
57
+ },
58
+ {
59
+ id: "robinhood",
60
+ name: "Robinhood",
61
+ category: "stock",
62
+ description: "Stock/options/crypto trading. Uses OAuth token.",
63
+ keys: [
64
+ { env: "ROBINHOOD_TOKEN", label: "Access Token", hint: "OAuth token from Robinhood API", required: true },
65
+ ],
66
+ },
67
+ {
68
+ id: "polygon",
69
+ name: "Polygon.io",
70
+ category: "stock",
71
+ description: "Real-time & historical market data for stocks, options, crypto. polygon.io",
72
+ keys: [
73
+ { env: "POLYGON_API_KEY", label: "API Key", hint: "From polygon.io dashboard (free tier available)", required: true },
74
+ ],
75
+ },
76
+ {
77
+ id: "fmp",
78
+ name: "Financial Modeling Prep",
79
+ category: "stock",
80
+ description: "Fundamental data, financials, earnings. financialmodelingprep.com",
81
+ keys: [
82
+ { env: "FMP_API_KEY", label: "API Key", hint: "From FMP dashboard", required: true },
83
+ ],
84
+ },
85
+ ];
86
+
87
+ export type ConnectionStatus = "connected" | "partial" | "disconnected";
88
+
89
+ export interface ExchangeStatus {
90
+ profile: ExchangeProfile;
91
+ status: ConnectionStatus;
92
+ keysSet: string[];
93
+ keysMissing: string[];
94
+ }
95
+
96
+ /** Check which exchanges have credentials configured */
97
+ export function getExchangeStatuses(): ExchangeStatus[] {
98
+ const envNames = new Set(listEncryptedEnvNames());
99
+ // Also check process.env for keys set externally
100
+ const allKeys = new Set([...envNames, ...Object.keys(process.env).filter(k =>
101
+ EXCHANGE_PROFILES.some(p => p.keys.some(key => key.env === k && process.env[k]))
102
+ )]);
103
+
104
+ return EXCHANGE_PROFILES.map(profile => {
105
+ const keysSet = profile.keys.filter(k => allKeys.has(k.env)).map(k => k.env);
106
+ const keysMissing = profile.keys.filter(k => k.required && !allKeys.has(k.env)).map(k => k.env);
107
+ const requiredKeys = profile.keys.filter(k => k.required);
108
+ const requiredSet = requiredKeys.filter(k => allKeys.has(k.env));
109
+
110
+ let status: ConnectionStatus;
111
+ if (requiredSet.length === requiredKeys.length) {
112
+ status = "connected";
113
+ } else if (keysSet.length > 0) {
114
+ status = "partial";
115
+ } else {
116
+ status = "disconnected";
117
+ }
118
+
119
+ return { profile, status, keysSet, keysMissing };
120
+ });
121
+ }
122
+
123
+ /** Get status for a specific exchange */
124
+ export function getExchangeStatus(exchangeId: string): ExchangeStatus | null {
125
+ return getExchangeStatuses().find(s => s.profile.id === exchangeId) ?? null;
126
+ }
127
+
128
+ /** Set a credential for an exchange */
129
+ export function setExchangeKey(envName: string, value: string): boolean {
130
+ try {
131
+ setEncryptedEnv(envName, value);
132
+ return true;
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+
138
+ /** Remove a credential */
139
+ export function removeExchangeKey(envName: string): boolean {
140
+ try {
141
+ removeEncryptedEnv(envName);
142
+ return true;
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
147
+
148
+ /** Get a decrypted credential (for passing to subprocesses) */
149
+ export function getExchangeKey(envName: string): string | null {
150
+ // Check encrypted env first, then process.env
151
+ const decrypted = getDecryptedEnv(envName);
152
+ if (decrypted) return decrypted;
153
+ return process.env[envName] ?? null;
154
+ }