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 +1 -1
- package/src/ai/client.ts +33 -8
- package/src/ai/system-prompt.ts +48 -6
- package/src/app.ts +82 -3
- package/src/components/code-panel.ts +82 -3
- package/src/components/footer.ts +3 -0
- package/src/components/settings-panel.ts +14 -0
- package/src/platform/exchanges.ts +154 -0
- package/src/research/apis.ts +208 -11
- package/src/research/stock-apis.ts +117 -0
- package/src/research/tools.ts +929 -17
- package/src/research/widgets.ts +1042 -29
package/package.json
CHANGED
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:
|
|
205
|
+
signal: combinedSignal,
|
|
190
206
|
headers: { "Authorization": `Bearer ${config.api_key}`, "Content-Type": "application/json" },
|
|
191
|
-
body
|
|
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
|
package/src/ai/system-prompt.ts
CHANGED
|
@@ -15,7 +15,7 @@ const MODE_PROMPTS: Record<Mode, string> = {
|
|
|
15
15
|
|
|
16
16
|
## Mode: Research
|
|
17
17
|
|
|
18
|
-
You have
|
|
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
|
|
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) —
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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", "
|
|
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
|
-
|
|
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 {
|
package/src/components/footer.ts
CHANGED
|
@@ -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
|
+
}
|