opentradex 0.1.3 → 0.1.4

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.
@@ -1,120 +1,562 @@
1
1
  "use client";
2
2
 
3
- import { useRef, useEffect, useState } from "react";
4
- import { ScrollArea } from "@/components/ui/scroll-area";
3
+ import { useEffect, useMemo, useRef, useState } from "react";
5
4
  import ReactMarkdown from "react-markdown";
6
5
  import remarkGfm from "remark-gfm";
7
6
  import {
7
+ CheckCircle2,
8
+ ChevronDown,
9
+ ChevronRight,
10
+ Link2,
11
+ Radio,
8
12
  Send,
13
+ ShieldCheck,
9
14
  Terminal,
10
- ChevronRight,
11
- ChevronDown,
12
- CheckCircle2,
15
+ TrendingUp,
16
+ Waves,
13
17
  } from "lucide-react";
14
- import type { StreamLine } from "@/lib/types";
18
+ import { ScrollArea } from "@/components/ui/scroll-area";
19
+ import { HarnessBootPanel } from "@/components/HarnessBootPanel";
20
+ import type { PromptEntry, StreamLine, WorkspaceSummary } from "@/lib/types";
15
21
 
16
22
  interface LiveStreamProps {
17
23
  lines: StreamLine[];
18
24
  liveStatus: string;
19
25
  customPrompt: string;
20
- onCustomPromptChange: (v: string) => void;
21
- onSendCommand: (prompt: string) => void;
26
+ prompts: PromptEntry[];
27
+ workspace: WorkspaceSummary | null;
28
+ onCustomPromptChange: (value: string) => void;
29
+ onSendCommand: (prompt: string, channel: string) => void;
22
30
  }
23
31
 
32
+ type ChannelId =
33
+ | "all"
34
+ | "command"
35
+ | "markets"
36
+ | "feeds"
37
+ | "risk"
38
+ | "execution"
39
+ | "tradingview";
40
+
41
+ type GroupedLine =
42
+ | { kind: "text"; text: string }
43
+ | { kind: "tools"; tools: { tool: StreamLine; result?: StreamLine }[] }
44
+ | { kind: "result"; result: string };
45
+
46
+ type ChatMessage =
47
+ | {
48
+ id: string;
49
+ role: "user";
50
+ channel: ChannelId;
51
+ body: string;
52
+ createdAt: string;
53
+ }
54
+ | {
55
+ id: string;
56
+ role: "assistant" | "result";
57
+ channel: ChannelId;
58
+ body: string;
59
+ createdAt: string;
60
+ }
61
+ | {
62
+ id: string;
63
+ role: "tool";
64
+ channel: ChannelId;
65
+ tools: { tool: StreamLine; result?: StreamLine }[];
66
+ createdAt: string;
67
+ };
68
+
69
+ const CHANNEL_META: Record<
70
+ Exclude<ChannelId, "all">,
71
+ { label: string; description: string; icon: typeof Terminal }
72
+ > = {
73
+ command: {
74
+ label: "Command",
75
+ description: "Direct the harness and launch missions.",
76
+ icon: Terminal,
77
+ },
78
+ markets: {
79
+ label: "Markets",
80
+ description: "Cross-market comparison and contract selection.",
81
+ icon: TrendingUp,
82
+ },
83
+ feeds: {
84
+ label: "Feeds",
85
+ description: "News and social flow for fresh catalysts.",
86
+ icon: Waves,
87
+ },
88
+ risk: {
89
+ label: "Risk",
90
+ description: "Portfolio review, sizing, and exits.",
91
+ icon: ShieldCheck,
92
+ },
93
+ execution: {
94
+ label: "Execution",
95
+ description: "Trade readiness, routing, and outcomes.",
96
+ icon: CheckCircle2,
97
+ },
98
+ tradingview: {
99
+ label: "TradingView",
100
+ description: "Watchlist and chart-context lane.",
101
+ icon: Link2,
102
+ },
103
+ };
104
+
24
105
  export function LiveStream({
25
106
  lines,
26
107
  liveStatus,
27
108
  customPrompt,
109
+ prompts,
110
+ workspace,
28
111
  onCustomPromptChange,
29
112
  onSendCommand,
30
113
  }: LiveStreamProps) {
31
114
  const endRef = useRef<HTMLDivElement>(null);
32
- const prevLinesCount = useRef(0);
115
+ const [selectedChannel, setSelectedChannel] = useState<ChannelId>("command");
116
+ const showHarnessBoot = liveStatus !== "running" && lines.length < 8 && prompts.length === 0;
117
+
118
+ const availableChannels = useMemo<ChannelId[]>(() => {
119
+ const base: ChannelId[] = ["all", "command", "markets", "feeds", "risk", "execution"];
120
+ if (workspace?.channels.includes("tradingview") || workspace?.enabledMarkets.includes("tradingview")) {
121
+ base.push("tradingview");
122
+ }
123
+ return base;
124
+ }, [workspace]);
33
125
 
34
126
  useEffect(() => {
35
- if (lines.length > 0 && lines.length !== prevLinesCount.current) {
36
- endRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
127
+ if (!availableChannels.includes(selectedChannel)) {
128
+ setSelectedChannel("command");
37
129
  }
38
- prevLinesCount.current = lines.length;
39
- }, [lines.length]);
130
+ }, [availableChannels, selectedChannel]);
40
131
 
