kalshi-trading-bot-cli 2.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 (198) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +360 -0
  3. package/assets/kalshi-flow-light.png +0 -0
  4. package/assets/screenshot.png +0 -0
  5. package/env.example +43 -0
  6. package/kalshi-flow-light.png +0 -0
  7. package/package.json +66 -0
  8. package/src/agent/agent.ts +249 -0
  9. package/src/agent/channels.ts +53 -0
  10. package/src/agent/index.ts +29 -0
  11. package/src/agent/prompts.ts +171 -0
  12. package/src/agent/run-context.ts +23 -0
  13. package/src/agent/scratchpad.ts +465 -0
  14. package/src/agent/token-counter.ts +33 -0
  15. package/src/agent/tool-executor.ts +166 -0
  16. package/src/agent/types.ts +221 -0
  17. package/src/audit/index.ts +25 -0
  18. package/src/audit/reader.ts +43 -0
  19. package/src/audit/trail.ts +29 -0
  20. package/src/audit/types.ts +133 -0
  21. package/src/backtest/discovery.ts +170 -0
  22. package/src/backtest/fetcher.ts +247 -0
  23. package/src/backtest/metrics.ts +165 -0
  24. package/src/backtest/renderer.ts +196 -0
  25. package/src/backtest/types.ts +45 -0
  26. package/src/cli.ts +943 -0
  27. package/src/commands/alerts.ts +48 -0
  28. package/src/commands/analyze.ts +662 -0
  29. package/src/commands/backtest.ts +276 -0
  30. package/src/commands/clear-cache.ts +24 -0
  31. package/src/commands/config.ts +107 -0
  32. package/src/commands/dispatch.ts +473 -0
  33. package/src/commands/edge.ts +62 -0
  34. package/src/commands/formatters.ts +339 -0
  35. package/src/commands/help.ts +263 -0
  36. package/src/commands/helpers.ts +48 -0
  37. package/src/commands/index.ts +287 -0
  38. package/src/commands/json.ts +43 -0
  39. package/src/commands/parse-args.ts +229 -0
  40. package/src/commands/portfolio.ts +236 -0
  41. package/src/commands/review.ts +176 -0
  42. package/src/commands/scan-formatters.ts +98 -0
  43. package/src/commands/scan.ts +38 -0
  44. package/src/commands/search-edge.ts +139 -0
  45. package/src/commands/status.ts +70 -0
  46. package/src/commands/themes.ts +117 -0
  47. package/src/commands/watch.ts +295 -0
  48. package/src/components/answer-box.ts +57 -0
  49. package/src/components/approval-prompt.ts +34 -0
  50. package/src/components/browse-list.ts +134 -0
  51. package/src/components/chat-log.ts +291 -0
  52. package/src/components/custom-editor.ts +18 -0
  53. package/src/components/debug-panel.ts +52 -0
  54. package/src/components/index.ts +17 -0
  55. package/src/components/intro.ts +92 -0
  56. package/src/components/select-list.ts +155 -0
  57. package/src/components/tool-event.ts +127 -0
  58. package/src/components/user-query.ts +18 -0
  59. package/src/components/working-indicator.ts +87 -0
  60. package/src/controllers/agent-runner.ts +283 -0
  61. package/src/controllers/browse.ts +1013 -0
  62. package/src/controllers/index.ts +7 -0
  63. package/src/controllers/input-history.ts +76 -0
  64. package/src/controllers/model-selection.ts +244 -0
  65. package/src/db/alerts.ts +77 -0
  66. package/src/db/edge.ts +105 -0
  67. package/src/db/event-index.ts +323 -0
  68. package/src/db/events.ts +41 -0
  69. package/src/db/index.ts +60 -0
  70. package/src/db/octagon-cache.ts +118 -0
  71. package/src/db/positions.ts +71 -0
  72. package/src/db/risk.ts +51 -0
  73. package/src/db/schema.ts +227 -0
  74. package/src/db/themes.ts +34 -0
  75. package/src/db/trades.ts +50 -0
  76. package/src/eval/brier.ts +90 -0
  77. package/src/eval/index.ts +4 -0
  78. package/src/eval/performance.ts +87 -0
  79. package/src/gateway/access-control.ts +253 -0
  80. package/src/gateway/agent-runner.ts +75 -0
  81. package/src/gateway/alerts/formatter.ts +90 -0
  82. package/src/gateway/alerts/index.ts +4 -0
  83. package/src/gateway/alerts/router.ts +32 -0
  84. package/src/gateway/alerts/terminal.ts +16 -0
  85. package/src/gateway/alerts/types.ts +13 -0
  86. package/src/gateway/channels/index.ts +9 -0
  87. package/src/gateway/channels/manager.ts +153 -0
  88. package/src/gateway/channels/types.ts +48 -0
  89. package/src/gateway/channels/whatsapp/README.md +234 -0
  90. package/src/gateway/channels/whatsapp/auth-store.ts +140 -0
  91. package/src/gateway/channels/whatsapp/dedupe.ts +60 -0
  92. package/src/gateway/channels/whatsapp/error.ts +122 -0
  93. package/src/gateway/channels/whatsapp/inbound.ts +326 -0
  94. package/src/gateway/channels/whatsapp/index.ts +5 -0
  95. package/src/gateway/channels/whatsapp/lid.ts +56 -0
  96. package/src/gateway/channels/whatsapp/logger.ts +25 -0
  97. package/src/gateway/channels/whatsapp/login.ts +94 -0
  98. package/src/gateway/channels/whatsapp/outbound.ts +119 -0
  99. package/src/gateway/channels/whatsapp/plugin.ts +54 -0
  100. package/src/gateway/channels/whatsapp/reconnect.ts +40 -0
  101. package/src/gateway/channels/whatsapp/runtime.ts +122 -0
  102. package/src/gateway/channels/whatsapp/session.ts +89 -0
  103. package/src/gateway/channels/whatsapp/types.ts +32 -0
  104. package/src/gateway/commands/handler.ts +64 -0
  105. package/src/gateway/commands/index.ts +7 -0
  106. package/src/gateway/commands/parser.ts +29 -0
  107. package/src/gateway/commands/wa-formatters.ts +92 -0
  108. package/src/gateway/config.ts +244 -0
  109. package/src/gateway/extension-points.ts +17 -0
  110. package/src/gateway/gateway.ts +301 -0
  111. package/src/gateway/group/history-buffer.ts +75 -0
  112. package/src/gateway/group/index.ts +8 -0
  113. package/src/gateway/group/member-tracker.ts +60 -0
  114. package/src/gateway/group/mention-detection.ts +42 -0
  115. package/src/gateway/heartbeat/index.ts +8 -0
  116. package/src/gateway/heartbeat/prompt.ts +73 -0
  117. package/src/gateway/heartbeat/runner.ts +200 -0
  118. package/src/gateway/heartbeat/suppression.ts +74 -0
  119. package/src/gateway/index.ts +138 -0
  120. package/src/gateway/routing/resolve-route.ts +119 -0
  121. package/src/gateway/sessions/store.ts +65 -0
  122. package/src/gateway/types.ts +11 -0
  123. package/src/gateway/utils.ts +82 -0
  124. package/src/index.tsx +30 -0
  125. package/src/model/llm.ts +247 -0
  126. package/src/providers.ts +94 -0
  127. package/src/risk/circuit-breaker.ts +113 -0
  128. package/src/risk/correlation.ts +40 -0
  129. package/src/risk/gate.ts +125 -0
  130. package/src/risk/index.ts +10 -0
  131. package/src/risk/kelly.ts +230 -0
  132. package/src/scan/alerter.ts +64 -0
  133. package/src/scan/edge-computer.ts +164 -0
  134. package/src/scan/invoker.ts +199 -0
  135. package/src/scan/loop.ts +184 -0
  136. package/src/scan/octagon-client.ts +627 -0
  137. package/src/scan/octagon-events-api.ts +105 -0
  138. package/src/scan/octagon-prefetch.ts +172 -0
  139. package/src/scan/theme-resolver.ts +179 -0
  140. package/src/scan/types.ts +62 -0
  141. package/src/scan/watchdog.ts +126 -0
  142. package/src/setup/wizard.ts +659 -0
  143. package/src/theme.ts +67 -0
  144. package/src/tools/fetch/cache.ts +95 -0
  145. package/src/tools/fetch/external-content.ts +200 -0
  146. package/src/tools/fetch/index.ts +1 -0
  147. package/src/tools/fetch/web-fetch-utils.ts +122 -0
  148. package/src/tools/fetch/web-fetch.ts +419 -0
  149. package/src/tools/index.ts +10 -0
  150. package/src/tools/kalshi/api.ts +251 -0
  151. package/src/tools/kalshi/dlq.ts +35 -0
  152. package/src/tools/kalshi/events.ts +84 -0
  153. package/src/tools/kalshi/exchange.ts +24 -0
  154. package/src/tools/kalshi/historical.ts +89 -0
  155. package/src/tools/kalshi/index.ts +11 -0
  156. package/src/tools/kalshi/kalshi-search.ts +437 -0
  157. package/src/tools/kalshi/kalshi-trade.ts +102 -0
  158. package/src/tools/kalshi/markets.ts +76 -0
  159. package/src/tools/kalshi/portfolio.ts +100 -0
  160. package/src/tools/kalshi/search-index.ts +198 -0
  161. package/src/tools/kalshi/series.ts +16 -0
  162. package/src/tools/kalshi/trading.ts +115 -0
  163. package/src/tools/kalshi/types.ts +199 -0
  164. package/src/tools/registry.ts +160 -0
  165. package/src/tools/search/index.ts +25 -0
  166. package/src/tools/search/tavily.ts +35 -0
  167. package/src/tools/types.ts +53 -0
  168. package/src/tools/v2/edge-query.ts +135 -0
  169. package/src/tools/v2/octagon-report.ts +112 -0
  170. package/src/tools/v2/portfolio-query.ts +79 -0
  171. package/src/tools/v2/portfolio-review.ts +59 -0
  172. package/src/tools/v2/risk-status.ts +94 -0
  173. package/src/tools/v2/scan.ts +78 -0
  174. package/src/types/qrcode-terminal.d.ts +7 -0
  175. package/src/types/whiskeysockets-baileys.d.ts +41 -0
  176. package/src/types.ts +22 -0
  177. package/src/utils/ai-message.ts +26 -0
  178. package/src/utils/bot-config.ts +219 -0
  179. package/src/utils/cache.ts +195 -0
  180. package/src/utils/config.ts +113 -0
  181. package/src/utils/env.ts +111 -0
  182. package/src/utils/errors.ts +313 -0
  183. package/src/utils/history-context.ts +32 -0
  184. package/src/utils/in-memory-chat-history.ts +268 -0
  185. package/src/utils/index.ts +28 -0
  186. package/src/utils/input-key-handlers.ts +64 -0
  187. package/src/utils/logger.ts +67 -0
  188. package/src/utils/long-term-chat-history.ts +138 -0
  189. package/src/utils/markdown-table.ts +227 -0
  190. package/src/utils/model.ts +70 -0
  191. package/src/utils/ollama.ts +37 -0
  192. package/src/utils/paths.ts +12 -0
  193. package/src/utils/progress-channel.ts +84 -0
  194. package/src/utils/telemetry.ts +103 -0
  195. package/src/utils/text-navigation.ts +81 -0
  196. package/src/utils/thinking-verbs.ts +18 -0
  197. package/src/utils/tokens.ts +36 -0
  198. package/src/utils/tool-description.ts +61 -0
