horizon-code 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/assets/python/highlights.scm +137 -0
  2. package/assets/python/tree-sitter-python.wasm +0 -0
  3. package/bin/horizon.js +2 -0
  4. package/package.json +40 -0
  5. package/src/ai/client.ts +369 -0
  6. package/src/ai/system-prompt.ts +86 -0
  7. package/src/app.ts +1454 -0
  8. package/src/chat/messages.ts +48 -0
  9. package/src/chat/renderer.ts +243 -0
  10. package/src/chat/types.ts +18 -0
  11. package/src/components/code-panel.ts +329 -0
  12. package/src/components/footer.ts +72 -0
  13. package/src/components/hooks-panel.ts +224 -0
  14. package/src/components/input-bar.ts +193 -0
  15. package/src/components/mode-bar.ts +245 -0
  16. package/src/components/session-panel.ts +294 -0
  17. package/src/components/settings-panel.ts +372 -0
  18. package/src/components/splash.ts +156 -0
  19. package/src/components/strategy-panel.ts +489 -0
  20. package/src/components/tab-bar.ts +112 -0
  21. package/src/components/tutorial-panel.ts +680 -0
  22. package/src/components/widgets/progress-bar.ts +38 -0
  23. package/src/components/widgets/sparkline.ts +57 -0
  24. package/src/hooks/executor.ts +109 -0
  25. package/src/index.ts +22 -0
  26. package/src/keys/handler.ts +198 -0
  27. package/src/platform/auth.ts +36 -0
  28. package/src/platform/client.ts +159 -0
  29. package/src/platform/config.ts +121 -0
  30. package/src/platform/session-sync.ts +158 -0
  31. package/src/platform/supabase.ts +376 -0
  32. package/src/platform/sync.ts +149 -0
  33. package/src/platform/tiers.ts +103 -0
  34. package/src/platform/tools.ts +163 -0
  35. package/src/platform/types.ts +86 -0
  36. package/src/platform/usage.ts +224 -0
  37. package/src/research/apis.ts +367 -0
  38. package/src/research/tools.ts +205 -0
  39. package/src/research/widgets.ts +523 -0
  40. package/src/state/store.ts +256 -0
  41. package/src/state/types.ts +109 -0
  42. package/src/strategy/ascii-chart.ts +74 -0
  43. package/src/strategy/code-stream.ts +146 -0
  44. package/src/strategy/dashboard.ts +140 -0
  45. package/src/strategy/persistence.ts +82 -0
  46. package/src/strategy/prompts.ts +626 -0
  47. package/src/strategy/sandbox.ts +137 -0
  48. package/src/strategy/tools.ts +764 -0
  49. package/src/strategy/validator.ts +216 -0
  50. package/src/strategy/widgets.ts +270 -0
  51. package/src/syntax/setup.ts +54 -0
  52. package/src/theme/colors.ts +107 -0
  53. package/src/theme/icons.ts +27 -0
  54. package/src/util/hyperlink.ts +21 -0
@@ -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
+ }