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 +1 -1
- package/src/ai/client.ts +13 -6
- package/src/ai/system-prompt.ts +20 -1
- package/src/app.ts +184 -11
- package/src/chat/renderer.ts +106 -24
- package/src/components/footer.ts +1 -1
- package/src/keys/handler.ts +8 -0
- package/src/platform/auth.ts +3 -3
- package/src/platform/profile.ts +202 -0
- package/src/platform/session-sync.ts +3 -3
- package/src/platform/supabase.ts +10 -9
- package/src/platform/sync.ts +9 -1
- package/src/research/apis.ts +13 -13
- package/src/research/scanner.ts +212 -0
- package/src/research/tools.ts +58 -0
- package/src/strategy/alerts.ts +190 -0
- package/src/strategy/export.ts +159 -0
- package/src/strategy/health.ts +127 -0
- package/src/strategy/ledger.ts +185 -0
- package/src/strategy/prompts.ts +136 -551
- package/src/strategy/replay.ts +191 -0
- package/src/strategy/tools.ts +495 -1
- package/src/strategy/versioning.ts +168 -0
package/package.json
CHANGED
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 (!
|
|
349
|
-
// Server sent events (meta, usage) but no actual content
|
|
350
|
-
|
|
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
|
package/src/ai/system-prompt.ts
CHANGED
|
@@ -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")
|
|
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
|
-
|
|
943
|
-
|
|
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] =
|
|
1516
|
+
currentBlocks[callIdx] = resultBlock;
|
|
1344
1517
|
} else {
|
|
1345
|
-
currentBlocks.push(
|
|
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();
|
package/src/chat/renderer.ts
CHANGED
|
@@ -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
|
|
70
|
+
const brailleChar = BRAILLE[this.spinnerFrame]!;
|
|
71
|
+
const orbitChar = ORBIT[this.spinnerFrame % ORBIT.length]!;
|
|
66
72
|
for (const node of this.spinnerNodes) {
|
|
67
|
-
node.
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
262
|
+
fg: COLORS.text,
|
|
201
263
|
}));
|
|
202
264
|
|
|
203
265
|
return box;
|
|
204
266
|
}
|
|
205
267
|
|
|
206
|
-
// Tool completed:
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
}
|
package/src/components/footer.ts
CHANGED
|
@@ -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
|
|
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);
|
package/src/keys/handler.ts
CHANGED
|
@@ -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; }
|