41
- const grouped = groupLines(lines);
132
+ const messages = useMemo(
133
+ () => buildMessages(prompts, lines),
134
+ [prompts, lines]
135
+ );
136
+
137
+ const channelCounts = useMemo(() => {
138
+ const counts = new Map<ChannelId, number>();
139
+ counts.set("all", messages.length);
140
+ for (const channel of availableChannels) {
141
+ if (channel === "all") continue;
142
+ counts.set(channel, messages.filter((message) => message.channel === channel).length);
143
+ }
144
+ return counts;
145
+ }, [availableChannels, messages]);
146
+
147
+ const filteredMessages = useMemo(() => {
148
+ if (selectedChannel === "all") {
149
+ return messages;
150
+ }
151
+ return messages.filter((message) => message.channel === selectedChannel);
152
+ }, [messages, selectedChannel]);
153
+
154
+ const quickPrompts = useMemo(
155
+ () => getQuickPrompts(selectedChannel, workspace),
156
+ [selectedChannel, workspace]
157
+ );
158
+
159
+ useEffect(() => {
160
+ endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
161
+ }, [filteredMessages.length, showHarnessBoot]);
42
162
 
43
163
  return (
44
- <div className="flex flex-col h-full">
45
- <div className="h-9 flex items-center justify-between px-3 border-b border-border bg-card shrink-0">
46
- <div className="flex items-center gap-2">
47
- <Terminal className="h-3.5 w-3.5 text-muted-foreground" />
48
- <span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
49
- Live Stream
50
- </span>
164
+ <div className="flex h-full min-h-0 flex-col overflow-hidden bg-[linear-gradient(180deg,#120815_0%,#190b17_32%,#120815_100%)] text-[#f7e6ee]">
165
+ <div className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden xl:grid-cols-[240px_minmax(0,1fr)]">
166
+ <aside className="hidden min-h-0 overflow-hidden border-r border-[#4f3042] bg-[#1b0c18]/85 xl:block">
167
+ <div className="flex h-full min-h-0 flex-col">
168
+ <div className="border-b border-[#4f3042] px-4 py-4">
169
+ <p className="font-mono text-[0.68rem] uppercase tracking-[0.28em] text-[#f3a96f]">
170
+ Messaging channels
171
+ </p>
172
+ <p className="mt-2 text-sm leading-6 text-[#e6cfda]/72">
173
+ Route prompts by desk so the assistant knows whether you want markets, feeds,
174
+ risk, execution, or TradingView context.
175
+ </p>
176
+ </div>
177
+
178
+ <ScrollArea className="flex-1 min-h-0">
179
+ <div className="space-y-2 p-3">
180
+ {availableChannels.map((channel) => (
181
+ <ChannelButton
182
+ key={channel}
183
+ channel={channel}
184
+ selected={selectedChannel === channel}
185
+ count={channelCounts.get(channel) || 0}
186
+ onClick={() => setSelectedChannel(channel)}
187
+ />
188
+ ))}
189
+
190
+ <div className="mt-4 rounded-[1.2rem] border border-[#58364c] bg-[#271120]/80 p-3">
191
+ <p className="font-mono text-[0.66rem] uppercase tracking-[0.24em] text-[#f3a96f]">
192
+ Connectors
193
+ </p>
194
+ <div className="mt-3 space-y-2 text-[12px]">
195
+ <ConnectorRow
196
+ label={workspace?.primaryMarket || "kalshi"}
197
+ detail={workspace?.mode === "live" ? "live rail armed" : "paper rail active"}
198
+ />
199
+ <ConnectorRow
200
+ label="feeds"
201
+ detail={workspace?.integrations.join(", ") || "apify, rss"}
202
+ />
203
+ {workspace?.tradingview.enabled ? (
204
+ <ConnectorRow
205
+ label="tradingview"
206
+ detail={
207
+ workspace.tradingview.connectorMode === "mcp"
208
+ ? workspace.tradingview.configured
209
+ ? `${workspace.tradingview.transport} mcp ready`
210
+ : `${workspace.tradingview.transport} mcp needs setup`
211
+ : `watchlist: ${workspace.tradingview.watchlist.join(", ")}`
212
+ }
213
+ />
214
+ ) : null}
215
+ </div>
216
+ </div>
217
+ </div>
218
+ </ScrollArea>
219
+ </div>
220
+ </aside>
221
+
222
+ <div className="flex min-h-0 flex-col overflow-hidden">
223
+ <div className="border-b border-[#4f3042] bg-[#1b0c18]/75 px-4 py-3">
224
+ <div className="flex flex-wrap items-center justify-between gap-3">
225
+ <div>
226
+ <p className="font-mono text-[0.68rem] uppercase tracking-[0.28em] text-[#f3a96f]">
227
+ Chat cockpit
228
+ </p>
229
+ <div className="mt-2 flex items-center gap-2 text-sm text-[#f4dfea]">
230
+ <Radio className={`h-3.5 w-3.5 ${liveStatus === "running" ? "text-emerald-400" : "text-[#f3a96f]"}`} />
231
+ <span>{selectedChannel === "all" ? "All channels" : CHANNEL_META[selectedChannel].label}</span>
232
+ <span className="text-[#8b6578]">/</span>
233
+ <span className="text-[#d4b5c3]/72">
234
+ {selectedChannel === "all"
235
+ ? "Full operator conversation"
236
+ : CHANNEL_META[selectedChannel].description}
237
+ </span>
238
+ </div>
239
+ </div>
240
+ <div className="flex flex-wrap items-center gap-2 xl:hidden">
241
+ {availableChannels.map((channel) => (
242
+ <CompactChannelButton
243
+ key={channel}
244
+ channel={channel}
245
+ selected={selectedChannel === channel}
246
+ onClick={() => setSelectedChannel(channel)}
247
+ />
248
+ ))}
249
+ </div>
250
+ </div>
251
+ </div>
252
+
253
+ <ScrollArea className="min-h-0 flex-1">
254
+ <div className="space-y-4 p-4">
255
+ {showHarnessBoot ? (
256
+ <HarnessBootPanel
257
+ workspace={workspace}
258
+ onLaunchMission={(prompt) => onSendCommand(prompt, "command")}
259
+ />
260
+ ) : null}
261
+
262
+ {showHarnessBoot ? (
263
+ <p className="px-1 text-center text-xs text-[#c9abbb]/48">
264
+ Pick a mission above or drop your own prompt into the selected channel.
265
+ </p>
266
+ ) : null}
267
+
268
+ {!showHarnessBoot && filteredMessages.length === 0 ? (
269
+ <div className="rounded-[1.2rem] border border-dashed border-[#5c3b4f] bg-[#1b0c18]/45 px-4 py-5 text-sm text-[#d3bac6]/68">
270
+ No messages in this channel yet. Send a prompt to start the conversation.
271
+ </div>
272
+ ) : null}
273
+
274
+ {filteredMessages.map((message) => (
275
+ <ChatBlock key={message.id} message={message} />
276
+ ))}
277
+
278
+ {liveStatus === "running" && filteredMessages.length === 0 ? (
279
+ <div className="flex items-center justify-center gap-2 py-6">
280
+ <div className="h-1.5 w-1.5 animate-bounce rounded-full bg-emerald-400 [animation-delay:0ms]" />
281
+ <div className="h-1.5 w-1.5 animate-bounce rounded-full bg-emerald-400 [animation-delay:150ms]" />
282
+ <div className="h-1.5 w-1.5 animate-bounce rounded-full bg-emerald-400 [animation-delay:300ms]" />
283
+ </div>
284
+ ) : null}
285
+
286
+ <div ref={endRef} />
287
+ </div>
288
+ </ScrollArea>
289
+
290
+ <div className="border-t border-[#4f3042] bg-[#1b0c18]/80 px-4 py-3">
291
+ <div className="mb-3 flex flex-wrap gap-2">
292
+ {quickPrompts.map((prompt) => (
293
+ <button
294
+ key={prompt}
295
+ onClick={() => onSendCommand(prompt, selectedChannel === "all" ? "command" : selectedChannel)}
296
+ className="rounded-full border border-[#654154] bg-[#311425] px-3 py-1.5 text-[11px] text-[#f4dfea]/82 transition-colors hover:border-[#92667f] hover:bg-[#442039]"
297
+ >
298
+ {prompt}
299
+ </button>
300
+ ))}
301
+ </div>
302
+
303
+ <div className="flex items-center gap-2 rounded-[1.2rem] border border-[#654154] bg-[#2a1121] px-3 py-2">
304
+ <span className="rounded-full border border-[#84586f] bg-[#3b1730] px-2.5 py-1 font-mono text-[10px] uppercase tracking-[0.2em] text-[#f3a96f]">
305
+ {selectedChannel === "all" ? "command" : selectedChannel}
306
+ </span>
307
+ <input
308
+ type="text"
309
+ value={customPrompt}
310
+ onChange={(event) => onCustomPromptChange(event.target.value)}
311
+ onKeyDown={(event) => {
312
+ if (event.key === "Enter" && customPrompt.trim()) {
313
+ onSendCommand(customPrompt, selectedChannel === "all" ? "command" : selectedChannel);
314
+ onCustomPromptChange("");
315
+ }
316
+ }}
317
+ placeholder={getPlaceholder(selectedChannel, workspace)}
318
+ className="min-w-0 flex-1 bg-transparent text-sm text-white outline-none placeholder:text-[#a88598]"
319
+ />
320
+ <button
321
+ onClick={() => {
322
+ if (customPrompt.trim()) {
323
+ onSendCommand(customPrompt, selectedChannel === "all" ? "command" : selectedChannel);
324
+ onCustomPromptChange("");
325
+ }
326
+ }}
327
+ className="rounded-full border border-[#84586f] bg-[#3b1730] p-2 text-[#ffd7ae] transition-colors hover:border-[#b47f99] hover:bg-[#53233f]"
328
+ >
329
+ <Send className="h-4 w-4" />
330
+ </button>
331
+ </div>
332
+ </div>
51
333
  </div>
52
- {liveStatus === "running" && (
53
- <span className="text-[10px] text-primary font-medium flex items-center gap-1.5">
54
- <span className="w-1.5 h-1.5 rounded-full bg-primary pulse-glow" />
55
- Streaming
56
- </span>
57
- )}
58
334
  </div>
335
+ </div>
336
+ );
337
+ }
59
338
 
