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
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
import { BoxRenderable, TextRenderable, type CliRenderer } from "@opentui/core";
|
|
2
|
+
import { COLORS } from "../theme/colors.ts";
|
|
3
|
+
import { ICONS } from "../theme/icons.ts";
|
|
4
|
+
import { store } from "../state/store.ts";
|
|
5
|
+
import { isAuthenticated } from "../platform/auth.ts";
|
|
6
|
+
import { platform } from "../platform/client.ts";
|
|
7
|
+
import type { Deployment } from "../state/types.ts";
|
|
8
|
+
|
|
9
|
+
let privacyMode = false;
|
|
10
|
+
export function setPrivacyMode(enabled: boolean): void { privacyMode = enabled; }
|
|
11
|
+
|
|
12
|
+
function blur(value: string): string {
|
|
13
|
+
return privacyMode ? "***" : value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const BRAILLE = [
|
|
17
|
+
"\u2801", "\u2803", "\u2807", "\u280f", "\u281f", "\u283f", "\u287f", "\u28ff",
|
|
18
|
+
"\u28fe", "\u28fc", "\u28f8", "\u28f0", "\u28e0", "\u28c0", "\u2880", "\u2800",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const SPARK = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
|
|
22
|
+
|
|
23
|
+
function sparkline(values: number[], width: number): string {
|
|
24
|
+
if (values.length === 0) return "";
|
|
25
|
+
const sampled: number[] = [];
|
|
26
|
+
for (let i = 0; i < width; i++) {
|
|
27
|
+
const idx = Math.floor((i / width) * values.length);
|
|
28
|
+
sampled.push(values[idx] ?? 0);
|
|
29
|
+
}
|
|
30
|
+
const min = Math.min(...sampled);
|
|
31
|
+
const max = Math.max(...sampled);
|
|
32
|
+
const range = max - min || 1;
|
|
33
|
+
return sampled.map((v) => {
|
|
34
|
+
const norm = (v - min) / range;
|
|
35
|
+
const idx = Math.min(Math.floor(norm * SPARK.length), SPARK.length - 1);
|
|
36
|
+
return SPARK[idx];
|
|
37
|
+
}).join("");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function formatUptime(startedAt: number): string {
|
|
41
|
+
const ms = Date.now() - startedAt;
|
|
42
|
+
const days = Math.floor(ms / 86400000);
|
|
43
|
+
const hours = Math.floor((ms % 86400000) / 3600000);
|
|
44
|
+
if (days > 0) return `${days}d ${hours}h`;
|
|
45
|
+
const mins = Math.floor((ms % 3600000) / 60000);
|
|
46
|
+
return `${hours}h ${mins}m`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class StrategyPanel {
|
|
50
|
+
private panel: BoxRenderable;
|
|
51
|
+
private listBox: BoxRenderable;
|
|
52
|
+
private detailBox: BoxRenderable;
|
|
53
|
+
private _visible = false;
|
|
54
|
+
private selectedIndex = 0;
|
|
55
|
+
private expandedId: string | null = null;
|
|
56
|
+
private spinnerTimer: ReturnType<typeof setInterval> | null = null;
|
|
57
|
+
private spinnerFrame = 0;
|
|
58
|
+
private spinnerNodes: TextRenderable[] = [];
|
|
59
|
+
|
|
60
|
+
constructor(private renderer: CliRenderer) {
|
|
61
|
+
this.panel = new BoxRenderable(renderer, {
|
|
62
|
+
id: "strat-panel",
|
|
63
|
+
position: "absolute",
|
|
64
|
+
right: 0,
|
|
65
|
+
top: 0,
|
|
66
|
+
width: 52,
|
|
67
|
+
height: "100%",
|
|
68
|
+
backgroundColor: COLORS.bgDarker,
|
|
69
|
+
flexDirection: "column",
|
|
70
|
+
border: ["left"],
|
|
71
|
+
borderStyle: "single",
|
|
72
|
+
borderColor: COLORS.borderDim,
|
|
73
|
+
paddingTop: 1,
|
|
74
|
+
paddingLeft: 1,
|
|
75
|
+
paddingRight: 1,
|
|
76
|
+
});
|
|
77
|
+
this.panel.visible = false;
|
|
78
|
+
|
|
79
|
+
this.panel.add(new TextRenderable(renderer, {
|
|
80
|
+
id: "strat-title",
|
|
81
|
+
content: "strategies",
|
|
82
|
+
fg: COLORS.textMuted,
|
|
83
|
+
attributes: 1,
|
|
84
|
+
marginBottom: 1,
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
this.listBox = new BoxRenderable(renderer, {
|
|
88
|
+
id: "strat-list",
|
|
89
|
+
flexDirection: "column",
|
|
90
|
+
});
|
|
91
|
+
this.panel.add(this.listBox);
|
|
92
|
+
|
|
93
|
+
this.panel.add(new TextRenderable(renderer, {
|
|
94
|
+
id: "strat-divider",
|
|
95
|
+
content: "\u2500".repeat(48),
|
|
96
|
+
fg: COLORS.borderDim,
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
this.detailBox = new BoxRenderable(renderer, {
|
|
100
|
+
id: "strat-detail",
|
|
101
|
+
flexDirection: "column",
|
|
102
|
+
flexGrow: 1,
|
|
103
|
+
paddingTop: 1,
|
|
104
|
+
});
|
|
105
|
+
this.panel.add(this.detailBox);
|
|
106
|
+
|
|
107
|
+
this.panel.add(new TextRenderable(renderer, {
|
|
108
|
+
id: "strat-hints",
|
|
109
|
+
content: "\u2191\u2193 nav \u23ce expand esc close",
|
|
110
|
+
fg: COLORS.borderDim,
|
|
111
|
+
}));
|
|
112
|
+
|
|
113
|
+
renderer.root.add(this.panel);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
get visible(): boolean { return this._visible; }
|
|
117
|
+
|
|
118
|
+
toggle(): void { this._visible ? this.hide() : this.show(); }
|
|
119
|
+
|
|
120
|
+
show(): void {
|
|
121
|
+
this._visible = true;
|
|
122
|
+
this.panel.visible = true;
|
|
123
|
+
this.selectedIndex = 0;
|
|
124
|
+
this.expandedId = null;
|
|
125
|
+
this.updateList();
|
|
126
|
+
this.updateDetail();
|
|
127
|
+
this.startSpinner();
|
|
128
|
+
this.renderer.requestRender();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
hide(): void {
|
|
132
|
+
this._visible = false;
|
|
133
|
+
this.panel.visible = false;
|
|
134
|
+
this.stopSpinner();
|
|
135
|
+
this.renderer.requestRender();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
navigate(delta: number): void {
|
|
139
|
+
const n = store.get().deployments.length;
|
|
140
|
+
if (n === 0) return;
|
|
141
|
+
this.selectedIndex = Math.max(0, Math.min(n - 1, this.selectedIndex + delta));
|
|
142
|
+
this.updateList();
|
|
143
|
+
if (this.expandedId) {
|
|
144
|
+
this.expandedId = store.get().deployments[this.selectedIndex]?.id ?? null;
|
|
145
|
+
this.updateDetail();
|
|
146
|
+
}
|
|
147
|
+
this.renderer.requestRender();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
toggleExpand(): void {
|
|
151
|
+
const d = store.get().deployments[this.selectedIndex];
|
|
152
|
+
if (!d) return;
|
|
153
|
+
this.expandedId = this.expandedId === d.id ? null : d.id;
|
|
154
|
+
this.updateList();
|
|
155
|
+
this.updateDetail();
|
|
156
|
+
this.renderer.requestRender();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
update(): void {
|
|
160
|
+
if (!this._visible) return;
|
|
161
|
+
this.updateList();
|
|
162
|
+
if (this.expandedId) this.updateDetail();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private startSpinner(): void {
|
|
166
|
+
if (this.spinnerTimer) return;
|
|
167
|
+
this.spinnerFrame = 0;
|
|
168
|
+
this.spinnerTimer = setInterval(() => {
|
|
169
|
+
this.spinnerFrame = (this.spinnerFrame + 1) % BRAILLE.length;
|
|
170
|
+
const ch = BRAILLE[this.spinnerFrame]!;
|
|
171
|
+
for (const n of this.spinnerNodes) n.content = ch;
|
|
172
|
+
this.renderer.requestRender();
|
|
173
|
+
}, 60);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private stopSpinner(): void {
|
|
177
|
+
if (this.spinnerTimer) { clearInterval(this.spinnerTimer); this.spinnerTimer = null; }
|
|
178
|
+
this.spinnerNodes = [];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── List ──
|
|
182
|
+
|
|
183
|
+
private updateList(): void {
|
|
184
|
+
this.spinnerNodes = [];
|
|
185
|
+
for (const c of this.listBox.getChildren()) this.listBox.remove(c.id);
|
|
186
|
+
|
|
187
|
+
const deployments = store.get().deployments;
|
|
188
|
+
if (deployments.length === 0) {
|
|
189
|
+
this.listBox.add(new TextRenderable(this.renderer, { id: "strat-empty", content: " no strategies yet", fg: COLORS.textMuted }));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Fixed column layout: ptr(2) status(2) name(22) status_label(8) pnl(10)
|
|
194
|
+
for (let i = 0; i < deployments.length; i++) {
|
|
195
|
+
const d = deployments[i]!;
|
|
196
|
+
const sel = i === this.selectedIndex;
|
|
197
|
+
|
|
198
|
+
const row = new BoxRenderable(this.renderer, {
|
|
199
|
+
id: `strat-row-${i}`,
|
|
200
|
+
flexDirection: "row",
|
|
201
|
+
width: "100%",
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Pointer (2 chars)
|
|
205
|
+
row.add(new TextRenderable(this.renderer, {
|
|
206
|
+
id: `strat-ptr-${i}`,
|
|
207
|
+
content: sel ? `${ICONS.pointer} ` : " ",
|
|
208
|
+
fg: COLORS.textMuted,
|
|
209
|
+
}));
|
|
210
|
+
|
|
211
|
+
// Status icon (2 chars)
|
|
212
|
+
if (["running", "starting", "pending"].includes(d.status)) {
|
|
213
|
+
const s = new TextRenderable(this.renderer, {
|
|
214
|
+
id: `strat-spin-${i}`, content: BRAILLE[this.spinnerFrame]!, fg: COLORS.success,
|
|
215
|
+
});
|
|
216
|
+
this.spinnerNodes.push(s);
|
|
217
|
+
row.add(s);
|
|
218
|
+
row.add(new TextRenderable(this.renderer, { id: `strat-sp1-${i}`, content: " ", fg: COLORS.textMuted }));
|
|
219
|
+
} else {
|
|
220
|
+
const icon = d.status === "error" ? "x" : d.status === "stopped" ? "-" : ".";
|
|
221
|
+
const color = d.status === "error" ? COLORS.error : d.status === "stopped" ? COLORS.textMuted : COLORS.borderDim;
|
|
222
|
+
row.add(new TextRenderable(this.renderer, {
|
|
223
|
+
id: `strat-icon-${i}`, content: `${icon} `, fg: color,
|
|
224
|
+
}));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Name (truncated to 22 chars, padded)
|
|
228
|
+
const name = d.name.length > 22 ? d.name.slice(0, 21) + "." : d.name.padEnd(22);
|
|
229
|
+
row.add(new TextRenderable(this.renderer, {
|
|
230
|
+
id: `strat-name-${i}`,
|
|
231
|
+
content: name,
|
|
232
|
+
fg: sel ? COLORS.text : COLORS.textMuted,
|
|
233
|
+
attributes: sel ? 1 : 0,
|
|
234
|
+
}));
|
|
235
|
+
|
|
236
|
+
// Status label (8 chars)
|
|
237
|
+
const statusLabel = d.status.padEnd(8).slice(0, 8);
|
|
238
|
+
const statusColor = d.status === "running" ? COLORS.success
|
|
239
|
+
: d.status === "error" ? COLORS.error
|
|
240
|
+
: COLORS.borderDim;
|
|
241
|
+
row.add(new TextRenderable(this.renderer, {
|
|
242
|
+
id: `strat-status-${i}`,
|
|
243
|
+
content: ` ${statusLabel}`,
|
|
244
|
+
fg: statusColor,
|
|
245
|
+
}));
|
|
246
|
+
|
|
247
|
+
// P&L (right-aligned, 10 chars)
|
|
248
|
+
const pnl = d.metrics.total_pnl;
|
|
249
|
+
const pnlStr = pnl === 0 ? "" : this.fmtPnl(pnl);
|
|
250
|
+
row.add(new BoxRenderable(this.renderer, { id: `strat-sp-${i}`, flexGrow: 1 }));
|
|
251
|
+
if (pnlStr) {
|
|
252
|
+
row.add(new TextRenderable(this.renderer, {
|
|
253
|
+
id: `strat-pnl-${i}`,
|
|
254
|
+
content: pnlStr,
|
|
255
|
+
fg: pnl >= 0 ? COLORS.success : COLORS.error,
|
|
256
|
+
}));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
this.listBox.add(row);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── Detail ──
|
|
264
|
+
|
|
265
|
+
private updateDetail(): void {
|
|
266
|
+
for (const c of this.detailBox.getChildren()) this.detailBox.remove(c.id);
|
|
267
|
+
if (!this.expandedId) {
|
|
268
|
+
this.detailBox.add(new TextRenderable(this.renderer, {
|
|
269
|
+
id: "strat-detail-hint", content: "select a deployment and press enter", fg: COLORS.borderDim,
|
|
270
|
+
}));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const d = store.get().deployments.find((d) => d.id === this.expandedId);
|
|
275
|
+
if (!d) return;
|
|
276
|
+
const m = d.metrics;
|
|
277
|
+
|
|
278
|
+
// ── Equity sparkline ──
|
|
279
|
+
if (d.pnl_history.length > 3) {
|
|
280
|
+
const last = d.pnl_history[d.pnl_history.length - 1] ?? 0;
|
|
281
|
+
const first = d.pnl_history[0] ?? 0;
|
|
282
|
+
const color = last >= first ? COLORS.success : COLORS.error;
|
|
283
|
+
const spark = sparkline(d.pnl_history, 36);
|
|
284
|
+
|
|
285
|
+
this.detailBox.add(new TextRenderable(this.renderer, {
|
|
286
|
+
id: "strat-chart-label", content: "equity", fg: COLORS.borderDim,
|
|
287
|
+
}));
|
|
288
|
+
this.detailBox.add(new TextRenderable(this.renderer, {
|
|
289
|
+
id: "strat-chart", content: spark, fg: color,
|
|
290
|
+
}));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── Key metrics grid ──
|
|
294
|
+
this.detailBox.add(new TextRenderable(this.renderer, {
|
|
295
|
+
id: "strat-m-sep", content: "", fg: COLORS.borderDim,
|
|
296
|
+
}));
|
|
297
|
+
|
|
298
|
+
const grid: [string, string, string?][] = [
|
|
299
|
+
["P&L", this.fmtPnl(m.total_pnl), m.total_pnl >= 0 ? COLORS.success : COLORS.error],
|
|
300
|
+
["Realized", this.fmtPnl(m.realized_pnl)],
|
|
301
|
+
["Unrealized", this.fmtPnl(m.unrealized_pnl)],
|
|
302
|
+
["Exposure", blur(`$${m.total_exposure.toLocaleString()}`)],
|
|
303
|
+
["Sharpe", m.sharpe_ratio.toFixed(2), m.sharpe_ratio >= 1.5 ? COLORS.success : m.sharpe_ratio < 0 ? COLORS.error : undefined],
|
|
304
|
+
["Win Rate", `${(m.win_rate * 100).toFixed(0)}%`],
|
|
305
|
+
["Profit Fct", m.profit_factor > 0 ? m.profit_factor.toFixed(1) : "\u2014"],
|
|
306
|
+
["Max DD", `${m.max_drawdown_pct.toFixed(1)}%`, m.max_drawdown_pct < -2 ? COLORS.error : undefined],
|
|
307
|
+
["Trades", `${m.total_trades}`],
|
|
308
|
+
["Avg Return", `$${m.avg_return_per_trade.toFixed(2)}`],
|
|
309
|
+
["Uptime", formatUptime(d.started_at)],
|
|
310
|
+
["Orders", `${m.open_order_count} open`],
|
|
311
|
+
];
|
|
312
|
+
|
|
313
|
+
for (let i = 0; i < grid.length; i++) {
|
|
314
|
+
const [label, value, color] = grid[i]!;
|
|
315
|
+
const row = new BoxRenderable(this.renderer, { id: `strat-g-${i}`, flexDirection: "row" });
|
|
316
|
+
row.add(new TextRenderable(this.renderer, {
|
|
317
|
+
id: `strat-gl-${i}`, content: (label ?? "").padEnd(12), fg: COLORS.textMuted,
|
|
318
|
+
}));
|
|
319
|
+
row.add(new TextRenderable(this.renderer, {
|
|
320
|
+
id: `strat-gv-${i}`, content: value ?? "", fg: (color as string) ?? COLORS.text,
|
|
321
|
+
}));
|
|
322
|
+
this.detailBox.add(row);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ── Positions ──
|
|
326
|
+
if (d.positions.length > 0) {
|
|
327
|
+
this.detailBox.add(new TextRenderable(this.renderer, {
|
|
328
|
+
id: "strat-pos-h", content: "\npositions", fg: COLORS.borderDim,
|
|
329
|
+
}));
|
|
330
|
+
|
|
331
|
+
for (let i = 0; i < Math.min(d.positions.length, 5); i++) {
|
|
332
|
+
const p = d.positions[i]!;
|
|
333
|
+
const pnl = p.unrealized_pnl;
|
|
334
|
+
const pnlStr = pnl >= 0 ? `+$${pnl.toFixed(0)}` : `-$${Math.abs(pnl).toFixed(0)}`;
|
|
335
|
+
const sideChar = p.side === "BUY" ? "\u25b2" : "\u25bc";
|
|
336
|
+
const sizeStr = blur(`${p.size}@${p.avg_entry_price.toFixed(2)}`);
|
|
337
|
+
const row = new BoxRenderable(this.renderer, { id: `strat-pos-${i}`, flexDirection: "row" });
|
|
338
|
+
row.add(new TextRenderable(this.renderer, {
|
|
339
|
+
id: `strat-ps-${i}`, content: ` ${sideChar} `, fg: p.side === "BUY" ? COLORS.success : COLORS.error,
|
|
340
|
+
}));
|
|
341
|
+
row.add(new TextRenderable(this.renderer, {
|
|
342
|
+
id: `strat-pn-${i}`, content: p.slug, fg: COLORS.textMuted,
|
|
343
|
+
}));
|
|
344
|
+
row.add(new BoxRenderable(this.renderer, { id: `strat-psp-${i}`, flexGrow: 1 }));
|
|
345
|
+
row.add(new TextRenderable(this.renderer, {
|
|
346
|
+
id: `strat-pp-${i}`, content: `${sizeStr} ${blur(pnlStr)}`,
|
|
347
|
+
fg: privacyMode ? COLORS.textMuted : (pnl >= 0 ? COLORS.success : COLORS.error),
|
|
348
|
+
}));
|
|
349
|
+
this.detailBox.add(row);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ── Recent orders ──
|
|
354
|
+
if (d.orders.length > 0) {
|
|
355
|
+
this.detailBox.add(new TextRenderable(this.renderer, {
|
|
356
|
+
id: "strat-ord-h", content: "\nrecent orders", fg: COLORS.borderDim,
|
|
357
|
+
}));
|
|
358
|
+
|
|
359
|
+
for (let i = 0; i < Math.min(d.orders.length, 3); i++) {
|
|
360
|
+
const o = d.orders[i]!;
|
|
361
|
+
const statusChar = o.status === "FILLED" ? ICONS.check : o.status === "LIVE" ? ICONS.running : ICONS.dot;
|
|
362
|
+
this.detailBox.add(new TextRenderable(this.renderer, {
|
|
363
|
+
id: `strat-ord-${i}`,
|
|
364
|
+
content: ` ${statusChar} ${o.side} ${o.slug} ${blur(`${o.size}@${o.price.toFixed(2)}`)} ${o.status.toLowerCase()}`,
|
|
365
|
+
fg: o.status === "FILLED" ? COLORS.textMuted : COLORS.text,
|
|
366
|
+
}));
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── Actions ──
|
|
371
|
+
this.detailBox.add(new TextRenderable(this.renderer, {
|
|
372
|
+
id: "strat-act-sep", content: "\n" + "\u2500".repeat(38), fg: COLORS.borderDim,
|
|
373
|
+
}));
|
|
374
|
+
|
|
375
|
+
const actions: string[] = [];
|
|
376
|
+
if (d.status === "running") {
|
|
377
|
+
actions.push("p stop", "k kill");
|
|
378
|
+
} else if (d.status === "stopped" || d.status === "error" || d.status === "killed") {
|
|
379
|
+
actions.push("r restart");
|
|
380
|
+
} else if (d.status === "starting" || d.status === "pending") {
|
|
381
|
+
actions.push("k kill");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (actions.length > 0) {
|
|
385
|
+
this.detailBox.add(new TextRenderable(this.renderer, {
|
|
386
|
+
id: "strat-actions",
|
|
387
|
+
content: actions.join(" \u00b7 "),
|
|
388
|
+
fg: COLORS.textMuted,
|
|
389
|
+
}));
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ── Actions ──
|
|
394
|
+
|
|
395
|
+
handleAction(key: string): void {
|
|
396
|
+
if (!this.expandedId) return;
|
|
397
|
+
const deployments = store.get().deployments;
|
|
398
|
+
const idx = deployments.findIndex((d) => d.id === this.expandedId);
|
|
399
|
+
if (idx < 0) return;
|
|
400
|
+
const d = deployments[idx]!;
|
|
401
|
+
const strategyId = d.strategyId;
|
|
402
|
+
|
|
403
|
+
let newStatus = d.status;
|
|
404
|
+
if (key === "p" && d.status === "running") newStatus = "stopped";
|
|
405
|
+
else if (key === "k" && ["running", "starting", "pending"].includes(d.status)) newStatus = "killed";
|
|
406
|
+
else if (key === "r" && ["stopped", "error", "killed"].includes(d.status)) newStatus = "starting";
|
|
407
|
+
else return;
|
|
408
|
+
|
|
409
|
+
// Optimistic update
|
|
410
|
+
const updated = deployments.map((dep) =>
|
|
411
|
+
dep.id === d.id ? { ...dep, status: newStatus as any } : dep
|
|
412
|
+
);
|
|
413
|
+
store.update({ deployments: updated });
|
|
414
|
+
this.updateList();
|
|
415
|
+
this.updateDetail();
|
|
416
|
+
this.renderer.requestRender();
|
|
417
|
+
|
|
418
|
+
if (!isAuthenticated()) return;
|
|
419
|
+
|
|
420
|
+
// All actions go through the platform API — it holds the worker secrets
|
|
421
|
+
if (key === "p" || key === "k") {
|
|
422
|
+
platform.stop(strategyId).catch((err) => {
|
|
423
|
+
this.showError(d.id, `Stop failed: ${err.message}`);
|
|
424
|
+
});
|
|
425
|
+
} else if (key === "r") {
|
|
426
|
+
platform.listDeployments(strategyId).then((deps) => {
|
|
427
|
+
const lastDep = deps.sort((a, b) =>
|
|
428
|
+
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
|
429
|
+
)[0];
|
|
430
|
+
const credId = lastDep?.credential_id;
|
|
431
|
+
if (credId) {
|
|
432
|
+
platform.deploy(strategyId, {
|
|
433
|
+
credentialId: credId,
|
|
434
|
+
dryRun: d.dry_run,
|
|
435
|
+
deploymentMode: d.mode,
|
|
436
|
+
}).catch((err) => {
|
|
437
|
+
this.showError(d.id, `Restart failed: ${err.message}`);
|
|
438
|
+
});
|
|
439
|
+
} else {
|
|
440
|
+
this.showError(d.id, "No credential found for restart");
|
|
441
|
+
}
|
|
442
|
+
}).catch((err) => {
|
|
443
|
+
this.showError(d.id, `Restart failed: ${err.message}`);
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private showError(deploymentId: string, message: string): void {
|
|
449
|
+
// Revert optimistic update and show error in status
|
|
450
|
+
const deployments = store.get().deployments;
|
|
451
|
+
const updated = deployments.map((dep) =>
|
|
452
|
+
dep.id === deploymentId ? { ...dep, status: "error" as any } : dep
|
|
453
|
+
);
|
|
454
|
+
store.update({ deployments: updated });
|
|
455
|
+
// Show error in detail panel
|
|
456
|
+
this.detailBox.add(new TextRenderable(this.renderer, {
|
|
457
|
+
id: "strat-error",
|
|
458
|
+
content: `\n${message}`,
|
|
459
|
+
fg: COLORS.error,
|
|
460
|
+
}));
|
|
461
|
+
this.updateList();
|
|
462
|
+
this.renderer.requestRender();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ── Helpers ──
|
|
466
|
+
|
|
467
|
+
private statusIcon(status: string): string {
|
|
468
|
+
switch (status) {
|
|
469
|
+
case "running": return ICONS.running;
|
|
470
|
+
case "stopped": return ICONS.stopped;
|
|
471
|
+
case "error": case "killed": return ICONS.error;
|
|
472
|
+
case "scanner_idle": return ICONS.paused;
|
|
473
|
+
default: return ICONS.dot;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private statusColor(status: string): string {
|
|
478
|
+
switch (status) {
|
|
479
|
+
case "running": return COLORS.success;
|
|
480
|
+
case "error": case "killed": return COLORS.error;
|
|
481
|
+
default: return COLORS.textMuted;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private fmtPnl(pnl: number): string {
|
|
486
|
+
const raw = pnl >= 0 ? `+$${pnl.toFixed(0)}` : `-$${Math.abs(pnl).toFixed(0)}`;
|
|
487
|
+
return blur(raw);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Chat bar — shows open chats at the top of the chat column
|
|
2
|
+
// Each chat is an independent workspace with its own mode, messages, strategy draft
|
|
3
|
+
|
|
4
|
+
import { BoxRenderable, TextRenderable, type CliRenderer } from "@opentui/core";
|
|
5
|
+
import { COLORS } from "../theme/colors.ts";
|
|
6
|
+
import { store } from "../state/store.ts";
|
|
7
|
+
|
|
8
|
+
const MODE_LABEL: Record<string, string> = {
|
|
9
|
+
research: "(r)",
|
|
10
|
+
strategy: "(s)",
|
|
11
|
+
portfolio: "(p)",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const MODE_COLOR: Record<string, string> = {
|
|
15
|
+
research: "#5c9cf5", // COLORS.secondary
|
|
16
|
+
strategy: "#9d7cd8", // COLORS.accent
|
|
17
|
+
portfolio: "#7fd88f", // COLORS.success
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const BRAILLE = ["\u2801", "\u2803", "\u2807", "\u280f", "\u281f", "\u283f", "\u287f", "\u28ff", "\u28fe", "\u28fc", "\u28f8", "\u28f0", "\u28e0", "\u28c0", "\u2880", "\u2800"];
|
|
21
|
+
|
|
22
|
+
export class TabBar {
|
|
23
|
+
readonly container: BoxRenderable;
|
|
24
|
+
private spinnerFrame = 0;
|
|
25
|
+
private spinnerTimer: ReturnType<typeof setInterval> | null = null;
|
|
26
|
+
|
|
27
|
+
constructor(private renderer: CliRenderer) {
|
|
28
|
+
this.container = new BoxRenderable(renderer, {
|
|
29
|
+
id: "tab-bar",
|
|
30
|
+
height: 1,
|
|
31
|
+
width: "100%",
|
|
32
|
+
flexDirection: "row",
|
|
33
|
+
alignItems: "center",
|
|
34
|
+
backgroundColor: COLORS.bgDarker,
|
|
35
|
+
flexShrink: 0,
|
|
36
|
+
paddingLeft: 1,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
this.startSpinner();
|
|
40
|
+
this.update();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
update(): void {
|
|
44
|
+
for (const child of this.container.getChildren()) this.container.remove(child.id);
|
|
45
|
+
|
|
46
|
+
const state = store.get();
|
|
47
|
+
const { openTabIds, activeSessionId, sessions } = state;
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < openTabIds.length; i++) {
|
|
50
|
+
const tabId = openTabIds[i]!;
|
|
51
|
+
const session = sessions.find((s) => s.id === tabId);
|
|
52
|
+
if (!session) continue;
|
|
53
|
+
|
|
54
|
+
const isActive = tabId === activeSessionId;
|
|
55
|
+
const modeTag = MODE_LABEL[session.mode] ?? "(r)";
|
|
56
|
+
const modeColor = MODE_COLOR[session.mode] ?? COLORS.secondary;
|
|
57
|
+
|
|
58
|
+
// Truncate name
|
|
59
|
+
let name = session.name.length > 12 ? session.name.slice(0, 11) + "." : session.name;
|
|
60
|
+
|
|
61
|
+
// Streaming indicator
|
|
62
|
+
let indicator = "";
|
|
63
|
+
if (session.isStreaming) {
|
|
64
|
+
indicator = ` ${BRAILLE[this.spinnerFrame]!}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const label = ` ${name} ${modeTag}${indicator} `;
|
|
68
|
+
|
|
69
|
+
const tab = new TextRenderable(this.renderer, {
|
|
70
|
+
id: `tab-${i}`,
|
|
71
|
+
content: label,
|
|
72
|
+
fg: isActive ? "#212121" : COLORS.textMuted,
|
|
73
|
+
bg: isActive ? modeColor : undefined,
|
|
74
|
+
});
|
|
75
|
+
this.container.add(tab);
|
|
76
|
+
|
|
77
|
+
// Separator
|
|
78
|
+
if (i < openTabIds.length - 1) {
|
|
79
|
+
this.container.add(new TextRenderable(this.renderer, {
|
|
80
|
+
id: `tab-sep-${i}`,
|
|
81
|
+
content: " ",
|
|
82
|
+
fg: COLORS.borderDim,
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// [+] new tab button
|
|
88
|
+
this.container.add(new BoxRenderable(this.renderer, { id: "tab-spacer", flexGrow: 1 }));
|
|
89
|
+
this.container.add(new TextRenderable(this.renderer, {
|
|
90
|
+
id: "tab-new",
|
|
91
|
+
content: " + ",
|
|
92
|
+
fg: COLORS.textMuted,
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private startSpinner(): void {
|
|
97
|
+
this.spinnerTimer = setInterval(() => {
|
|
98
|
+
const anyStreaming = store.isAnyStreaming();
|
|
99
|
+
if (!anyStreaming) return;
|
|
100
|
+
this.spinnerFrame = (this.spinnerFrame + 1) % BRAILLE.length;
|
|
101
|
+
this.update();
|
|
102
|
+
this.renderer.requestRender();
|
|
103
|
+
}, 80);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
destroy(): void {
|
|
107
|
+
if (this.spinnerTimer) {
|
|
108
|
+
clearInterval(this.spinnerTimer);
|
|
109
|
+
this.spinnerTimer = null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|