horizon-code 0.1.2 → 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/bin/horizon.js CHANGED
@@ -1,2 +1,19 @@
1
1
  #!/usr/bin/env bun
2
- import "../src/index.ts";
2
+ import { checkForUpdates } from "../src/updater.ts";
3
+
4
+ // Auto-update before loading the app
5
+ const { updated, from, to } = await checkForUpdates();
6
+ if (updated) {
7
+ // Re-exec so the new version's code loads
8
+ process.stderr.write(`\x1b[2mUpdated ${from} → ${to}. Restarting...\x1b[0m\n`);
9
+ const result = Bun.spawnSync(["bun", "run", import.meta.path], {
10
+ stdin: "inherit",
11
+ stdout: "inherit",
12
+ stderr: "inherit",
13
+ env: { ...process.env, __HORIZON_SKIP_UPDATE: "1" },
14
+ });
15
+ process.exit(result.exitCode ?? 0);
16
+ }
17
+
18
+ // Load the app
19
+ import("../src/index.ts");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "horizon-code",
3
- "version": "0.1.2",
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,12 +13,14 @@ 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";
19
21
  import { chat } from "./ai/client.ts";
20
22
  import { dashboard } from "./strategy/dashboard.ts";
21
- import { cleanupStrategyProcesses, runningProcesses } from "./strategy/tools.ts";
23
+ import { cleanupStrategyProcesses, runningProcesses, parseLocalMetrics } from "./strategy/tools.ts";
22
24
  import { abortChat } from "./ai/client.ts";
23
25
  import type { ModelPower } from "./platform/tiers.ts";
24
26
  import { listSavedStrategies, loadStrategy } from "./strategy/persistence.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
  };
