horizon-code 0.5.1 → 0.6.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.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "AI-powered trading strategy terminal for Polymarket",
5
5
  "type": "module",
6
6
  "bin": {
package/src/ai/client.ts CHANGED
@@ -32,7 +32,7 @@ export async function initMCP(): Promise<{ toolCount: number }> {
32
32
  });
33
33
  mcpTools = await mcpClient.tools();
34
34
  return { toolCount: Object.keys(mcpTools).length };
35
- } catch { return { toolCount: 0 }; }
35
+ } catch (e) { console.error("[mcp] init failed:", e); return { toolCount: 0 }; }
36
36
  }
37
37
 
38
38
  export async function closeMCP(): Promise<void> {
@@ -141,7 +141,7 @@ async function* consumeSSE(res: Response): AsyncGenerator<Record<string, any>> {
141
141
  if (!line.startsWith("data: ")) continue;
142
142
  const data = line.slice(6);
143
143
  if (data === "[DONE]") return;
144
- try { yield JSON.parse(data); } catch {}
144
+ try { yield JSON.parse(data); } catch (e) { console.error("[sse] malformed JSON:", data.slice(0, 100)); }
145
145
  }
146
146
  }
147
147
  }
@@ -162,7 +162,7 @@ function toolsToServerFormat(tools: Record<string, any>): Record<string, any> {
162
162
  const converted = zodSchema(tool.parameters);
163
163
  params = converted.jsonSchema ?? params;
164
164
  }
165
- } catch {}
165
+ } catch (e) { failures++; console.error("[tools] schema conversion failed for", name, e); }
166
166
  result[name] = { description: tool.description ?? "", parameters: params };
167
167
  }
168
168
  return result;
