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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "horizon-code",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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
  };
@@ -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
- this.showSystemMsg("Your session has expired. Type /login to sign in again.");
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
- currentBlocks.push({ type: "tool-widget", toolName: part.toolName, widgetData: part.result });
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 = "#212121";
389
+ text.fg = COLORS.bg;
311
390
  text.bg = COLORS.accent;
312
391
  text.content = ` ${label} `;
313
392
  } else {
@@ -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: "#212121", bg: COLORS.accent,
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 = "#212121";
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: "#212121",
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
- const MODE_COLOR: Record<string, string> = {
15
- research: "#5c9cf5", // COLORS.secondary
16
- strategy: "#9d7cd8", // COLORS.accent
17
- portfolio: "#7fd88f", // COLORS.success
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 = MODE_COLOR[session.mode] ?? COLORS.secondary;
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 ? "#212121" : COLORS.textMuted,
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 = "#212121";
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 {