gloomberb 0.9.1 → 0.9.3

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 (136) hide show
  1. package/README.md +23 -2
  2. package/bin/gloomberb +1 -1
  3. package/package.json +1 -1
  4. package/src/app.tsx +10 -7
  5. package/src/capabilities/registry.ts +1 -0
  6. package/src/capabilities/types.ts +23 -0
  7. package/src/cli/commands/api.ts +119 -0
  8. package/src/cli/commands/automation.ts +173 -0
  9. package/src/cli/commands/command-utils.ts +53 -0
  10. package/src/cli/commands/market.ts +527 -0
  11. package/src/cli/commands/overview.ts +222 -0
  12. package/src/cli/commands/search.ts +39 -0
  13. package/src/cli/commands/system.ts +553 -0
  14. package/src/cli/commands/ticker.ts +104 -12
  15. package/src/cli/context.ts +35 -2
  16. package/src/cli/entry.ts +51 -0
  17. package/src/cli/errors.ts +56 -5
  18. package/src/cli/index.ts +73 -11
  19. package/src/cli/options.test.ts +38 -0
  20. package/src/cli/options.ts +95 -0
  21. package/src/cli/pane-functions/index.ts +10 -1
  22. package/src/cli/registry.test.ts +40 -0
  23. package/src/cli/registry.ts +46 -5
  24. package/src/cli/result.test.ts +82 -0
  25. package/src/cli/result.ts +149 -0
  26. package/src/cli/types.ts +11 -0
  27. package/src/cli.test.ts +11 -11
  28. package/src/components/chart/comparison-stock-chart/helpers.ts +0 -32
  29. package/src/components/chart/comparison-stock-chart/interactions.ts +7 -1
  30. package/src/components/chart/comparison-stock-chart/layout-metrics.ts +6 -6
  31. package/src/components/chart/comparison-stock-chart/legend.tsx +45 -44
  32. package/src/components/chart/comparison-stock-chart/render-data.ts +37 -8
  33. package/src/components/chart/comparison-stock-chart/selection-runtime.ts +19 -2
  34. package/src/components/chart/comparison-stock-chart/sources.ts +8 -4
  35. package/src/components/chart/comparison-stock-chart/toolbar.tsx +13 -3
  36. package/src/components/chart/comparison-stock-chart/view.tsx +56 -6
  37. package/src/components/chart/core/pane-settings.test.ts +27 -0
  38. package/src/components/chart/core/pane-settings.ts +60 -1
  39. package/src/components/chart/core/pointer.ts +2 -0
  40. package/src/components/chart/stock-chart/resolved/runtime.ts +1 -1
  41. package/src/components/chart/stock-chart/settings.ts +7 -3
  42. package/src/components/chart/stock-chart/viewport/runtime.ts +5 -5
  43. package/src/components/command-bar/pane-templates/workflow-route.ts +10 -0
  44. package/src/components/command-bar/surface/pane.test.tsx +76 -0
  45. package/src/components/data-table/view.tsx +13 -2
  46. package/src/components/feed-data-table/stack-view.tsx +9 -1
  47. package/src/components/index.ts +1 -1
  48. package/src/components/input-search-bar.test.tsx +103 -0
  49. package/src/components/input-search-bar.tsx +16 -0
  50. package/src/components/layout/detached-pane-shell.tsx +5 -2
  51. package/src/components/layout/header.tsx +13 -5
  52. package/src/components/layout/shell/active-drag.ts +5 -1
  53. package/src/components/layout/shell/drag/runtime.ts +21 -3
  54. package/src/components/layout/shell/fullscreen.ts +10 -0
  55. package/src/components/layout/shell/index.test.tsx +395 -2
  56. package/src/components/layout/shell/index.tsx +128 -2
  57. package/src/components/layout/shell/native/pointer-runtime.ts +10 -4
  58. package/src/components/layout/shell/pane/layers.tsx +26 -14
  59. package/src/components/layout/shell/pane/management-shortcuts.ts +19 -0
  60. package/src/components/layout/shell/shortcuts.ts +4 -0
  61. package/src/components/layout/shell/terminal-pointer-runtime.ts +29 -19
  62. package/src/components/layout/shell/window-mode/index.ts +11 -3
  63. package/src/components/layout/shell/window-mode/overlays.tsx +8 -1
  64. package/src/components/layout/status-bar.test.tsx +105 -0
  65. package/src/components/layout/status-bar.tsx +32 -6
  66. package/src/components/layout/transient-layout.tsx +57 -0
  67. package/src/components/layout/window-controls.tsx +19 -10
  68. package/src/components/layout/window-edit/mode.test.ts +76 -5
  69. package/src/components/layout/window-edit/mode.ts +47 -2
  70. package/src/components/layout/window-edit/presentation.ts +2 -1
  71. package/src/components/price-performance.tsx +183 -0
  72. package/src/core/app-services.ts +7 -4
  73. package/src/data/config/store/index.ts +1 -2
  74. package/src/market-data/performance.test.ts +54 -0
  75. package/src/market-data/performance.ts +109 -0
  76. package/src/plugins/builtin/cloud-tweets/pane.tsx +2 -0
  77. package/src/plugins/builtin/cloud-tweets/search-bar.tsx +3 -0
  78. package/src/plugins/builtin/cloud-tweets/table.tsx +14 -2
  79. package/src/plugins/builtin/comparison-chart/index.test.tsx +218 -15
  80. package/src/plugins/builtin/congress-trades/detail.tsx +204 -68
  81. package/src/plugins/builtin/congress-trades/footer.ts +14 -8
  82. package/src/plugins/builtin/congress-trades/keyboard.ts +4 -23
  83. package/src/plugins/builtin/congress-trades/model.ts +29 -0
  84. package/src/plugins/builtin/congress-trades/pane.tsx +8 -13
  85. package/src/plugins/builtin/congress-trades/table.tsx +10 -1
  86. package/src/plugins/builtin/news/wire/read-state.ts +9 -32
  87. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +23 -0
  88. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +19 -2
  89. package/src/plugins/builtin/portfolio-list/pane/index.tsx +1 -1
  90. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +2 -7
  91. package/src/plugins/builtin/shared/read-state.ts +43 -0
  92. package/src/plugins/builtin/substack/article-detail.test.tsx +10 -0
  93. package/src/plugins/builtin/substack/article-detail.tsx +39 -15
  94. package/src/plugins/builtin/substack/article-stack.tsx +6 -2
  95. package/src/plugins/builtin/substack/feed-tabs.tsx +12 -9
  96. package/src/plugins/builtin/substack/pane.tsx +8 -3
  97. package/src/plugins/builtin/substack/read-state.ts +55 -0
  98. package/src/plugins/builtin/thirteenf/pane.test.tsx +26 -0
  99. package/src/plugins/builtin/thirteenf/pane.tsx +12 -1
  100. package/src/plugins/builtin/ticker-detail/overview/model.ts +0 -14
  101. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -0
  102. package/src/plugins/pane-manager/docking.ts +9 -2
  103. package/src/plugins/pane-manager/queries.ts +8 -2
  104. package/src/plugins/prediction-markets/controller/index.ts +2 -0
  105. package/src/plugins/prediction-markets/controller/keyboard.ts +34 -5
  106. package/src/plugins/prediction-markets/pane.test.tsx +32 -0
  107. package/src/plugins/prediction-markets/pane.tsx +21 -1
  108. package/src/plugins/prediction-markets/test-helpers.tsx +1 -0
  109. package/src/renderers/electrobun/bun/application-menu/click.test.ts +9 -0
  110. package/src/renderers/electrobun/bun/application-menu/click.ts +1 -0
  111. package/src/renderers/electrobun/bun/application-menu/index.test.ts +9 -0
  112. package/src/renderers/electrobun/bun/application-menu/index.ts +3 -2
  113. package/src/renderers/electrobun/bun/desktop/detached-windows.ts +9 -2
  114. package/src/renderers/electrobun/bun/desktop/initialization.ts +1 -0
  115. package/src/renderers/electrobun/bun/desktop/window-controls.ts +11 -2
  116. package/src/renderers/electrobun/bun/desktop/window-style.ts +27 -1
  117. package/src/renderers/electrobun/bun/desktop/windows-custom-chrome.ts +58 -0
  118. package/src/renderers/electrobun/bun/desktop/windows-icons.ts +11 -64
  119. package/src/renderers/electrobun/bun/desktop/windows-native.ts +75 -0
  120. package/src/renderers/electrobun/bun/index.ts +22 -2
  121. package/src/renderers/electrobun/bun/window/focus.test.ts +35 -0
  122. package/src/renderers/electrobun/bun/window/focus.ts +5 -0
  123. package/src/renderers/electrobun/shared/protocol.ts +1 -0
  124. package/src/renderers/electrobun/view/app-services.ts +6 -4
  125. package/src/renderers/electrobun/view/host/ascii-text.ts +50 -0
  126. package/src/renderers/electrobun/view/host/text.test.ts +22 -0
  127. package/src/renderers/electrobun/view/host/text.tsx +24 -20
  128. package/src/renderers/electrobun/view/main.tsx +2 -1
  129. package/src/renderers/electrobun/view/styles.css +9 -8
  130. package/src/renderers/electrobun/view/ui-host.tsx +102 -77
  131. package/src/renderers/opentui/start.tsx +13 -5
  132. package/src/state/app/input-capture.ts +12 -0
  133. package/src/types/plugin.ts +28 -0
  134. package/src/utils/cli-output.ts +6 -0
  135. package/src/utils/search-focus-navigation.ts +24 -0
  136. package/src/version.ts +1 -1
