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.
- package/README.md +23 -2
- package/bin/gloomberb +1 -1
- package/package.json +1 -1
- package/src/app.tsx +10 -7
- package/src/capabilities/registry.ts +1 -0
- package/src/capabilities/types.ts +23 -0
- package/src/cli/commands/api.ts +119 -0
- package/src/cli/commands/automation.ts +173 -0
- package/src/cli/commands/command-utils.ts +53 -0
- package/src/cli/commands/market.ts +527 -0
- package/src/cli/commands/overview.ts +222 -0
- package/src/cli/commands/search.ts +39 -0
- package/src/cli/commands/system.ts +553 -0
- package/src/cli/commands/ticker.ts +104 -12
- package/src/cli/context.ts +35 -2
- package/src/cli/entry.ts +51 -0
- package/src/cli/errors.ts +56 -5
- package/src/cli/index.ts +73 -11
- package/src/cli/options.test.ts +38 -0
- package/src/cli/options.ts +95 -0
- package/src/cli/pane-functions/index.ts +10 -1
- package/src/cli/registry.test.ts +40 -0
- package/src/cli/registry.ts +46 -5
- package/src/cli/result.test.ts +82 -0
- package/src/cli/result.ts +149 -0
- package/src/cli/types.ts +11 -0
- package/src/cli.test.ts +11 -11
- package/src/components/chart/comparison-stock-chart/helpers.ts +0 -32
- package/src/components/chart/comparison-stock-chart/interactions.ts +7 -1
- package/src/components/chart/comparison-stock-chart/layout-metrics.ts +6 -6
- package/src/components/chart/comparison-stock-chart/legend.tsx +45 -44
- package/src/components/chart/comparison-stock-chart/render-data.ts +37 -8
- package/src/components/chart/comparison-stock-chart/selection-runtime.ts +19 -2
- package/src/components/chart/comparison-stock-chart/sources.ts +8 -4
- package/src/components/chart/comparison-stock-chart/toolbar.tsx +13 -3
- package/src/components/chart/comparison-stock-chart/view.tsx +56 -6
- package/src/components/chart/core/pane-settings.test.ts +27 -0
- package/src/components/chart/core/pane-settings.ts +60 -1
- package/src/components/chart/core/pointer.ts +2 -0
- package/src/components/chart/stock-chart/resolved/runtime.ts +1 -1
- package/src/components/chart/stock-chart/settings.ts +7 -3
- package/src/components/chart/stock-chart/viewport/runtime.ts +5 -5
- package/src/components/command-bar/pane-templates/workflow-route.ts +10 -0
- package/src/components/command-bar/surface/pane.test.tsx +76 -0
- package/src/components/data-table/view.tsx +13 -2
- package/src/components/feed-data-table/stack-view.tsx +9 -1
- package/src/components/index.ts +1 -1
- package/src/components/input-search-bar.test.tsx +103 -0
- package/src/components/input-search-bar.tsx +16 -0
- package/src/components/layout/detached-pane-shell.tsx +5 -2
- package/src/components/layout/header.tsx +13 -5
- package/src/components/layout/shell/active-drag.ts +5 -1
- package/src/components/layout/shell/drag/runtime.ts +21 -3
- package/src/components/layout/shell/fullscreen.ts +10 -0
- package/src/components/layout/shell/index.test.tsx +395 -2
- package/src/components/layout/shell/index.tsx +128 -2
- package/src/components/layout/shell/native/pointer-runtime.ts +10 -4
- package/src/components/layout/shell/pane/layers.tsx +26 -14
- package/src/components/layout/shell/pane/management-shortcuts.ts +19 -0
- package/src/components/layout/shell/shortcuts.ts +4 -0
- package/src/components/layout/shell/terminal-pointer-runtime.ts +29 -19
- package/src/components/layout/shell/window-mode/index.ts +11 -3
- package/src/components/layout/shell/window-mode/overlays.tsx +8 -1
- package/src/components/layout/status-bar.test.tsx +105 -0
- package/src/components/layout/status-bar.tsx +32 -6
- package/src/components/layout/transient-layout.tsx +57 -0
- package/src/components/layout/window-controls.tsx +19 -10
- package/src/components/layout/window-edit/mode.test.ts +76 -5
- package/src/components/layout/window-edit/mode.ts +47 -2
- package/src/components/layout/window-edit/presentation.ts +2 -1
- package/src/components/price-performance.tsx +183 -0
- package/src/core/app-services.ts +7 -4
- package/src/data/config/store/index.ts +1 -2
- package/src/market-data/performance.test.ts +54 -0
- package/src/market-data/performance.ts +109 -0
- package/src/plugins/builtin/cloud-tweets/pane.tsx +2 -0
- package/src/plugins/builtin/cloud-tweets/search-bar.tsx +3 -0
- package/src/plugins/builtin/cloud-tweets/table.tsx +14 -2
- package/src/plugins/builtin/comparison-chart/index.test.tsx +218 -15
- package/src/plugins/builtin/congress-trades/detail.tsx +204 -68
- package/src/plugins/builtin/congress-trades/footer.ts +14 -8
- package/src/plugins/builtin/congress-trades/keyboard.ts +4 -23
- package/src/plugins/builtin/congress-trades/model.ts +29 -0
- package/src/plugins/builtin/congress-trades/pane.tsx +8 -13
- package/src/plugins/builtin/congress-trades/table.tsx +10 -1
- package/src/plugins/builtin/news/wire/read-state.ts +9 -32
- package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +23 -0
- package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +19 -2
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +1 -1
- package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +2 -7
- package/src/plugins/builtin/shared/read-state.ts +43 -0
- package/src/plugins/builtin/substack/article-detail.test.tsx +10 -0
- package/src/plugins/builtin/substack/article-detail.tsx +39 -15
- package/src/plugins/builtin/substack/article-stack.tsx +6 -2
- package/src/plugins/builtin/substack/feed-tabs.tsx +12 -9
- package/src/plugins/builtin/substack/pane.tsx +8 -3
- package/src/plugins/builtin/substack/read-state.ts +55 -0
- package/src/plugins/builtin/thirteenf/pane.test.tsx +26 -0
- package/src/plugins/builtin/thirteenf/pane.tsx +12 -1
- package/src/plugins/builtin/ticker-detail/overview/model.ts +0 -14
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -0
- package/src/plugins/pane-manager/docking.ts +9 -2
- package/src/plugins/pane-manager/queries.ts +8 -2
- package/src/plugins/prediction-markets/controller/index.ts +2 -0
- package/src/plugins/prediction-markets/controller/keyboard.ts +34 -5
- package/src/plugins/prediction-markets/pane.test.tsx +32 -0
- package/src/plugins/prediction-markets/pane.tsx +21 -1
- package/src/plugins/prediction-markets/test-helpers.tsx +1 -0
- package/src/renderers/electrobun/bun/application-menu/click.test.ts +9 -0
- package/src/renderers/electrobun/bun/application-menu/click.ts +1 -0
- package/src/renderers/electrobun/bun/application-menu/index.test.ts +9 -0
- package/src/renderers/electrobun/bun/application-menu/index.ts +3 -2
- package/src/renderers/electrobun/bun/desktop/detached-windows.ts +9 -2
- package/src/renderers/electrobun/bun/desktop/initialization.ts +1 -0
- package/src/renderers/electrobun/bun/desktop/window-controls.ts +11 -2
- package/src/renderers/electrobun/bun/desktop/window-style.ts +27 -1
- package/src/renderers/electrobun/bun/desktop/windows-custom-chrome.ts +58 -0
- package/src/renderers/electrobun/bun/desktop/windows-icons.ts +11 -64
- package/src/renderers/electrobun/bun/desktop/windows-native.ts +75 -0
- package/src/renderers/electrobun/bun/index.ts +22 -2
- package/src/renderers/electrobun/bun/window/focus.test.ts +35 -0
- package/src/renderers/electrobun/bun/window/focus.ts +5 -0
- package/src/renderers/electrobun/shared/protocol.ts +1 -0
- package/src/renderers/electrobun/view/app-services.ts +6 -4
- package/src/renderers/electrobun/view/host/ascii-text.ts +50 -0
- package/src/renderers/electrobun/view/host/text.test.ts +22 -0
- package/src/renderers/electrobun/view/host/text.tsx +24 -20
- package/src/renderers/electrobun/view/main.tsx +2 -1
- package/src/renderers/electrobun/view/styles.css +9 -8
- package/src/renderers/electrobun/view/ui-host.tsx +102 -77
- package/src/renderers/opentui/start.tsx +13 -5
- package/src/state/app/input-capture.ts +12 -0
- package/src/types/plugin.ts +28 -0
- package/src/utils/cli-output.ts +6 -0
- package/src/utils/search-focus-navigation.ts +24 -0
- package/src/version.ts +1 -1
package/src/cli/registry.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import type { AppConfig } from "../types/config";
|
|
2
|
-
import type {
|
|
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(
|
|
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
|
-
|
|
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;
|
|
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
|
|
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.
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
123
|
+
exitCode: process.exitCode,
|
|
124
124
|
};
|
|
125
125
|
} finally {
|
|
126
126
|
console.log = originalLog;
|
|
127
127
|
console.error = originalError;
|
|
128
|
-
process.
|
|
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(
|
|
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(
|
|
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 =
|
|
40
|
-
const legendNeededRows = symbolCount > 0 ?
|
|
41
|
-
const
|
|
42
|
-
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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
|
|
34
|
-
|
|
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
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
}
|