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.
- package/assets/python/highlights.scm +137 -0
- package/assets/python/tree-sitter-python.wasm +0 -0
- package/bin/horizon.js +2 -0
- package/package.json +40 -0
- package/src/ai/client.ts +369 -0
- package/src/ai/system-prompt.ts +86 -0
- package/src/app.ts +1454 -0
- package/src/chat/messages.ts +48 -0
- package/src/chat/renderer.ts +243 -0
- package/src/chat/types.ts +18 -0
- package/src/components/code-panel.ts +329 -0
- package/src/components/footer.ts +72 -0
- package/src/components/hooks-panel.ts +224 -0
- package/src/components/input-bar.ts +193 -0
- package/src/components/mode-bar.ts +245 -0
- package/src/components/session-panel.ts +294 -0
- package/src/components/settings-panel.ts +372 -0
- package/src/components/splash.ts +156 -0
- package/src/components/strategy-panel.ts +489 -0
- package/src/components/tab-bar.ts +112 -0
- package/src/components/tutorial-panel.ts +680 -0
- package/src/components/widgets/progress-bar.ts +38 -0
- package/src/components/widgets/sparkline.ts +57 -0
- package/src/hooks/executor.ts +109 -0
- package/src/index.ts +22 -0
- package/src/keys/handler.ts +198 -0
- package/src/platform/auth.ts +36 -0
- package/src/platform/client.ts +159 -0
- package/src/platform/config.ts +121 -0
- package/src/platform/session-sync.ts +158 -0
- package/src/platform/supabase.ts +376 -0
- package/src/platform/sync.ts +149 -0
- package/src/platform/tiers.ts +103 -0
- package/src/platform/tools.ts +163 -0
- package/src/platform/types.ts +86 -0
- package/src/platform/usage.ts +224 -0
- package/src/research/apis.ts +367 -0
- package/src/research/tools.ts +205 -0
- package/src/research/widgets.ts +523 -0
- package/src/state/store.ts +256 -0
- package/src/state/types.ts +109 -0
- package/src/strategy/ascii-chart.ts +74 -0
- package/src/strategy/code-stream.ts +146 -0
- package/src/strategy/dashboard.ts +140 -0
- package/src/strategy/persistence.ts +82 -0
- package/src/strategy/prompts.ts +626 -0
- package/src/strategy/sandbox.ts +137 -0
- package/src/strategy/tools.ts +764 -0
- package/src/strategy/validator.ts +216 -0
- package/src/strategy/widgets.ts +270 -0
- package/src/syntax/setup.ts +54 -0
- package/src/theme/colors.ts +107 -0
- package/src/theme/icons.ts +27 -0
- 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
|
+
}
|