@@ -1,9 +1,17 @@
1
1
  import type { AppConfig } from "../types/config";
2
- import type { CliCommandContext, CliCommandDef, CliDispatchResult, GloomPlugin } from "../types/plugin";
2
+ import type {
3
+ CliCommandContext,
4
+ CliCommandDef,
5
+ CliDispatchResult,
6
+ GloomPlugin,
7
+ PluginCliCommandDescriptor,
8
+ } from "../types/plugin";
3
9
  import type { LoadedExternalPlugin } from "../plugins/loader";
4
10
  import { getPluginCatalog } from "../plugins/catalog";
5
- import { initConfigData, initMarketData } from "./context";
11
+ import { initCliServices, initConfigData, initMarketData } from "./context";
6
12
  import { closeAndFail, fail } from "./errors";
13
+ import { DEFAULT_CLI_OPTIONS, type CliGlobalOptions } from "./options";
14
+ import { printCliResult } from "./result";
7
15
  import {
8
16
  cliStyles,
9
17
  colorBySign,
@@ -26,6 +34,8 @@ export interface CliCommandRegistry {
26
34
  lookup: ReadonlyMap<string, RegisteredCliCommand>;
27
35
  config: AppConfig | null;
28
36
  plugins: GloomPlugin[];
37
+ externalPlugins: LoadedExternalPlugin[];
38
+ descriptors: Array<{ pluginId: string; descriptor: PluginCliCommandDescriptor }>;
29
39
  }
30
40
 
31
41
  interface BuildCliCommandRegistryOptions {
@@ -61,6 +71,19 @@ function getCommandUsageLabel(command: CliCommandDef): string {
61
71
  return command.help?.usage?.[0] ?? command.name;
62
72
  }
63
73
 
74
+ function descriptorToCommand(descriptor: PluginCliCommandDescriptor): CliCommandDef | null {
75
+ if (!descriptor.execute) return null;
76
+ return {
77
+ name: descriptor.name,
78
+ aliases: descriptor.aliases,
79
+ description: descriptor.summary,
80
+ help: descriptor.examples?.length
81
+ ? { usage: descriptor.examples }
82
+ : undefined,
83
+ execute: descriptor.execute,
84
+ };
85
+ }
86
+
64
87
  function renderHelpSections(registry: CliCommandRegistry): string[] {
65
88
  const lines: string[] = [];
66
89
 
@@ -149,6 +172,7 @@ export function buildCliCommandRegistry({
149
172
  }
150
173
 
151
174
  const loadablePlugins: GloomPlugin[] = [];
175
+ const descriptors: Array<{ pluginId: string; descriptor: PluginCliCommandDescriptor }> = [];
152
176
  for (const entry of catalog) {
153
177
  if (entry.error) {
154
178
  registryLog.warn(`Skipping external plugin "${entry.plugin.id}" for CLI registration.`, {
@@ -161,6 +185,11 @@ export function buildCliCommandRegistry({
161
185
  for (const command of entry.plugin.cliCommands ?? []) {
162
186
  registerCommand(command, entry.plugin.id, "plugin");
163
187
  }
188
+ for (const descriptor of entry.plugin.cli?.commands ?? []) {
189
+ descriptors.push({ pluginId: entry.plugin.id, descriptor });
190
+ const command = descriptorToCommand(descriptor);
191
+ if (command) registerCommand(command, entry.plugin.id, "plugin");
192
+ }
164
193
  }
165
194
 
166
195
  const commands = allCommands.filter((entry) => (
@@ -180,14 +209,25 @@ export function buildCliCommandRegistry({
180
209
  lookup,
181
210
  config,
182
211
  plugins: loadablePlugins,
212
+ externalPlugins,
213
+ descriptors,
183
214
  };
184
215
  }
185
216
 
186
- export function createCliCommandContext(ownerId: string, plugins: GloomPlugin[]): CliCommandContext {
217
+ export function createCliCommandContext(
218
+ ownerId: string,
219
+ registryOrPlugins: Pick<CliCommandRegistry, "plugins" | "externalPlugins"> | GloomPlugin[],
220
+ cliOptions: CliGlobalOptions = DEFAULT_CLI_OPTIONS,
221
+ ): CliCommandContext {
222
+ const registry = Array.isArray(registryOrPlugins)
223
+ ? { plugins: registryOrPlugins, externalPlugins: [] }
224
+ : registryOrPlugins;
187
225
  return {
188
226
  initConfigData,
189
- initMarketData: () => initMarketData({ plugins }),
190
- plugins,
227
+ initMarketData: () => initMarketData({ plugins: registry.plugins }),
228
+ initServices: () => initCliServices({ externalPlugins: registry.externalPlugins }),
229
+ cliOptions,
230
+ plugins: registry.plugins,
191
231
  fail,
192
232
  closeAndFail,
193
233
  output: {
@@ -197,6 +237,7 @@ export function createCliCommandContext(ownerId: string, plugins: GloomPlugin[])
197
237
  renderStat,
198
238
  renderTable,
199
239
  },
240
+ printResult: (result, options) => printCliResult(result, cliOptions, options),
200
241
  log: debugLog.createLogger(ownerId === "core" ? "cli" : ownerId),
201
242
  };
202
243
  }
@@ -0,0 +1,82 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { serializeCliError, serializeCliResult } from "./result";
3
+ import type { CliGlobalOptions } from "./options";
4
+
5
+ const baseOptions: CliGlobalOptions = {
6
+ format: "text",
7
+ quiet: false,
8
+ color: null,
9
+ refresh: false,
10
+ dryRun: false,
11
+ yes: false,
12
+ };
13
+
14
+ describe("serializeCliResult", () => {
15
+ test("renders limited JSON result envelopes", () => {
16
+ const output = serializeCliResult(
17
+ { data: [{ symbol: "AAPL" }, { symbol: "MSFT" }] },
18
+ { ...baseOptions, format: "json", limit: 1 },
19
+ );
20
+ expect(JSON.parse(output)).toEqual({
21
+ ok: true,
22
+ data: [{ symbol: "AAPL" }],
23
+ });
24
+ });
25
+
26
+ test("includes display column metadata in JSON envelopes", () => {
27
+ const output = serializeCliResult(
28
+ { data: [{ quote: { symbol: "AAPL", price: 123 } }] },
29
+ { ...baseOptions, format: "json" },
30
+ {
31
+ rows: (data) => data.map((row) => row.quote),
32
+ columns: [
33
+ { key: "symbol", header: "Symbol" },
34
+ { key: "price", header: "Last", align: "right" },
35
+ ],
36
+ },
37
+ );
38
+ expect(JSON.parse(output)).toEqual({
39
+ ok: true,
40
+ data: [{ quote: { symbol: "AAPL", price: 123 } }],
41
+ columns: [
42
+ { key: "symbol", header: "Symbol" },
43
+ { key: "price", header: "Last", align: "right" },
44
+ ],
45
+ });
46
+ });
47
+
48
+ test("renders CSV with provided column order", () => {
49
+ const output = serializeCliResult(
50
+ { data: [{ symbol: "AAPL", name: "Apple, Inc." }] },
51
+ { ...baseOptions, format: "csv" },
52
+ {
53
+ columns: [
54
+ { key: "symbol", header: "Symbol" },
55
+ { key: "name", header: "Name" },
56
+ ],
57
+ },
58
+ );
59
+ expect(output).toBe('Symbol,Name\nAAPL,"Apple, Inc."');
60
+ });
61
+
62
+ test("renders NDJSON rows", () => {
63
+ const output = serializeCliResult(
64
+ { data: [{ symbol: "AAPL" }, { symbol: "MSFT" }] },
65
+ { ...baseOptions, format: "ndjson" },
66
+ );
67
+ expect(output).toBe('{"symbol":"AAPL"}\n{"symbol":"MSFT"}');
68
+ });
69
+ });
70
+
71
+ describe("serializeCliError", () => {
72
+ test("renders stable structured errors for JSON", () => {
73
+ const output = serializeCliError(
74
+ { code: "auth_required", message: "Sign in first.", retryable: true },
75
+ { ...baseOptions, format: "json" },
76
+ );
77
+ expect(JSON.parse(output)).toEqual({
78
+ ok: false,
79
+ error: { code: "auth_required", message: "Sign in first.", retryable: true },
80
+ });
81
+ });
82
+ });
@@ -0,0 +1,149 @@
1
+ import type { CliGlobalOptions } from "./options";
2
+ import { renderTable, type CliTableColumn } from "../utils/cli-output";
3
+
4
+ export interface CliResult<T = unknown> {
5
+ data: T;
6
+ metadata?: Record<string, unknown>;
7
+ warnings?: string[];
8
+ }
9
+
10
+ export interface CliErrorObject {
11
+ code: string;
12
+ message: string;
13
+ details?: unknown;
14
+ retryable?: boolean;
15
+ }
16
+
17
+ export interface CliResultColumn<Row = Record<string, unknown>> extends CliTableColumn {
18
+ key: string;
19
+ value?: (row: Row) => unknown;
20
+ }
21
+
22
+ export interface CliResultRenderOptions<T = unknown, Row = Record<string, unknown>> {
23
+ text?: (data: T) => string;
24
+ columns?: CliResultColumn<Row>[];
25
+ rows?: (data: T) => Row[];
26
+ }
27
+
28
+ interface CliResultJsonEnvelope<T> extends CliResult<T> {
29
+ ok: true;
30
+ columns?: Array<Pick<CliResultColumn, "key" | "header" | "align" | "width">>;
31
+ }
32
+
33
+ function applyLimit<T>(data: T, limit?: number): T {
34
+ if (!Array.isArray(data) || limit == null) return data;
35
+ return data.slice(0, limit) as T;
36
+ }
37
+
38
+ function asRows<T, Row>(data: T, limit?: number, rows?: (data: T) => Row[]): Row[] {
39
+ const resolvedRows = rows ? rows(data) : (Array.isArray(data) ? data : [data]) as Row[];
40
+ return limit == null ? resolvedRows : resolvedRows.slice(0, limit);
41
+ }
42
+
43
+ function normalizeCell(value: unknown): string {
44
+ if (value == null) return "";
45
+ if (value instanceof Date) return value.toISOString();
46
+ if (typeof value === "object") return JSON.stringify(value);
47
+ return String(value);
48
+ }
49
+
50
+ function inferColumns(rows: Record<string, unknown>[]): CliResultColumn<Record<string, unknown>>[] {
51
+ const keys = new Set<string>();
52
+ for (const row of rows) {
53
+ if (row && typeof row === "object" && !Array.isArray(row)) {
54
+ for (const key of Object.keys(row)) keys.add(key);
55
+ }
56
+ }
57
+ return [...keys].map((key) => ({ key, header: key }));
58
+ }
59
+
60
+ function csvEscape(value: string): string {
61
+ if (!/[",\n\r]/.test(value)) return value;
62
+ return `"${value.replace(/"/g, '""')}"`;
63
+ }
64
+
65
+ function serializeColumns<Row>(columns?: CliResultColumn<Row>[]) {
66
+ return columns?.map((column) => ({
67
+ key: column.key,
68
+ header: column.header,
69
+ ...(column.align ? { align: column.align } : {}),
70
+ ...(column.width ? { width: column.width } : {}),
71
+ }));
72
+ }
73
+
74
+ function renderCsv<Row extends Record<string, unknown>>(
75
+ rows: Row[],
76
+ columns?: CliResultColumn<Row>[],
77
+ ): string {
78
+ const resolvedColumns = columns && columns.length > 0
79
+ ? columns
80
+ : inferColumns(rows as Record<string, unknown>[]) as CliResultColumn<Row>[];
81
+ const header = resolvedColumns.map((column) => csvEscape(column.header)).join(",");
82
+ const body = rows.map((row) => resolvedColumns
83
+ .map((column) => csvEscape(normalizeCell(column.value ? column.value(row) : row[column.key])))
84
+ .join(","));
85
+ return [header, ...body].join("\n");
86
+ }
87
+
88
+ function renderTextTable<Row extends Record<string, unknown>>(
89
+ rows: Row[],
90
+ columns?: CliResultColumn<Row>[],
91
+ ): string {
92
+ const resolvedColumns = columns && columns.length > 0
93
+ ? columns
94
+ : inferColumns(rows as Record<string, unknown>[]) as CliResultColumn<Row>[];
95
+ return renderTable(
96
+ resolvedColumns.map((column) => ({
97
+ header: column.header,
98
+ align: column.align,
99
+ width: column.width,
100
+ })),
101
+ rows.map((row) => resolvedColumns.map((column) => normalizeCell(column.value ? column.value(row) : row[column.key]))),
102
+ );
103
+ }
104
+
105
+ export function serializeCliResult<T, Row extends Record<string, unknown> = Record<string, unknown>>(
106
+ result: CliResult<T>,
107
+ options: CliGlobalOptions,
108
+ renderOptions: CliResultRenderOptions<T, Row> = {},
109
+ ): string {
110
+ if (options.format === "json") {
111
+ const columns = serializeColumns(renderOptions.columns);
112
+ const envelope: CliResultJsonEnvelope<T> = {
113
+ ok: true,
114
+ ...result,
115
+ data: applyLimit(result.data, options.limit),
116
+ ...(columns?.length ? { columns } : {}),
117
+ };
118
+ return JSON.stringify(envelope, null, 2);
119
+ }
120
+
121
+ const rows = asRows(result.data, options.limit, renderOptions.rows);
122
+ if (options.format === "ndjson") {
123
+ return rows.map((row) => JSON.stringify(row)).join("\n");
124
+ }
125
+ if (options.format === "csv") {
126
+ return renderCsv(rows as Row[], renderOptions.columns);
127
+ }
128
+ if (renderOptions.text) {
129
+ return renderOptions.text(result.data);
130
+ }
131
+ return renderTextTable(rows as Row[], renderOptions.columns);
132
+ }
133
+
134
+ export function printCliResult<T, Row extends Record<string, unknown> = Record<string, unknown>>(
135
+ result: CliResult<T>,
136
+ options: CliGlobalOptions,
137
+ renderOptions: CliResultRenderOptions<T, Row> = {},
138
+ ): void {
139
+ if (options.quiet && options.format === "text") return;
140
+ const output = serializeCliResult(result, options, renderOptions);
141
+ if (output) console.log(output);
142
+ }
143
+
144
+ export function serializeCliError(error: CliErrorObject, options: CliGlobalOptions): string {
145
+ if (options.format === "json" || options.format === "ndjson") {
146
+ return JSON.stringify({ ok: false, error }, null, options.format === "json" ? 2 : 0);
147
+ }
148
+ return error.details == null ? error.message : `${error.message}\n${String(error.details)}`;
149
+ }
package/src/cli/types.ts CHANGED
@@ -2,6 +2,7 @@ import type { AppConfig } from "../types/config";
2
2
  import { AppPersistence } from "../data/app-persistence";
3
3
  import { TickerRepository } from "../data/ticker-repository";
4
4
  import { AssetDataRouter } from "../sources/provider-router";
5
+ import type { AppServices } from "../core/app-services";
5
6
 
6
7
  export type ConfigContext = {
7
8
  config: AppConfig;
@@ -13,3 +14,13 @@ export type ConfigContext = {
13
14
  export type MarketContext = ConfigContext & {
14
15
  dataProvider: AssetDataRouter;
15
16
  };
17
+
18
+ export type CliServicesContext = {
19
+ config: AppConfig;
20
+ dataDir: string;
21
+ services: AppServices;
22
+ dataProvider: AssetDataRouter;
23
+ persistence: AppPersistence;
24
+ store: TickerRepository;
25
+ destroy(): void;
26
+ };
package/src/cli.test.ts CHANGED
@@ -22,6 +22,7 @@ const originalHome = process.env.HOME;
22
22
 
23
23
  afterEach(async () => {
24
24
  process.env.HOME = originalHome;
25
+ process.exitCode = 0;
25
26
  await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
26
27
  });
27
28
 
@@ -96,12 +97,12 @@ async function captureConsole<T>(fn: () => Promise<T> | T): Promise<{ result: T;
96
97
  }
97
98
  }
98
99
 
99
- async function captureConsoleFailure(fn: () => Promise<unknown> | unknown): Promise<{ stdout: string; stderr: string; error: unknown }> {
100
+ async function captureConsoleFailure(fn: () => Promise<unknown> | unknown): Promise<{ stdout: string; stderr: string; exitCode: string | number | undefined }> {
100
101
  const logs: string[] = [];
101
102
  const errors: string[] = [];
102
103
  const originalLog = console.log;
103
104
  const originalError = console.error;
104
- const originalExit = process.exit;
105
+ const originalExitCode = process.exitCode;
105
106
 
106
107
  console.log = (...args: unknown[]) => {
107
108
  logs.push(args.map(String).join(" "));
@@ -109,23 +110,22 @@ async function captureConsoleFailure(fn: () => Promise<unknown> | unknown): Prom
109
110
  console.error = (...args: unknown[]) => {
110
111
  errors.push(args.map(String).join(" "));
111
112
  };
112
- process.exit = ((code?: number) => {
113
- throw new Error(`process.exit:${code ?? 0}`);
114
- }) as typeof process.exit;
113
+ process.exitCode = undefined;
115
114
 
116
115
  try {
117
116
  await fn();
118
- throw new Error("Expected command to fail.");
119
- } catch (error) {
117
+ if (process.exitCode == null || process.exitCode === 0) {
118
+ throw new Error("Expected command to fail.");
119
+ }
120
120
  return {
121
121
  stdout: logs.join("\n"),
122
122
  stderr: errors.join("\n"),
123
- error,
123
+ exitCode: process.exitCode,
124
124
  };
125
125
  } finally {
126
126
  console.log = originalLog;
127
127
  console.error = originalError;
128
- process.exit = originalExit;
128
+ process.exitCode = originalExitCode ?? 0;
129
129
  }
130
130
  }
131
131
 
@@ -226,7 +226,7 @@ describe("CLI portfolio commands", () => {
226
226
  });
227
227
 
228
228
  const result = await captureConsoleFailure(() => runCli(["portfolio", "create", "Research"]));
229
- expect(String(result.error)).toContain("process.exit:1");
229
+ expect(result.exitCode).toBe(1);
230
230
  expect(result.stderr).toContain('Portfolio "Research" already exists.');
231
231
  });
232
232
 
@@ -370,7 +370,7 @@ describe("CLI portfolio commands", () => {
370
370
  });
371
371
 
372
372
  const result = await captureConsoleFailure(() => runCli(["portfolio", "add", "IBKR Account", "NVDA"]));
373
- expect(String(result.error)).toContain("process.exit:1");
373
+ expect(result.exitCode).toBe(1);
374
374
  expect(result.stderr).toContain('Portfolio "IBKR Account" is broker-managed and cannot be modified manually.');
375
375
  });
376
376
  });
@@ -2,8 +2,6 @@ import type { PricePoint } from "../../../types/financials";
2
2
  import type { LocalPlotPointer } from "../core/pointer";
3
3
  import type { ComparisonChartRenderMode } from "../core/types";
4
4
  import type { projectComparisonChartData } from "../comparison/data";
5
- import { formatPercentRaw } from "../../../utils/format";
6
- import { formatMarketPriceWithCurrency } from "../../../market-data/market/format";
7
5
 
8
6
  export function clamp(value: number, min: number, max: number): number {
9
7
  return Math.max(min, Math.min(max, value));
@@ -42,36 +40,6 @@ export function buildComparisonNativeBitmapKey(
42
40
  ].join("::");
43
41
  }
44
42
 
45
- export function getLegendColumns(width: number): number {
46
- if (width >= 110) return 3;
47
- if (width >= 72) return 2;
48
- return 1;
49
- }
50
-
51
- export function clipText(text: string, width: number): string {
52
- if (width <= 0) return "";
53
- if (text.length <= width) return text.padEnd(width);
54
- if (width <= 3) return text.slice(0, width);
55
- return `${text.slice(0, width - 3)}...`;
56
- }
57
-
58
- export function formatLegendSummary(
59
- symbol: string,
60
- rawValue: number | null,
61
- baseValue: number | null,
62
- currency: string,
63
- priceRange: number | undefined,
64
- ): string {
65
- if (rawValue === null) return `${symbol} waiting`;
66
- const price = formatMarketPriceWithCurrency(rawValue, currency, {
67
- minimumFractionDigits: 2,
68
- precisionOffset: 1,
69
- priceRange,
70
- });
71
- if (baseValue === null || baseValue === 0) return `${symbol} ${price}`;
72
- return `${symbol} ${price} ${formatPercentRaw(((rawValue - baseValue) / baseValue) * 100)}`;
73
- }
74
-
75
43
  export function getInitialComparisonMode(mode: string | undefined): ComparisonChartRenderMode {
76
44
  return mode === "line" ? "line" : "area";
77
45
  }
@@ -28,6 +28,7 @@ import {
28
28
  resolveCursorMotionKind,
29
29
  type ChartMouseEvent,
30
30
  type DisplayCursorState,
31
+ type MouseInteractionEvent,
31
32
  } from "../core/pointer";
32
33
  import type {
33
34
  ComparisonChartSeries,
@@ -48,6 +49,7 @@ interface UseComparisonChartPointerInteractionsOptions {
48
49
  chartWidth: number;
49
50
  commitSelectionCursor: (next: { cursorX: number | null; cursorY: number | null }) => void;
50
51
  expandBufferRange: (action: PendingExpansionAction) => boolean;
52
+ focusPaneForMouseInteraction: (event: MouseInteractionEvent | null | undefined) => void;
51
53
  mouseCrosshairDisabledRef: MutableRefObject<boolean>;
52
54
  plotRef: RefObject<BoxRenderable | null>;
53
55
  pointCount: number;
@@ -65,6 +67,7 @@ export function useComparisonChartPointerInteractions({
65
67
  chartWidth,
66
68
  commitSelectionCursor,
67
69
  expandBufferRange,
70
+ focusPaneForMouseInteraction,
68
71
  mouseCrosshairDisabledRef,
69
72
  plotRef,
70
73
  pointCount,
@@ -113,13 +116,14 @@ export function useComparisonChartPointerInteractions({
113
116
 
114
117
  const handlePlotDown = useCallback((event: ChartMouseEvent) => {
115
118
  consumeChartMouseEvent(event);
119
+ focusPaneForMouseInteraction(null);
116
120
  mouseCrosshairDisabledRef.current = false;
117
121
  if (!syncPointerCursor(event)) return;
118
122
  dragRef.current = {
119
123
  startGlobalX: getGlobalMouseX(event, renderer),
120
124
  startWindow: visibleDateWindow,
121
125
  };
122
- }, [mouseCrosshairDisabledRef, renderer, syncPointerCursor, visibleDateWindow]);
126
+ }, [focusPaneForMouseInteraction, mouseCrosshairDisabledRef, renderer, syncPointerCursor, visibleDateWindow]);
123
127
 
124
128
  const handlePlotDrag = useCallback((event: ChartMouseEvent) => {
125
129
  consumeChartMouseEvent(event);
@@ -147,6 +151,7 @@ export function useComparisonChartPointerInteractions({
147
151
 
148
152
  const handlePlotScroll = useCallback((event: ChartMouseEvent) => {
149
153
  consumeChartMouseEvent(event);
154
+ focusPaneForMouseInteraction(null);
150
155
  const direction = event.scroll?.direction;
151
156
  if (!direction) return;
152
157
  const scrollPan = consumeScrollPanMovement(
@@ -179,6 +184,7 @@ export function useComparisonChartPointerInteractions({
179
184
  chartWidth,
180
185
  dateBounds,
181
186
  expandBufferRange,
187
+ focusPaneForMouseInteraction,
182
188
  scrollPanCellRemainderRef,
183
189
  series,
184
190
  seriesDates,
@@ -1,5 +1,4 @@
1
1
  import type { ChartAxisMode } from "../core/types";
2
- import { getLegendColumns } from "./helpers";
3
2
 
4
3
  export interface ComparisonChartLayoutMetrics {
5
4
  axisGap: number;
@@ -36,15 +35,16 @@ export function resolveComparisonChartLayoutMetrics({
36
35
  const controlRows = 2;
37
36
  const timeAxisRows = 1;
38
37
  const helpRows = 0;
39
- const legendColumns = getLegendColumns(width);
40
- const legendNeededRows = symbolCount > 0 ? Math.ceil(symbolCount / legendColumns) : 0;
41
- const legendRows = legendNeededRows > 0
42
- ? Math.min(4, Math.max(Math.min(height - (headerRows + controlRows + timeAxisRows + helpRows + 4), legendNeededRows), 1))
38
+ const legendColumns = 1;
39
+ const legendNeededRows = symbolCount > 0 ? symbolCount + 1 : 0;
40
+ const legendAvailableRows = Math.max(height - (headerRows + controlRows + timeAxisRows + helpRows + 6), 0);
41
+ const legendRows = legendNeededRows > 0 && legendAvailableRows > 0
42
+ ? Math.min(5, Math.max(2, Math.min(legendAvailableRows, legendNeededRows)))
43
43
  : 0;
44
44
  const chartHeight = Math.max(height - headerRows - controlRows - timeAxisRows - helpRows - legendRows, 4);
45
45
  const minChartWidth = 20;
46
46
  const measurementChartWidth = Math.max(width - axisSectionWidthBudget - axisGap, minChartWidth);
47
- const legendItemWidth = Math.max(Math.floor((width - Math.max(legendColumns - 1, 0)) / legendColumns), 20);
47
+ const legendItemWidth = Math.max(width, 20);
48
48
 
49
49
  return {
50
50
  axisGap,
@@ -1,73 +1,74 @@
1
- import { Box, ScrollBox, Text, TextAttributes } from "../../../ui";
2
- import { blendHex, colors } from "../../../theme/colors";
3
1
  import type { ComparisonChartProjection } from "../comparison/data";
4
- import { clipText, formatLegendSummary } from "./helpers";
2
+ import { PriceReturnTable, type PriceReturnTableRow } from "../../price-performance";
3
+ import type { PriceReturnField } from "../../../market-data/performance";
4
+ import type { MouseInteractionEvent } from "../core/pointer";
5
5
 
6
6
  interface ComparisonChartLegendProps {
7
7
  legendActiveIndex: number | null;
8
- legendColumns: number;
9
8
  legendItemWidth: number;
10
9
  legendRows: number;
10
+ onFocusInteraction: (event: MouseInteractionEvent | null | undefined) => void;
11
11
  onOpenSymbol: (symbol: string) => void;
12
12
  onSelectSymbol: (symbol: string) => void;
13
+ performanceBySymbol: ReadonlyMap<string, PriceReturnField[]>;
13
14
  projection: ComparisonChartProjection;
14
15
  selectedSymbol: string | null;
15
16
  symbols: string[];
16
- visiblePriceRange: number | undefined;
17
+ }
18
+
19
+ function buildRangeReturnField(
20
+ item: ComparisonChartProjection["series"][number] | null,
21
+ legendActiveIndex: number | null,
22
+ ): PriceReturnField {
23
+ const activeRaw = legendActiveIndex !== null
24
+ ? item?.points[legendActiveIndex]?.rawValue ?? null
25
+ : item?.latestRawValue ?? null;
26
+ const baseValue = item?.baseValue ?? null;
27
+ const value = activeRaw != null && baseValue != null && baseValue !== 0
28
+ ? (activeRaw - baseValue) / baseValue
29
+ : null;
30
+ return {
31
+ id: "RNG",
32
+ label: "Rng",
33
+ value,
34
+ };
17
35
  }
18
36
 
19
37
  export function ComparisonChartLegend({
20
38
  legendActiveIndex,
21
- legendColumns,
22
39
  legendItemWidth,
23
40
  legendRows,
41
+ onFocusInteraction,
24
42
  onOpenSymbol,
25
43
  onSelectSymbol,
44
+ performanceBySymbol,
26
45
  projection,
27
46
  selectedSymbol,
28
47
  symbols,
29
- visiblePriceRange,
30
48
  }: ComparisonChartLegendProps) {
31
49
  if (legendRows <= 0) return null;
32
50
 
33
- const legendRowsData = Array.from({ length: Math.ceil(symbols.length / legendColumns) }, (_, rowIndex) => (
34
- symbols.slice(rowIndex * legendColumns, rowIndex * legendColumns + legendColumns)
35
- ));
51
+ const rows: PriceReturnTableRow[] = symbols.map((symbol) => {
52
+ const item = projection.series.find((entry) => entry.symbol === symbol) ?? null;
53
+ return {
54
+ symbol,
55
+ color: item?.color ?? "#888888",
56
+ fields: [
57
+ buildRangeReturnField(item, legendActiveIndex),
58
+ ...(performanceBySymbol.get(symbol) ?? []),
59
+ ],
60
+ selected: selectedSymbol === symbol,
61
+ };
62
+ });
36
63
 
37
64
  return (
38
- <ScrollBox height={legendRows} scrollY>
39
- <Box flexDirection="column">
40
- {legendRowsData.map((legendRow, rowIndex) => (
41
- <Box key={`legend-row:${rowIndex}`} flexDirection="row" gap={1}>
42
- {legendRow.map((symbol) => {
43
- const item = projection.series.find((entry) => entry.symbol === symbol) ?? null;
44
- const isSelected = selectedSymbol === symbol;
45
- const activeRaw = legendActiveIndex !== null
46
- ? item?.points[legendActiveIndex]?.rawValue ?? null
47
- : item?.latestRawValue ?? null;
48
- const currency = item?.currency ?? "USD";
49
- const summary = formatLegendSummary(symbol, activeRaw, item?.baseValue ?? null, currency, visiblePriceRange);
50
-
51
- return (
52
- <Box
53
- key={symbol}
54
- width={legendItemWidth}
55
- backgroundColor={isSelected ? blendHex(colors.panel, colors.borderFocused, 0.18) : colors.panel}
56
- onMouseMove={() => onSelectSymbol(symbol)}
57
- onMouseDown={() => {
58
- onSelectSymbol(symbol);
59
- onOpenSymbol(symbol);
60
- }}
61
- >
62
- <Text fg={item?.color ?? colors.textDim} attributes={isSelected ? TextAttributes.BOLD : 0}>
63
- {clipText(`${isSelected ? ">" : " "} ${summary}`, legendItemWidth)}
64
- </Text>
65
- </Box>
66
- );
67
- })}
68
- </Box>
69
- ))}
70
- </Box>
71
- </ScrollBox>
65
+ <PriceReturnTable
66
+ height={legendRows}
67
+ onFocusInteraction={onFocusInteraction}
68
+ onOpenSymbol={onOpenSymbol}
69
+ onSelectSymbol={onSelectSymbol}
70
+ rows={rows}
71
+ width={legendItemWidth}
72
+ />
72
73
  );
73
74
  }