@@ -273,6 +273,7 @@ export async function* chat(
273
273
  let emittedResponseLen = 0;
274
274
  let emittedCode: string | null = null;
275
275
  let structuredActive = useStructuredOutput;
276
+ let emittedAnyText = false;
276
277
  const pendingToolCalls: { toolName: string; args: Record<string, unknown> }[] = [];
277
278
  let eventCount = 0;
278
279
 
@@ -297,6 +298,7 @@ export async function* chat(
297
298
  if (trimmed.length > 3 && !trimmed.startsWith("{") && !trimmed.startsWith("[")) {
298
299
  structuredActive = false;
299
300
  yield { type: "text-delta", textDelta: jsonBuf };
301
+ emittedAnyText = true;
300
302
  jsonBuf = "";
301
303
  continue;
302
304
  }
@@ -304,6 +306,7 @@ export async function* chat(
304
306
  const response = extractJsonStringField(jsonBuf, "response");
305
307
  if (response && response.length > emittedResponseLen) {
306
308
  yield { type: "text-delta", textDelta: response.slice(emittedResponseLen) };
309
+ emittedAnyText = true;
307
310
  emittedResponseLen = response.length;
308
311
  }
309
312
 
@@ -314,6 +317,7 @@ export async function* chat(
314
317
  }
315
318
  } else {
316
319
  yield { type: "text-delta", textDelta: delta };
320
+ emittedAnyText = true;
317
321
  }
318
322
  } else if (event.type === "tool-call") {
319
323
  hasToolCalls = true;
@@ -339,15 +343,17 @@ export async function* chat(
339
343
  // the model likely responded with plain text — flush jsonBuf as text
340
344
  if (structuredActive && jsonBuf && emittedResponseLen === 0) {
341
345
  yield { type: "text-delta", textDelta: jsonBuf };
346
+ emittedAnyText = true;
342
347
  }
343
348
 
344
349
  // If no tool calls, we're done
345
350
  if (!hasToolCalls || pendingToolCalls.length === 0) {
346
351
  if (eventCount === 0) {
347
352
  yield { type: "error", message: "No response from server. Check your connection or try again." };
348
- } else if (!hasToolCalls && jsonBuf === "" && emittedResponseLen === 0) {
349
- // Server sent events (meta, usage) but no actual content
350
- yield { type: "text-delta", textDelta: "*(Server returned no content. This may happen if the model is overloaded. Try again.)*" };
353
+ } else if (!emittedAnyText && !hasToolCalls && emittedCode === null) {
354
+ // Server sent events (meta, usage) but no actual text or code content
355
+ console.error("[chat] No content received. Events:", eventCount, "jsonBuf:", jsonBuf.slice(0, 200));
356
+ yield { type: "text-delta", textDelta: "*(No response received. Try again.)*" };
351
357
  }
352
358
  yield { type: "finish" };
353
359
  return;
@@ -391,6 +397,7 @@ export async function* chat(
391
397
  if (structuredActive) {
392
398
  jsonBuf = "";
393
399
  emittedResponseLen = 0;
400
+ emittedAnyText = false;
394
401
  }
395
402
 
396
403
  // Loop back — server will call OpenRouter again with the full conversation including tool results
@@ -1,5 +1,6 @@
1
1
  import type { Mode } from "../components/mode-bar.ts";
2
2
  import { buildGeneratePrompt } from "../strategy/prompts.ts";
3
+ import { store } from "../state/store.ts";
3
4
 
4
5
  const BASE = `You are Horizon, an AI trading research assistant running in a CLI terminal.
5
6
 
@@ -123,7 +124,25 @@ const VERBOSITY_PREFIX: Record<string, string> = {
123
124
 
124
125
  export function getSystemPrompt(mode: Mode, verbosity: string = "normal"): string {
125
126
  // Strategy mode has its own verbosity rules — don't override
126
- if (mode === "strategy") return buildGeneratePrompt();
127
+ if (mode === "strategy") {
128
+ let prompt = buildGeneratePrompt();
129
+ // Inject user profile context if available
130
+ try {
131
+ const { buildProfileContext } = require("../platform/profile.ts");
132
+ const profileCtx = buildProfileContext();
133
+ if (profileCtx) prompt += "\n\n" + profileCtx;
134
+ } catch (e) { console.error("[prompt] profile context failed:", e); }
135
+ // Inject active strategy context so research tools auto-scope
136
+ const draft = store.getActiveSession()?.strategyDraft;
137
+ if (draft?.code) {
138
+ const marketsMatch = draft.code.match(/markets\s*=\s*\[([\s\S]*?)\]/);
139
+ const slugs = marketsMatch ? [...marketsMatch[1]!.matchAll(/["']([^"']+)["']/g)].map(m => m[1]!) : [];
140
+ if (slugs.length > 0) {
141
+ prompt += `\n\n## Active Strategy Context\nThe user is working on "${draft.name}" trading these markets: ${slugs.join(", ")}. When they ask about "the market" or "what's happening", scope to these markets first. Use these slugs for polymarket_data, scan_correlations, etc.`;
142
+ }
143
+ }
144
+ return prompt;
145
+ }
127
146
  const prefix = VERBOSITY_PREFIX[verbosity] ?? "";
128
147
  return MODE_PROMPTS[mode] + prefix;
129
148
  }
package/src/app.ts CHANGED
@@ -76,6 +76,17 @@ const SLASH_COMMANDS: Record<string, { description: string; usage: string }> = {
76
76
  "/exchanges": { description: "Show exchange connection status", usage: "/exchanges" },
77
77
  "/quit": { description: "Exit Horizon", usage: "/quit" },
78
78
  "/exit": { description: "Exit Horizon", usage: "/exit" },
79
+ "/bt": { description: "Backtest current strategy", usage: "/bt" },
80
+ "/backtest": { description: "Backtest current strategy", usage: "/backtest" },
81
+ "/run": { description: "Run current strategy", usage: "/run" },
82
+ "/dash": { description: "Open strategy dashboard", usage: "/dash" },
83
+ "/dashboard": { description: "Open strategy dashboard", usage: "/dashboard" },
84
+ "/save": { description: "Save current strategy", usage: "/save [name]" },
85
+ "/health": { description: "Check strategy health score", usage: "/health" },
86
+ "/versions": { description: "List strategy versions", usage: "/versions [name]" },
87
+ "/export": { description: "Export strategy to file", usage: "/export" },
88
+ "/report": { description: "Generate strategy ledger report", usage: "/report [name]" },
89
+ "/session-export": { description: "Export chat session to markdown", usage: "/session-export" },
79
90
  };
80
91
 
81
92
  export class App {
@@ -212,7 +223,7 @@ export class App {
212
223
  for (const s of dbSessions) {
213
224
  await deleteDbSession(s.id);
214
225
  }
215
- } catch {}
226
+ } catch (e: any) { console.error("[app] delete chats failed:", e?.message); }
216
227
  this.showSystemMsg("All chats deleted.");
217
228
  this.settingsPanel.hide();
218
229
  });