@@ -92,6 +101,7 @@ export class App {
92
101
  private inChatMode = false;
93
102
  private authenticated = false;
94
103
  private _openIdsSaveTimer: ReturnType<typeof setTimeout> | null = null;
104
+ private _hasLocalMetrics = false;
95
105
 
96
106
  // Per-tab stream state
97
107
  private tabStreams: Map<string, {
@@ -403,6 +413,27 @@ export class App {
403
413
  this.codePanel.setStrategy(draft.name, draft.phase);
404
414
  }
405
415
 
416
+ // Feed active deployment metrics into the dashboard tab
417
+ // Skip if local process metrics are active (they take priority)
418
+ if (!this._hasLocalMetrics) {
419
+ const state = store.get();
420
+ const running = state.deployments.find((d) => d.status === "running" || d.status === "starting");
421
+ if (running) {
422
+ this.codePanel.setMetrics({
423
+ name: running.name,
424
+ status: running.status,
425
+ dryRun: running.dry_run,
426
+ metrics: running.metrics,
427
+ positions: running.positions,
428
+ orders: running.orders,
429
+ pnlHistory: running.pnl_history,
430
+ startedAt: running.started_at,
431
+ });
432
+ } else {
433
+ this.codePanel.setMetrics(null);
434
+ }
435
+ }
436
+
406
437
  // Sync code panel visibility to key handler for Tab cycling
407
438
  this.keyHandler.codePanelVisible = this.codePanel.visible;
408
439
 
@@ -412,28 +443,82 @@ export class App {
412
443
 
413
444
  renderer.on("resize", () => renderer.requestRender());
414
445
 
415
- // Poll background processes — update count, live logs, clean dead processes
446
+ // Poll background processes — update count, live logs, parse metrics, clean dead
416
447
  setInterval(() => {
417
448
  let alive = 0;
418
449
  let latestLogs = "";
419
450
  const deadPids: number[] = [];
420
451
  const now = Date.now();
452
+ let localMetricsData: ReturnType<typeof parseLocalMetrics> = null;
453
+ let localProcessStartedAt = 0;
421
454
 
422
455
  for (const [pid, m] of runningProcesses) {
423
456
  if (m.proc.exitCode === null) {
424
457
  alive++;
425
- const recent = m.stdout.slice(-30).join("\n");
426
- const recentErr = m.stderr.slice(-5).join("\n");
427
- latestLogs = recent + (recentErr ? "\n--- stderr ---\n" + recentErr : "");
458
+ // Filter out __HZ_METRICS__ lines from visible logs
459
+ const stdoutLines = m.stdout.slice(-30);
460
+ const stderrLines = m.stderr.slice(-10).filter((l: string) => !l.startsWith("__HZ_METRICS__"));
461
+ latestLogs = stdoutLines.join("\n") + (stderrLines.length > 0 ? "\n--- stderr ---\n" + stderrLines.join("\n") : "");
462
+
463
+ // Parse latest metrics from this process
464
+ const metrics = parseLocalMetrics(m);
465
+ if (metrics) {
466
+ localMetricsData = metrics;
467
+ localProcessStartedAt = m.startedAt;
468
+ }
428
469
  } else if (now - m.startedAt > 300000) {
429
- // Dead for 5+ minutes — clean up
430
470
  deadPids.push(pid);
431
471
  }
432
472
  }
433
473
  for (const pid of deadPids) runningProcesses.delete(pid);
434
474
 
435
475
  this.modeBar.setBgProcessCount(alive);
436
- // Only update logs tab if it's visible (avoid unnecessary markdown re-parse)
476
+
477
+ // Feed local metrics to the Metrics tab (takes priority over platform deployments)
478
+ if (localMetricsData) {
479
+ const draft = store.getActiveSession()?.strategyDraft;
480
+ this.codePanel.setMetrics({
481
+ name: draft?.name ?? "Local Strategy",
482
+ status: "running",
483
+ dryRun: true,
484
+ metrics: {
485
+ total_pnl: localMetricsData.pnl,
486
+ realized_pnl: localMetricsData.rpnl,
487
+ unrealized_pnl: localMetricsData.upnl,
488
+ total_exposure: 0,
489
+ position_count: localMetricsData.positions,
490
+ open_order_count: localMetricsData.orders,
491
+ win_rate: 0,
492
+ total_trades: 0,
493
+ max_drawdown_pct: 0,
494
+ sharpe_ratio: 0,
495
+ profit_factor: 0,
496
+ avg_return_per_trade: 0,
497
+ gross_profit: 0,
498
+ gross_loss: 0,
499
+ },
500
+ positions: (localMetricsData.pos ?? []).map((p) => ({
501
+ market_id: p.id,
502
+ slug: p.id,
503
+ question: "",
504
+ side: p.side === "Yes" || p.side === "Buy" ? "BUY" as const : "SELL" as const,
505
+ size: p.sz,
506
+ avg_entry_price: p.entry,
507
+ cost_basis: p.sz * p.entry,
508
+ realized_pnl: p.rpnl,
509
+ unrealized_pnl: p.upnl,
510
+ })),
511
+ orders: [],
512
+ pnlHistory: localMetricsData.hist ?? [],
513
+ startedAt: localProcessStartedAt,
514
+ });
515
+ this._hasLocalMetrics = true;
516
+ } else if (this._hasLocalMetrics && alive === 0) {
517
+ // Local process stopped — clear local metrics, let platform data take over
518
+ this._hasLocalMetrics = false;
519
+ store.update({});
520
+ }
521
+
437
522
  if (alive > 0 && latestLogs && this.codePanel.visible && this.codePanel.activeTab === "logs") {
438
523
  this.codePanel.setLogs(latestLogs);
439
524
  }
@@ -655,7 +740,7 @@ export class App {
655
740
  const code = autoFixStrategyCode(loaded.code);
656
741
  store.setStrategyDraft({
657
742
  name: arg, code, params: {}, explanation: "", riskConfig: null,
658
- validationStatus: "none", validationErrors: [], phase: "generated",
743
+ validationStatus: "none", validationErrors: [], validationWarnings: [], phase: "generated",
659
744
  });
660
745
  this.codePanel.setCode(code, "pending");
661
746
  this.codePanel.setStrategy(arg, "loaded");
@@ -693,6 +778,50 @@ export class App {
693
778
  }
694
779
  break;
695
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
+ }
696
825
  case "/quit":
697
826
  case "/exit":
698
827
  this.quit();
@@ -1198,7 +1327,33 @@ export class App {
1198
1327
  }
1199
1328
 
1200
1329
  if (WIDGET_TOOLS.has(part.toolName)) {
1201
- 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
1202
1357
  }
1203
1358
 
1204
1359
  // Update code panel from edit_strategy/load_saved_strategy results
@@ -1226,12 +1381,10 @@ export class App {
1226
1381
  }
1227
1382
  }
1228
1383
 
1229
- // Feed dashboard HTML into code panel dashboard tab
1384
+ // Switch to dashboard/metrics tab when dashboard is spawned
1230
1385
  if (part.toolName === "spawn_dashboard") {
1231
1386
  const result = part.result as any;
1232
1387
  if (result?.success) {
1233
- const html = (part as any).args?.custom_html;
1234
- if (html) this.codePanel.setDashboardHtml(html);
1235
1388
  this.codePanel.setTab("dashboard");
1236
1389
  if (!this.codePanel.visible) this.codePanel.show();
1237
1390
  }