package/src/cli.ts ADDED
@@ -0,0 +1,943 @@
1
+ import { Container, ProcessTerminal, Spacer, Text, TUI, CombinedAutocompleteProvider } from '@mariozechner/pi-tui';
2
+ import type { SlashCommand, AutocompleteItem } from '@mariozechner/pi-tui';
3
+ import type {
4
+ ApprovalDecision,
5
+ ToolEndEvent,
6
+ ToolErrorEvent,
7
+ ToolStartEvent,
8
+ } from './agent/index.js';
9
+ import { checkApiKeyExists, getApiKeyNameForProvider, getProviderDisplayName } from './utils/env.js';
10
+ import { logger } from './utils/logger.js';
11
+ import {
12
+ AgentRunnerController,
13
+ BrowseController,
14
+ InputHistoryController,
15
+ ModelSelectionController,
16
+ } from './controllers/index.js';
17
+ import {
18
+ ApiKeyInputComponent,
19
+ ApprovalPromptComponent,
20
+ ChatLogComponent,
21
+ CustomEditor,
22
+ DebugPanelComponent,
23
+ IntroComponent,
24
+ WorkingIndicatorComponent,
25
+ createApiKeyConfirmSelector,
26
+ createBrowseActionSelector,
27
+ createBrowseMarketSelector,
28
+ updateBrowseMarketSelector,
29
+ createModelSelector,
30
+ createProviderSelector,
31
+ } from './components/index.js';
32
+ import { editorTheme, theme } from './theme.js';
33
+ import { handleSlashCommand, executePendingTrade } from './commands/index.js';
34
+ import type { CommandResult } from './commands/index.js';
35
+ import { formatResponse } from './utils/markdown-table.js';
36
+ import { ensureIndex, onIndexProgress, getRefreshPromise } from './tools/kalshi/search-index.js';
37
+ import { callKalshiApi } from './tools/kalshi/api.js';
38
+ import type { KalshiMarket } from './tools/kalshi/types.js';
39
+ import { SetupWizardController } from './setup/wizard.js';
40
+ import { trackEvent } from './utils/telemetry.js';
41
+
42
+ function truncateAtWord(str: string, maxLength: number): string {
43
+ if (str.length <= maxLength) {
44
+ return str;
45
+ }
46
+ const lastSpace = str.lastIndexOf(' ', maxLength);
47
+ if (lastSpace > maxLength * 0.5) {
48
+ return `${str.slice(0, lastSpace)}...`;
49
+ }
50
+ return `${str.slice(0, maxLength)}...`;
51
+ }
52
+
53
+ function summarizeToolResult(tool: string, args: Record<string, unknown>, result: string): string {
54
+ try {
55
+ const parsed = JSON.parse(result);
56
+ if (parsed.data) {
57
+ if (Array.isArray(parsed.data)) {
58
+ return `Received ${parsed.data.length} items`;
59
+ }
60
+ if (typeof parsed.data === 'object') {
61
+ const keys = Object.keys(parsed.data).filter((key) => !key.startsWith('_'));
62
+ if (tool === 'kalshi_search') {
63
+ return keys.length === 1 ? 'Called 1 data source' : `Called ${keys.length} data sources`;
64
+ }
65
+ if (tool === 'kalshi_trade') {
66
+ return 'Trade executed';
67
+ }
68
+ if (tool === 'portfolio_overview') {
69
+ return 'Fetched portfolio';
70
+ }
71
+ if (tool === 'exchange_status') {
72
+ return 'Fetched exchange status';
73
+ }
74
+ if (tool === 'web_search') {
75
+ return 'Did 1 search';
76
+ }
77
+ return `Received ${keys.length} fields`;
78
+ }
79
+ }
80
+ } catch {
81
+ return truncateAtWord(result, 50);
82
+ }
83
+ return 'Received data';
84
+ }
85
+
86
+ function createScreen(
87
+ title: string,
88
+ description: string,
89
+ body: any,
90
+ footer?: string,
91
+ ): Container {
92
+ const container = new Container();
93
+ if (title) {
94
+ container.addChild(new Text(theme.bold(theme.primary(title)), 0, 0));
95
+ }
96
+ if (description) {
97
+ container.addChild(new Text(theme.muted(description), 0, 0));
98
+ }
99
+ container.addChild(new Spacer(1));
100
+ container.addChild(body);
101
+ if (footer) {
102
+ container.addChild(new Spacer(1));
103
+ container.addChild(new Text(theme.muted(footer), 0, 0));
104
+ }
105
+ return container;
106
+ }
107
+
108
+ function renderHistory(chatLog: ChatLogComponent, history: AgentRunnerController['history']) {
109
+ chatLog.clearAll();
110
+ for (const item of history) {
111
+ chatLog.addQuery(item.query);
112
+ chatLog.resetToolGrouping();
113
+
114
+ if (item.status === 'interrupted') {
115
+ chatLog.addInterrupted();
116
+ }
117
+
118
+ for (const display of item.events) {
119
+ const event = display.event;
120
+ if (event.type === 'thinking') {
121
+ const message = event.message.trim();
122
+ if (message) {
123
+ chatLog.addChild(
124
+ new Text(message.length > 200 ? `${message.slice(0, 200)}...` : message, 0, 0),
125
+ );
126
+ }
127
+ continue;
128
+ }
129
+
130
+ if (event.type === 'tool_start') {
131
+ const toolStart = event as ToolStartEvent;
132
+ const component = chatLog.startTool(display.id, toolStart.tool, toolStart.args);
133
+ if (display.completed && display.endEvent?.type === 'tool_end') {
134
+ const done = display.endEvent as ToolEndEvent;
135
+ component.setComplete(
136
+ summarizeToolResult(done.tool, toolStart.args, done.result),
137
+ done.duration,
138
+ );
139
+ } else if (display.completed && display.endEvent?.type === 'tool_error') {
140
+ const toolError = display.endEvent as ToolErrorEvent;
141
+ component.setError(toolError.error);
142
+ } else if (display.progressMessage) {
143
+ component.setActive(display.progressMessage);
144
+ }
145
+ continue;
146
+ }
147
+
148
+ if (event.type === 'tool_approval') {
149
+ const approval = chatLog.startTool(display.id, event.tool, event.args);
150
+ approval.setApproval(event.approved);
151
+ continue;
152
+ }
153
+
154
+ if (event.type === 'tool_denied') {
155
+ const denied = chatLog.startTool(display.id, event.tool, event.args);
156
+ const path = (event.args.path as string) ?? '';
157
+ denied.setDenied(path, event.tool);
158
+ continue;
159
+ }
160
+
161
+ if (event.type === 'tool_limit') {
162
+ continue;
163
+ }
164
+
165
+ if (event.type === 'context_cleared') {
166
+ chatLog.addContextCleared(event.clearedCount, event.keptCount);
167
+ }
168
+ }
169
+
170
+ if (item.answer) {
171
+ chatLog.finalizeAnswer(item.answer);
172
+ }
173
+ if (item.status === 'complete') {
174
+ chatLog.addPerformanceStats(item.duration ?? 0, item.tokenUsage, item.tokensPerSecond);
175
+ }
176
+ }
177
+ }
178
+
179
+ export async function runCli(options?: { forceSetup?: boolean }) {
180
+ const tui = new TUI(new ProcessTerminal());
181
+ const root = new Container();
182
+ const chatLog = new ChatLogComponent(tui);
183
+ const inputHistory = new InputHistoryController(() => tui.requestRender());
184
+ let lastError: string | null = null;
185
+ let pendingTrade: CommandResult['pendingTrade'] | null = null;
186
+
187
+ const onError = (message: string) => {
188
+ lastError = message;
189
+ logger.error(message);
190
+ tui.requestRender();
191
+ };
192
+
193
+ const modelSelection = new ModelSelectionController(onError, () => {
194
+ intro.setModel(modelSelection.model);
195
+ renderSelectionOverlay();
196
+ tui.requestRender();
197
+ });
198
+
199
+ const browseController = new BrowseController(onError, () => {
200
+ renderSelectionOverlay();
201
+ tui.requestRender();
202
+ });
203
+
204
+ // Slash command autocomplete — start with top-level themes, load subcategories in background
205
+ const baseThemes = ['top50', 'climate', 'companies', 'crypto', 'economics', 'elections', 'entertainment', 'financials', 'health', 'mentions', 'politics', 'science', 'social', 'sports', 'transportation', 'world'];
206
+ let allThemes = baseThemes.map((t) => ({ value: t, label: t }));
207
+
208
+ // Pre-warm the event index on startup (non-blocking, only if credentials exist)
209
+ let indexStatusMessage: string | null = null;
210
+ const hasKalshiCreds = checkApiKeyExists('KALSHI_API_KEY') &&
211
+ (checkApiKeyExists('KALSHI_PRIVATE_KEY_FILE') || checkApiKeyExists('KALSHI_PRIVATE_KEY'));
212
+ const unsubIndexProgress = onIndexProgress((info) => {
213
+ if (info.phase === 'fetching_events') {
214
+ indexStatusMessage = `Indexing markets... ${info.fetchedItems} fetched (page ${info.page}/${info.maxPages})`;
215
+ } else if (info.detail) {
216
+ indexStatusMessage = info.detail;
217
+ }
218
+ tui.requestRender();
219
+ });
220
+ const initPostCredentials = () => {
221
+ void ensureIndex();
222
+ const refreshPromise = getRefreshPromise();
223
+ if (refreshPromise) {
224
+ void refreshPromise.catch((err) => {
225
+ console.warn(`[warn] Background index refresh failed: ${err instanceof Error ? err.message : String(err)}`);
226
+ }).finally(() => {
227
+ unsubIndexProgress();
228
+ indexStatusMessage = null;
229
+ tui.requestRender();
230
+ });
231
+ }
232
+ // Load subcategories in background
233
+ void (async () => {
234
+ try {
235
+ const { fetchSubcategories, CATEGORY_MAP } = await import('./scan/theme-resolver.js');
236
+ const labelToKey: Record<string, string> = {};
237
+ for (const [key, label] of Object.entries(CATEGORY_MAP)) {
238
+ labelToKey[label] = key;
239
+ }
240
+ const subcats = await fetchSubcategories();
241
+ const subEntries: Array<{ value: string; label: string }> = [];
242
+ for (const [catLabel, tags] of Object.entries(subcats)) {
243
+ const catKey = labelToKey[catLabel];
244
+ if (!catKey) continue;
245
+ for (const tag of tags) {
246
+ const kebab = tag.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
247
+ const value = `${catKey}:${kebab}`;
248
+ subEntries.push({ value, label: value });
249
+ }
250
+ }
251
+ allThemes = [
252
+ ...baseThemes.map((t) => ({ value: t, label: t })),
253
+ ...subEntries,
254
+ ];
255
+ } catch {
256
+ // Subcategory loading failed — keep base themes only
257
+ }
258
+ })();
259
+ };
260
+
261
+ if (hasKalshiCreds) initPostCredentials();
262
+
263
+
264
+ const agentRunner = new AgentRunnerController(
265
+ { model: modelSelection.model, modelProvider: modelSelection.provider, maxIterations: 10 },
266
+ modelSelection.inMemoryChatHistory,
267
+ () => {
268
+ renderHistory(chatLog, agentRunner.history);
269
+ workingIndicator.setState(agentRunner.workingState);
270
+ renderSelectionOverlay();
271
+ tui.requestRender();
272
+ },
273
+ );
274
+
275
+ const intro = new IntroComponent(modelSelection.model);
276
+ const errorText = new Text('', 0, 0);
277
+ const workingIndicator = new WorkingIndicatorComponent(tui);
278
+ const editor = new CustomEditor(tui, editorTheme);
279
+ const debugPanel = new DebugPanelComponent(8, true);
280
+
281
+ // Setup wizard for first-run or /setup command
282
+ const setupWizard = new SetupWizardController(
283
+ () => {
284
+ renderSelectionOverlay();
285
+ tui.requestRender();
286
+ },
287
+ () => {
288
+ // On complete: trigger index + subcategory loading that was skipped at startup
289
+ initPostCredentials();
290
+ renderSelectionOverlay();
291
+ tui.requestRender();
292
+ },
293
+ );
294
+
295
+ const themeCompletions = (typed: string) => {
296
+ if (!typed) return allThemes;
297
+ const lower = typed.toLowerCase();
298
+ return allThemes.filter((t) => t.value.toLowerCase().startsWith(lower));
299
+ };
300
+
301
+ const usageHint = (label: string, description: string) =>
302
+ (prefix: string): AutocompleteItem[] | null =>
303
+ prefix ? null : [{ value: '', label, description }];
304
+
305
+ const portfolioSubcommands = (typed: string): AutocompleteItem[] | null => {
306
+ const subs = [
307
+ { value: 'positions', label: 'positions', description: 'Open positions with P&L' },
308
+ { value: 'orders', label: 'orders', description: 'Resting orders' },
309
+ { value: 'balance', label: 'balance', description: 'Account balance' },
310
+ { value: 'status', label: 'status', description: 'Exchange status' },
311
+ ];
312
+ if (!typed) return subs;
313
+ const lower = typed.toLowerCase();
314
+ return subs.filter((s) => s.value.startsWith(lower));
315
+ };
316
+
317
+ const searchSubcommands = (typed: string): AutocompleteItem[] | null => {
318
+ const edgeItem = { value: 'edge', label: 'edge', description: 'Scan all markets by model edge (default: ≥5pp, top 20)' };
319
+ const edgeOptions = [
320
+ { value: 'edge --min-edge 30', label: 'edge --min-edge 30', description: 'Markets with ≥30pp edge' },
321
+ { value: 'edge --min-edge 10', label: 'edge --min-edge 10', description: 'Markets with ≥10pp edge' },
322
+ { value: 'edge --category crypto', label: 'edge --category crypto', description: 'Crypto markets by edge' },
323
+ { value: 'edge --limit 50', label: 'edge --limit 50', description: 'Top 50 results' },
324
+ ];
325
+ const themesItem = { value: 'themes', label: 'themes', description: 'List all available themes' };
326
+ if (!typed) return [edgeItem, themesItem, ...allThemes];
327
+ const lower = typed.toLowerCase();
328
+ if (lower.startsWith('edge')) {
329
+ const afterEdge = lower.slice(4).trimStart();
330
+ if (!afterEdge) return [edgeItem, ...edgeOptions];
331
+ return edgeOptions.filter(o => o.value.toLowerCase().includes(afterEdge));
332
+ }
333
+ const results = [edgeItem, themesItem, ...allThemes].filter((t) => t.value.toLowerCase().startsWith(lower));
334
+ return results.length > 0 ? results : null;
335
+ };
336
+
337
+ const watchSubcommands = (typed: string): AutocompleteItem[] | null => {
338
+ const themeFlag = { value: '--theme', label: '--theme', description: 'Continuous theme scan (e.g. --theme crypto)' };
339
+ if (!typed) return [{ value: '', label: '<ticker>', description: 'Live price/orderbook feed (e.g. KXBTC-26MAR14-T50049)' }, themeFlag];
340
+ const lower = typed.toLowerCase();
341
+ // After --theme, complete with theme names
342
+ if (lower.startsWith('--theme ')) {
343
+ const themeTyped = typed.slice('--theme '.length);
344
+ const themeLower = themeTyped.toLowerCase();
345
+ const results = allThemes
346
+ .map((t) => ({ value: `--theme ${t.value}`, label: t.label, description: `Scan theme: ${t.value}` }))
347
+ .filter((t) => !themeLower || t.label.toLowerCase().startsWith(themeLower));
348
+ return results.length > 0 ? results : null;
349
+ }
350
+ if ('--theme'.startsWith(lower)) return [themeFlag];
351
+ return null;
352
+ };
353
+
354
+ const helpTopicCompletions = (typed: string): AutocompleteItem[] | null => {
355
+ const topics = [
356
+ { value: 'search', label: 'search', description: 'Discovery commands' },
357
+ { value: 'portfolio', label: 'portfolio', description: 'Account state' },
358
+ { value: 'analyze', label: 'analyze', description: 'Market analysis' },
359
+ { value: 'watch', label: 'watch', description: 'Live monitoring' },
360
+ { value: 'buy', label: 'buy', description: 'Buy contracts' },
361
+ { value: 'sell', label: 'sell', description: 'Sell contracts' },
362
+ { value: 'cancel', label: 'cancel', description: 'Cancel an order' },
363
+ { value: 'backtest', label: 'backtest', description: 'Model accuracy & edge scanner' },
364
+ { value: 'help', label: 'help', description: 'Show help' },
365
+ { value: 'setup', label: 'setup', description: 'Re-run setup wizard' },
366
+ ];
367
+ if (!typed) return topics;
368
+ const lower = typed.toLowerCase();
369
+ return topics.filter((t) => t.value.startsWith(lower));
370
+ };
371
+
372
+ const slashCommands: SlashCommand[] = [
373
+ // Core 6 commands
374
+ { name: 'search', description: 'Search events by theme, ticker, or free-text (use "themes" to list)', getArgumentCompletions: searchSubcommands },
375
+ { name: 'portfolio', description: 'Portfolio overview, positions, orders, balance, status', getArgumentCompletions: portfolioSubcommands },
376
+ { name: 'analyze', description: 'Full market analysis: edge, research, Kelly sizing', getArgumentCompletions: usageHint('<ticker>', 'e.g. KXBTC-26MAR14-T50049') },
377
+ { name: 'watch', description: 'Live monitoring: ticker feed or continuous theme scan', getArgumentCompletions: watchSubcommands },
378
+ { name: 'buy', description: 'Buy contracts (defaults to YES side)', getArgumentCompletions: usageHint('<ticker> <count> [price] [yes|no]', 'e.g. KXBTC-26MAR14-T50049 10 56') },
379
+ { name: 'sell', description: 'Sell contracts (defaults to YES side)', getArgumentCompletions: usageHint('<ticker> <count> [price] [yes|no]', 'e.g. KXBTC-26MAR14-T50049 10 56') },
380
+ { name: 'cancel', description: 'Cancel a resting order', getArgumentCompletions: usageHint('<order_id>', 'the order UUID') },
381
+ // Analysis
382
+ { name: 'backtest', description: 'Model accuracy scorecard + live edge scanner', getArgumentCompletions: (typed: string): AutocompleteItem[] | null => {
383
+ const opts = [
384
+ { value: '--days 15', label: '--days 15', description: '15-day lookback (default)' },
385
+ { value: '--days 7', label: '--days 7', description: '7-day lookback' },
386
+ { value: '--days 30', label: '--days 30', description: '30-day lookback' },
387
+ { value: '--resolved', label: '--resolved', description: 'Resolved markets only' },
388
+ { value: '--unresolved', label: '--unresolved', description: 'Unresolved markets only' },
389
+ { value: '--category crypto', label: '--category crypto', description: 'Filter by category' },
390
+ { value: '--min-edge 10', label: '--min-edge 10', description: '10pp edge threshold' },
391
+ { value: '--export results.csv', label: '--export results.csv', description: 'Export CSV' },
392
+ ];
393
+ if (!typed) return opts;
394
+ const lower = typed.toLowerCase();
395
+ return opts.filter(o => o.value.toLowerCase().includes(lower));
396
+ }},
397
+ // Utility
398
+ { name: 'help', description: 'Show help (/help <command> for details)', getArgumentCompletions: helpTopicCompletions },
399
+ { name: 'model', description: 'Change LLM model/provider', getArgumentCompletions: usageHint('<provider:model>', 'e.g. anthropic:sonnet') },
400
+ { name: 'setup', description: 'Re-run the setup wizard to configure API keys' },
401
+ { name: 'quit', description: 'Quit CLI session' },
402
+ ];
403
+ editor.setAutocompleteProvider(new CombinedAutocompleteProvider(slashCommands));
404
+
405
+ tui.addChild(root);
406
+
407
+ const refreshError = () => {
408
+ const message = lastError ?? agentRunner.error;
409
+ errorText.setText(message ? theme.error(`Error: ${message}`) : '');
410
+ };
411
+
412
+ const handleSubmit = async (query: string) => {
413
+ // Gracefully quit the CLI on exit or quit commands with or without leading slash
414
+ if (query.match(/^\/?(quit|exit)$/i)) {
415
+ tui.stop();
416
+ process.exit(0);
417
+ return;
418
+ }
419
+
420
+ // While wizard is active, Enter progresses non-component states
421
+ if (setupWizard.isActive) {
422
+ setupWizard.handleInput('\r');
423
+ return;
424
+ }
425
+
426
+ if (modelSelection.isInSelectionFlow() || browseController.isInBrowseFlow() || agentRunner.pendingApproval || agentRunner.isProcessing) {
427
+ return;
428
+ }
429
+
430
+ if (query === '/model') {
431
+ modelSelection.startSelection();
432
+ return;
433
+ }
434
+
435
+ if (query === '/setup') {
436
+ setupWizard.start();
437
+ return;
438
+ }
439
+
440
+ if (query.startsWith('/search')) {
441
+ const themeArg = query.slice('/search'.length).trim() || 'top50';
442
+ // /search edge → edge scanner (inline, no browse flow)
443
+ if (themeArg.startsWith('edge')) {
444
+ chatLog.addQuery(query);
445
+ chatLog.resetToolGrouping();
446
+ try {
447
+ workingIndicator.setState({ status: 'thinking' });
448
+ tui.requestRender();
449
+ // Parse edge-specific flags from the rest of the args
450
+ const edgeArgs = themeArg.slice('edge'.length).trim().split(/\s+/).filter(Boolean);
451
+ let minEdgePp = 5;
452
+ let edgeLimit = 20;
453
+ let edgeCategory: string | undefined;
454
+ for (let i = 0; i < edgeArgs.length; i++) {
455
+ if (edgeArgs[i] === '--min-edge') { const v = Number(edgeArgs[++i]?.replace('%', '')); if (Number.isFinite(v)) minEdgePp = v; }
456
+ else if (edgeArgs[i] === '--limit') { const v = Number(edgeArgs[++i]); if (Number.isFinite(v) && v > 0) edgeLimit = v; }
457
+ else if (edgeArgs[i] === '--category' || edgeArgs[i] === '--theme') { edgeCategory = edgeArgs[++i]; }
458
+ }
459
+ const { scanEdges, formatEdgeScanHuman } = await import('./commands/search-edge.js');
460
+ const { getDb } = await import('./db/index.js');
461
+ const result = scanEdges(getDb(), { minEdgePp, limit: edgeLimit, category: edgeCategory });
462
+ workingIndicator.setState({ status: 'idle' });
463
+ chatLog.finalizeAnswer(formatResponse(formatEdgeScanHuman(result, minEdgePp)));
464
+ tui.requestRender();
465
+ } catch (err) {
466
+ workingIndicator.setState({ status: 'idle' });
467
+ chatLog.finalizeAnswer(`Error: ${err instanceof Error ? err.message : String(err)}`);
468
+ tui.requestRender();
469
+ }
470
+ return;
471
+ }
472
+ // /search themes → inline themes list (no browse flow)
473
+ if (themeArg === 'themes') {
474
+ // Handled as slash command in handleSlashCommand via 'themes' case
475
+ chatLog.addQuery(query);
476
+ chatLog.resetToolGrouping();
477
+ try {
478
+ workingIndicator.setState({ status: 'thinking' });
479
+ tui.requestRender();
480
+ const cmdResult = await handleSlashCommand('/themes');
481
+ workingIndicator.setState({ status: 'idle' });
482
+ if (cmdResult) {
483
+ chatLog.finalizeAnswer(formatResponse(cmdResult.output));
484
+ tui.requestRender();
485
+ }
486
+ } catch (err) {
487
+ workingIndicator.setState({ status: 'idle' });
488
+ chatLog.finalizeAnswer(`Error: ${err instanceof Error ? err.message : String(err)}`);
489
+ tui.requestRender();
490
+ }
491
+ return;
492
+ }
493
+ browseController.startBrowse(themeArg);
494
+ return;
495
+ }
496
+
497
+ // Handle pending trade confirmation (yes/no)
498
+ if (pendingTrade) {
499
+ const answer = query.trim().toLowerCase();
500
+ if (answer === 'y' || answer === 'yes') {
501
+ chatLog.addQuery(query);
502
+ chatLog.resetToolGrouping();
503
+ try {
504
+ const result = await executePendingTrade(pendingTrade);
505
+ chatLog.finalizeAnswer(result);
506
+ } catch (err) {
507
+ chatLog.finalizeAnswer(`Error: ${err instanceof Error ? err.message : String(err)}`);
508
+ }
509
+ pendingTrade = null;
510
+ tui.requestRender();
511
+ return;
512
+ } else {
513
+ trackEvent('trade_rejected', { action: pendingTrade.action, side: pendingTrade.side });
514
+ chatLog.addQuery(query);
515
+ chatLog.resetToolGrouping();
516
+ chatLog.finalizeAnswer('Order canceled.');
517
+ pendingTrade = null;
518
+ tui.requestRender();
519
+ return;
520
+ }
521
+ }
522
+
523
+ // Handle slash commands
524
+ if (query.startsWith('/')) {
525
+ chatLog.addQuery(query);
526
+ chatLog.resetToolGrouping();
527
+ try {
528
+ // Show loading state while slash command runs
529
+ workingIndicator.setState({ status: 'thinking' });
530
+ tui.requestRender();
531
+ const cmdResult = await handleSlashCommand(query);
532
+ if (cmdResult !== null) {
533
+ const formatted = formatResponse(cmdResult.output);
534
+ const answerBox = chatLog.finalizeAnswer(formatted);
535
+ tui.requestRender();
536
+
537
+ // If the command has an async follow-up (e.g., backtest), animate the spinner while it runs
538
+ if (cmdResult.asyncFollowUp) {
539
+ answerBox.startSpinner(tui);
540
+ try {
541
+ const followUp = await cmdResult.asyncFollowUp();
542
+ answerBox.stopSpinner();
543
+ chatLog.finalizeAnswer(formatResponse(followUp));
544
+ } catch (err) {
545
+ answerBox.stopSpinner();
546
+ chatLog.finalizeAnswer(`Error: ${err instanceof Error ? err.message : String(err)}`);
547
+ }
548
+ }
549
+
550
+ workingIndicator.setState({ status: 'idle' });
551
+ if (cmdResult.pendingTrade) {
552
+ pendingTrade = cmdResult.pendingTrade;
553
+ chatLog.finalizeAnswer(
554
+ formatResponse(
555
+ `\n**Confirm order?** Type **yes** to submit or **no** to cancel.`
556
+ )
557
+ );
558
+ }
559
+ tui.requestRender();
560
+ return;
561
+ }
562
+ workingIndicator.setState({ status: 'idle' });
563
+ } catch (err) {
564
+ workingIndicator.setState({ status: 'idle' });
565
+ chatLog.finalizeAnswer(`Error: ${err instanceof Error ? err.message : String(err)}`);
566
+ tui.requestRender();
567
+ return;
568
+ }
569
+ // Unknown slash command — fall through to agent
570
+ workingIndicator.setState({ status: 'idle' });
571
+ }
572
+
573
+ await inputHistory.saveMessage(query);
574
+ inputHistory.resetNavigation();
575
+ const result = await agentRunner.runQuery(query);
576
+ if (result?.answer) {
577
+ await inputHistory.updateAgentResponse(result.answer);
578
+ }
579
+ refreshError();
580
+ tui.requestRender();
581
+ };
582
+
583
+ editor.onSubmit = (text) => {
584
+ const value = text.trim();
585
+ // Allow empty Enter to progress the setup wizard
586
+ if (setupWizard.isActive) {
587
+ setupWizard.handleInput('\r');
588
+ return;
589
+ }
590
+ if (!value) return;
591
+ editor.setText('');
592
+ editor.addToHistory(value);
593
+ void handleSubmit(value);
594
+ };
595
+
596
+ editor.onEscape = () => {
597
+ if (setupWizard.isActive) {
598
+ setupWizard.handleInput('\u001b');
599
+ return;
600
+ }
601
+ if (browseController.isInBrowseFlow()) {
602
+ browseController.cancelBrowse();
603
+ return;
604
+ }
605
+ if (modelSelection.isInSelectionFlow()) {
606
+ modelSelection.cancelSelection();
607
+ return;
608
+ }
609
+ if (agentRunner.isProcessing || agentRunner.pendingApproval) {
610
+ agentRunner.cancelExecution();
611
+ return;
612
+ }
613
+ };
614
+
615
+ editor.onCtrlC = () => {
616
+ if (setupWizard.isActive) {
617
+ setupWizard.cancel();
618
+ tui.stop();
619
+ process.exit(0);
620
+ return;
621
+ }
622
+ if (browseController.isInBrowseFlow()) {
623
+ browseController.cancelBrowse();
624
+ return;
625
+ }
626
+ if (modelSelection.isInSelectionFlow()) {
627
+ modelSelection.cancelSelection();
628
+ return;
629
+ }
630
+ if (agentRunner.isProcessing || agentRunner.pendingApproval) {
631
+ agentRunner.cancelExecution();
632
+ return;
633
+ }
634
+ tui.stop();
635
+ process.exit(0);
636
+ };
637
+
638
+ const renderMainView = () => {
639
+ root.clear();
640
+ root.addChild(intro);
641
+ if (indexStatusMessage) {
642
+ root.addChild(new Text(theme.muted(indexStatusMessage), 0, 0));
643
+ }
644
+ root.addChild(chatLog);
645
+ if (lastError ?? agentRunner.error) {
646
+ root.addChild(errorText);
647
+ }
648
+ if (agentRunner.workingState.status !== 'idle') {
649
+ root.addChild(workingIndicator);
650
+ }
651
+ root.addChild(new Spacer(1));
652
+ root.addChild(editor);
653
+ root.addChild(debugPanel);
654
+ tui.setFocus(editor);
655
+ };
656
+
657
+ const renderScreenView = (
658
+ title: string,
659
+ description: string,
660
+ body: any,
661
+ footer?: string,
662
+ focusTarget?: any,
663
+ ) => {
664
+ root.clear();
665
+ root.addChild(createScreen(title, description, body, footer));
666
+ if (focusTarget) {
667
+ tui.setFocus(focusTarget);
668
+ }
669
+ };
670
+
671
+ // Cache for browse market selector to enable in-place updates without flicker
672
+ let cachedBrowseSelector: Container | null = null;
673
+ let cachedBrowseTheme = '';
674
+ let cachedBrowseEventCount = 0;
675
+
676
+ const renderSelectionOverlay = () => {
677
+ // Setup wizard overlay
678
+ if (setupWizard.isActive) {
679
+ const component = setupWizard.ensureComponent();
680
+ const bodyLines = setupWizard.getBodyLines();
681
+ const bodyContainer = new Container();
682
+ if (component) {
683
+ bodyContainer.addChild(component as any);
684
+ }
685
+ for (const line of bodyLines) {
686
+ bodyContainer.addChild(new Text(line, 0, 0));
687
+ }
688
+ const focusTarget = setupWizard.getFocusTarget();
689
+ renderScreenView(
690
+ setupWizard.getTitle(),
691
+ setupWizard.getDescription(),
692
+ bodyContainer,
693
+ setupWizard.getFooter(),
694
+ focusTarget ?? editor, // editor for non-interactive states so keys route through
695
+ );
696
+ return;
697
+ }
698
+
699
+ const browseState = browseController.state;
700
+ const state = modelSelection.state;
701
+
702
+ // Invalidate cache when leaving event_list
703
+ if (browseState.appState !== 'event_list') {
704
+ cachedBrowseSelector = null;
705
+ }
706
+
707
+ // Check for pending recommend ticker from browse
708
+ if (browseState.appState === 'idle' && browseState.pendingRecommendTicker) {
709
+ const ticker = browseController.consumePendingRecommendTicker();
710
+ if (ticker) {
711
+ refreshError();
712
+ renderMainView();
713
+ // Feed the ticker into the analyze flow
714
+ void handleSubmit(`/analyze ${ticker}`);
715
+ return;
716
+ }
717
+ }
718
+
719
+ // Check for pending trade ticker from browse
720
+ if (browseState.appState === 'idle' && browseState.pendingTradeTicker) {
721
+ const ticker = browseController.consumePendingTradeTicker();
722
+ if (ticker) {
723
+ refreshError();
724
+ renderMainView();
725
+ // Fetch live prices then show trade prompt
726
+ void (async () => {
727
+ let priceInfo = '';
728
+ try {
729
+ const res = await callKalshiApi('GET', `/markets/${ticker}`);
730
+ const mkt = (res.market ?? res) as KalshiMarket;
731
+ const yesBid = mkt.yes_bid ?? Math.round((parseFloat(mkt.yes_bid_dollars ?? mkt.dollar_yes_bid ?? '0') || 0) * 100);
732
+ const yesAsk = mkt.yes_ask ?? Math.round((parseFloat(mkt.yes_ask_dollars ?? mkt.dollar_yes_ask ?? '0') || 0) * 100);
733
+ const noBid = mkt.no_bid ?? (Math.round((parseFloat(mkt.no_bid_dollars ?? mkt.dollar_no_bid ?? '0') || 0) * 100) || (100 - yesAsk));
734
+ const noAsk = mkt.no_ask ?? (Math.round((parseFloat(mkt.no_ask_dollars ?? mkt.dollar_no_ask ?? '0') || 0) * 100) || (100 - yesBid));
735
+ priceInfo = `**Current market prices:**\n` +
736
+ ` YES: ${yesBid}c bid / ${yesAsk}c ask\n` +
737
+ ` NO: ${noBid}c bid / ${noAsk}c ask\n\n`;
738
+ } catch {
739
+ // Skip price info on error
740
+ }
741
+ chatLog.finalizeAnswer(
742
+ `Trade **${ticker}**\n\n` +
743
+ priceInfo +
744
+ `**Examples** (count = number of contracts):\n` +
745
+ ` /buy ${ticker} 10 ← buy 10 YES contracts at market price\n` +
746
+ ` /buy ${ticker} 10 no ← buy 10 NO contracts at market price\n` +
747
+ ` /buy ${ticker} 10 50 ← buy 10 YES contracts, limit 50c each\n` +
748
+ ` /sell ${ticker} 10 no ← sell 10 NO contracts at market price`);
749
+ tui.requestRender();
750
+ })();
751
+ return;
752
+ }
753
+ }
754
+
755
+ // Browse states
756
+ if (browseState.appState === 'loading') {
757
+ const loadingMsg = browseState.progressMessage ?? 'Please wait...';
758
+ const isReport = loadingMsg.includes('report for');
759
+ renderScreenView(
760
+ isReport ? 'Loading Octagon Report...' : 'Search',
761
+ isReport ? '' : `Loading events for "${browseState.theme}"...`,
762
+ new Text(theme.muted(loadingMsg), 0, 0),
763
+ );
764
+ return;
765
+ }
766
+
767
+ if (browseState.appState === 'event_list') {
768
+ // If the cached selector still matches, update labels in-place (no flicker)
769
+ if (cachedBrowseSelector && cachedBrowseTheme === browseState.theme
770
+ && cachedBrowseEventCount === browseState.events.length) {
771
+ updateBrowseMarketSelector(cachedBrowseSelector, browseState.events);
772
+ tui.requestRender();
773
+ return;
774
+ }
775
+ const selector = createBrowseMarketSelector(
776
+ browseState.events,
777
+ (eventTicker, marketTicker) => browseController.selectMarket(eventTicker, marketTicker),
778
+ () => browseController.cancelBrowse(),
779
+ browseState.lastError,
780
+ browseState.progressMessage,
781
+ );
782
+ cachedBrowseSelector = selector;
783
+ cachedBrowseTheme = browseState.theme;
784
+ cachedBrowseEventCount = browseState.events.length;
785
+ const focusTarget = (selector as any)._browseList;
786
+ renderScreenView(
787
+ `Browse: ${browseState.theme}`,
788
+ `${browseState.events.length} events, ${browseState.events.reduce((n, e) => n + e.markets.length, 0)} markets`,
789
+ selector,
790
+ 'Enter to select · esc to exit',
791
+ focusTarget,
792
+ );
793
+ return;
794
+ }
795
+
796
+ if (browseState.appState === 'view_report' && browseState.reportText) {
797
+ const reportBody = new Text(browseState.reportText, 0, 0);
798
+ renderScreenView(
799
+ '',
800
+ '',
801
+ reportBody,
802
+ 'esc to go back',
803
+ );
804
+ tui.setFocus(editor);
805
+ return;
806
+ }
807
+
808
+ if (browseState.appState === 'action_menu' && browseState.selectedMarket) {
809
+ const hasReport = browseState.selectedMarket.modelProb !== null;
810
+ const selector = createBrowseActionSelector(
811
+ (action) => browseController.handleAction(action),
812
+ () => browseController.handleAction('back'),
813
+ hasReport,
814
+ browseController.isDirectReport,
815
+ );
816
+ const focusTarget = (selector as any)._browseList;
817
+ renderScreenView(
818
+ browseState.selectedMarket.ticker,
819
+ `${browseState.selectedMarket.title} — Mkt: ${browseState.selectedMarket.marketProb !== null ? `${(browseState.selectedMarket.marketProb * 100).toFixed(1)}%` : '—'}`,
820
+ selector,
821
+ 'Enter to confirm · esc to go back',
822
+ focusTarget,
823
+ );
824
+ return;
825
+ }
826
+
827
+
828
+ if (state.appState === 'idle' && !agentRunner.pendingApproval) {
829
+ refreshError();
830
+ renderMainView();
831
+ return;
832
+ }
833
+
834
+ if (agentRunner.pendingApproval) {
835
+ const prompt = new ApprovalPromptComponent(
836
+ agentRunner.pendingApproval.tool,
837
+ agentRunner.pendingApproval.args,
838
+ );
839
+ prompt.onSelect = (decision: ApprovalDecision) => {
840
+ agentRunner.respondToApproval(decision);
841
+ };
842
+ renderScreenView('', '', prompt, undefined, prompt.selector);
843
+ return;
844
+ }
845
+
846
+ if (state.appState === 'provider_select') {
847
+ const selector = createProviderSelector(modelSelection.provider, (providerId) => {
848
+ void modelSelection.handleProviderSelect(providerId);
849
+ });
850
+ renderScreenView(
851
+ 'Select provider',
852
+ 'Switch between LLM providers. Applies to this session and future sessions.',
853
+ selector,
854
+ 'Enter to confirm · esc to exit',
855
+ selector,
856
+ );
857
+ return;
858
+ }
859
+
860
+ if (state.appState === 'model_select' && state.pendingProvider) {
861
+ const selector = createModelSelector(
862
+ state.pendingModels,
863
+ modelSelection.provider === state.pendingProvider ? modelSelection.model : undefined,
864
+ (modelId) => modelSelection.handleModelSelect(modelId),
865
+ state.pendingProvider,
866
+ );
867
+ renderScreenView(
868
+ `Select model for ${getProviderDisplayName(state.pendingProvider)}`,
869
+ '',
870
+ selector,
871
+ 'Enter to confirm · esc to go back',
872
+ selector,
873
+ );
874
+ return;
875
+ }
876
+
877
+ if (state.appState === 'model_input' && state.pendingProvider) {
878
+ const input = new ApiKeyInputComponent();
879
+ input.onSubmit = (value) => modelSelection.handleModelInputSubmit(value);
880
+ input.onCancel = () => modelSelection.handleModelInputSubmit(null);
881
+ renderScreenView(
882
+ `Enter model name for ${getProviderDisplayName(state.pendingProvider)}`,
883
+ 'Type or paste the model name from openrouter.ai/models',
884
+ input,
885
+ 'Examples: anthropic/claude-3.5-sonnet, openai/gpt-4-turbo, meta-llama/llama-3-70b\nEnter to confirm · esc to go back',
886
+ input,
887
+ );
888
+ return;
889
+ }
890
+
891
+ if (state.appState === 'api_key_confirm' && state.pendingProvider) {
892
+ const selector = createApiKeyConfirmSelector((wantsToSet) =>
893
+ modelSelection.handleApiKeyConfirm(wantsToSet),
894
+ );
895
+ renderScreenView(
896
+ 'Set API Key',
897
+ `Would you like to set your ${getProviderDisplayName(state.pendingProvider)} API key?`,
898
+ selector,
899
+ 'Enter to confirm · esc to decline',
900
+ selector,
901
+ );
902
+ return;
903
+ }
904
+
905
+ if (state.appState === 'api_key_input' && state.pendingProvider) {
906
+ const input = new ApiKeyInputComponent(true);
907
+ input.onSubmit = (apiKey) => modelSelection.handleApiKeySubmit(apiKey);
908
+ input.onCancel = () => modelSelection.handleApiKeySubmit(null);
909
+ const apiKeyName = getApiKeyNameForProvider(state.pendingProvider) ?? '';
910
+ renderScreenView(
911
+ `Enter ${getProviderDisplayName(state.pendingProvider)} API Key`,
912
+ apiKeyName ? `(${apiKeyName})` : '',
913
+ input,
914
+ 'Enter to confirm · Esc to cancel',
915
+ input,
916
+ );
917
+ }
918
+ };
919
+
920
+ await inputHistory.init();
921
+ for (const msg of inputHistory.getMessages().reverse()) {
922
+ editor.addToHistory(msg);
923
+ }
924
+
925
+ // Auto-launch setup wizard if credentials are missing or `bun start init` was used
926
+ if (!hasKalshiCreds || options?.forceSetup) {
927
+ setupWizard.start();
928
+ }
929
+
930
+ renderSelectionOverlay();
931
+ refreshError();
932
+
933
+ tui.start();
934
+ await new Promise<void>((resolve) => {
935
+ const finish = () => resolve();
936
+ process.once('exit', finish);
937
+ process.once('SIGINT', finish);
938
+ process.once('SIGTERM', finish);
939
+ });
940
+
941
+ workingIndicator.dispose();
942
+ debugPanel.dispose();
943
+ }