@@ -304,6 +315,18 @@ export class App {
304
315
  this.keyHandler.onClear(() => {});
305
316
  this.keyHandler.onSessions(() => this.togglePanel("sessions"));
306
317
  this.keyHandler.onDeployments(() => this.togglePanel("deployments"));
318
+ this.keyHandler.onOpenDashboard(() => {
319
+ if (dashboard.running) {
320
+ Bun.spawn(["open", dashboard.url]);
321
+ } else {
322
+ // Auto-start built-in dashboard if processes running
323
+ const hasLocal = [...runningProcesses.values()].some(m => m.proc.exitCode === null);
324
+ if (hasLocal) {
325
+ const url = dashboard.start("local", 0, true);
326
+ Bun.spawn(["open", url]);
327
+ }
328
+ }
329
+ });
307
330
  this.keyHandler.onTabNext(() => { store.nextTab(); this.switchToActiveTab(); });
308
331
  this.keyHandler.onTabPrev(() => { store.prevTab(); this.switchToActiveTab(); });
309
332
  this.keyHandler.onTabClose(() => { store.closeTab(store.get().activeSessionId); this.switchToActiveTab(); });
@@ -438,7 +461,7 @@ export class App {
438
461
  this.keyHandler.codePanelVisible = this.codePanel.visible;
439
462
 
440
463
  this.renderer.requestRender();
441
- } catch {}
464
+ } catch (e: any) { console.error("[app] state listener error:", e?.message); }
442
465
  });
443
466
 
444
467
  renderer.on("resize", () => renderer.requestRender());
@@ -527,6 +550,49 @@ export class App {
527
550
  startedAt: localProcessStartedAt,
528
551
  });
529
552
  this._hasLocalMetrics = true;
553
+
554
+ // Check alerts against current local metrics (fire-and-forget — setInterval is not async)
555
+ ((metrics) => {
556
+ (async () => {
557
+ try {
558
+ const { checkAlerts } = await import("./strategy/alerts.ts");
559
+ const draft = store.getActiveSession()?.strategyDraft;
560
+ const alertResults = await checkAlerts(
561
+ draft?.name ?? "local",
562
+ {
563
+ pnl: metrics.pnl,
564
+ max_dd: metrics.max_dd ?? 0,
565
+ exposure: metrics.exposure ?? 0,
566
+ win_rate: metrics.win_rate ?? 0,
567
+ trades: metrics.trades ?? 0,
568
+ },
569
+ () => {
570
+ // Stop all running processes
571
+ for (const [pid, m] of runningProcesses) {
572
+ if (m.proc.exitCode === null) m.cleanup?.();
573
+ }
574
+ },
575
+ );
576
+ for (const r of alertResults) {
577
+ if (r.triggered && r.actionTaken) {
578
+ this.codePanel.appendLog(r.actionTaken);
579
+ }
580
+ }
581
+ } catch (e: any) { console.error("[app] alert check failed:", e?.message); }
582
+ })();
583
+ })(localMetricsData);
584
+
585
+ // Record replay metrics (fire-and-forget)
586
+ ((metrics) => {
587
+ (async () => {
588
+ try {
589
+ const { recordMetrics, isRecording } = await import("./strategy/replay.ts");
590
+ for (const [pid] of runningProcesses) {
591
+ if (isRecording(pid)) recordMetrics(pid, metrics as any);
592
+ }
593
+ } catch (e: any) { console.error("[app] replay record failed:", e?.message); }
594
+ })();
595
+ })(localMetricsData);
530
596
  } else if (this._hasLocalMetrics && alive === 0) {
531
597
  // Local process stopped — clear local metrics, let platform data take over
532
598
  this._hasLocalMetrics = false;
@@ -654,11 +720,11 @@ export class App {
654
720
  this.showSystemMsg(`Logged in as ${loginResult.email}`);
655
721
  // Start session sync + platform sync now that we have a live session
656
722
  import("./platform/session-sync.ts").then(({ loadSessions, startAutoSave }) => {
657
- loadSessions().catch(() => {});
723
+ loadSessions().catch((e: any) => console.error("[app] session/sync init failed:", e?.message));
658
724
  startAutoSave();
659
- }).catch(() => {});
725
+ }).catch((e: any) => console.error("[app] session/sync init failed:", e?.message));
660
726
  import("./platform/sync.ts").then(({ platformSync }) => {
661
- platformSync.start(30000).catch(() => {});
727
+ platformSync.start(30000).catch((e: any) => console.error("[app] platformSync start failed:", e?.message));
662
728
  });
