horizon-code 0.1.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.
Files changed (54) hide show
  1. package/assets/python/highlights.scm +137 -0
  2. package/assets/python/tree-sitter-python.wasm +0 -0
  3. package/bin/horizon.js +2 -0
  4. package/package.json +40 -0
  5. package/src/ai/client.ts +369 -0
  6. package/src/ai/system-prompt.ts +86 -0
  7. package/src/app.ts +1454 -0
  8. package/src/chat/messages.ts +48 -0
  9. package/src/chat/renderer.ts +243 -0
  10. package/src/chat/types.ts +18 -0
  11. package/src/components/code-panel.ts +329 -0
  12. package/src/components/footer.ts +72 -0
  13. package/src/components/hooks-panel.ts +224 -0
  14. package/src/components/input-bar.ts +193 -0
  15. package/src/components/mode-bar.ts +245 -0
  16. package/src/components/session-panel.ts +294 -0
  17. package/src/components/settings-panel.ts +372 -0
  18. package/src/components/splash.ts +156 -0
  19. package/src/components/strategy-panel.ts +489 -0
  20. package/src/components/tab-bar.ts +112 -0
  21. package/src/components/tutorial-panel.ts +680 -0
  22. package/src/components/widgets/progress-bar.ts +38 -0
  23. package/src/components/widgets/sparkline.ts +57 -0
  24. package/src/hooks/executor.ts +109 -0
  25. package/src/index.ts +22 -0
  26. package/src/keys/handler.ts +198 -0
  27. package/src/platform/auth.ts +36 -0
  28. package/src/platform/client.ts +159 -0
  29. package/src/platform/config.ts +121 -0
  30. package/src/platform/session-sync.ts +158 -0
  31. package/src/platform/supabase.ts +376 -0
  32. package/src/platform/sync.ts +149 -0
  33. package/src/platform/tiers.ts +103 -0
  34. package/src/platform/tools.ts +163 -0
  35. package/src/platform/types.ts +86 -0
  36. package/src/platform/usage.ts +224 -0
  37. package/src/research/apis.ts +367 -0
  38. package/src/research/tools.ts +205 -0
  39. package/src/research/widgets.ts +523 -0
  40. package/src/state/store.ts +256 -0
  41. package/src/state/types.ts +109 -0
  42. package/src/strategy/ascii-chart.ts +74 -0
  43. package/src/strategy/code-stream.ts +146 -0
  44. package/src/strategy/dashboard.ts +140 -0
  45. package/src/strategy/persistence.ts +82 -0
  46. package/src/strategy/prompts.ts +626 -0
  47. package/src/strategy/sandbox.ts +137 -0
  48. package/src/strategy/tools.ts +764 -0
  49. package/src/strategy/validator.ts +216 -0
  50. package/src/strategy/widgets.ts +270 -0
  51. package/src/syntax/setup.ts +54 -0
  52. package/src/theme/colors.ts +107 -0
  53. package/src/theme/icons.ts +27 -0
  54. package/src/util/hyperlink.ts +21 -0