60
- <ScrollArea className="flex-1 min-h-0">
61
- <div className="p-3 space-y-0.5">
62
- {lines.length === 0 && liveStatus !== "running" && (
63
- <p className="text-muted-foreground/40 text-center py-12 text-xs">
64
- Run a cycle to see live agent output
65
- </p>
66
- )}
67
- {lines.length === 0 && liveStatus === "running" && (
68
- <div className="flex items-center gap-2 justify-center py-12">
69
- <div className="h-1.5 w-1.5 rounded-full bg-primary animate-bounce [animation-delay:0ms]" />
70
- <div className="h-1.5 w-1.5 rounded-full bg-primary animate-bounce [animation-delay:150ms]" />
71
- <div className="h-1.5 w-1.5 rounded-full bg-primary animate-bounce [animation-delay:300ms]" />
72
- </div>
73
- )}
74
- {grouped.map((group, i) => (
75
- <GroupedBlock key={i} group={group} />
76
- ))}
77
- <div ref={endRef} />
339
+ function ChannelButton({
340
+ channel,
341
+ selected,
342
+ count,
343
+ onClick,
344
+ }: {
345
+ channel: ChannelId;
346
+ selected: boolean;
347
+ count: number;
348
+ onClick: () => void;
349
+ }) {
350
+ if (channel === "all") {
351
+ return (
352
+ <button
353
+ onClick={onClick}
354
+ className={`w-full rounded-[1rem] border px-3 py-3 text-left transition-colors ${
355
+ selected
356
+ ? "border-[#95687f] bg-[#3d182f] text-white"
357
+ : "border-[#523345] bg-[#24101d] text-[#dec4d0] hover:border-[#7c5469] hover:bg-[#311425]"
358
+ }`}
359
+ >
360
+ <div className="flex items-center justify-between gap-2">
361
+ <span className="font-mono text-[0.68rem] uppercase tracking-[0.22em]">All channels</span>
362
+ <span className="rounded-full border border-current/20 px-2 py-0.5 text-[10px]">{count}</span>
78
363
  </div>
79
- </ScrollArea>
364
+ <p className="mt-2 text-[12px] text-current/70">Full operator conversation across every lane.</p>
365
+ </button>
366
+ );
367
+ }
80
368
 
81
- <div className="h-11 flex items-center gap-2 px-3 border-t border-border bg-card shrink-0">
82
- <span className="text-muted-foreground/30 text-xs font-mono">
83
- {">"}
369
+ const meta = CHANNEL_META[channel];
370
+ const Icon = meta.icon;
371
+
372
+ return (
373
+ <button
374
+ onClick={onClick}
375
+ className={`w-full rounded-[1rem] border px-3 py-3 text-left transition-colors ${
376
+ selected
377
+ ? "border-[#95687f] bg-[#3d182f] text-white"
378
+ : "border-[#523345] bg-[#24101d] text-[#dec4d0] hover:border-[#7c5469] hover:bg-[#311425]"
379
+ }`}
380
+ >
381
+ <div className="flex items-center justify-between gap-2">
382
+ <span className="flex items-center gap-2 font-mono text-[0.68rem] uppercase tracking-[0.22em]">
383
+ <Icon className="h-3.5 w-3.5" />
384
+ {meta.label}
84
385
  </span>
85
- <input
86
- type="text"
87
- value={customPrompt}
88
- onChange={(e) => onCustomPromptChange(e.target.value)}
89
- onKeyDown={(e) => {
90
- if (e.key === "Enter" && customPrompt.trim()) {
91
- onSendCommand(customPrompt);
92
- onCustomPromptChange("");
93
- }
94
- }}
95
- placeholder="Send a command to the agent..."
96
- className="flex-1 bg-transparent text-xs focus:outline-none placeholder:text-muted-foreground/30"
97
- />
98
- <button
99
- onClick={() => {
100
- if (customPrompt.trim()) {
101
- onSendCommand(customPrompt);
102
- onCustomPromptChange("");
103
- }
104
- }}
105
- className="text-muted-foreground/40 hover:text-primary transition-colors"
106
- >
107
- <Send className="h-3.5 w-3.5" />
108
- </button>
386
+ <span className="rounded-full border border-current/20 px-2 py-0.5 text-[10px]">{count}</span>
109
387
  </div>
388
+ <p className="mt-2 text-[12px] text-current/70">{meta.description}</p>
389
+ </button>
390
+ );
391
+ }
392
+
393
+ function CompactChannelButton({
394
+ channel,
395
+ selected,
396
+ onClick,
397
+ }: {
398
+ channel: ChannelId;
399
+ selected: boolean;
400
+ onClick: () => void;
401
+ }) {
402
+ const label = channel === "all" ? "All" : CHANNEL_META[channel].label;
403
+ return (
404
+ <button
405
+ onClick={onClick}
406
+ className={`rounded-full border px-3 py-1.5 text-[11px] uppercase tracking-[0.18em] ${
407
+ selected
408
+ ? "border-[#95687f] bg-[#3d182f] text-white"
409
+ : "border-[#523345] bg-[#24101d] text-[#dec4d0]"
410
+ }`}
411
+ >
412
+ {label}
413
+ </button>
414
+ );
415
+ }
416
+
417
+ function ConnectorRow({ label, detail }: { label: string; detail: string }) {
418
+ return (
419
+ <div className="rounded-lg border border-[#4e3143] bg-[#1d0d18] px-3 py-2">
420
+ <p className="font-mono text-[10px] uppercase tracking-[0.18em] text-[#f3a96f]">{label}</p>
421
+ <p className="mt-1 text-[12px] leading-5 text-[#ead4de]/70">{detail}</p>
110
422
  </div>
111
423
  );
112
424
  }