663
729
  } else {
664
730
  this.showSystemMsg(`Login failed: ${loginResult.error}`);
@@ -845,6 +911,81 @@ export class App {
845
911
  case "/exit":
846
912
  this.quit();
847
913
  break;
914
+ case "/bt":
915
+ case "/backtest": {
916
+ const tool = (await import("./strategy/tools.ts")).strategyTools.backtest_strategy;
917
+ const result = await tool.execute({});
918
+ this.codePanel.appendLog(typeof result === "object" ? JSON.stringify(result, null, 2) : String(result));
919
+ this.codePanel.setTab("logs");
920
+ break;
921
+ }
922
+ case "/run": {
923
+ const tool = (await import("./strategy/tools.ts")).strategyTools.run_strategy;
924
+ const result = await tool.execute({});
925
+ this.codePanel.appendLog(typeof result === "object" ? JSON.stringify(result, null, 2) : String(result));
926
+ this.codePanel.setTab("logs");
927
+ break;
928
+ }
929
+ case "/dash":
930
+ case "/dashboard": {
931
+ const tool = (await import("./strategy/tools.ts")).strategyTools.spawn_dashboard;
932
+ const result = await tool.execute({ strategy_id: "local" });
933
+ if (result?.url) Bun.spawn(["open", result.url]);
934
+ break;
935
+ }
936
+ case "/save": {
937
+ const tool = (await import("./strategy/tools.ts")).strategyTools.save_strategy;
938
+ const draft = store.getActiveSession()?.strategyDraft;
939
+ const result = await tool.execute({ name: draft?.name ?? arg ?? "untitled" });
940
+ this.codePanel.appendLog(`Saved: ${JSON.stringify(result)}`);
941
+ break;
942
+ }
943
+ case "/health": {
944
+ const { computeHealthScore } = await import("./strategy/health.ts");
945
+ const draft = store.getActiveSession()?.strategyDraft;
946
+ if (!draft?.code) { this.codePanel.appendLog("No strategy loaded"); break; }
947
+ const report = computeHealthScore(draft.code);
948
+ this.codePanel.appendLog(`Health: ${report.score}/${report.maxScore} (${report.grade}) — ${report.summary}`);
949
+ break;
950
+ }
951
+ case "/versions": {
952
+ const { listVersions } = await import("./strategy/versioning.ts");
953
+ const draft = store.getActiveSession()?.strategyDraft;
954
+ const versions = await listVersions(draft?.name ?? arg ?? "");
955
+ this.codePanel.appendLog(versions.length === 0 ? "No versions found" : versions.map(v => `v${v.version} [${v.hash}] ${v.label} — ${v.timestamp}`).join("\n"));
956
+ break;
957
+ }
958
+ case "/export": {
959
+ const { exportStrategy } = await import("./strategy/export.ts");
960
+ const draft = store.getActiveSession()?.strategyDraft;
961
+ if (!draft?.code) { this.codePanel.appendLog("No strategy loaded"); break; }
962
+ const result = await exportStrategy(draft.name, draft.code, draft.params ?? {}, null);
963
+ this.codePanel.appendLog(`Exported to ${result.path} (${result.size} bytes)`);
964
+ break;
965
+ }
966
+ case "/report": {
967
+ const { generateReport } = await import("./strategy/ledger.ts");
968
+ const draft = store.getActiveSession()?.strategyDraft;
969
+ const report = generateReport(draft?.name ?? arg ?? "");
970
+ this.codePanel.appendLog(report.summary);
971
+ break;
972
+ }
973
+ case "/session-export": {
974
+ const session = store.getActiveSession();
975
+ if (!session) break;
976
+ const { writeWorkspaceFile } = await import("./strategy/workspace.ts");
977
+ const lines: string[] = [];
978
+ lines.push(`# ${session.id} — ${new Date().toISOString()}\n`);
979
+ for (const msg of session.messages) {
980
+ const role = msg.role === "user" ? "**User**" : "**Horizon**";
981
+ const text = msg.content.map((b: any) => b.text ?? b.markdown ?? "").join("\n");
982
+ lines.push(`### ${role}\n${text}\n`);
983
+ }
984
+ const fileName = `session_${Date.now()}.md`;
985
+ await writeWorkspaceFile(`exports/${fileName}`, lines.join("\n"));
986
+ this.codePanel.appendLog(`Session exported to exports/${fileName}`);
987
+ break;
988
+ }
848
989
  default: {
849
990
  // Unknown command — show as system message
850
991
  const msg: Message = {
@@ -938,15 +1079,19 @@ export class App {
938
1079
  const { hasLiveSession } = await import("./platform/supabase.ts");
939
1080
  const live = await hasLiveSession();
940
1081
 
1082
+ const { loadSessions, startAutoSave } = await import("./platform/session-sync.ts");
941
1083
  if (live) {
942
- const { loadSessions, startAutoSave } = await import("./platform/session-sync.ts");
943
- await loadSessions().catch(() => {});
1084
+ await loadSessions().catch((e) => {
1085
+ this.splash.setLoading(`Failed to load chats: ${e?.message ?? "unknown error"}`);
1086
+ });
944
1087
  startAutoSave();
1088
+ } else {
1089
+ this.splash.setLoading("Session expired -- type /login to restore your chats");
945
1090
  }
946
1091
 
947
1092
  // Start platform sync (works with API key)
948
1093
  const { platformSync } = await import("./platform/sync.ts");
949
- platformSync.start(30000).catch(() => {});
1094
+ platformSync.start(30000).catch((e: any) => console.error("[app] platformSync start failed:", e?.message));
950
1095
 
951
1096
  // Final status
952
1097
  const firstTime = !cfg.has_launched;
@@ -987,6 +1132,10 @@ export class App {
987
1132
  store.update({ sessions });
988
1133
  for (const [id] of this.messageRenderables) this.scrollBox.remove(`msg-${id}`);
989
1134
  this.messageRenderables.clear();
1135
+ this.codePanel.clearWidgets();
1136
+ this.codePanel.setLogs("");
1137
+ this.codePanel.setCode("", "none");
1138
+ this.codePanel.setMetrics(null);
990
1139
  this.updateContextMeter();
991
1140
  this.inputBar.focus();
992
1141
  this.renderer.requestRender();
@@ -1158,6 +1307,23 @@ export class App {
1158
1307
  contextParts.push(`Active strategy: ${draft.name} (${draft.phase}, ${draft.validationStatus})`);
1159
1308
  }
1160
1309
 
1310
+ // Preserve strategy context through compaction
1311
+ if (draft?.code) {
1312
+ contextParts.push(`\nActive strategy code hash: ${draft.code.length} chars`);
1313
+ // Preserve active market slugs
1314
+ const marketsMatch = draft.code.match(/markets\s*=\s*\[([\s\S]*?)\]/);
1315
+ if (marketsMatch) {
1316
+ const slugs2 = [...marketsMatch[1]!.matchAll(/["']([^"']+)["']/g)].map(m => m[1]!);
1317
+ contextParts.push(`Active markets: ${slugs2.join(", ")}`);
1318
+ }
1319
+ }
1320
+
1321
+ // Preserve running process info
1322
+ const pids = [...runningProcesses.keys()].filter(pid => runningProcesses.get(pid)?.proc.exitCode === null);
1323
+ if (pids.length > 0) {
1324
+ contextParts.push(`Running processes: PIDs ${pids.join(", ")}`);
1325
+ }
1326
+
1161
1327
  contextParts.push("");
1162
1328
  contextParts.push("Conversation summary:");
1163
1329
  contextParts.push(...summaryParts.slice(-15)); // last 15 exchanges max
@@ -1336,13 +1502,20 @@ export class App {
1336
1502
  }
1337
1503
  } else if (part.type === "tool-result") {
1338
1504
  // Replace the spinning tool-call with a completed tool-result (same line, not stacked)
1505
+ // Include toolResult so renderer can detect errors and show brief explanations
1506
+ const hasError = part.result && typeof part.result === "object" && "error" in (part.result as any);
1507
+ const resultBlock: import("./chat/types.ts").ContentBlock = {
1508
+ type: "tool-result",
1509
+ toolName: part.toolName,
1510
+ ...(hasError ? { toolResult: part.result } : {}),
1511
+ };
1339
1512
  const callIdx = currentBlocks.findIndex(
1340
1513
  (b) => b.type === "tool-call" && b.toolName === part.toolName,
1341
1514
  );
1342
1515
  if (callIdx !== -1) {
1343
- currentBlocks[callIdx] = { type: "tool-result", toolName: part.toolName };
1516
+ currentBlocks[callIdx] = resultBlock;
1344
1517
  } else {
1345
- currentBlocks.push({ type: "tool-result", toolName: part.toolName });
1518
+ currentBlocks.push(resultBlock);
1346
1519
  }
1347
1520
 
1348
1521
  if (WIDGET_TOOLS.has(part.toolName)) {
@@ -1616,7 +1789,7 @@ export class App {
1616
1789
  const { saveActiveSession, stopAutoSave } = await import("./platform/session-sync.ts");
1617
1790
  stopAutoSave();
1618
1791
  await saveActiveSession();
1619
- } catch {}
1792
+ } catch (e: any) { console.error("[app] shutdown save failed:", e?.message); }
1620
1793
  if (dashboard.running) dashboard.stop();
1621
1794
  cleanupStrategyProcesses();
1622
1795
  destroyTreeSitterClient();
@@ -33,7 +33,7 @@ const syntaxStyle = SyntaxStyle.fromStyles({
33
33
  "@punctuation.special": { fg: h("#D7BA7D") },
34
34
  });
35
35
 
36
- // Braille spinner frames
36
+ // Braille spinner frames (tool calls)
37
37
  const BRAILLE = [
38
38
  "\u2801", "\u2803", "\u2807", "\u280f",
39
39
  "\u281f", "\u283f", "\u287f", "\u28ff",
@@ -41,6 +41,11 @@ const BRAILLE = [
41
41
  "\u28e0", "\u28c0", "\u2880", "\u2800",
42
42
  ];
43
43
 
44
+ // Dot orbit spinner for header (different visual rhythm)
45
+ const ORBIT = [
46
+ "\u25DC", "\u25DD", "\u25DE", "\u25DF", // ◜ ◝ ◞ ◟
47
+ ];
48
+
44
49
  // Tool name → human-readable label
45
50
  function toolLabel(name: string): string {
46
51
  return name.replace(/_/g, " ");
@@ -62,9 +67,14 @@ export class ChatRenderer {
62
67
  this.spinnerTimer = setInterval(() => {
63
68
  if (this.spinnerNodes.length === 0) return;
64
69
  this.spinnerFrame = (this.spinnerFrame + 1) % BRAILLE.length;
65
- const char = BRAILLE[this.spinnerFrame]!;
70
+ const brailleChar = BRAILLE[this.spinnerFrame]!;
71
+ const orbitChar = ORBIT[this.spinnerFrame % ORBIT.length]!;
66
72
  for (const node of this.spinnerNodes) {
67
- node.content = `${char} `;
73
+ if (node.id.includes("stream-spin")) {
74
+ node.content = ` ${orbitChar}`;
75
+ } else {
76
+ node.content = `${brailleChar} `;
77
+ }
68
78
  }
69
79
  this.renderer.requestRender();
70
80
  }, 60);
@@ -80,6 +90,7 @@ export class ChatRenderer {
80
90
 
81
91
  renderMessage(message: Message): BoxRenderable {
82
92
  const isUser = message.role === "user";
93
+ const isStreaming = message.status === "streaming" || message.status === "thinking";
83
94
 
84
95
  const box = new BoxRenderable(this.renderer, {
85
96
  id: `msg-${message.id}`,
@@ -92,16 +103,60 @@ export class ChatRenderer {
92
103
  });
93
104
 
94
105
  if (message.role === "user" || message.role === "assistant") {
95
- box.add(new TextRenderable(this.renderer, {
106
+ const headerBox = new BoxRenderable(this.renderer, {
107
+ id: `msg-header-${message.id}`,
108
+ flexDirection: "row",
109
+ width: "100%",
110
+ });
111
+ headerBox.add(new TextRenderable(this.renderer, {
96
112
  id: `msg-label-${message.id}`,
97
113
  content: isUser ? "U S E R" : "H O R I Z O N",
98
114
  fg: isUser ? COLORS.textMuted : COLORS.borderDim,
99
115
  }));
116
+
117
+ // Streaming indicator next to the role label — always visible at top
118
+ if (!isUser && isStreaming) {
119
+ const streamSpinner = new TextRenderable(this.renderer, {
120
+ id: `msg-stream-spin-${message.id}`,
121
+ content: ` ${ORBIT[0]}`,
122
+ fg: COLORS.accent,
123
+ });
124
+ this.spinnerNodes.push(streamSpinner);
125
+ headerBox.add(streamSpinner);
126
+ }
127
+
128
+ box.add(headerBox);
100
129
  }
101
130
 
131
+ // Render tool blocks first, then content blocks (text/markdown/widgets)
132
+ // This ensures tools are always at the top, generation text at the bottom
133
+ const toolBlocks: { block: ContentBlock; idx: number }[] = [];
134
+ const contentBlocks: { block: ContentBlock; idx: number }[] = [];
135
+
102
136
  for (let i = 0; i < message.content.length; i++) {
103
137
  const block = message.content[i]!;
104
- const renderable = this.renderBlock(block, message, i);
138
+ if (block.type === "tool-call" || block.type === "tool-result") {
139
+ toolBlocks.push({ block, idx: i });
140
+ } else {
141
+ contentBlocks.push({ block, idx: i });
142
+ }
143
+ }
144
+
145
+ for (const { block, idx } of toolBlocks) {
146
+ const renderable = this.renderBlock(block, message, idx);
147
+ if (renderable) box.add(renderable);
148
+ }
149
+
150
+ // Add spacing between tool section and content
151
+ if (toolBlocks.length > 0 && contentBlocks.length > 0) {
152
+ box.add(new TextRenderable(this.renderer, {
153
+ id: `msg-tool-spacer-${message.id}`,
154
+ content: "",
155
+ }));
156
+ }
157
+
158
+ for (const { block, idx } of contentBlocks) {
159
+ const renderable = this.renderBlock(block, message, idx);
105
160
  if (renderable) box.add(renderable);
106
161
  }
107
162
 
@@ -144,12 +199,19 @@ export class ChatRenderer {
144
199
  case "thinking":
145
200
  return this.renderThinking(message.id, index);
146
201
 
147
- case "error":
202
+ case "error": {
203
+ const rawErr = block.text ?? "Unknown error";
204
+ // Clean up error text: first line only, remove stack traces and verbose prefixes
205
+ const cleanErr = rawErr
206
+ .replace(/^Error:\s*/i, "")
207
+ .replace(/\n[\s\S]*/, "")
208
+ .slice(0, 120);
148
209
  return new TextRenderable(this.renderer, {
149
210
  id: `msg-err-${message.id}-${index}`,
150
- content: `x ${block.text ?? "Unknown error"}`,
211
+ content: `\u2716 ${cleanErr}`,
151
212
  fg: COLORS.error,
152
213
  });
214
+ }
153
215
 
154
216
  default:
155
217
  return null;
@@ -179,7 +241,7 @@ export class ChatRenderer {
179
241
  return box;
180
242
  }
181
243
 
182
- // Tool in-progress: braille spinner + name
244
+ // Tool in-progress: accent braille spinner + name
183
245
  private renderToolCall(block: ContentBlock, msgId: string, index: number): BoxRenderable {
184
246
  const box = new BoxRenderable(this.renderer, {
185
247
  id: `msg-tool-${msgId}-${index}`,
@@ -189,7 +251,7 @@ export class ChatRenderer {
189
251
  const spinner = new TextRenderable(this.renderer, {
190
252
  id: `msg-tool-icon-${msgId}-${index}`,
191
253
  content: `${BRAILLE[0]} `,
192
- fg: COLORS.textMuted,
254
+ fg: COLORS.accent,
193
255
  });
194
256
  this.spinnerNodes.push(spinner);
195
257
  box.add(spinner);
@@ -197,34 +259,54 @@ export class ChatRenderer {
197
259
  box.add(new TextRenderable(this.renderer, {
198
260
  id: `msg-tool-name-${msgId}-${index}`,
199
261
  content: toolLabel(block.toolName ?? "tool"),
200
- fg: COLORS.textMuted,
262
+ fg: COLORS.text,
201
263
  }));
202
264
 
203
265
  return box;
204
266
  }
205
267
 
206
- // Tool completed: tick or error icon + name
268
+ // Tool completed: green check or red error with brief explanation
207
269
  private renderToolResult(block: ContentBlock, msgId: string, index: number): BoxRenderable {
208
270
  const box = new BoxRenderable(this.renderer, {
209
271
  id: `msg-result-${msgId}-${index}`,
210
272
  flexDirection: "row",
211
- marginBottom: 1,
212
273
  });
213
274
 
214
275
  const hasError = block.toolResult && typeof block.toolResult === "object" && "error" in (block.toolResult as any);
215
- box.add(new TextRenderable(this.renderer, {
216
- id: `msg-result-icon-${msgId}-${index}`,
217
- content: hasError ? "x " : ". ",
218
- fg: hasError ? COLORS.error : COLORS.success,
219
- }));
220
276
 
221
- const label = toolLabel(block.toolName ?? "done");
222
- const errorText = hasError ? ` ${((block.toolResult as any).error ?? "").slice(0, 60)}` : "";
223
- box.add(new TextRenderable(this.renderer, {
224
- id: `msg-result-name-${msgId}-${index}`,
225
- content: label + errorText,
226
- fg: hasError ? COLORS.error : COLORS.textMuted,
227
- }));
277
+ if (hasError) {
278
+ // Red x + tool name + brief, human-readable error
279
+ box.add(new TextRenderable(this.renderer, {
280
+ id: `msg-result-icon-${msgId}-${index}`,
281
+ content: "\u2716 ",
282
+ fg: COLORS.error,
283
+ }));
284
+
285
+ const label = toolLabel(block.toolName ?? "tool");
286
+ const rawError = String((block.toolResult as any).error ?? "failed");
287
+ // Clean up common error patterns to be user-friendly
288
+ const briefError = rawError
289
+ .replace(/^Error:\s*/i, "")
290
+ .replace(/\n[\s\S]*/, "") // first line only
291
+ .slice(0, 80);
292
+ box.add(new TextRenderable(this.renderer, {
293
+ id: `msg-result-name-${msgId}-${index}`,
294
+ content: `${label} -- ${briefError}`,
295
+ fg: COLORS.error,
296
+ }));
297
+ } else {
298
+ // Green check + tool name
299
+ box.add(new TextRenderable(this.renderer, {
300
+ id: `msg-result-icon-${msgId}-${index}`,
301
+ content: "\u2713 ",
302
+ fg: COLORS.success,
303
+ }));
304
+ box.add(new TextRenderable(this.renderer, {
305
+ id: `msg-result-name-${msgId}-${index}`,
306
+ content: toolLabel(block.toolName ?? "done"),
307
+ fg: COLORS.textMuted,
308
+ }));
309
+ }
228
310
 
229
311
  return box;
230
312
  }
@@ -33,7 +33,7 @@ export class Footer {
33
33
 
34
34
  this.hintsText = new TextRenderable(renderer, {
35
35
  id: "footer-hints",
36
- content: "esc stop ^N new chat ^L ^H switch ^W close ^R mode ^E chats ^D bots / cmd",
36
+ content: "esc stop ^N new ^L ^H switch ^W close ^R mode ^E chats ^D bots ^O dash / cmd",
37
37
  fg: COLORS.borderDim,
38
38
  });
39
39
  this.container.add(this.hintsText);
@@ -20,6 +20,7 @@ export class KeyHandler {
20
20
  private tabCloseCallback: (() => void) | null = null;
21
21
  private tabNewCallback: (() => void) | null = null;
22
22
  private acNavCallback: ((dir: "up" | "down" | "accept" | "dismiss") => void) | null = null;
23
+ private openDashboardCallback: (() => void) | null = null;
23
24
  acActive = false; // set by app when autocomplete is visible
24
25
 
25
26
  // Panel navigation (shared by session & strategy panels)
@@ -56,6 +57,7 @@ export class KeyHandler {
56
57
  onTabClose(cb: () => void): void { this.tabCloseCallback = cb; }
57
58
  onTabNew(cb: () => void): void { this.tabNewCallback = cb; }
58
59
  onAcNav(cb: (dir: "up" | "down" | "accept" | "dismiss") => void): void { this.acNavCallback = cb; }
60
+ onOpenDashboard(cb: () => void): void { this.openDashboardCallback = cb; }
59
61
 
60
62
  onPanelNav(cb: (delta: number) => void): void { this.panelNavCallback = cb; }
61
63
  onPanelSelect(cb: () => void): void { this.panelSelectCallback = cb; }
@@ -123,6 +125,12 @@ export class KeyHandler {
123
125
  return;
124
126
  }
125
127
 
128
+ // Ctrl+O — open dashboard in browser
129
+ if (key.ctrl && key.name === "o") {
130
+ this.openDashboardCallback?.();
131
+ return;
132
+ }
133
+
126
134
  // Ctrl+L — next tab (was clear, now tab navigation)
127
135
  if (key.ctrl && key.name === "l") {
128
136
  if (this.panelActive === "sessions") { this.panelNewCallback?.(); return; }