package/src/app.ts ADDED
@@ -0,0 +1,1454 @@
1
+ import { BoxRenderable, ScrollBoxRenderable, type CliRenderer } from "@opentui/core";
2
+ import { COLORS, setTheme, ULTRA_THEMES } from "./theme/colors.ts";
3
+ import { Footer } from "./components/footer.ts";
4
+ import { Splash } from "./components/splash.ts";
5
+ import { SessionPanel } from "./components/session-panel.ts";
6
+ import { StrategyPanel, setPrivacyMode } from "./components/strategy-panel.ts";
7
+ import { CodePanel } from "./components/code-panel.ts";
8
+ import { TutorialPanel } from "./components/tutorial-panel.ts";
9
+ import { SettingsPanel } from "./components/settings-panel.ts";
10
+ import { HooksPanel } from "./components/hooks-panel.ts";
11
+ import { getHooksForEvent, executeHook, TOOL_BEFORE_HOOK, TOOL_AFTER_HOOK } from "./hooks/executor.ts";
12
+ import { ModeBar } from "./components/mode-bar.ts";
13
+ import { TabBar } from "./components/tab-bar.ts";
14
+ import { InputBar } from "./components/input-bar.ts";
15
+ import { ChatRenderer } from "./chat/renderer.ts";
16
+ import { KeyHandler } from "./keys/handler.ts";
17
+ import { store } from "./state/store.ts";
18
+ import { createUserMessage } from "./chat/messages.ts";
19
+ import { chat } from "./ai/client.ts";
20
+ import { dashboard } from "./strategy/dashboard.ts";
21
+ import { cleanupStrategyProcesses, runningProcesses } from "./strategy/tools.ts";
22
+ import { abortChat } from "./ai/client.ts";
23
+ import type { ModelPower } from "./platform/tiers.ts";
24
+ import { listSavedStrategies, loadStrategy } from "./strategy/persistence.ts";
25
+ import { autoFixStrategyCode } from "./strategy/validator.ts";
26
+ import { CodeFenceDetector, finalizeStrategy, isStrategyCode, extractStrategyName } from "./strategy/code-stream.ts";
27
+ import { loginWithBrowser, loginWithPassword, signOut, isLoggedIn, getUser } from "./platform/supabase.ts";
28
+ import { loadConfig, saveConfig, setEncryptedEnv, removeEncryptedEnv, listEncryptedEnvNames } from "./platform/config.ts";
29
+ import { getTreeSitterClient, destroyTreeSitterClient } from "./syntax/setup.ts";
30
+ import type { Message } from "./chat/types.ts";
31
+
32
+ // Tools that render as visual widgets in chat
33
+ const WIDGET_TOOLS = new Set([
34
+ "polymarketEvents", "polymarketEventDetail", "polymarketPriceHistory",
35
+ "polymarketOrderBook", "whaleTracker", "newsSentiment", "evCalculator",
36
+ "kalshiEvents", "compareMarkets", "historicalVolatility",
37
+ "webSearch", "calaKnowledge", "probabilityCalculator",
38
+ "edit_strategy", "validate_strategy",
39
+ "backtest_strategy", "polymarket_data",
40
+ ]);
41
+
42
+ // Strategy tools that update the code panel (edit_strategy + load)
43
+ const CODE_PANEL_TOOLS = new Set([
44
+ "edit_strategy", "load_saved_strategy",
45
+ ]);
46
+
47
+ // ── Slash commands ──
48
+ const SLASH_COMMANDS: Record<string, { description: string; usage: string }> = {
49
+ "/help": { description: "Show available commands", usage: "/help" },
50
+ "/resume": { description: "Resume paused queue after Esc", usage: "/resume" },
51
+ "/clear": { description: "Clear current chat messages", usage: "/clear" },
52
+ "/mode": { description: "Switch mode", usage: "/mode [research|strategy|portfolio]" },
53
+ "/usage": { description: "Toggle token/cost metrics", usage: "/usage" },
54
+ "/privacy": { description: "Toggle privacy mode", usage: "/privacy" },
55
+ "/code": { description: "Toggle code panel", usage: "/code" },
56
+ "/login": { description: "Login with Google or email/password", usage: "/login [email password]" },
57
+ "/logout": { description: "Sign out", usage: "/logout" },
58
+ "/whoami": { description: "Show current user", usage: "/whoami" },
59
+ "/tutorial": { description: "Open the guide", usage: "/tutorial" },
60
+ "/settings": { description: "Open settings", usage: "/settings" },
61
+ "/hooks": { description: "Manage hooks", usage: "/hooks" },
62
+ "/home": { description: "Back to splash screen", usage: "/home" },
63
+ "/compact": { description: "Force context compaction", usage: "/compact" },
64
+ "/context": { description: "Show context window usage", usage: "/context" },
65
+ "/strategies": { description: "List saved strategies", usage: "/strategies" },
66
+ "/load": { description: "Load a saved strategy", usage: "/load <name>" },
67
+ "/env": { description: "Manage encrypted API keys", usage: "/env [list|add|remove]" },
68
+ "/quit": { description: "Exit Horizon", usage: "/quit" },
69
+ "/exit": { description: "Exit Horizon", usage: "/exit" },
70
+ };
71
+
72
+ export class App {
73
+ private footer: Footer;
74
+ private splash: Splash;
75
+ private sessionPanel: SessionPanel;
76
+ private strategyPanel: StrategyPanel;
77
+ private codePanel: CodePanel;
78
+ private tutorialPanel: TutorialPanel;
79
+ private settingsPanel: SettingsPanel;
80
+ private hooksPanel: HooksPanel;
81
+ private tabBar: TabBar;
82
+ private modeBar: ModeBar;
83
+ private inputBar: InputBar;
84
+ private chatRenderer: ChatRenderer;
85
+ private keyHandler: KeyHandler;
86
+ private scrollBox: ScrollBoxRenderable;
87
+ private outerLayout: BoxRenderable;
88
+ private splitRow: BoxRenderable;
89
+ private chatColumn: BoxRenderable;
90
+ private messageRenderables: Map<string, BoxRenderable> = new Map();
91
+ private destroyed = false;
92
+ private inChatMode = false;
93
+ private authenticated = false;
94
+ private _openIdsSaveTimer: ReturnType<typeof setTimeout> | null = null;
95
+
96
+ // Per-tab stream state
97
+ private tabStreams: Map<string, {
98
+ processing: boolean;
99
+ streamingMsgId: string | null;
100
+ messageQueue: string[];
101
+ queuePaused: boolean;
102
+ thresholdWarned: boolean;
103
+ }> = new Map();
104
+
105
+ // Compat getters/setters that delegate to the active tab's stream
106
+ private get processing(): boolean { return this.getTabStream(store.get().activeSessionId).processing; }
107
+ private set processing(v: boolean) { this.getTabStream(store.get().activeSessionId).processing = v; }
108
+ private get currentStreamingMsgId(): string | null { return this.getTabStream(store.get().activeSessionId).streamingMsgId; }
109
+ private set currentStreamingMsgId(v: string | null) { this.getTabStream(store.get().activeSessionId).streamingMsgId = v; }
110
+ private get messageQueue(): string[] { return this.getTabStream(store.get().activeSessionId).messageQueue; }
111
+ private set messageQueue(v: string[]) { this.getTabStream(store.get().activeSessionId).messageQueue = v; }
112
+ private get queuePaused(): boolean { return this.getTabStream(store.get().activeSessionId).queuePaused; }
113
+ private set queuePaused(v: boolean) { this.getTabStream(store.get().activeSessionId).queuePaused = v; }
114
+
115
+ constructor(private renderer: CliRenderer) {
116
+ renderer.setBackgroundColor(COLORS.bg);
117
+
118
+ // ── Outer layout (hidden until chat mode) ──
119
+ this.outerLayout = new BoxRenderable(renderer, {
120
+ id: "outer-layout",
121
+ flexDirection: "column",
122
+ width: "100%",
123
+ height: "100%",
124
+ });
125
+ this.outerLayout.visible = false;
126
+ renderer.root.add(this.outerLayout);
127
+
128
+ // ── Horizontal split: chat (left) + code panel (right) ──
129
+ this.splitRow = new BoxRenderable(renderer, {
130
+ id: "split-row",
131
+ flexDirection: "row",
132
+ flexGrow: 1,
133
+ width: "100%",
134
+ });
135
+ this.outerLayout.add(this.splitRow);
136
+
137
+ // ── Chat column (left side of split) ──
138
+ this.chatColumn = new BoxRenderable(renderer, {
139
+ id: "chat-column",
140
+ flexDirection: "column",
141
+ flexGrow: 1,
142
+ height: "100%",
143
+ });
144
+ this.splitRow.add(this.chatColumn);
145
+
146
+ this.scrollBox = new ScrollBoxRenderable(renderer, {
147
+ id: "chat-scroll",
148
+ flexGrow: 1,
149
+ stickyScroll: true,
150
+ stickyStart: "bottom",
151
+ paddingTop: 1,
152
+ paddingLeft: 3,
153
+ paddingRight: 3,
154
+ });
155
+ // Tab bar at top of chat
156
+ this.tabBar = new TabBar(renderer);
157
+ this.chatColumn.add(this.tabBar.container);
158
+
159
+ this.chatColumn.add(this.scrollBox);
160
+
161
+ // Input bar
162
+ this.inputBar = new InputBar(renderer);
163
+ this.chatColumn.add(this.inputBar.renderable);
164
+
165
+ // ── Right panels (code and tutorial — hidden by default) ──
166
+ this.codePanel = new CodePanel(renderer);
167
+ this.splitRow.add(this.codePanel.container);
168
+
169
+ this.tutorialPanel = new TutorialPanel(renderer);
170
+ this.splitRow.add(this.tutorialPanel.container);
171
+
172
+ this.settingsPanel = new SettingsPanel(renderer);
173
+ this.hooksPanel = new HooksPanel(renderer);
174
+ this.settingsPanel.onChange((s) => {
175
+ // Gate Ultra themes — revert to dark if user isn't Ultra tier
176
+ if (ULTRA_THEMES.has(s.theme) && this.modeBar.currentTier !== "ultra") {
177
+ s.theme = "dark";
178
+ this.showSystemMsg("Ultra theme requires Ultra plan.");
179
+ }
180
+ this.chatRenderer.setShowToolCalls(s.showToolCalls);
181
+ setTheme(s.theme);
182
+ this.renderer.setBackgroundColor(COLORS.bg);
183
+ this.renderer.requestRender();
184
+ // Persist settings
185
+ const cfg = loadConfig();
186
+ cfg.settings = { ...s };
187
+ saveConfig(cfg);
188
+ });
189
+ this.settingsPanel.onDeleteChats(async () => {
190
+ // Delete all sessions from store
191
+ const sessions = store.get().sessions;
192
+ for (const s of sessions) {
193
+ store.deleteSession(s.id);
194
+ }
195
+ // Clear the chat display
196
+ for (const [id] of this.messageRenderables) this.scrollBox.remove(`msg-${id}`);
197
+ this.messageRenderables.clear();
198
+ // Anonymize in Supabase
199
+ try {
200
+ const { fetchSessions, deleteDbSession } = await import("./platform/supabase.ts");
201
+ const dbSessions = await fetchSessions();
202
+ for (const s of dbSessions) {
203
+ await deleteDbSession(s.id);
204
+ }
205
+ } catch {}
206
+ this.showSystemMsg("All chats deleted.");
207
+ this.settingsPanel.hide();
208
+ });
209
+ this.splitRow.add(this.settingsPanel.container);
210
+ this.splitRow.add(this.hooksPanel.container);
211
+
212
+ // ── Bottom bar: mode pills + footer (below the split, full width) ──
213
+ this.modeBar = new ModeBar(renderer);
214
+ this.outerLayout.add(this.modeBar.renderable);
215
+
216
+ this.footer = new Footer(renderer);
217
+ this.outerLayout.add(this.footer.renderable);
218
+
219
+ // ── Splash ──
220
+ this.splash = new Splash(renderer);
221
+ this.splash.onSubmit((text) => this.onFirstInput(text));
222
+ this.splash.focus();
223
+
224
+ // ── Overlay panels (sessions, deployments) ──
225
+ this.sessionPanel = new SessionPanel(renderer);
226
+ this.sessionPanel.onSelect((id) => this.switchSession(id));
227
+ this.sessionPanel.onNew(() => this.newSession());
228
+ this.sessionPanel.onDelete((id) => {
229
+ store.deleteSession(id);
230
+ // Sync to Supabase
231
+ import("./platform/supabase.ts").then(({ deleteDbSession }) => {
232
+ deleteDbSession(id).catch(() => {});
233
+ });
234
+ // Rebuild chat display
235
+ const active = store.getActiveSession();
236
+ if (active) {
237
+ for (const [mid] of this.messageRenderables) this.scrollBox.remove(`msg-${mid}`);
238
+ this.messageRenderables.clear();
239
+ for (const msg of active.messages) this.renderNewMessage(msg);
240
+ }
241
+ });
242
+ this.sessionPanel.onPin((id) => store.togglePinSession(id));
243
+ this.sessionPanel.onOpenTab((id) => {
244
+ store.openTab(id);
245
+ this.switchToActiveTab();
246
+ });
247
+ this.sessionPanel.onRename((id, name) => {
248
+ store.renameSession(id, name);
249
+ // Sync to Supabase
250
+ import("./platform/supabase.ts").then(({ updateDbSession }) => {
251
+ updateDbSession(id, { name }).catch(() => {});
252
+ });
253
+ });
254
+ this.sessionPanel.onRenameEnd(() => {
255
+ this.keyHandler.panelRenaming = false;
256
+ this.inputBar.focus();
257
+ });
258
+
259
+ this.strategyPanel = new StrategyPanel(renderer);
260
+
261
+ // ── Pass command list to input bar for autocomplete ──
262
+ const cmdDescriptions: Record<string, string> = {};
263
+ for (const [name, { description }] of Object.entries(SLASH_COMMANDS)) {
264
+ cmdDescriptions[name] = description;
265
+ }
266
+ this.inputBar.setCommands(cmdDescriptions);
267
+ this.inputBar.onAcChange((visible) => {
268
+ this.keyHandler.acActive = visible;
269
+ });
270
+
271
+ // ── Chat renderer ──
272
+ this.chatRenderer = new ChatRenderer(renderer);
273
+
274
+ // Load saved settings and open chats from config
275
+ {
276
+ const cfg = loadConfig();
277
+ if (cfg.settings) {
278
+ Object.assign(this.settingsPanel.settings, cfg.settings);
279
+ this.chatRenderer.setShowToolCalls(this.settingsPanel.settings.showToolCalls);
280
+ setTheme(this.settingsPanel.settings.theme as any);
281
+ this.renderer.setBackgroundColor(COLORS.bg);
282
+ }
283
+ // Open chat IDs are reconciled after sessions load from Supabase
284
+ // Don't restore them here — stale IDs cause ghost tabs
285
+ }
286
+
287
+ this.inputBar.onSubmit((text) => this.handleInput(text));
288
+
289
+ // ── Keyboard ──
290
+ this.keyHandler = new KeyHandler(renderer);
291
+ this.keyHandler.onQuit(() => this.handleQuitOrCancel());
292
+ this.keyHandler.onCancel(() => this.handleQuitOrCancel());
293
+ // Ctrl+L is now tab-next, clear available via /clear command only
294
+ this.keyHandler.onClear(() => {});
295
+ this.keyHandler.onSessions(() => this.togglePanel("sessions"));
296
+ this.keyHandler.onDeployments(() => this.togglePanel("deployments"));
297
+ this.keyHandler.onTabNext(() => { store.nextTab(); this.switchToActiveTab(); });
298
+ this.keyHandler.onTabPrev(() => { store.prevTab(); this.switchToActiveTab(); });
299
+ this.keyHandler.onTabClose(() => { store.closeTab(store.get().activeSessionId); this.switchToActiveTab(); });
300
+ this.keyHandler.onTabNew(() => { this.enterChatMode(); store.newSession(); this.switchToActiveTab(); });
301
+
302
+ this.keyHandler.onCodePanel(() => {
303
+ this.enterChatMode();
304
+ if (this.tutorialPanel.visible) this.tutorialPanel.hide();
305
+ this.codePanel.toggle();
306
+ });
307
+ this.keyHandler.onTutorialNav((dir) => {
308
+ if (this.settingsPanel.visible) {
309
+ this.settingsPanel.adjust(dir);
310
+ } else if (this.tutorialPanel.visible) {
311
+ if (dir === "left") this.tutorialPanel.prevTab();
312
+ else this.tutorialPanel.nextTab();
313
+ }
314
+ });
315
+ this.keyHandler.onCodeTab((tab) => {
316
+ if (!this.codePanel.visible) return;
317
+ if (tab === 0) {
318
+ // 0 = cycle to next tab
319
+ this.codePanel.cycleTab();
320
+ } else if (tab >= 1 && tab <= 3) {
321
+ const tabs = ["code", "logs", "dashboard"] as const;
322
+ this.codePanel.setTab(tabs[tab - 1]!);
323
+ }
324
+ });
325
+ this.keyHandler.onAcNav((dir) => {
326
+ if (dir === "up") this.inputBar.acUp();
327
+ else if (dir === "down") this.inputBar.acDown();
328
+ else if (dir === "accept") this.inputBar.acAccept();
329
+ else if (dir === "dismiss") this.inputBar.acDismiss();
330
+ this.keyHandler.acActive = this.inputBar.acVisible;
331
+ });
332
+
333
+ this.keyHandler.onPanelNav((d) => {
334
+ if (this.keyHandler.panelActive === "sessions") this.sessionPanel.navigate(d);
335
+ if (this.keyHandler.panelActive === "deployments") this.strategyPanel.navigate(d);
336
+ });
337
+ this.keyHandler.onPanelSelect(() => {
338
+ if (this.keyHandler.panelActive === "sessions") this.sessionPanel.selectCurrent();
339
+ if (this.keyHandler.panelActive === "deployments") this.strategyPanel.toggleExpand();
340
+ });
341
+ this.keyHandler.onPanelNew(() => {
342
+ if (this.keyHandler.panelActive === "sessions") this.sessionPanel.createNew();
343
+ });
344
+ this.keyHandler.onPanelClose(() => this.closePanel());
345
+ this.keyHandler.onPanelAction((key) => {
346
+ if (this.keyHandler.panelActive === "sessions") {
347
+ this.sessionPanel.handleAction(key);
348
+ this.keyHandler.panelRenaming = this.sessionPanel.isRenaming;
349
+ }
350
+ if (this.keyHandler.panelActive === "deployments") this.strategyPanel.handleAction(key);
351
+ });
352
+ this.keyHandler.onSettingsNav((delta) => {
353
+ if (this.settingsPanel.visible) {
354
+ if (delta === 0) this.settingsPanel.confirmAction(); // Enter key
355
+ else this.settingsPanel.navigate(delta);
356
+ }
357
+ });
358
+ this.keyHandler.onModeCycle(() => {
359
+ // Cycle mode for the active tab
360
+ this.modeBar.cycle();
361
+ const session = store.getActiveSession();
362
+ if (session) store.setSessionMode(session.id, this.modeBar.current);
363
+ });
364
+ this.keyHandler.onPrivacy(() => {
365
+ this.modeBar.togglePrivacy();
366
+ setPrivacyMode(this.modeBar.privacyEnabled);
367
+ this.footer.setPrivacy(this.modeBar.privacyEnabled);
368
+ this.strategyPanel.update();
369
+ this.renderer.requestRender();
370
+ });
371
+ this.keyHandler.onMetrics(() => {
372
+ this.modeBar.toggleMetrics();
373
+ this.footer.setMetrics(this.modeBar.metricsEnabled);
374
+ this.renderer.requestRender();
375
+ });
376
+
377
+ // ── Store subscription ──
378
+ store.subscribe(() => {
379
+ if (this.destroyed) return;
380
+ try {
381
+ if (this.inChatMode) this.footer.update();
382
+ this.strategyPanel.update();
383
+ this.tabBar.update();
384
+
385
+ // Persist open chat IDs (throttled to avoid excessive disk writes)
386
+ if (!this._openIdsSaveTimer) {
387
+ this._openIdsSaveTimer = setTimeout(() => {
388
+ this._openIdsSaveTimer = null;
389
+ const c = loadConfig();
390
+ const ids = [...new Set(store.get().openTabIds)]; // deduplicate
391
+ if (JSON.stringify(c.open_chat_ids) !== JSON.stringify(ids)) {
392
+ c.open_chat_ids = ids;
393
+ saveConfig(c);
394
+ }
395
+ }, 2000);
396
+ }
397
+
398
+ // Sync code panel with active session's strategy draft
399
+ const session = store.getActiveSession();
400
+ const draft = session?.strategyDraft;
401
+ if (draft) {
402
+ this.codePanel.setCode(draft.code, draft.validationStatus === "none" ? "pending" : draft.validationStatus);
403
+ this.codePanel.setStrategy(draft.name, draft.phase);
404
+ }
405
+
406
+ // Sync code panel visibility to key handler for Tab cycling
407
+ this.keyHandler.codePanelVisible = this.codePanel.visible;
408
+
409
+ this.renderer.requestRender();
410
+ } catch {}
411
+ });
412
+
413
+ renderer.on("resize", () => renderer.requestRender());
414
+
415
+ // Poll background processes — update count, live logs, clean dead processes
416
+ setInterval(() => {
417
+ let alive = 0;
418
+ let latestLogs = "";
419
+ const deadPids: number[] = [];
420
+ const now = Date.now();
421
+
422
+ for (const [pid, m] of runningProcesses) {
423
+ if (m.proc.exitCode === null) {
424
+ 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 : "");
428
+ } else if (now - m.startedAt > 300000) {
429
+ // Dead for 5+ minutes — clean up
430
+ deadPids.push(pid);
431
+ }
432
+ }
433
+ for (const pid of deadPids) runningProcesses.delete(pid);
434
+
435
+ this.modeBar.setBgProcessCount(alive);
436
+ // Only update logs tab if it's visible (avoid unnecessary markdown re-parse)
437
+ if (alive > 0 && latestLogs && this.codePanel.visible && this.codePanel.activeTab === "logs") {
438
+ this.codePanel.setLogs(latestLogs);
439
+ }
440
+ }, 2000);
441
+
442
+ // ── Python syntax highlighting (async init) ──
443
+ getTreeSitterClient().then((client) => {
444
+ this.codePanel.setTreeSitterClient(client);
445
+ }).catch(() => {});
446
+
447
+ // ── Auth restore + session loading + sync startup ──
448
+ this.initAuth();
449
+
450
+ }
451
+
452
+ // ── Splash → Chat ──
453
+
454
+ private onFirstInput(text: string): void {
455
+ this.enterChatMode();
456
+
457
+ if (!text.startsWith("/")) {
458
+ // Ensure there's a session to chat in
459
+ const current = store.getActiveSession();
460
+ if (!current || current.messages.length > 0) {
461
+ store.newSession();
462
+ }
463
+ this.switchToActiveTab();
464
+ }
465
+
466
+ this.handleInput(text);
467
+ }
468
+
469
+ // ── Input routing: slash commands, queue, or chat ──
470
+
471
+ private handleInput(text: string): void {
472
+ if (text.startsWith("/")) {
473
+ this.handleSlashCommand(text);
474
+ return;
475
+ }
476
+
477
+ if (!this.authenticated) {
478
+ this.showSystemMsg("You need to authenticate first. Type /login to sign in.");
479
+ return;
480
+ }
481
+
482
+ const sessionId = store.get().activeSessionId;
483
+ const ts = this.getTabStream(sessionId);
484
+
485
+ if (ts.processing) {
486
+ ts.messageQueue.push(text);
487
+ this.modeBar.setQueueCount(ts.messageQueue.length);
488
+ this.renderer.requestRender();
489
+ return;
490
+ }
491
+
492
+ this.processMessage(text, sessionId);
493
+ }
494
+
495
+ private async handleSlashCommand(input: string): Promise<void> {
496
+ const [cmd, ...args] = input.trim().split(/\s+/);
497
+ const arg = args.join(" ");
498
+
499
+ switch (cmd) {
500
+ case "/help": {
501
+ const lines = Object.entries(SLASH_COMMANDS)
502
+ .map(([name, { description, usage }]) => ` ${usage.padEnd(35)} ${description}`)
503
+ .join("\n");
504
+ const helpMsg = createUserMessage("/help");
505
+ store.addMessage(helpMsg);
506
+ this.renderNewMessage(helpMsg);
507
+ const sysMsg: Message = {
508
+ id: `msg-${Date.now()}-sys`, role: "assistant",
509
+ content: [{ type: "markdown", text: `**Available commands:**\n\`\`\`\n${lines}\n\`\`\`` }],
510
+ timestamp: Date.now(), status: "complete",
511
+ };
512
+ store.addMessage(sysMsg);
513
+ this.renderNewMessage(sysMsg);
514
+ break;
515
+ }
516
+ case "/resume":
517
+ if (this.queuePaused) {
518
+ this.queuePaused = false;
519
+ this.modeBar.setPaused(false);
520
+ this.drainQueue();
521
+ }
522
+ break;
523
+ case "/clear":
524
+ this.clearCurrentTab();
525
+ break;
526
+ case "/mode":
527
+ if (arg === "research" || arg === "strategy" || arg === "portfolio") {
528
+ this.modeBar.setMode(arg);
529
+ } else {
530
+ this.modeBar.cycle();
531
+ }
532
+ break;
533
+ case "/usage":
534
+ this.modeBar.toggleMetrics();
535
+ break;
536
+ case "/privacy":
537
+ this.modeBar.togglePrivacy();
538
+ setPrivacyMode(this.modeBar.privacyEnabled);
539
+ this.strategyPanel.update();
540
+ break;
541
+ case "/login": {
542
+ const parts = arg.split(/\s+/);
543
+ let loginResult: { success: boolean; error?: string; email?: string };
544
+
545
+ if (parts.length >= 2 && parts[0]!.includes("@")) {
546
+ this.showSystemMsg("Authenticating...");
547
+ loginResult = await loginWithPassword(parts[0]!, parts.slice(1).join(" "));
548
+ } else {
549
+ this.showSystemMsg("Opening browser for Google authentication...");
550
+ loginResult = await loginWithBrowser();
551
+ }
552
+
553
+ if (loginResult.success) {
554
+ this.authenticated = true;
555
+ this.showSystemMsg(`Logged in as ${loginResult.email}`);
556
+ import("./platform/sync.ts").then(({ platformSync }) => {
557
+ platformSync.start(30000).catch(() => {});
558
+ });
559
+ } else {
560
+ this.showSystemMsg(`Login failed: ${loginResult.error}`);
561
+ }
562
+ break;
563
+ }
564
+ case "/logout":
565
+ await signOut();
566
+ this.authenticated = false;
567
+ store.update({ deployments: [] });
568
+ this.showSystemMsg("Logged out. Type /login to re-authenticate.");
569
+ break;
570
+ case "/whoami": {
571
+ const loggedIn = await isLoggedIn();
572
+ if (loggedIn) {
573
+ const user = getUser();
574
+ this.showSystemMsg(`Logged in as ${user?.email ?? "unknown"} (${user?.id?.slice(0, 8)}...)`);
575
+ } else {
576
+ this.showSystemMsg("Not logged in. Use /login <email> <password>");
577
+ }
578
+ break;
579
+ }
580
+ case "/code":
581
+ if (this.tutorialPanel.visible) this.tutorialPanel.hide();
582
+ this.codePanel.toggle();
583
+ break;
584
+ case "/tutorial":
585
+ if (this.codePanel.visible) this.codePanel.hide();
586
+ if (this.settingsPanel.visible) this.settingsPanel.hide();
587
+ this.tutorialPanel.toggle();
588
+ break;
589
+ case "/settings":
590
+ if (this.codePanel.visible) this.codePanel.hide();
591
+ if (this.tutorialPanel.visible) this.tutorialPanel.hide();
592
+ if (this.hooksPanel.visible) this.hooksPanel.hide();
593
+ this.settingsPanel.toggle();
594
+ break;
595
+ case "/hooks":
596
+ if (this.codePanel.visible) this.codePanel.hide();
597
+ if (this.tutorialPanel.visible) this.tutorialPanel.hide();
598
+ if (this.settingsPanel.visible) this.settingsPanel.hide();
599
+ this.hooksPanel.toggle();
600
+ break;
601
+ case "/home":
602
+ this.goHome();
603
+ break;
604
+ case "/compact": {
605
+ this.compactContext();
606
+ // If there was a pending message waiting for compact, re-send it
607
+ if (this.pendingCompactMessage) {
608
+ const msg = this.pendingCompactMessage;
609
+ this.pendingCompactMessage = null;
610
+ this.showSystemMsg("Compacted. Continuing with your message...");
611
+ this.processMessage(msg);
612
+ }
613
+ break;
614
+ }
615
+ case "/context": {
616
+ const usage = this.estimateContextUsage();
617
+ const pct = Math.round(usage.pct * 100);
618
+ const bar = "\u2588".repeat(Math.round(usage.pct * 30)) + "\u2591".repeat(30 - Math.round(usage.pct * 30));
619
+ const ctxMsg: Message = {
620
+ id: `msg-${Date.now()}-sys`, role: "assistant",
621
+ content: [{ type: "markdown", text: `**Context window**\n\`[${bar}] ${pct}%\`\n\n~${usage.tokens.toLocaleString()} / ${usage.max.toLocaleString()} tokens · ${usage.messages} messages · /compact to force compaction` }],
622
+ timestamp: Date.now(), status: "complete",
623
+ };
624
+ store.addMessage(ctxMsg);
625
+ this.renderNewMessage(ctxMsg);
626
+ break;
627
+ }
628
+ case "/strategies": {
629
+ const saved = await listSavedStrategies();
630
+ const list = saved.length === 0
631
+ ? "No saved strategies. Use strategy mode to create one."
632
+ : saved.map((s) => ` ${s.name.padEnd(30)} ${s.modified ? new Date(s.modified).toLocaleDateString() : ""}`).join("\n");
633
+ const listMsg: Message = {
634
+ id: `msg-${Date.now()}-sys`, role: "assistant",
635
+ content: [{ type: "markdown", text: `**Saved strategies** (~/.horizon/strategies/)\n\`\`\`\n${list}\n\`\`\`` }],
636
+ timestamp: Date.now(), status: "complete",
637
+ };
638
+ store.addMessage(listMsg);
639
+ this.renderNewMessage(listMsg);
640
+ break;
641
+ }
642
+ case "/load": {
643
+ if (!arg) { break; }
644
+ const loaded = await loadStrategy(arg);
645
+ if (!loaded) {
646
+ const errMsg: Message = {
647
+ id: `msg-${Date.now()}-sys`, role: "assistant",
648
+ content: [{ type: "text", text: `Strategy "${arg}" not found.` }],
649
+ timestamp: Date.now(), status: "complete",
650
+ };
651
+ store.addMessage(errMsg);
652
+ this.renderNewMessage(errMsg);
653
+ break;
654
+ }
655
+ const code = autoFixStrategyCode(loaded.code);
656
+ store.setStrategyDraft({
657
+ name: arg, code, params: {}, explanation: "", riskConfig: null,
658
+ validationStatus: "none", validationErrors: [], phase: "generated",
659
+ });
660
+ this.codePanel.setCode(code, "pending");
661
+ this.codePanel.setStrategy(arg, "loaded");
662
+ this.codePanel.setTab("code");
663
+ if (!this.codePanel.visible) this.codePanel.show();
664
+ const okMsg: Message = {
665
+ id: `msg-${Date.now()}-sys`, role: "assistant",
666
+ content: [{ type: "text", text: `Loaded ${arg} from ${loaded.path}` }],
667
+ timestamp: Date.now(), status: "complete",
668
+ };
669
+ store.addMessage(okMsg);
670
+ this.renderNewMessage(okMsg);
671
+ break;
672
+ }
673
+ case "/env": {
674
+ const parts = arg.split(/\s+/);
675
+ const sub = parts[0];
676
+ if (!sub || sub === "list") {
677
+ const names = listEncryptedEnvNames();
678
+ this.showSystemMsg(names.length === 0
679
+ ? "No encrypted API keys stored. Use /env add <NAME> <VALUE>"
680
+ : `Stored keys:\n${names.map((n) => ` ${n}`).join("\n")}`);
681
+ } else if (sub === "add" && parts[1] && parts[2]) {
682
+ try {
683
+ setEncryptedEnv(parts[1], parts.slice(2).join(" "));
684
+ this.showSystemMsg(`Stored ${parts[1]} (encrypted)`);
685
+ } catch (e) {
686
+ this.showSystemMsg(`Failed: ${e instanceof Error ? e.message : String(e)}`);
687
+ }
688
+ } else if (sub === "remove" && parts[1]) {
689
+ removeEncryptedEnv(parts[1]);
690
+ this.showSystemMsg(`Removed ${parts[1]}`);
691
+ } else {
692
+ this.showSystemMsg("Usage: /env list | /env add <NAME> <VALUE> | /env remove <NAME>");
693
+ }
694
+ break;
695
+ }
696
+ case "/quit":
697
+ case "/exit":
698
+ this.quit();
699
+ break;
700
+ default: {
701
+ // Unknown command — show as system message
702
+ const msg: Message = {
703
+ id: `msg-${Date.now()}-sys`, role: "assistant",
704
+ content: [{ type: "text", text: `Unknown command: ${cmd}. Type /help for available commands.` }],
705
+ timestamp: Date.now(), status: "complete",
706
+ };
707
+ store.addMessage(msg);
708
+ this.renderNewMessage(msg);
709
+ }
710
+ }
711
+ this.renderer.requestRender();
712
+ }
713
+
714
+ private pendingCompactMessage: string | null = null;
715
+
716
+ private getTabStream(sessionId: string) {
717
+ let ts = this.tabStreams.get(sessionId);
718
+ if (!ts) {
719
+ ts = { processing: false, streamingMsgId: null, messageQueue: [], queuePaused: false, thresholdWarned: false };
720
+ this.tabStreams.set(sessionId, ts);
721
+ }
722
+ return ts;
723
+ }
724
+
725
+ private async processMessage(text: string, _sessionId?: string): Promise<void> {
726
+ // Capture session ID NOW — not dynamically via getter (which resolves to active tab)
727
+ const sessionId = _sessionId ?? store.get().activeSessionId;
728
+ const ts = this.getTabStream(sessionId);
729
+ ts.processing = true;
730
+
731
+ const settings = this.settingsPanel.settings;
732
+ const power = settings.modelPower as ModelPower;
733
+
734
+ const usage = this.estimateContextUsage();
735
+ const threshold = settings.compactThreshold / 100;
736
+
737
+ if (settings.autoCompact && usage.pct >= 0.90) {
738
+ this.pendingCompactMessage = text;
739
+ this.showSystemMsg(
740
+ `Context at ${Math.round(usage.pct * 100)}%. Compaction recommended to maintain quality.\n` +
741
+ `Type /compact to compact and continue, or just send your message to skip.`
742
+ );
743
+ ts.processing = false;
744
+ return;
745
+ } else if (!settings.autoCompact && usage.pct >= 0.95) {
746
+ this.showSystemMsg("Context critically full. Auto-compacting to prevent cutoff...");
747
+ this.compactContext();
748
+ } else if (usage.pct >= threshold && usage.pct < 0.90 && !ts.thresholdWarned) {
749
+ ts.thresholdWarned = true;
750
+ this.showSystemMsg(`Context at ${Math.round(usage.pct * 100)}%. Use /compact to free space.`, sessionId);
751
+ }
752
+
753
+ await this.handleChat(text, power);
754
+ ts.processing = false;
755
+ this.drainQueueFor(sessionId);
756
+ }
757
+
758
+ private drainQueueFor(sessionId: string): void {
759
+ const ts = this.getTabStream(sessionId);
760
+ if (ts.queuePaused || ts.processing || ts.messageQueue.length === 0) return;
761
+ const next = ts.messageQueue.shift()!;
762
+ this.modeBar.setQueueCount(ts.messageQueue.length);
763
+ this.processMessage(next, sessionId);
764
+ }
765
+
766
+ private drainQueue(): void {
767
+ this.drainQueueFor(store.get().activeSessionId);
768
+ }
769
+
770
+ private async initAuth(): Promise<void> {
771
+ try {
772
+ const loggedIn = await isLoggedIn();
773
+ this.authenticated = loggedIn;
774
+
775
+ const cfg = loadConfig();
776
+ const email = cfg.user_email || getUser()?.email || undefined;
777
+
778
+ if (!loggedIn) {
779
+ this.splash.setAuthStatus(false);
780
+ this.renderer.requestRender();
781
+ return;
782
+ }
783
+
784
+ // Show loading
785
+ this.splash.setLoading(`Logged in as ${email ?? "user"} -- loading chats...`);
786
+ this.renderer.requestRender();
787
+
788
+ // Load chats from Supabase (only works with live session)
789
+ const { hasLiveSession } = await import("./platform/supabase.ts");
790
+ const live = await hasLiveSession();
791
+
792
+ if (live) {
793
+ const { loadSessions, startAutoSave } = await import("./platform/session-sync.ts");
794
+ await loadSessions().catch(() => {});
795
+ startAutoSave();
796
+ }
797
+
798
+ // Start platform sync (works with API key)
799
+ const { platformSync } = await import("./platform/sync.ts");
800
+ platformSync.start(30000).catch(() => {});
801
+
802
+ // Final status
803
+ const firstTime = !cfg.has_launched;
804
+ if (firstTime) { cfg.has_launched = true; saveConfig(cfg); }
805
+
806
+ if (!live) {
807
+ this.splash.setAuthStatus(true, email);
808
+ this.showSystemMsg("Your session has expired. Type /login to sign in again.");
809
+ } else {
810
+ this.splash.setAuthStatus(true, email, firstTime);
811
+ }
812
+
813
+ this.switchToActiveTab();
814
+ } catch {
815
+ this.splash.setAuthStatus(false);
816
+ }
817
+ this.renderer.requestRender();
818
+ }
819
+
820
+ private clearCurrentTab(): void {
821
+ this.enterChatMode();
822
+ const session = store.getActiveSession();
823
+ if (!session) return;
824
+
825
+ // Cancel any active stream on this tab
826
+ if (session.isStreaming) {
827
+ const ts = this.getTabStream(session.id);
828
+ ts.streamingMsgId = null;
829
+ ts.processing = false;
830
+ store.setSessionStreaming(session.id, false);
831
+ abortChat();
832
+ this.chatRenderer.stopSpinnerAnimation();
833
+ }
834
+
835
+ // Clear messages but keep the tab
836
+ const sessions = store.get().sessions.map((s) =>
837
+ s.id === session.id ? { ...s, messages: [], updatedAt: Date.now(), strategyDraft: null } : s
838
+ );
839
+ store.update({ sessions });
840
+ for (const [id] of this.messageRenderables) this.scrollBox.remove(`msg-${id}`);
841
+ this.messageRenderables.clear();
842
+ this.updateContextMeter();
843
+ this.inputBar.focus();
844
+ this.renderer.requestRender();
845
+ }
846
+
847
+ private switchToActiveTab(): void {
848
+ // Rebuild the chat display for the new active tab
849
+ for (const [id] of this.messageRenderables) this.scrollBox.remove(`msg-${id}`);
850
+ this.messageRenderables.clear();
851
+
852
+ const session = store.getActiveSession();
853
+ if (session) {
854
+ // Sync mode bar to this tab's mode
855
+ this.modeBar.setMode(session.mode);
856
+
857
+ // Sync code panel to this tab's strategy draft
858
+ if (session.strategyDraft) {
859
+ this.codePanel.setCode(session.strategyDraft.code, session.strategyDraft.validationStatus === "none" ? "pending" : session.strategyDraft.validationStatus);
860
+ this.codePanel.setStrategy(session.strategyDraft.name, session.strategyDraft.phase);
861
+ }
862
+
863
+ // Render messages
864
+ for (const msg of session.messages) this.renderNewMessage(msg);
865
+
866
+ // Resume spinner if this tab is streaming
867
+ if (session.isStreaming) {
868
+ this.chatRenderer.startSpinnerAnimation();
869
+ } else {
870
+ this.chatRenderer.stopSpinnerAnimation();
871
+ }
872
+ }
873
+
874
+ this.tabBar.update();
875
+ this.updateContextMeter();
876
+ this.inputBar.focus();
877
+ this.renderer.requestRender();
878
+ }
879
+
880
+ private goHome(): void {
881
+ // Hide everything and show the splash screen
882
+ this.outerLayout.visible = false;
883
+ this.codePanel.hide();
884
+ this.tutorialPanel.hide();
885
+ this.sessionPanel.hide();
886
+ this.strategyPanel.hide();
887
+ this.inChatMode = false;
888
+ this.splash.show();
889
+ this.splash.focus();
890
+ this.renderer.requestRender();
891
+ }
892
+
893
+ private _sysMsgCounter = 0;
894
+ private showSystemMsg(text: string, sessionId?: string): void {
895
+ if (!this.inChatMode) {
896
+ // On splash: show on splash status line instead of entering chat mode
897
+ this.splash?.setLoading?.(text);
898
+ return;
899
+ }
900
+ const msg: Message = {
901
+ id: `msg-${Date.now()}-${++this._sysMsgCounter}-sys`, role: "assistant",
902
+ content: [{ type: "text", text }],
903
+ timestamp: Date.now(), status: "complete",
904
+ };
905
+ const sid = sessionId ?? store.get().activeSessionId;
906
+ store.addMessageTo(sid, msg);
907
+ if (sid === store.get().activeSessionId) this.renderNewMessage(msg);
908
+ }
909
+
910
+ // ── Context management ──
911
+
912
+ private estimateContextUsage(): { tokens: number; max: number; pct: number; messages: number } {
913
+ // Only count user conversation — system prompt + tool defs are invisible overhead
914
+ const MODEL_WINDOW = 200000;
915
+ const SYSTEM_OVERHEAD = 12000; // ~12k tokens for system prompt + tool schemas (hidden from user)
916
+ const userBudget = MODEL_WINDOW - SYSTEM_OVERHEAD;
917
+
918
+ const session = store.getActiveSession();
919
+ const messages = session?.messages ?? [];
920
+ let chars = 0;
921
+ for (const m of messages) {
922
+ for (const b of m.content) {
923
+ if (b.text) chars += b.text.length;
924
+ if (b.widgetData) chars += Math.min(JSON.stringify(b.widgetData).length, 2000);
925
+ if (b.toolResult) chars += Math.min(JSON.stringify(b.toolResult).length, 2000);
926
+ }
927
+ }
928
+ const tokens = Math.round(chars / 4);
929
+ return { tokens, max: userBudget, pct: userBudget > 0 ? Math.min(1, tokens / userBudget) : 0, messages: messages.length };
930
+ }
931
+
932
+ private updateBudgetMeter(): void {
933
+ // Budget is updated from server meta events — nothing to do here
934
+ }
935
+
936
+ private updateContextMeter(): void {
937
+ const usage = this.estimateContextUsage();
938
+ this.modeBar.setContextUsage(usage.tokens, usage.max);
939
+ }
940
+
941
+ private compactContext(): void {
942
+ const session = store.getActiveSession();
943
+ if (!session) return;
944
+
945
+ const messages = session.messages;
946
+ if (messages.length <= 6) return;
947
+
948
+ // Keep last 6 messages intact for recent context continuity
949
+ const keep = messages.slice(-6);
950
+ const old = messages.slice(0, -6);
951
+
952
+ // Build a rich summary preserving key context
953
+ const summaryParts: string[] = [];
954
+
955
+ // Extract key context: market slugs, strategy names, decisions
956
+ const slugs = new Set<string>();
957
+ const strategyNames = new Set<string>();
958
+ let lastTopic = "";
959
+
960
+ for (const m of old) {
961
+ for (const b of m.content) {
962
+ // Extract slugs from tool results
963
+ if (b.widgetData) {
964
+ const data = b.widgetData as any;
965
+ if (data.slug) slugs.add(data.slug);
966
+ if (data.strategy_name) strategyNames.add(data.strategy_name);
967
+ if (Array.isArray(data)) {
968
+ for (const item of data.slice(0, 5)) {
969
+ if (item.slug) slugs.add(item.slug);
970
+ }
971
+ }
972
+ if (data.markets) {
973
+ for (const mk of data.markets.slice(0, 3)) {
974
+ if (mk.slug) slugs.add(mk.slug);
975
+ }
976
+ }
977
+ }
978
+ }
979
+
980
+ if (m.role === "user") {
981
+ const text = m.content.map((b) => b.text ?? "").filter(Boolean).join(" ").slice(0, 150);
982
+ if (text) {
983
+ summaryParts.push(`User: ${text}`);
984
+ lastTopic = text.slice(0, 50);
985
+ }
986
+ } else {
987
+ const tools = m.content
988
+ .filter((b) => b.type === "tool-result" || b.type === "tool-call")
989
+ .map((b) => b.toolName).filter(Boolean);
990
+ const text = m.content.map((b) => b.text ?? "").filter(Boolean).join(" ").slice(0, 150);
991
+ if (tools.length > 0) {
992
+ summaryParts.push(`Horizon: [${tools.join(", ")}] ${text.slice(0, 80)}`);
993
+ } else if (text) {
994
+ summaryParts.push(`Horizon: ${text}`);
995
+ }
996
+ }
997
+ }
998
+
999
+ // Build the compacted context message
1000
+ const contextParts: string[] = [];
1001
+ contextParts.push(`[Compacted ${old.length} messages]`);
1002
+
1003
+ if (lastTopic) contextParts.push(`Last topic: ${lastTopic}`);
1004
+ if (slugs.size > 0) contextParts.push(`Markets discussed: ${[...slugs].slice(0, 8).join(", ")}`);
1005
+ if (strategyNames.size > 0) contextParts.push(`Strategies: ${[...strategyNames].join(", ")}`);
1006
+
1007
+ // Include the strategy draft context if one exists
1008
+ const draft = store.getActiveSession()?.strategyDraft;
1009
+ if (draft) {
1010
+ contextParts.push(`Active strategy: ${draft.name} (${draft.phase}, ${draft.validationStatus})`);
1011
+ }
1012
+
1013
+ contextParts.push("");
1014
+ contextParts.push("Conversation summary:");
1015
+ contextParts.push(...summaryParts.slice(-15)); // last 15 exchanges max
1016
+
1017
+ const summaryMsg: Message = {
1018
+ id: `msg-${Date.now()}-compact`,
1019
+ role: "assistant",
1020
+ content: [{ type: "text", text: contextParts.join("\n") }],
1021
+ timestamp: Date.now(),
1022
+ status: "complete",
1023
+ };
1024
+
1025
+ const newMessages = [summaryMsg, ...keep];
1026
+ const sessions = store.get().sessions.map((s) => {
1027
+ if (s.id !== session.id) return s;
1028
+ return { ...s, messages: newMessages, updatedAt: Date.now() };
1029
+ });
1030
+ store.update({ sessions });
1031
+
1032
+ // Rebuild display
1033
+ for (const [id] of this.messageRenderables) this.scrollBox.remove(`msg-${id}`);
1034
+ this.messageRenderables.clear();
1035
+ for (const msg of newMessages) this.renderNewMessage(msg);
1036
+
1037
+ this.updateContextMeter();
1038
+ }
1039
+
1040
+ private enterChatMode(): void {
1041
+ if (this.inChatMode) return;
1042
+ this.splash.dismiss();
1043
+ this.outerLayout.visible = true;
1044
+ this.inChatMode = true;
1045
+ this.inputBar.focus();
1046
+ this.renderer.requestRender();
1047
+ }
1048
+
1049
+ // ── Chat ──
1050
+
1051
+ private async handleChat(text: string, modelPower?: ModelPower): Promise<void> {
1052
+ // Capture the session ID at start — this stream belongs to THIS session
1053
+ // even if the user switches to another tab during generation
1054
+ const sessionId = store.get().activeSessionId;
1055
+ const ts = this.getTabStream(sessionId);
1056
+
1057
+ const userMsg = createUserMessage(text);
1058
+ store.addMessageTo(sessionId, userMsg);
1059
+ if (sessionId === store.get().activeSessionId) this.renderNewMessage(userMsg);
1060
+
1061
+ const msgId = `msg-${Date.now()}-ai`;
1062
+ const assistantMsg: Message = {
1063
+ id: msgId, role: "assistant",
1064
+ content: [{ type: "thinking" }],
1065
+ timestamp: Date.now(), status: "thinking",
1066
+ };
1067
+ store.addMessageTo(sessionId, assistantMsg);
1068
+ if (sessionId === store.get().activeSessionId) this.renderNewMessage(assistantMsg);
1069
+ store.setSessionStreaming(sessionId, true, msgId);
1070
+ ts.streamingMsgId = msgId;
1071
+ this.chatRenderer.startSpinnerAnimation();
1072
+ this.modeBar.streamStart();
1073
+
1074
+ const container = this.messageRenderables.get(msgId);
1075
+
1076
+ const rebuildContainer = (blocks: import("./chat/types.ts").ContentBlock[], status: Message["status"]) => {
1077
+ if (!container) return;
1078
+ for (const child of container.getChildren()) container.remove(child.id);
1079
+ const tmpMsg: Message = { id: msgId, role: "assistant", content: blocks, timestamp: Date.now(), status };
1080
+ const rendered = this.chatRenderer.renderMessage(tmpMsg);
1081
+ for (const child of rendered.getChildren()) container.add(child);
1082
+ this.renderer.requestRender();
1083
+ };
1084
+
1085
+ try {
1086
+ const session = store.getActiveSession();
1087
+ const messages = session?.messages ?? [];
1088
+ const allMessages = messages.filter((m) => m.role === "user" || m.role === "assistant");
1089
+
1090
+ // Context compaction: recent messages get full detail, old ones get compressed
1091
+ const RECENT_WINDOW = 8; // last 8 messages get full tool results
1092
+ const recentStart = Math.max(0, allMessages.length - RECENT_WINDOW);
1093
+
1094
+ const aiMessages = allMessages
1095
+ .map((m, idx) => {
1096
+ const isRecent = idx >= recentStart;
1097
+ return {
1098
+ role: m.role as "user" | "assistant",
1099
+ content: m.content.map((b) => {
1100
+ if (b.text) return b.text;
1101
+ if (b.type === "tool-call") return isRecent ? `[Called ${b.toolName}]` : "";
1102
+ if (b.type === "tool-result" || b.type === "tool-widget") {
1103
+ const data = b.widgetData ?? b.toolResult;
1104
+
1105
+ // Old messages: ultra-compact summary (1 line)
1106
+ if (!isRecent) {
1107
+ if (!data) return `[${b.toolName}]`;
1108
+ const obj = data as any;
1109
+ const label = obj.strategy_name ?? obj.title ?? obj.query ?? "";
1110
+ return `[${b.toolName}${label ? ": " + label : ""}]`;
1111
+ }
1112
+
1113
+ // Recent messages: full detail
1114
+ if (!data) return `[Result from ${b.toolName}]`;
1115
+ try {
1116
+ if (Array.isArray(data)) {
1117
+ const summary = data.slice(0, 10).map((item: any) => {
1118
+ const parts = [];
1119
+ if (item.slug) parts.push(`slug:${item.slug}`);
1120
+ if (item.title) parts.push(item.title);
1121
+ if (item.yesPrice ?? item.markets?.[0]?.yesPrice) parts.push(`yes:${item.markets?.[0]?.yesPrice ?? item.yesPrice}`);
1122
+ return parts.join(" | ");
1123
+ }).join("\n");
1124
+ return `[${b.toolName} results]\n${summary}`;
1125
+ }
1126
+ const obj = data as any;
1127
+ const parts = [];
1128
+ if (obj.slug) parts.push(`slug:${obj.slug}`);
1129
+ if (obj.title ?? obj.strategy_name) parts.push(obj.title ?? obj.strategy_name);
1130
+ if (obj.markets) {
1131
+ for (const mk of obj.markets.slice(0, 5)) {
1132
+ parts.push(`market: ${mk.question ?? mk.title} yes:${mk.yesPrice} no:${mk.noPrice}`);
1133
+ }
1134
+ }
1135
+ if (obj.code) parts.push(`[code: ${obj.code.split("\n").length} lines]`);
1136
+ if (obj.validation) parts.push(obj.validation.valid ? "[valid]" : `[${obj.validation.errors?.length ?? 0} errors]`);
1137
+ return `[${b.toolName} result] ${parts.join(" | ") || JSON.stringify(data).slice(0, 800)}`;
1138
+ } catch {
1139
+ return `[${b.toolName} result]`;
1140
+ }
1141
+ }
1142
+ return "";
1143
+ }).filter(Boolean).join("\n"),
1144
+ };
1145
+ })
1146
+ .filter((m) => m.content.length > 0);
1147
+
1148
+ let fullText = "";
1149
+ let currentBlocks: import("./chat/types.ts").ContentBlock[] = [];
1150
+ let hasStartedText = false;
1151
+
1152
+ // Code fence detection — fallback when structured output isn't available
1153
+ const codeFence = new CodeFenceDetector();
1154
+ const isStrategyMode = this.modeBar.current === "strategy";
1155
+ let structuredCodeActive = false; // true when strategy-code events are flowing (disables fence detection)
1156
+
1157
+ for await (const part of chat(aiMessages, this.modeBar.current, modelPower, this.settingsPanel.settings.verbosity)) {
1158
+ if (ts.streamingMsgId !== msgId) break;
1159
+
1160
+ if (part.type === "thinking") {
1161
+ currentBlocks = [{ type: "thinking" }];
1162
+ rebuildContainer(currentBlocks, "thinking");
1163
+ } else if (part.type === "tool-call") {
1164
+ currentBlocks = currentBlocks.filter((b) => b.type !== "thinking");
1165
+ currentBlocks.push({ type: "tool-call", toolName: part.toolName });
1166
+ rebuildContainer(currentBlocks, "streaming");
1167
+
1168
+ // Execute before-hooks for this tool
1169
+ const beforeEvent = TOOL_BEFORE_HOOK[part.toolName];
1170
+ if (beforeEvent) {
1171
+ const hooks = getHooksForEvent(beforeEvent);
1172
+ for (const hook of hooks) {
1173
+ this.showSystemMsg(`Hook: ${beforeEvent} -- ${hook.command}`);
1174
+ const result = await executeHook(hook);
1175
+ if (result.stdout) this.showSystemMsg(result.stdout);
1176
+ if (result.stderr) this.showSystemMsg(`stderr: ${result.stderr}`);
1177
+ }
1178
+ }
1179
+
1180
+ // Show pending state in code panel for edit/load tools
1181
+ if (CODE_PANEL_TOOLS.has(part.toolName)) {
1182
+ this.codePanel.setCode("", "pending");
1183
+ this.codePanel.setStrategy(
1184
+ store.getActiveSession()?.strategyDraft?.name ?? "Strategy",
1185
+ "editing...",
1186
+ );
1187
+ if (!this.codePanel.visible) this.codePanel.show();
1188
+ }
1189
+ } else if (part.type === "tool-result") {
1190
+ // Replace the spinning tool-call with a completed tool-result (same line, not stacked)
1191
+ const callIdx = currentBlocks.findIndex(
1192
+ (b) => b.type === "tool-call" && b.toolName === part.toolName,
1193
+ );
1194
+ if (callIdx !== -1) {
1195
+ currentBlocks[callIdx] = { type: "tool-result", toolName: part.toolName };
1196
+ } else {
1197
+ currentBlocks.push({ type: "tool-result", toolName: part.toolName });
1198
+ }
1199
+
1200
+ if (WIDGET_TOOLS.has(part.toolName)) {
1201
+ currentBlocks.push({ type: "tool-widget", toolName: part.toolName, widgetData: part.result });
1202
+ }
1203
+
1204
+ // Update code panel from edit_strategy/load_saved_strategy results
1205
+ if (CODE_PANEL_TOOLS.has(part.toolName)) {
1206
+ const result = part.result as any;
1207
+ if (result?.code) {
1208
+ this.codePanel.setCode(result.code, result.validation?.valid ? "valid" : "invalid");
1209
+ const phase = part.toolName === "load_saved_strategy" ? "generated" : "iterated";
1210
+ this.codePanel.setStrategy(result.strategy_name ?? "Strategy", phase);
1211
+ this.codePanel.setTab("code");
1212
+ if (!this.codePanel.visible) this.codePanel.show();
1213
+ }
1214
+ }
1215
+
1216
+ // Feed logs into code panel logs tab
1217
+ if (part.toolName === "run_strategy" || part.toolName === "read_logs") {
1218
+ const result = part.result as any;
1219
+ if (result?.initial_output) this.codePanel.setLogs(result.initial_output);
1220
+ if (result?.stdout) this.codePanel.setLogs(result.stdout);
1221
+ if (result?.stderr) this.codePanel.appendLog(result.stderr);
1222
+ if (result?.error) this.codePanel.appendLog(`ERROR: ${result.error}`);
1223
+ if (part.toolName === "run_strategy") {
1224
+ this.codePanel.setTab("logs");
1225
+ if (!this.codePanel.visible) this.codePanel.show();
1226
+ }
1227
+ }
1228
+
1229
+ // Feed dashboard HTML into code panel dashboard tab
1230
+ if (part.toolName === "spawn_dashboard") {
1231
+ const result = part.result as any;
1232
+ if (result?.success) {
1233
+ const html = (part as any).args?.custom_html;
1234
+ if (html) this.codePanel.setDashboardHtml(html);
1235
+ this.codePanel.setTab("dashboard");
1236
+ if (!this.codePanel.visible) this.codePanel.show();
1237
+ }
1238
+ }
1239
+
1240
+ // Execute after-hooks for this tool
1241
+ const afterEvent = TOOL_AFTER_HOOK[part.toolName];
1242
+ if (afterEvent) {
1243
+ const hooks = getHooksForEvent(afterEvent);
1244
+ for (const hook of hooks) {
1245
+ this.showSystemMsg(`Hook: ${afterEvent} -- ${hook.command}`);
1246
+ const result = await executeHook(hook);
1247
+ if (result.stdout) this.showSystemMsg(result.stdout);
1248
+ if (result.stderr) this.showSystemMsg(`stderr: ${result.stderr}`);
1249
+ }
1250
+ }
1251
+
1252
+ rebuildContainer(currentBlocks, "streaming");
1253
+ } else if (part.type === "text-delta") {
1254
+ if (!hasStartedText) {
1255
+ currentBlocks = currentBlocks.filter((b) => b.type !== "thinking");
1256
+ currentBlocks.push({ type: "markdown", text: "" });
1257
+ hasStartedText = true;
1258
+ rebuildContainer(currentBlocks, "streaming");
1259
+ }
1260
+ fullText += part.textDelta;
1261
+ this.modeBar.streamDelta(part.textDelta.length);
1262
+
1263
+ // ── Code fence detection (fallback when structured output isn't active) ──
1264
+ if (isStrategyMode && !structuredCodeActive) {
1265
+ const fenceEvent = codeFence.update(fullText);
1266
+ if (fenceEvent) {
1267
+ if (fenceEvent.event === "open") {
1268
+ this.codePanel.setCode("", "pending");
1269
+ this.codePanel.setStrategy("Strategy", "writing...");
1270
+ if (!this.codePanel.visible) this.codePanel.show();
1271
+ } else if (fenceEvent.event === "delta") {
1272
+ this.codePanel.setCode(fenceEvent.code, "pending");
1273
+ const name = extractStrategyName(fenceEvent.code);
1274
+ if (name !== "Strategy") this.codePanel.setStrategy(name, "writing...");
1275
+ } else if (fenceEvent.event === "close") {
1276
+ // Finalize IMMEDIATELY so backtest/run tools can use the draft in the same response
1277
+ if (isStrategyCode(fenceEvent.code)) {
1278
+ const draft = await finalizeStrategy(fenceEvent.code);
1279
+ this.codePanel.setCode(draft.code, draft.validationStatus === "valid" ? "valid" : "invalid");
1280
+ this.codePanel.setStrategy(draft.name, "generated");
1281
+ this.codePanel.setTab("code");
1282
+ if (!this.codePanel.visible) this.codePanel.show();
1283
+ }
1284
+ }
1285
+ }
1286
+ }
1287
+
1288
+ if (container) {
1289
+ this.chatRenderer.updateStreamingMessage(msgId, container, fullText);
1290
+ this.renderer.requestRender();
1291
+ }
1292
+ } else if (part.type === "strategy-code" as any) {
1293
+ // Structured output: code field streaming to the code panel
1294
+ structuredCodeActive = true;
1295
+ const code = (part as any).code as string;
1296
+ this.codePanel.setCode(code, "pending");
1297
+ const name = extractStrategyName(code);
1298
+ this.codePanel.setStrategy(name !== "Strategy" ? name : "Strategy", "writing...");
1299
+ if (!this.codePanel.visible) this.codePanel.show();
1300
+ } else if (part.type === "usage") {
1301
+ // Server already recorded usage — just update the UI
1302
+ this.updateBudgetMeter();
1303
+ } else if (part.type === "meta") {
1304
+ // Server tells us the tier, model, budget state
1305
+ this.modeBar.setBudgetUsage(
1306
+ part.budgetTotal > 0 ? part.budgetUsed / part.budgetTotal : 0,
1307
+ part.tier,
1308
+ );
1309
+ if (part.overflowing) {
1310
+ this.showSystemMsg(`Budget used. Switched to Fast mode until window refreshes.`);
1311
+ }
1312
+ } else if (part.type === "error") {
1313
+ currentBlocks = currentBlocks.filter((b) => b.type !== "thinking");
1314
+ currentBlocks.push({ type: "error", text: part.message });
1315
+ rebuildContainer(currentBlocks, "error");
1316
+ }
1317
+ }
1318
+
1319
+ // Finalize strategy code from structured output (fence path finalizes immediately on close)
1320
+ if (structuredCodeActive) {
1321
+ // Get the last code emitted from structured output and finalize
1322
+ const draft = store.getActiveSession()?.strategyDraft;
1323
+ const pendingCode = this.codePanel.getCode();
1324
+ if (pendingCode && isStrategyCode(pendingCode) && (!draft || draft.code !== pendingCode)) {
1325
+ const finalized = await finalizeStrategy(pendingCode);
1326
+ this.codePanel.setCode(finalized.code, finalized.validationStatus === "valid" ? "valid" : "invalid");
1327
+ this.codePanel.setStrategy(finalized.name, "generated");
1328
+ this.codePanel.setTab("code");
1329
+ if (!this.codePanel.visible) this.codePanel.show();
1330
+ }
1331
+ }
1332
+
1333
+ const finalBlocks = currentBlocks.map((b) =>
1334
+ b.type === "markdown" ? { ...b, text: fullText || "*(no response)*" } : b
1335
+ );
1336
+ store.updateMessageIn(sessionId, msgId, { content: finalBlocks, status: "complete" });
1337
+ rebuildContainer(finalBlocks, "complete");
1338
+
1339
+ this.modeBar.streamEnd(aiMessages.map((m) => m.content).join(" "), fullText);
1340
+ this.updateContextMeter();
1341
+ this.updateBudgetMeter();
1342
+ } catch (err) {
1343
+ const errText = err instanceof Error ? err.message : String(err);
1344
+ store.updateMessageIn(sessionId, msgId, { content: [{ type: "error", text: errText }], status: "error" });
1345
+ rebuildContainer([{ type: "error", text: errText }], "error");
1346
+ } finally {
1347
+ this.chatRenderer.stopSpinnerAnimation();
1348
+ store.setSessionStreaming(sessionId, false);
1349
+ ts.streamingMsgId = null;
1350
+ // Completion sound
1351
+ if (this.settingsPanel.settings.soundEnabled) {
1352
+ process.stdout.write("\x07");
1353
+ }
1354
+ this.renderer.requestRender();
1355
+ }
1356
+ }
1357
+
1358
+ private renderNewMessage(message: Message): void {
1359
+ const renderable = this.chatRenderer.renderMessage(message);
1360
+ this.messageRenderables.set(message.id, renderable);
1361
+ this.scrollBox.add(renderable);
1362
+ this.renderer.requestRender();
1363
+ }
1364
+
1365
+ // ── Panels ──
1366
+
1367
+ private togglePanel(which: "sessions" | "deployments"): void {
1368
+ this.enterChatMode();
1369
+ if (which === "sessions") {
1370
+ if (this.strategyPanel.visible) this.strategyPanel.hide();
1371
+ this.sessionPanel.toggle();
1372
+ this.keyHandler.panelActive = this.sessionPanel.visible ? "sessions" : null;
1373
+ } else {
1374
+ if (this.sessionPanel.visible) this.sessionPanel.hide();
1375
+ this.strategyPanel.toggle();
1376
+ this.keyHandler.panelActive = this.strategyPanel.visible ? "deployments" : null;
1377
+ }
1378
+ }
1379
+
1380
+ private closePanel(): void {
1381
+ this.sessionPanel.hide();
1382
+ this.strategyPanel.hide();
1383
+ this.keyHandler.panelActive = null;
1384
+ this.keyHandler.panelRenaming = false;
1385
+ this.inputBar.focus();
1386
+ }
1387
+
1388
+ private switchSession(sessionId: string): void {
1389
+ store.openTab(sessionId);
1390
+ this.keyHandler.panelActive = null;
1391
+ this.switchToActiveTab();
1392
+ }
1393
+
1394
+ private newSession(): void {
1395
+ this.enterChatMode();
1396
+ store.newSession();
1397
+ this.switchToActiveTab();
1398
+ }
1399
+
1400
+ // ── Controls ──
1401
+
1402
+ private handleQuitOrCancel(): void {
1403
+ // Close right panels first
1404
+ if (this.hooksPanel.visible) { this.hooksPanel.hide(); return; }
1405
+ if (this.settingsPanel.visible) { this.settingsPanel.hide(); return; }
1406
+ if (this.tutorialPanel.visible) { this.tutorialPanel.hide(); return; }
1407
+ if (this.sessionPanel.visible || this.strategyPanel.visible) { this.closePanel(); return; }
1408
+
1409
+ // If streaming: stop generation and abort the HTTP request
1410
+ if (store.getActiveSession()?.isStreaming) {
1411
+ const sid = store.get().activeSessionId;
1412
+ const activeTs = this.getTabStream(sid);
1413
+ activeTs.streamingMsgId = null;
1414
+ store.setSessionStreaming(sid, false);
1415
+ abortChat(); // Cancel the HTTP stream to stop wasting tokens
1416
+
1417
+ // If queue has messages: pause and wait for /resume
1418
+ if (this.messageQueue.length > 0) {
1419
+ this.queuePaused = true;
1420
+ this.modeBar.setPaused(true);
1421
+ }
1422
+ return;
1423
+ }
1424
+
1425
+ // If paused queue: clear queue
1426
+ if (this.queuePaused) {
1427
+ this.messageQueue = [];
1428
+ this.queuePaused = false;
1429
+ this.modeBar.setPaused(false);
1430
+ this.modeBar.setQueueCount(0);
1431
+ return;
1432
+ }
1433
+
1434
+ // Nothing active: quit
1435
+ this.quit();
1436
+ }
1437
+
1438
+ // ── Tick ──
1439
+
1440
+ private async quit(): Promise<void> {
1441
+ this.destroyed = true;
1442
+ // Save sessions before exit
1443
+ try {
1444
+ const { saveActiveSession, stopAutoSave } = await import("./platform/session-sync.ts");
1445
+ stopAutoSave();
1446
+ await saveActiveSession();
1447
+ } catch {}
1448
+ if (dashboard.running) dashboard.stop();
1449
+ cleanupStrategyProcesses();
1450
+ destroyTreeSitterClient();
1451
+ this.renderer.destroy();
1452
+ process.exit(0);
1453
+ }
1454
+ }