113
425
 
114
- type GroupedLine =
115
- | { kind: "text"; text: string }
116
- | { kind: "tools"; tools: { tool: StreamLine; result?: StreamLine }[] }
117
- | { kind: "result"; result: string };
426
+ function ChatBlock({ message }: { message: ChatMessage }) {
427
+ if (message.role === "user") {
428
+ return (
429
+ <div className="flex justify-end">
430
+ <div className="max-w-[85%] rounded-[1.2rem] border border-[#8c5e77] bg-[#4a2038] px-4 py-3 shadow-[0_10px_30px_rgba(24,6,14,0.25)]">
431
+ <div className="mb-2 flex items-center gap-2 text-[10px] uppercase tracking-[0.22em] text-[#ffd7ae]">
432
+ <span>Operator</span>
433
+ <span className="text-[#b6859d]">/</span>
434
+ <span>{message.channel}</span>
435
+ </div>
436
+ <p className="text-sm leading-7 text-[#fff3f8]">{message.body}</p>
437
+ </div>
438
+ </div>
439
+ );
440
+ }
441
+
442
+ if (message.role === "tool") {
443
+ return <ToolBlock message={message} />;
444
+ }
445
+
446
+ const title = message.role === "result" ? "Cycle complete" : "Harness";
447
+ const borderColor = message.role === "result" ? "border-emerald-400/30" : "border-[#5c3b4f]";
448
+ const background = message.role === "result" ? "bg-emerald-500/10" : "bg-[#21111b]";
449
+
450
+ return (
451
+ <div className={`max-w-[92%] rounded-[1.2rem] border ${borderColor} ${background} px-4 py-3 shadow-[0_10px_30px_rgba(24,6,14,0.18)]`}>
452
+ <div className="mb-2 flex items-center gap-2 text-[10px] uppercase tracking-[0.22em] text-[#f3a96f]">
453
+ <span>{title}</span>
454
+ <span className="text-[#765268]">/</span>
455
+ <span>{message.channel}</span>
456
+ </div>
457
+ <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
458
+ {message.body}
459
+ </ReactMarkdown>
460
+ </div>
461
+ );
462
+ }
463
+
464
+ function ToolBlock({
465
+ message,
466
+ }: {
467
+ message: Extract<ChatMessage, { role: "tool" }>;
468
+ }) {
469
+ const [expanded, setExpanded] = useState(false);
470
+
471
+ return (
472
+ <div className="max-w-[92%] rounded-[1.2rem] border border-[#5c3b4f] bg-[#1c0d16] px-4 py-3">
473
+ <button
474
+ onClick={() => setExpanded((value) => !value)}
475
+ className="flex w-full items-start gap-2 text-left"
476
+ >
477
+ <span className="mt-0.5 text-[#b793a5]">
478
+ {expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
479
+ </span>
480
+ <div className="min-w-0 flex-1">
481
+ <div className="flex items-center gap-2 text-[10px] uppercase tracking-[0.22em] text-[#f3a96f]">
482
+ <span>Tool trace</span>
483
+ <span className="text-[#765268]">/</span>
484
+ <span>{message.channel}</span>
485
+ </div>
486
+ <p className="mt-2 text-[12px] leading-6 text-[#e6cfda]/74">
487
+ {message.tools
488
+ .map((item) => item.tool.tool || "tool")
489
+ .filter(Boolean)
490
+ .join(" · ")}
491
+ </p>
492
+ </div>
493
+ </button>
494
+
495
+ {expanded ? (
496
+ <div className="mt-3 space-y-2 border-t border-[#4f3042] pt-3">
497
+ {message.tools.map((item, index) => (
498
+ <div key={`${message.id}-${index}`} className="rounded-lg border border-[#4e3143] bg-[#130913]">
499
+ {item.tool.input ? (
500
+ <pre className="whitespace-pre-wrap break-all px-3 py-2 font-mono text-[11px] leading-6 text-[#e8d4dd]/72">
501
+ {item.tool.input}
502
+ </pre>
503
+ ) : null}
504
+ {item.result?.text ? (
505
+ <pre className="max-h-40 overflow-y-auto whitespace-pre-wrap break-all border-t border-[#4e3143] px-3 py-2 font-mono text-[11px] leading-6 text-[#bda1af]">
506
+ {item.result.text}
507
+ </pre>
508
+ ) : null}
509
+ </div>
510
+ ))}
511
+ </div>
512
+ ) : null}
513
+ </div>
514
+ );
515
+ }
516
+
517
+ function buildMessages(prompts: PromptEntry[], lines: StreamLine[]): ChatMessage[] {
518
+ const promptMessages: ChatMessage[] = prompts.map((prompt) => ({
519
+ id: prompt.id,
520
+ role: "user",
521
+ channel: (prompt.channel as ChannelId) || "command",
522
+ body: prompt.text,
523
+ createdAt: prompt.createdAt,
524
+ }));
525
+
526
+ const grouped = groupLines(lines);
527
+ const streamMessages: ChatMessage[] = grouped.map((group, index) => {
528
+ const createdAt = new Date(Date.now() + index).toISOString();
529
+ if (group.kind === "text") {
530
+ return {
531
+ id: `stream-text-${index}`,
532
+ role: "assistant",
533
+ channel: classifyChannel(group.text),
534
+ body: group.text,
535
+ createdAt,
536
+ };
537
+ }
538
+
539
+ if (group.kind === "result") {
540
+ return {
541
+ id: `stream-result-${index}`,
542
+ role: "result",
543
+ channel: classifyChannel(group.result),
544
+ body: group.result,
545
+ createdAt,
546
+ };
547
+ }
548
+
549
+ return {
550
+ id: `stream-tool-${index}`,
551
+ role: "tool",
552
+ channel: classifyToolChannel(group.tools),
553
+ tools: group.tools,
554
+ createdAt,
555
+ };
556
+ });
557
+
558
+ return [...promptMessages, ...streamMessages];
559
+ }
118
560
 
119
561
  function groupLines(lines: StreamLine[]): GroupedLine[] {
120
562
  const out: GroupedLine[] = [];
@@ -135,195 +577,158 @@ function groupLines(lines: StreamLine[]): GroupedLine[] {
135
577
  }
136
578
  };
137
579
 
138
- for (let i = 0; i < lines.length; i++) {
139
- const line = lines[i];
580
+ for (let index = 0; index < lines.length; index += 1) {
581
+ const line = lines[index];
140
582
  if (line.type === "text") {
141
583
  flushTools();
142
- textBuffer += (textBuffer ? "\n" : "") + (line.text || "");
143
- } else if (line.type === "tool_use") {
584
+ textBuffer += `${textBuffer ? "\n" : ""}${line.text || ""}`;
585
+ continue;
586
+ }
587
+
588
+ if (line.type === "tool_use") {
144
589
  flushText();
145
- const next = lines[i + 1];
590
+ const next = lines[index + 1];
146
591
  const result = next?.type === "tool_result" ? next : undefined;
147
592
  toolBuffer.push({ tool: line, result });
148
- if (result) i++;
149
- } else if (line.type === "tool_result") {
150
- // orphan
151
- } else if (line.type === "result") {
593
+ if (result) index += 1;
594
+ continue;
595
+ }
596
+
597
+ if (line.type === "result") {
152
598
  flushText();
153
599
  flushTools();
154
600
  out.push({ kind: "result", result: line.result || "" });
155
601
  }
156
602
  }
603
+
157
604
  flushText();
158
605
  flushTools();
159
606
  return out;
160
607
  }
161
608
 
162
- function GroupedBlock({ group }: { group: GroupedLine }) {
163
- if (group.kind === "text") return <TextBlock text={group.text} />;
164
- if (group.kind === "tools") return <ToolGroup tools={group.tools} />;
165
- if (group.kind === "result") return <ResultBlock text={group.result} />;
166
- return null;
167
- }
168
-
169
- function TextBlock({ text }: { text: string }) {
170
- return (
171
- <div className="py-1 px-1">
172
- <ReactMarkdown
173
- remarkPlugins={[remarkGfm]}
174
- components={markdownComponents}
175
- >
176
- {text}
177
- </ReactMarkdown>
178
- </div>
179
- );
609
+ function classifyToolChannel(tools: { tool: StreamLine; result?: StreamLine }[]): ChannelId {
610
+ const haystack = tools
611
+ .map((item) => `${item.tool.tool || ""} ${item.tool.input || ""} ${item.result?.text || ""}`.toLowerCase())
612
+ .join(" ");
613
+ return classifyChannel(haystack);
180
614
  }
181
615
 
182
- function ResultBlock({ text }: { text: string }) {
183
- return (
184
- <div className="my-2 rounded-lg border border-primary/20 bg-primary/5 overflow-hidden">
185
- <div className="flex items-center gap-2 px-3 py-1.5 border-b border-primary/10">
186
- <CheckCircle2 className="h-3 w-3 text-primary" />
187
- <span className="text-[10px] text-primary font-semibold uppercase tracking-wider">
188
- Cycle Complete
189
- </span>
190
- </div>
191
- <div className="p-3">
192
- <ReactMarkdown
193
- remarkPlugins={[remarkGfm]}
194
- components={markdownComponents}
195
- >
196
- {text}
197
- </ReactMarkdown>
198
- </div>
199
- </div>
200
- );
616
+ function classifyChannel(text: string): ChannelId {
617
+ const value = text.toLowerCase();
618
+ if (value.includes("tradingview") || value.includes("watchlist") || value.includes("chart")) {
619
+ return "tradingview";
620
+ }
621
+ if (value.includes("risk") || value.includes("portfolio") || value.includes("exposure") || value.includes("kelly") || value.includes("exit")) {
622
+ return "risk";
623
+ }
624
+ if (value.includes("trade") || value.includes("execution") || value.includes("order") || value.includes("fill") || value.includes("contracts")) {
625
+ return "execution";
626
+ }
627
+ if (value.includes("news") || value.includes("rss") || value.includes("twitter") || value.includes("reddit") || value.includes("tiktok") || value.includes("headline") || value.includes("feed")) {
628
+ return "feeds";
629
+ }
630
+ if (value.includes("kalshi") || value.includes("polymarket") || value.includes("market") || value.includes("bid") || value.includes("ask") || value.includes("probability")) {
631
+ return "markets";
632
+ }
633
+ return "command";
201
634
  }
202
635
 
203
- function ToolGroup({
204
- tools,
205
- }: {
206
- tools: { tool: StreamLine; result?: StreamLine }[];
207
- }) {
208
- const [expanded, setExpanded] = useState(false);
209
-
210
- const summary = tools
211
- .map((t) => {
212
- const name = t.tool.tool || "tool";
213
- const short = formatToolInput(t.tool.input || "");
214
- return { name, short };
215
- });
216
-
217
- return (
218
- <div className="my-0.5">
219
- <button
220
- onClick={() => setExpanded(!expanded)}
221
- className="w-full flex items-start gap-1.5 px-1 py-0.5 text-left hover:bg-secondary/20 rounded transition-colors group"
222
- >
223
- <span className="text-muted-foreground/30 mt-px shrink-0">
224
- {expanded ? (
225
- <ChevronDown className="h-3 w-3" />
226
- ) : (
227
- <ChevronRight className="h-3 w-3" />
228
- )}
229
- </span>
230
- <div className="flex flex-wrap gap-x-3 gap-y-0 flex-1 min-w-0">
231
- {summary.map((s, i) => (
232
- <span key={i} className="flex items-center gap-1 text-[10px] text-muted-foreground/50 font-mono shrink-0">
233
- <span className="text-primary/50 font-medium">{s.name}</span>
234
- <span className="truncate max-w-[200px]">{s.short}</span>
235
- {tools[i].result && (
236
- <CheckCircle2 className="h-2.5 w-2.5 text-primary/30 shrink-0" />
237
- )}
238
- </span>
239
- ))}
240
- </div>
241
- </button>
636
+ function getQuickPrompts(channel: ChannelId, workspace: WorkspaceSummary | null) {
637
+ const watchlist = workspace?.tradingview.watchlist.join(", ") || "SPY, QQQ, BTCUSD, NQ1!";
242
638
 
243
- {expanded && (
244
- <div className="ml-4 mt-1 space-y-1 mb-1">
245
- {tools.map((t, i) => (
246
- <ToolDetail key={i} tool={t.tool} result={t.result} />
247
- ))}
248
- </div>
249
- )}
250
- </div>
251
- );
639
+ switch (channel) {
640
+ case "markets":
641
+ return [
642
+ "Compare the strongest overlapping Kalshi and Polymarket themes.",
643
+ "Find one liquid macro contract worth deeper work.",
644
+ ];
645
+ case "feeds":
646
+ return [
647
+ "Summarize what changed in the last hour.",
648
+ "Separate new information from priced-in narrative.",
649
+ ];
650
+ case "risk":
651
+ return [
652
+ "Audit the open book and tell me what should be cut.",
653
+ "List the weakest assumption in each active thesis.",
654
+ ];
655
+ case "execution":
656
+ return [
657
+ "Tell me if there is one executable trade right now or a clear pass.",
658
+ "Check supported rails before proposing a trade.",
659
+ ];
660
+ case "tradingview":
661
+ return [
662
+ `Scan the TradingView watchlist: ${watchlist}.`,
663
+ "Tell me which symbols deserve a closer look and why.",
664
+ ];
665
+ case "all":
666
+ return [
667
+ "Explain the full workspace setup.",
668
+ "Give me the smartest next command.",
669
+ ];
670
+ default:
671
+ return [
672
+ "Audit the workspace and tell me what is missing.",
673
+ "Warm boot the harness and propose the best paper trade.",
674
+ ];
675
+ }
252
676
  }
253
677
 
254
- function ToolDetail({
255
- tool,
256
- result,
257
- }: {
258
- tool: StreamLine;
259
- result?: StreamLine;
260
- }) {
261
- const resultText = result?.text;
262
- const isNoOutput =
263
- resultText === "(Bash completed with no output)" || !resultText;
264
-
265
- return (
266
- <div className="rounded border border-border/30 bg-background/30 text-[10px]">
267
- {tool.input && (
268
- <pre className="px-2.5 py-1.5 font-mono text-muted-foreground/50 whitespace-pre-wrap break-all leading-relaxed">
269
- {tool.input}
270
- </pre>
271
- )}
272
- {resultText && !isNoOutput && (
273
- <pre className="px-2.5 py-1.5 font-mono text-muted-foreground/40 whitespace-pre-wrap break-all max-h-32 overflow-y-auto leading-relaxed border-t border-border/20">
274
- {resultText}
275
- </pre>
276
- )}
277
- </div>
278
- );
678
+ function getPlaceholder(channel: ChannelId, workspace: WorkspaceSummary | null) {
679
+ if (channel === "tradingview") {
680
+ const watchlist = workspace?.tradingview.watchlist.join(", ") || "SPY, QQQ, BTCUSD, NQ1!";
681
+ return `Ask about the TradingView lane, watchlist, or connector setup (${watchlist})`;
682
+ }
683
+ if (channel === "risk") {
684
+ return "Ask for exits, exposure review, weak theses, or sizing discipline";
685
+ }
686
+ if (channel === "execution") {
687
+ return "Ask whether a trade is actually supported, executable, and worth firing";
688
+ }
689
+ if (channel === "feeds") {
690
+ return "Ask what changed in the feeds and what matters now";
691
+ }
692
+ if (channel === "markets") {
693
+ return "Ask for a cross-market scan, compare rails, or find a contract";
694
+ }
695
+ return "Ask the harness what to scan, compare, explain, or trade";
279
696
  }
280
697
 
281
698
  const markdownComponents = {
282
699
  h1: ({ children }: { children?: React.ReactNode }) => (
283
- <h1 className="text-[13px] font-bold text-foreground mt-2.5 mb-1 first:mt-0">
284
- {children}
285
- </h1>
700
+ <h1 className="mb-1 text-[13px] font-bold text-white first:mt-0">{children}</h1>
286
701
  ),
287
702
  h2: ({ children }: { children?: React.ReactNode }) => (
288
- <h2 className="text-[12px] font-bold text-foreground mt-2 mb-1 first:mt-0">
289
- {children}
290
- </h2>
703
+ <h2 className="mb-1 text-[12px] font-bold text-white first:mt-0">{children}</h2>
291
704
  ),
292
705
  h3: ({ children }: { children?: React.ReactNode }) => (
293
- <h3 className="text-[11px] font-semibold text-foreground/90 mt-2 mb-0.5 first:mt-0">
294
- {children}
295
- </h3>
706
+ <h3 className="mb-0.5 text-[11px] font-semibold text-[#fff7fb] first:mt-0">{children}</h3>
296
707
  ),
297
708
  p: ({ children }: { children?: React.ReactNode }) => (
298
- <p className="text-[12px] text-foreground/80 leading-relaxed mb-1 last:mb-0">
299
- {children}
300
- </p>
709
+ <p className="mb-1 text-[12px] leading-7 text-[#f2dce5]/84 last:mb-0">{children}</p>
301
710
  ),
302
711
  strong: ({ children }: { children?: React.ReactNode }) => (
303
- <strong className="font-semibold text-foreground">{children}</strong>
712
+ <strong className="font-semibold text-white">{children}</strong>
304
713
  ),
305
714
  em: ({ children }: { children?: React.ReactNode }) => (
306
- <em className="text-foreground/60">{children}</em>
715
+ <em className="text-[#ffd7ae]">{children}</em>
307
716
  ),
308
717
  ul: ({ children }: { children?: React.ReactNode }) => (
309
- <ul className="text-[12px] text-foreground/80 ml-3 mb-1 space-y-0 list-disc list-outside">
310
- {children}
311
- </ul>
718
+ <ul className="ml-4 mb-1 list-disc space-y-0 text-[12px] text-[#f2dce5]/84">{children}</ul>
312
719
  ),
313
720
  ol: ({ children }: { children?: React.ReactNode }) => (
314
- <ol className="text-[12px] text-foreground/80 ml-3 mb-1 space-y-0 list-decimal list-outside">
315
- {children}
316
- </ol>
721
+ <ol className="ml-4 mb-1 list-decimal space-y-0 text-[12px] text-[#f2dce5]/84">{children}</ol>
317
722
  ),
318
723
  li: ({ children }: { children?: React.ReactNode }) => (
319
- <li className="leading-relaxed">{children}</li>
724
+ <li className="leading-7">{children}</li>
320
725
  ),
321
726
  a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
322
727
  <a
323
728
  href={href}
324
729
  target="_blank"
325
730
  rel="noopener noreferrer"
326
- className="text-primary/80 hover:text-primary underline underline-offset-2 transition-colors"
731
+ className="text-[#ffd7ae] underline underline-offset-2 transition-colors hover:text-white"
327
732
  >
328
733
  {children}
329
734
  </a>
@@ -331,64 +736,36 @@ const markdownComponents = {
331
736
  code: ({ children, className }: { children?: React.ReactNode; className?: string }) => {
332
737
  if (className?.includes("language-")) {
333
738
  return (
334
- <code className="block bg-background/50 border border-border/30 rounded px-2.5 py-1.5 text-[10px] font-mono text-foreground/60 overflow-x-auto my-1 whitespace-pre">
739
+ <code className="my-1 block overflow-x-auto rounded bg-[#120913] px-2.5 py-1.5 font-mono text-[10px] text-[#f7e6ee]/70">
335
740
  {children}
336
741
  </code>
337
742
  );
338
743
  }
339
744
  return (
340
- <code className="bg-background/50 rounded px-1 py-0.5 text-[10px] font-mono text-primary/70">
745
+ <code className="rounded bg-[#120913] px-1 py-0.5 font-mono text-[10px] text-[#ffd7ae]">
341
746
  {children}
342
747
  </code>
343
748
  );
344
749
  },
345
- pre: ({ children }: { children?: React.ReactNode }) => (
346
- <pre className="my-1">{children}</pre>
347
- ),
750
+ pre: ({ children }: { children?: React.ReactNode }) => <pre className="my-1">{children}</pre>,
348
751
  blockquote: ({ children }: { children?: React.ReactNode }) => (
349
- <blockquote className="border-l-2 border-primary/20 pl-2.5 my-1 text-foreground/50 italic">
752
+ <blockquote className="my-1 border-l-2 border-[#8f627a] pl-2.5 italic text-[#d6bdc8]">
350
753
  {children}
351
754
  </blockquote>
352
755
  ),
353
756
  table: ({ children }: { children?: React.ReactNode }) => (
354
- <div className="overflow-x-auto my-1.5 rounded border border-border/30">
757
+ <div className="my-1.5 overflow-x-auto rounded border border-[#5c3b4f]">
355
758
  <table className="w-full text-[10px]">{children}</table>
356
759
  </div>
357
760
  ),
358
761
  thead: ({ children }: { children?: React.ReactNode }) => (
359
- <thead className="bg-secondary/30 border-b border-border/30">
360
- {children}
361
- </thead>
762
+ <thead className="border-b border-[#5c3b4f] bg-[#2a1121]">{children}</thead>
362
763
  ),
363
764
  th: ({ children }: { children?: React.ReactNode }) => (
364
- <th className="text-left px-2 py-1 font-semibold text-foreground/70 text-[10px]">
365
- {children}
366
- </th>
765
+ <th className="px-2 py-1 text-left font-semibold text-[#fff3f8]/75">{children}</th>
367
766
  ),
368
767
  td: ({ children }: { children?: React.ReactNode }) => (
369
- <td className="px-2 py-1 text-foreground/60 border-t border-border/15 font-mono">
370
- {children}
371
- </td>
768
+ <td className="border-t border-[#4f3042] px-2 py-1 font-mono text-[#ead4de]/68">{children}</td>
372
769
  ),
373
- hr: () => <hr className="border-border/20 my-2" />,
770
+ hr: () => <hr className="my-2 border-[#4f3042]" />,
374
771
  };
375
-
376
- function formatToolInput(raw: string): string {
377
- try {
378
- const parsed = JSON.parse(raw);
379
- if (parsed.command) {
380
- const cmd = parsed.command as string;
381
- return cmd.length > 60 ? cmd.slice(0, 60) + "..." : cmd;
382
- }
383
- if (parsed.query) return parsed.query;
384
- if (parsed.file_path) {
385
- const fp = parsed.file_path as string;
386
- const parts = fp.split("/");
387
- return parts.length > 2 ? ".../" + parts.slice(-2).join("/") : fp;
388
- }
389
- if (parsed.pattern) return parsed.pattern;
390
- return raw.length > 60 ? raw.slice(0, 60) + "..." : raw;
391
- } catch {
392
- return raw.length > 60 ? raw.slice(0, 60) + "..." : raw;
393
- }
394
- }