horizon-code 0.2.0 → 0.3.1
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 +92 -4
- package/src/components/code-panel.ts +83 -4
- package/src/components/footer.ts +3 -0
- package/src/components/hooks-panel.ts +1 -1
- package/src/components/mode-bar.ts +1 -1
- package/src/components/settings-panel.ts +15 -1
- package/src/components/tab-bar.ts +10 -7
- package/src/components/tutorial-panel.ts +1 -1
- package/src/platform/exchanges.ts +154 -0
- package/src/platform/supabase.ts +17 -2
- package/src/research/apis.ts +291 -13
- package/src/research/stock-apis.ts +117 -0
- package/src/research/tools.ts +929 -17
- package/src/research/widgets.ts +1044 -29
- package/src/theme/colors.ts +6 -6
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
|
};
|
|
@@ -629,6 +638,11 @@ export class App {
|
|
|
629
638
|
if (loginResult.success) {
|
|
630
639
|
this.authenticated = true;
|
|
631
640
|
this.showSystemMsg(`Logged in as ${loginResult.email}`);
|
|
641
|
+
// Start session sync + platform sync now that we have a live session
|
|
642
|
+
import("./platform/session-sync.ts").then(({ loadSessions, startAutoSave }) => {
|
|
643
|
+
loadSessions().catch(() => {});
|
|
644
|
+
startAutoSave();
|
|
645
|
+
}).catch(() => {});
|
|
632
646
|
import("./platform/sync.ts").then(({ platformSync }) => {
|
|
633
647
|
platformSync.start(30000).catch(() => {});
|
|
634
648
|
});
|
|
@@ -769,6 +783,50 @@ export class App {
|
|
|
769
783
|
}
|
|
770
784
|
break;
|
|
771
785
|
}
|
|
786
|
+
case "/connect": {
|
|
787
|
+
const exchangeId = arg.trim().toLowerCase();
|
|
788
|
+
if (!exchangeId) {
|
|
789
|
+
const statuses = getExchangeStatuses();
|
|
790
|
+
const lines = statuses.map(s => {
|
|
791
|
+
const icon = s.status === "connected" ? "●" : s.status === "partial" ? "◐" : "○";
|
|
792
|
+
const color = s.status === "connected" ? "green" : s.status === "partial" ? "yellow" : "dim";
|
|
793
|
+
return ` ${icon} ${s.profile.name.padEnd(20)} ${s.status.padEnd(14)} ${s.profile.category}`;
|
|
794
|
+
});
|
|
795
|
+
this.showSystemMsg(`Exchange Connections:\n${lines.join("\n")}\n\nUse /connect <exchange> to set up keys.`);
|
|
796
|
+
} else {
|
|
797
|
+
const profile = EXCHANGE_PROFILES.find(p => p.id === exchangeId || p.name.toLowerCase() === exchangeId);
|
|
798
|
+
if (!profile) {
|
|
799
|
+
const ids = EXCHANGE_PROFILES.map(p => p.id).join(", ");
|
|
800
|
+
this.showSystemMsg(`Unknown exchange: ${exchangeId}. Available: ${ids}`);
|
|
801
|
+
} else {
|
|
802
|
+
const status = getExchangeStatus(profile.id);
|
|
803
|
+
const lines = profile.keys.map(k => {
|
|
804
|
+
const isSet = status?.keysSet.includes(k.env);
|
|
805
|
+
return ` ${isSet ? "●" : "○"} ${k.label.padEnd(18)} ${k.env.padEnd(28)} ${isSet ? "set" : k.required ? "REQUIRED" : "optional"}`;
|
|
806
|
+
});
|
|
807
|
+
this.showSystemMsg(
|
|
808
|
+
`${profile.name} — ${profile.description}\n${lines.join("\n")}\n\n` +
|
|
809
|
+
`Set keys with: /env add <ENV_NAME> <value>\n` +
|
|
810
|
+
`Example: /env add ${profile.keys[0].env} <your-key>`
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
break;
|
|
815
|
+
}
|
|
816
|
+
case "/exchanges": {
|
|
817
|
+
const statuses = getExchangeStatuses();
|
|
818
|
+
const connected = statuses.filter(s => s.status === "connected");
|
|
819
|
+
const partial = statuses.filter(s => s.status === "partial");
|
|
820
|
+
const disconnected = statuses.filter(s => s.status === "disconnected");
|
|
821
|
+
|
|
822
|
+
let msg = "Exchange Status:\n";
|
|
823
|
+
if (connected.length > 0) msg += `\nConnected:\n${connected.map(s => ` ● ${s.profile.name} (${s.profile.category})`).join("\n")}`;
|
|
824
|
+
if (partial.length > 0) msg += `\nPartial:\n${partial.map(s => ` ◐ ${s.profile.name} — missing: ${s.keysMissing.join(", ")}`).join("\n")}`;
|
|
825
|
+
if (disconnected.length > 0) msg += `\nNot Connected:\n${disconnected.map(s => ` ○ ${s.profile.name}`).join("\n")}`;
|
|
826
|
+
msg += "\n\nUse /connect <exchange> to set up.";
|
|
827
|
+
this.showSystemMsg(msg);
|
|
828
|
+
break;
|
|
829
|
+
}
|
|
772
830
|
case "/quit":
|
|
773
831
|
case "/exit":
|
|
774
832
|
this.quit();
|
|
@@ -881,7 +939,11 @@ export class App {
|
|
|
881
939
|
|
|
882
940
|
if (!live) {
|
|
883
941
|
this.splash.setAuthStatus(true, email);
|
|
884
|
-
|
|
942
|
+
// Only warn about expired session if they actually had one before
|
|
943
|
+
// Don't warn if they just have an api_key (chat still works fine)
|
|
944
|
+
if (config.supabase_session) {
|
|
945
|
+
this.showSystemMsg("Supabase session expired — chat sync disabled. Type /login to reconnect.");
|
|
946
|
+
}
|
|
885
947
|
} else {
|
|
886
948
|
this.splash.setAuthStatus(true, email, firstTime);
|
|
887
949
|
}
|
|
@@ -1274,7 +1336,33 @@ export class App {
|
|
|
1274
1336
|
}
|
|
1275
1337
|
|
|
1276
1338
|
if (WIDGET_TOOLS.has(part.toolName)) {
|
|
1277
|
-
|
|
1339
|
+
const { showWidgets, widgetsInTab } = this.settingsPanel.settings;
|
|
1340
|
+
if (showWidgets && !widgetsInTab) {
|
|
1341
|
+
// Render inline in chat (default)
|
|
1342
|
+
currentBlocks.push({ type: "tool-widget", toolName: part.toolName, widgetData: part.result });
|
|
1343
|
+
} else if (showWidgets && widgetsInTab) {
|
|
1344
|
+
// Render in the widgets tab
|
|
1345
|
+
try {
|
|
1346
|
+
const result = part.result;
|
|
1347
|
+
// Skip error results — they'd render as empty widgets
|
|
1348
|
+
if (result && typeof result === "object" && "error" in (result as any)) {
|
|
1349
|
+
const errBox = new BoxRenderable(this.renderer, { id: `wt-err-${Date.now()}`, flexDirection: "column" });
|
|
1350
|
+
errBox.add(new TextRenderable(this.renderer, { id: `wt-err-t-${Date.now()}`, content: `Error: ${(result as any).error}`, fg: COLORS.error }));
|
|
1351
|
+
this.codePanel.addWidget(part.toolName, errBox);
|
|
1352
|
+
} else {
|
|
1353
|
+
const widget = renderToolWidget(part.toolName, result, this.renderer)
|
|
1354
|
+
?? renderStrategyWidget(part.toolName, result, this.renderer);
|
|
1355
|
+
if (widget) {
|
|
1356
|
+
this.codePanel.addWidget(part.toolName, widget);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
if (!this.codePanel.visible) this.codePanel.show();
|
|
1360
|
+
this.codePanel.setTab("widgets");
|
|
1361
|
+
} catch {
|
|
1362
|
+
// Widget rendering failed silently — don't break the stream
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
// else showWidgets=false: skip rendering, LLM still got the data
|
|
1278
1366
|
}
|
|
1279
1367
|
|
|
1280
1368
|
// 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 {
|
|
@@ -307,7 +386,7 @@ export class CodePanel {
|
|
|
307
386
|
: id === "dashboard" ? "Metrics"
|
|
308
387
|
: id.charAt(0).toUpperCase() + id.slice(1);
|
|
309
388
|
if (id === this._activeTab) {
|
|
310
|
-
text.fg =
|
|
389
|
+
text.fg = COLORS.bg;
|
|
311
390
|
text.bg = COLORS.accent;
|
|
312
391
|
text.content = ` ${label} `;
|
|
313
392
|
} else {
|
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
|
}
|
|
@@ -35,7 +35,7 @@ export class HooksPanel {
|
|
|
35
35
|
backgroundColor: COLORS.bgDarker, flexShrink: 0,
|
|
36
36
|
});
|
|
37
37
|
header.add(new TextRenderable(renderer, {
|
|
38
|
-
id: "hooks-title", content: " Hooks ", fg:
|
|
38
|
+
id: "hooks-title", content: " Hooks ", fg: COLORS.bg, bg: COLORS.accent,
|
|
39
39
|
}));
|
|
40
40
|
this.container.add(header);
|
|
41
41
|
|
|
@@ -181,7 +181,7 @@ export class ModeBar {
|
|
|
181
181
|
private updateDisplay(): void {
|
|
182
182
|
const m = MODE_CONFIG[this._current];
|
|
183
183
|
this.modeText.content = ` ${m.icon} ${m.label} `;
|
|
184
|
-
this.modeText.fg =
|
|
184
|
+
this.modeText.fg = COLORS.bg;
|
|
185
185
|
this.modeText.bg = m.color;
|
|
186
186
|
this.updateStatus();
|
|
187
187
|
this.updateMetricsDisplay();
|
|
@@ -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",
|
|
@@ -139,7 +153,7 @@ export class SettingsPanel {
|
|
|
139
153
|
header.add(new TextRenderable(renderer, {
|
|
140
154
|
id: "settings-title",
|
|
141
155
|
content: " Settings ",
|
|
142
|
-
fg:
|
|
156
|
+
fg: COLORS.bg,
|
|
143
157
|
bg: COLORS.accent,
|
|
144
158
|
}));
|
|
145
159
|
this.container.add(header);
|
|
@@ -11,11 +11,14 @@ const MODE_LABEL: Record<string, string> = {
|
|
|
11
11
|
portfolio: "(p)",
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
function getModeColor(mode: string): string {
|
|
15
|
+
switch (mode) {
|
|
16
|
+
case "research": return COLORS.secondary;
|
|
17
|
+
case "strategy": return COLORS.accent;
|
|
18
|
+
case "portfolio": return COLORS.success;
|
|
19
|
+
default: return COLORS.secondary;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
19
22
|
|
|
20
23
|
const BRAILLE = ["\u2801", "\u2803", "\u2807", "\u280f", "\u281f", "\u283f", "\u287f", "\u28ff", "\u28fe", "\u28fc", "\u28f8", "\u28f0", "\u28e0", "\u28c0", "\u2880", "\u2800"];
|
|
21
24
|
|
|
@@ -53,7 +56,7 @@ export class TabBar {
|
|
|
53
56
|
|
|
54
57
|
const isActive = tabId === activeSessionId;
|
|
55
58
|
const modeTag = MODE_LABEL[session.mode] ?? "(r)";
|
|
56
|
-
const modeColor =
|
|
59
|
+
const modeColor = getModeColor(session.mode);
|
|
57
60
|
|
|
58
61
|
// Truncate name
|
|
59
62
|
let name = session.name.length > 12 ? session.name.slice(0, 11) + "." : session.name;
|
|
@@ -69,7 +72,7 @@ export class TabBar {
|
|
|
69
72
|
const tab = new TextRenderable(this.renderer, {
|
|
70
73
|
id: `tab-${i}`,
|
|
71
74
|
content: label,
|
|
72
|
-
fg: isActive ?
|
|
75
|
+
fg: isActive ? COLORS.bg : COLORS.textMuted,
|
|
73
76
|
bg: isActive ? modeColor : undefined,
|
|
74
77
|
});
|
|
75
78
|
this.container.add(tab);
|
|
@@ -667,7 +667,7 @@ export class TutorialPanel {
|
|
|
667
667
|
private updateTabBar(): void {
|
|
668
668
|
for (const [id, text] of this.tabTexts) {
|
|
669
669
|
if (id === this._activeTab) {
|
|
670
|
-
text.fg =
|
|
670
|
+
text.fg = COLORS.bg;
|
|
671
671
|
text.bg = COLORS.secondary;
|
|
672
672
|
text.content = ` ${TAB_LIST.find((t) => t.id === id)!.label} `;
|
|
673
673
|
} else {
|