gloomberb 0.7.1 → 0.7.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 +79 -132
- package/package.json +1 -1
- package/src/app.tsx +73 -67
- package/src/components/chart/chart-data.ts +7 -2
- package/src/components/chart/chart-pane-settings.ts +7 -4
- package/src/components/chart/chart-renderer.ts +49 -15
- package/src/components/chart/chart-types.ts +2 -2
- package/src/components/chart/chart.test.ts +27 -8
- package/src/components/chart/comparison-stock-chart.tsx +84 -121
- package/src/components/chart/native/chart-rasterizer.ts +43 -17
- package/src/components/chart/price-axis-labels.tsx +25 -9
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +1 -1
- package/src/components/chart/stock-chart.tsx +59 -41
- package/src/components/command-bar/command-bar.test.tsx +446 -120
- package/src/components/command-bar/command-bar.tsx +528 -383
- package/src/components/command-bar/command-registry.ts +16 -6
- package/src/components/command-bar/root-shortcuts.ts +4 -5
- package/src/components/command-bar/view-model.test.ts +1 -0
- package/src/components/command-bar/view-model.ts +2 -1
- package/src/components/command-bar/workflow-ops.ts +11 -32
- package/src/components/data-table-view.test.tsx +24 -1
- package/src/components/data-table-view.tsx +3 -2
- package/src/components/feed-data-table-stack-view.tsx +3 -2
- package/src/components/layout/detached-pane-shell.tsx +2 -2
- package/src/components/layout/floating-pane.tsx +9 -2
- package/src/components/layout/pane-footer.tsx +1 -1
- package/src/components/layout/pane-header.tsx +4 -2
- package/src/components/layout/pane-sizing.ts +12 -0
- package/src/components/layout/pane.tsx +24 -2
- package/src/components/layout/shell.tsx +24 -10
- package/src/components/layout/status-bar.test.tsx +4 -1
- package/src/components/layout/status-bar.tsx +4 -24
- package/src/components/markdown-text.tsx +53 -8
- package/src/components/onboarding/onboarding-wizard.tsx +7 -6
- package/src/components/pane-settings-dialog.tsx +9 -6
- package/src/components/pane-template-wizard.tsx +3 -2
- package/src/components/price-selector-dialog.tsx +3 -2
- package/src/components/price-sparkline-view.tsx +463 -0
- package/src/components/price-sparkline.ts +46 -0
- package/src/components/table-view-shared.tsx +47 -7
- package/src/components/ticker-list-table-view.tsx +3 -2
- package/src/components/ticker-list-table.tsx +11 -51
- package/src/components/toggle-list.tsx +3 -3
- package/src/components/ui/choice-dialog.tsx +3 -2
- package/src/components/ui/multi-select-dialog.tsx +3 -2
- package/src/components/ui/native-select.tsx +3 -3
- package/src/core/state/app-state.ts +210 -80
- package/src/data/config-store-node.ts +113 -25
- package/src/data/config-store.test.ts +65 -3
- package/src/data/plugin-state-store.ts +39 -13
- package/src/data/sqlite-cache.test.ts +13 -0
- package/src/market-data/coordinator.ts +8 -1
- package/src/news/aggregator.test.ts +41 -0
- package/src/news/aggregator.ts +61 -9
- package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
- package/src/plugins/builtin/ai/settings.ts +3 -0
- package/src/plugins/builtin/broker-manager/index.tsx +4 -3
- package/src/plugins/builtin/chat-controller.test.ts +398 -17
- package/src/plugins/builtin/chat-controller.ts +258 -44
- package/src/plugins/builtin/chat.test.tsx +257 -6
- package/src/plugins/builtin/chat.tsx +225 -60
- package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
- package/src/plugins/builtin/correlation/colors.test.ts +39 -0
- package/src/plugins/builtin/correlation/colors.ts +87 -0
- package/src/plugins/builtin/correlation/index.test.tsx +151 -0
- package/src/plugins/builtin/correlation/index.tsx +92 -21
- package/src/plugins/builtin/debug.tsx +4 -3
- package/src/plugins/builtin/econ/index.tsx +3 -2
- package/src/plugins/builtin/help.test.tsx +2 -2
- package/src/plugins/builtin/holders/index.tsx +25 -13
- package/src/plugins/builtin/layout-manager.tsx +1 -7
- package/src/plugins/builtin/news-wire/index.tsx +1 -1
- package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
- package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
- package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
- package/src/plugins/builtin/options.tsx +9 -17
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
- package/src/plugins/builtin/portfolio-list/metrics.ts +11 -0
- package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
- package/src/plugins/builtin/portfolio-list/pane.tsx +117 -18
- package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
- package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
- package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
- package/src/plugins/builtin/portfolio-list.test.tsx +282 -2
- package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
- package/src/plugins/builtin/sectors/index.tsx +252 -41
- package/src/plugins/builtin/sectors/sector-data.ts +41 -1
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
- package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
- package/src/plugins/builtin/ticker-detail/pane.tsx +0 -6
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +442 -63
- package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
- package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
- package/src/plugins/ibkr/trading-pane.tsx +3 -2
- package/src/plugins/plugin-runtime.tsx +2 -2
- package/src/plugins/prediction-markets/settings.ts +4 -0
- package/src/plugins/registry.test.ts +55 -0
- package/src/plugins/registry.ts +11 -4
- package/src/plugins/ticker-navigation.test.ts +35 -5
- package/src/plugins/ticker-navigation.ts +16 -22
- package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
- package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
- package/src/renderers/electrobun/bun/application-menu.ts +6 -1
- package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
- package/src/renderers/electrobun/bun/index.ts +52 -13
- package/src/renderers/electrobun/view/app-services.ts +51 -9
- package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
- package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
- package/src/renderers/electrobun/view/main.tsx +0 -16
- package/src/renderers/electrobun/view/styles.css +23 -21
- package/src/renderers/electrobun/view/ui-host.tsx +67 -11
- package/src/renderers/opentui/host.tsx +2 -1
- package/src/renderers/opentui/start.tsx +1 -1
- package/src/renderers/opentui/test-utils.tsx +6 -5
- package/src/state/app-bootstrap.ts +3 -3
- package/src/state/app-context.test.ts +113 -0
- package/src/state/app-context.tsx +8 -4
- package/src/theme/colors.ts +13 -14
- package/src/types/config.ts +9 -4
- package/src/types/plugin.ts +8 -1
- package/src/ui/context-menu.tsx +4 -4
- package/src/ui/host.tsx +8 -2
- package/src/ui/index.tsx +1 -0
- package/src/utils/api-client.test.ts +128 -0
- package/src/utils/api-client.ts +112 -7
- package/src/utils/fuzzy-search.ts +12 -1
- package/src/utils/keyboard.ts +31 -0
- package/src/utils/ticker-search.test.ts +2 -2
- package/src/utils/ticker-search.ts +48 -1
- package/src/version.ts +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { mkdir, readFile, rm, writeFile } from "fs/promises";
|
|
1
|
+
import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
|
|
2
|
+
import { homedir } from "os";
|
|
2
3
|
import { dirname, join } from "path";
|
|
3
4
|
import type {
|
|
4
5
|
AppConfig,
|
|
@@ -20,6 +21,15 @@ import { isLayoutConfig, sanitizeLayout } from "./config-layout";
|
|
|
20
21
|
|
|
21
22
|
const configLog = debugLog.createLogger("config");
|
|
22
23
|
const LEGACY_MAIN_PORTFOLIO_COLUMN_IDS = DEFAULT_COLUMNS.map((column) => column.id);
|
|
24
|
+
const PRE_SPARKLINE_PORTFOLIO_COLUMN_IDS = [
|
|
25
|
+
...DEFAULT_COLUMNS.map((column) => column.id),
|
|
26
|
+
"shares",
|
|
27
|
+
"avg_cost",
|
|
28
|
+
"cost_basis",
|
|
29
|
+
"mkt_value",
|
|
30
|
+
"pnl",
|
|
31
|
+
"pnl_pct",
|
|
32
|
+
];
|
|
23
33
|
const BUILTIN_SOURCE_IDS = new Set(["yahoo", "gloomberb-cloud"]);
|
|
24
34
|
const BUILTIN_PLUGIN_GROUP_ALIASES: Record<string, string> = {
|
|
25
35
|
"comparison-chart": "market-overview",
|
|
@@ -37,13 +47,25 @@ const BUILTIN_PLUGIN_GROUP_ALIASES: Record<string, string> = {
|
|
|
37
47
|
};
|
|
38
48
|
|
|
39
49
|
function getGlobalConfigDir(): string {
|
|
40
|
-
return join(
|
|
50
|
+
return join(getHomeDir(), ".gloomberb");
|
|
41
51
|
}
|
|
42
52
|
|
|
43
53
|
function getGlobalConfigFile(): string {
|
|
44
54
|
return join(getGlobalConfigDir(), "config.json");
|
|
45
55
|
}
|
|
46
56
|
|
|
57
|
+
function getHomeDir(): string {
|
|
58
|
+
return process.env.HOME || homedir();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function expandHomePath(filePath: string): string {
|
|
62
|
+
if (filePath === "~") return getHomeDir();
|
|
63
|
+
if (filePath.startsWith("~/") || filePath.startsWith("~\\")) {
|
|
64
|
+
return join(getHomeDir(), filePath.slice(2));
|
|
65
|
+
}
|
|
66
|
+
return filePath;
|
|
67
|
+
}
|
|
68
|
+
|
|
47
69
|
export async function getDataDir(): Promise<string | null> {
|
|
48
70
|
try {
|
|
49
71
|
const raw = await readFile(getGlobalConfigFile(), "utf-8");
|
|
@@ -89,7 +111,9 @@ function normalizeConfig(saved: Record<string, unknown>, dataDir: string): { con
|
|
|
89
111
|
const activeLayoutIndex = sanitizeActiveLayoutIndex(saved.activeLayoutIndex, layouts.length);
|
|
90
112
|
const layout = cloneLayout(layouts[activeLayoutIndex]?.layout ?? directLayout);
|
|
91
113
|
const syncedLayouts = layouts.map((entry, index) => (
|
|
92
|
-
index === activeLayoutIndex
|
|
114
|
+
index === activeLayoutIndex
|
|
115
|
+
? { ...entry, layout: cloneLayout(layout), paneState: sanitizeSavedPaneState(entry.paneState, layout) }
|
|
116
|
+
: entry
|
|
93
117
|
));
|
|
94
118
|
|
|
95
119
|
const disabledPlugins = sanitizeDisabledPlugins(saved, defaults.disabledPlugins, {
|
|
@@ -138,7 +162,9 @@ export async function saveConfig(config: AppConfig): Promise<void> {
|
|
|
138
162
|
const activeLayoutIndex = sanitizeActiveLayoutIndex(config.activeLayoutIndex, config.layouts.length || 1);
|
|
139
163
|
const layout = sanitizeLayout(config.layout, createDefaultConfig(config.dataDir).layout);
|
|
140
164
|
const layouts = sanitizeSavedLayouts(config.layouts, layout).map((entry, index) => (
|
|
141
|
-
index === activeLayoutIndex
|
|
165
|
+
index === activeLayoutIndex
|
|
166
|
+
? { ...entry, layout: cloneLayout(layout), paneState: sanitizeSavedPaneState(entry.paneState, layout) }
|
|
167
|
+
: entry
|
|
142
168
|
));
|
|
143
169
|
|
|
144
170
|
const persisted: AppConfig = {
|
|
@@ -158,7 +184,14 @@ export async function saveConfig(config: AppConfig): Promise<void> {
|
|
|
158
184
|
recentTickers: sanitizeStringArray(config.recentTickers, []),
|
|
159
185
|
};
|
|
160
186
|
|
|
161
|
-
|
|
187
|
+
const tempPath = `${configPath}.${process.pid}.${Date.now()}.tmp`;
|
|
188
|
+
try {
|
|
189
|
+
await writeFile(tempPath, JSON.stringify(persisted, null, 2), "utf-8");
|
|
190
|
+
await rename(tempPath, configPath);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
await rm(tempPath, { force: true }).catch(() => {});
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
162
195
|
}
|
|
163
196
|
|
|
164
197
|
export async function initDataDir(dataDir: string): Promise<AppConfig> {
|
|
@@ -177,11 +210,11 @@ export async function resetAllData(dataDir: string): Promise<void> {
|
|
|
177
210
|
|
|
178
211
|
export async function exportConfig(config: AppConfig, destPath: string): Promise<void> {
|
|
179
212
|
const { dataDir, ...rest } = config;
|
|
180
|
-
await writeFile(destPath, JSON.stringify(rest, null, 2), "utf-8");
|
|
213
|
+
await writeFile(expandHomePath(destPath), JSON.stringify(rest, null, 2), "utf-8");
|
|
181
214
|
}
|
|
182
215
|
|
|
183
216
|
export async function importConfig(dataDir: string, srcPath: string): Promise<AppConfig> {
|
|
184
|
-
const raw = await readFile(srcPath, "utf-8");
|
|
217
|
+
const raw = await readFile(expandHomePath(srcPath), "utf-8");
|
|
185
218
|
const saved = JSON.parse(raw) as Record<string, unknown>;
|
|
186
219
|
const { config } = normalizeConfig(saved, dataDir);
|
|
187
220
|
await saveConfig(config);
|
|
@@ -238,6 +271,41 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
|
238
271
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
239
272
|
}
|
|
240
273
|
|
|
274
|
+
function sanitizeSerializableValue(value: unknown): unknown {
|
|
275
|
+
if (value == null) return value;
|
|
276
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
277
|
+
return value;
|
|
278
|
+
}
|
|
279
|
+
if (Array.isArray(value)) {
|
|
280
|
+
return value
|
|
281
|
+
.map((entry) => sanitizeSerializableValue(entry))
|
|
282
|
+
.filter((entry) => entry !== undefined);
|
|
283
|
+
}
|
|
284
|
+
if (isPlainRecord(value)) {
|
|
285
|
+
return Object.fromEntries(
|
|
286
|
+
Object.entries(value)
|
|
287
|
+
.map(([key, entry]) => [key, sanitizeSerializableValue(entry)])
|
|
288
|
+
.filter(([, entry]) => entry !== undefined),
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function sanitizeSavedPaneState(
|
|
295
|
+
value: unknown,
|
|
296
|
+
layout: LayoutConfig,
|
|
297
|
+
): Record<string, Record<string, unknown>> | undefined {
|
|
298
|
+
if (!isPlainRecord(value)) return undefined;
|
|
299
|
+
const validPaneIds = new Set(layout.instances.map((instance) => instance.instanceId));
|
|
300
|
+
const paneState = Object.fromEntries(
|
|
301
|
+
Object.entries(value)
|
|
302
|
+
.filter(([paneId, entry]) => validPaneIds.has(paneId) && isPlainRecord(entry))
|
|
303
|
+
.map(([paneId, entry]) => [paneId, sanitizeSerializableValue(entry)])
|
|
304
|
+
.filter((entry): entry is [string, Record<string, unknown>] => isPlainRecord(entry[1])),
|
|
305
|
+
);
|
|
306
|
+
return paneState;
|
|
307
|
+
}
|
|
308
|
+
|
|
241
309
|
function isPluginConfigMap(value: unknown): value is Record<string, Record<string, unknown>> {
|
|
242
310
|
if (!isPlainRecord(value)) return false;
|
|
243
311
|
return Object.values(value).every((entry) => isPlainRecord(entry));
|
|
@@ -266,7 +334,8 @@ function isChartPreferences(value: unknown): value is ChartPreferences {
|
|
|
266
334
|
return (defaultRenderMode === "area"
|
|
267
335
|
|| defaultRenderMode === "line"
|
|
268
336
|
|| defaultRenderMode === "candles"
|
|
269
|
-
|| defaultRenderMode === "ohlc"
|
|
337
|
+
|| defaultRenderMode === "ohlc"
|
|
338
|
+
|| defaultRenderMode === "hlc")
|
|
270
339
|
&& (renderer === "auto" || renderer === "kitty" || renderer === "braille");
|
|
271
340
|
}
|
|
272
341
|
|
|
@@ -278,6 +347,7 @@ function sanitizeChartPreferences(value: unknown, fallback: ChartPreferences): C
|
|
|
278
347
|
|| candidate.defaultRenderMode === "line"
|
|
279
348
|
|| candidate.defaultRenderMode === "candles"
|
|
280
349
|
|| candidate.defaultRenderMode === "ohlc"
|
|
350
|
+
|| candidate.defaultRenderMode === "hlc"
|
|
281
351
|
? candidate.defaultRenderMode
|
|
282
352
|
: fallback.defaultRenderMode;
|
|
283
353
|
const renderer = candidate.renderer === "auto"
|
|
@@ -342,24 +412,31 @@ function hasExactColumnIds(value: unknown, expected: string[]): boolean {
|
|
|
342
412
|
&& value.every((entry, index) => entry === expected[index]);
|
|
343
413
|
}
|
|
344
414
|
|
|
415
|
+
function shouldMigratePortfolioColumnIds(value: unknown): boolean {
|
|
416
|
+
return hasExactColumnIds(value, LEGACY_MAIN_PORTFOLIO_COLUMN_IDS)
|
|
417
|
+
|| hasExactColumnIds(value, PRE_SPARKLINE_PORTFOLIO_COLUMN_IDS);
|
|
418
|
+
}
|
|
419
|
+
|
|
345
420
|
function migrateLegacyPortfolioDefaultColumns(layout: LayoutConfig, enabled: boolean): LayoutConfig {
|
|
346
421
|
if (!enabled) return layout;
|
|
347
422
|
|
|
348
|
-
const
|
|
349
|
-
instance.
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
);
|
|
353
|
-
if (
|
|
423
|
+
const instanceIndices = layout.instances.flatMap((instance, index) => (
|
|
424
|
+
instance.paneId === "portfolio-list" && shouldMigratePortfolioColumnIds(instance.settings?.columnIds)
|
|
425
|
+
? [index]
|
|
426
|
+
: []
|
|
427
|
+
));
|
|
428
|
+
if (instanceIndices.length === 0) return layout;
|
|
354
429
|
|
|
355
430
|
const nextLayout = cloneLayout(layout);
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
431
|
+
for (const instanceIndex of instanceIndices) {
|
|
432
|
+
nextLayout.instances[instanceIndex] = {
|
|
433
|
+
...nextLayout.instances[instanceIndex]!,
|
|
434
|
+
settings: {
|
|
435
|
+
...(nextLayout.instances[instanceIndex]?.settings ?? {}),
|
|
436
|
+
columnIds: [...DEFAULT_PORTFOLIO_COLUMN_IDS],
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
}
|
|
363
440
|
return nextLayout;
|
|
364
441
|
}
|
|
365
442
|
|
|
@@ -374,10 +451,21 @@ function sanitizeSavedLayouts(value: unknown, fallbackLayout: LayoutConfig): Sav
|
|
|
374
451
|
&& typeof entry === "object"
|
|
375
452
|
&& typeof (entry as SavedLayout).name === "string",
|
|
376
453
|
)
|
|
377
|
-
.map((entry) =>
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
454
|
+
.map((entry) => {
|
|
455
|
+
const layout = sanitizeLayout(entry.layout, fallbackLayout);
|
|
456
|
+
return {
|
|
457
|
+
id: typeof entry.id === "string" ? entry.id : undefined,
|
|
458
|
+
name: entry.name,
|
|
459
|
+
layout,
|
|
460
|
+
paneState: sanitizeSavedPaneState((entry as { paneState?: unknown }).paneState, layout),
|
|
461
|
+
focusedPaneId: typeof entry.focusedPaneId === "string" || entry.focusedPaneId === null
|
|
462
|
+
? entry.focusedPaneId
|
|
463
|
+
: undefined,
|
|
464
|
+
activePanel: entry.activePanel === "right" || entry.activePanel === "left"
|
|
465
|
+
? entry.activePanel
|
|
466
|
+
: undefined,
|
|
467
|
+
};
|
|
468
|
+
});
|
|
381
469
|
|
|
382
470
|
return layouts.length > 0 ? layouts : [{ name: "Default", layout: cloneLayout(fallbackLayout) }];
|
|
383
471
|
}
|
|
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtemp, readFile, rm, writeFile } from "fs/promises";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { tmpdir } from "os";
|
|
5
|
-
import { loadConfig, sanitizeLayout, saveConfig } from "./config-store";
|
|
5
|
+
import { exportConfig, importConfig, loadConfig, sanitizeLayout, saveConfig } from "./config-store";
|
|
6
6
|
import {
|
|
7
7
|
CURRENT_CONFIG_VERSION,
|
|
8
8
|
DEFAULT_COLUMNS,
|
|
@@ -219,7 +219,7 @@ describe("loadConfig", () => {
|
|
|
219
219
|
await writeConfigJson(dataDir, createSavedConfig({
|
|
220
220
|
configVersion: 7,
|
|
221
221
|
chartPreferences: {
|
|
222
|
-
defaultRenderMode: "
|
|
222
|
+
defaultRenderMode: "hlc",
|
|
223
223
|
renderer: "nope",
|
|
224
224
|
},
|
|
225
225
|
}));
|
|
@@ -227,7 +227,7 @@ describe("loadConfig", () => {
|
|
|
227
227
|
const config = await loadConfig(dataDir);
|
|
228
228
|
|
|
229
229
|
expect(config.chartPreferences).toEqual({
|
|
230
|
-
defaultRenderMode: "
|
|
230
|
+
defaultRenderMode: "hlc",
|
|
231
231
|
renderer: "auto",
|
|
232
232
|
});
|
|
233
233
|
expect(config.pluginConfig).toEqual({});
|
|
@@ -342,6 +342,40 @@ describe("loadConfig", () => {
|
|
|
342
342
|
});
|
|
343
343
|
});
|
|
344
344
|
|
|
345
|
+
test("preserves saved layout pane state and focus metadata", async () => {
|
|
346
|
+
const dataDir = await createTempConfigDir();
|
|
347
|
+
await writeConfigJson(dataDir, createSavedConfig({
|
|
348
|
+
layouts: [{
|
|
349
|
+
name: "Chart",
|
|
350
|
+
layout: DEFAULT_LAYOUT,
|
|
351
|
+
paneState: {
|
|
352
|
+
"ticker-detail:main": { activeTabId: "chart" },
|
|
353
|
+
"missing:pane": { activeTabId: "overview" },
|
|
354
|
+
},
|
|
355
|
+
focusedPaneId: "ticker-detail:main",
|
|
356
|
+
activePanel: "right",
|
|
357
|
+
}],
|
|
358
|
+
}));
|
|
359
|
+
|
|
360
|
+
const config = await loadConfig(dataDir);
|
|
361
|
+
|
|
362
|
+
expect(config.layouts[0]?.paneState).toEqual({
|
|
363
|
+
"ticker-detail:main": { activeTabId: "chart" },
|
|
364
|
+
});
|
|
365
|
+
expect(config.layouts[0]?.focusedPaneId).toBe("ticker-detail:main");
|
|
366
|
+
expect(config.layouts[0]?.activePanel).toBe("right");
|
|
367
|
+
|
|
368
|
+
await saveConfig(config);
|
|
369
|
+
const persisted = JSON.parse(await readFile(join(dataDir, "config.json"), "utf-8")) as {
|
|
370
|
+
layouts: Array<{ paneState?: Record<string, unknown>; focusedPaneId?: string | null; activePanel?: string }>;
|
|
371
|
+
};
|
|
372
|
+
expect(persisted.layouts[0]?.paneState).toEqual({
|
|
373
|
+
"ticker-detail:main": { activeTabId: "chart" },
|
|
374
|
+
});
|
|
375
|
+
expect(persisted.layouts[0]?.focusedPaneId).toBe("ticker-detail:main");
|
|
376
|
+
expect(persisted.layouts[0]?.activePanel).toBe("right");
|
|
377
|
+
});
|
|
378
|
+
|
|
345
379
|
test("migrates legacy main portfolio panes to the portfolio default columns", async () => {
|
|
346
380
|
const dataDir = await createTempConfigDir();
|
|
347
381
|
const legacyColumnIds = DEFAULT_COLUMNS.map((column) => column.id);
|
|
@@ -417,3 +451,31 @@ describe("loadConfig", () => {
|
|
|
417
451
|
expect(persisted.layouts[1]?.layout).toEqual(DEFAULT_LAYOUT);
|
|
418
452
|
});
|
|
419
453
|
});
|
|
454
|
+
|
|
455
|
+
describe("config backup files", () => {
|
|
456
|
+
test("expands a leading tilde when exporting and importing", async () => {
|
|
457
|
+
const originalHome = process.env.HOME;
|
|
458
|
+
const homeDir = await createTempConfigDir();
|
|
459
|
+
const dataDir = await createTempConfigDir();
|
|
460
|
+
const importDataDir = await createTempConfigDir();
|
|
461
|
+
process.env.HOME = homeDir;
|
|
462
|
+
|
|
463
|
+
try {
|
|
464
|
+
const config = await loadConfig(dataDir);
|
|
465
|
+
await exportConfig({ ...config, baseCurrency: "EUR" }, "~/gloomberb-config-backup.json");
|
|
466
|
+
|
|
467
|
+
const backupPath = join(homeDir, "gloomberb-config-backup.json");
|
|
468
|
+
const exported = JSON.parse(await readFile(backupPath, "utf-8")) as Record<string, unknown>;
|
|
469
|
+
expect(exported.baseCurrency).toBe("EUR");
|
|
470
|
+
expect(exported.dataDir).toBeUndefined();
|
|
471
|
+
|
|
472
|
+
await writeFile(backupPath, JSON.stringify({ ...exported, baseCurrency: "JPY" }), "utf-8");
|
|
473
|
+
const imported = await importConfig(importDataDir, "~/gloomberb-config-backup.json");
|
|
474
|
+
|
|
475
|
+
expect(imported.baseCurrency).toBe("JPY");
|
|
476
|
+
expect(imported.dataDir).toBe(importDataDir);
|
|
477
|
+
} finally {
|
|
478
|
+
process.env.HOME = originalHome;
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
});
|
|
@@ -10,6 +10,13 @@ export interface PluginStateRecord<T = unknown> {
|
|
|
10
10
|
updatedAt: number;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
export interface PluginStateSetEntry {
|
|
14
|
+
pluginId: string;
|
|
15
|
+
key: string;
|
|
16
|
+
value: unknown;
|
|
17
|
+
schemaVersion?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
13
20
|
interface CachedPluginStateRecord {
|
|
14
21
|
value: unknown;
|
|
15
22
|
schemaVersion: number;
|
|
@@ -64,22 +71,41 @@ export class PluginStateStore {
|
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
set(pluginId: string, key: string, value: unknown, schemaVersion = DEFAULT_PLUGIN_STATE_SCHEMA_VERSION): void {
|
|
67
|
-
|
|
74
|
+
this.setMany([{ pluginId, key, value, schemaVersion }]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
setMany(entries: PluginStateSetEntry[]): void {
|
|
78
|
+
if (entries.length === 0) return;
|
|
68
79
|
const updatedAt = Date.now();
|
|
80
|
+
const records = entries.map((entry) => ({
|
|
81
|
+
pluginId: entry.pluginId,
|
|
82
|
+
key: entry.key,
|
|
83
|
+
value: entry.value,
|
|
84
|
+
schemaVersion: entry.schemaVersion ?? DEFAULT_PLUGIN_STATE_SCHEMA_VERSION,
|
|
85
|
+
rawValue: serializeJson(entry.value),
|
|
86
|
+
}));
|
|
87
|
+
|
|
69
88
|
withSqliteBusyRetry("save plugin state", () => {
|
|
70
|
-
this.db
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
updatedAt,
|
|
81
|
-
rawValue,
|
|
89
|
+
const insert = this.db.query(
|
|
90
|
+
`INSERT OR REPLACE INTO plugin_state (plugin_id, key, schema_version, value, updated_at)
|
|
91
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
92
|
+
);
|
|
93
|
+
const tx = this.db.transaction(() => {
|
|
94
|
+
for (const record of records) {
|
|
95
|
+
insert.run(record.pluginId, record.key, record.schemaVersion, record.rawValue, updatedAt);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
tx();
|
|
82
99
|
});
|
|
100
|
+
|
|
101
|
+
for (const record of records) {
|
|
102
|
+
this.cache.set(`${record.pluginId}:${record.key}`, {
|
|
103
|
+
value: record.value,
|
|
104
|
+
schemaVersion: record.schemaVersion,
|
|
105
|
+
updatedAt,
|
|
106
|
+
rawValue: record.rawValue,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
83
109
|
}
|
|
84
110
|
|
|
85
111
|
delete(pluginId: string, key: string): void {
|
|
@@ -127,6 +127,19 @@ describe("AppPersistence", () => {
|
|
|
127
127
|
persistence.close();
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
+
test("stores multiple plugin state records in one batch", () => {
|
|
131
|
+
const dbPath = createTempDbPath("plugin-state-batch");
|
|
132
|
+
const persistence = new AppPersistence(dbPath);
|
|
133
|
+
persistence.pluginState.setMany([
|
|
134
|
+
{ pluginId: "ai", key: "provider", value: "openai", schemaVersion: 1 },
|
|
135
|
+
{ pluginId: "news", key: "resume:read", value: { ids: ["1"] }, schemaVersion: 2 },
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
expect(persistence.pluginState.get<string>("ai", "provider", 1)?.value).toBe("openai");
|
|
139
|
+
expect(persistence.pluginState.get<{ ids: string[] }>("news", "resume:read", 2)?.value.ids).toEqual(["1"]);
|
|
140
|
+
persistence.close();
|
|
141
|
+
});
|
|
142
|
+
|
|
130
143
|
test("stores and returns session snapshots", () => {
|
|
131
144
|
const dbPath = createTempDbPath("session-snapshot");
|
|
132
145
|
const persistence = new AppPersistence(dbPath);
|
|
@@ -202,6 +202,13 @@ function areStreamQuotesEquivalent(current: Quote | null | undefined, next: Quot
|
|
|
202
202
|
&& JSON.stringify(current.provenance ?? null) === JSON.stringify(next.provenance ?? null);
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
function hasCachedSnapshotData(financials: TickerFinancials): boolean {
|
|
206
|
+
return !!financials.profile
|
|
207
|
+
|| Object.keys(financials.fundamentals ?? {}).length > 0
|
|
208
|
+
|| financials.annualStatements.length > 0
|
|
209
|
+
|| financials.quarterlyStatements.length > 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
205
212
|
function errorEntry<T>(current: QueryEntry<T>, attempt: ProviderAttempt): QueryEntry<T> {
|
|
206
213
|
return {
|
|
207
214
|
...current,
|
|
@@ -365,7 +372,7 @@ export class MarketDataCoordinator {
|
|
|
365
372
|
const fundamentalsKey = buildFundamentalsKey(instrument);
|
|
366
373
|
const statementsKey = buildStatementsKey(instrument);
|
|
367
374
|
|
|
368
|
-
if (this.snapshotStore.get(snapshotKey).phase === "idle") {
|
|
375
|
+
if (hasCachedSnapshotData(normalized) && this.snapshotStore.get(snapshotKey).phase === "idle") {
|
|
369
376
|
this.snapshotStore.set(
|
|
370
377
|
snapshotKey,
|
|
371
378
|
readyEntry(this.snapshotStore.get(snapshotKey), normalized, source, [], { keepLastGoodOnEmpty: true }),
|
|
@@ -312,4 +312,45 @@ describe("NewsService", () => {
|
|
|
312
312
|
expect(detail?.items?.map((item) => item.id)).toEqual(["item-2", "item-1"]);
|
|
313
313
|
expect(state.articles[0]?.items?.map((item) => item.id)).toEqual(["item-2", "item-1"]);
|
|
314
314
|
});
|
|
315
|
+
|
|
316
|
+
it("keeps story detail identity when a duplicate feed item wins ranking", async () => {
|
|
317
|
+
const url = "https://detail.example.com/duplicate-story";
|
|
318
|
+
const cloudArticle = makeItem({
|
|
319
|
+
id: "story-1",
|
|
320
|
+
url,
|
|
321
|
+
importance: 60,
|
|
322
|
+
publishedAt: new Date("2026-04-01T10:00:00.000Z"),
|
|
323
|
+
items: [],
|
|
324
|
+
});
|
|
325
|
+
const feedDuplicate = makeItem({
|
|
326
|
+
id: "rss-1",
|
|
327
|
+
url,
|
|
328
|
+
importance: 95,
|
|
329
|
+
publishedAt: new Date("2026-04-01T10:05:00.000Z"),
|
|
330
|
+
});
|
|
331
|
+
const detailArticle = makeItem({
|
|
332
|
+
...cloudArticle,
|
|
333
|
+
items: [{
|
|
334
|
+
id: "item-1",
|
|
335
|
+
sourceKey: "wire-a",
|
|
336
|
+
sourceName: "Wire A",
|
|
337
|
+
title: "Original",
|
|
338
|
+
url: "https://detail.example.com/original",
|
|
339
|
+
publishedAt: new Date("2026-04-01T10:00:00.000Z"),
|
|
340
|
+
}],
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
agg.register({ ...makeStorySource("cloud", [cloudArticle], detailArticle), priority: 10 });
|
|
344
|
+
agg.register({ ...makeSource("rss", [feedDuplicate]), priority: 2000 });
|
|
345
|
+
|
|
346
|
+
const initial = await agg.load({ feed: "top", limit: 10 });
|
|
347
|
+
|
|
348
|
+
expect(initial.articles[0]?.id).toBe("story-1");
|
|
349
|
+
expect(initial.articles[0]?.importance).toBe(95);
|
|
350
|
+
|
|
351
|
+
await agg.loadStory(initial.articles[0]!.id);
|
|
352
|
+
const state = agg.getQueryState({ feed: "top", limit: 10 });
|
|
353
|
+
|
|
354
|
+
expect(state.articles[0]?.items?.map((item) => item.id)).toEqual(["item-1"]);
|
|
355
|
+
});
|
|
315
356
|
});
|
package/src/news/aggregator.ts
CHANGED
|
@@ -12,6 +12,9 @@ const MAX_ARTICLES = 500;
|
|
|
12
12
|
const DEFAULT_POLL_INTERVAL_MS = 2 * 60 * 1000;
|
|
13
13
|
const DEFAULT_GLOBAL_QUERY: NewsQuery = { feed: "latest", limit: MAX_ARTICLES };
|
|
14
14
|
const FEEDS = new Set<NewsFeed>(["latest", "top", "breaking", "ticker", "sector", "topic"]);
|
|
15
|
+
const DETAIL_CAPABLE_ARTICLE = Symbol("detail-capable-news-article");
|
|
16
|
+
|
|
17
|
+
type DetailCapableArticle = NewsArticle & { [DETAIL_CAPABLE_ARTICLE]?: true };
|
|
15
18
|
|
|
16
19
|
function normalizeTicker(ticker: string | undefined): string {
|
|
17
20
|
return ticker ? normalizeSymbol(ticker) : "";
|
|
@@ -101,6 +104,57 @@ function sortByPublishedAt(items: NewsArticle[]): NewsArticle[] {
|
|
|
101
104
|
return [...items].sort((a, b) => b.publishedAt.getTime() - a.publishedAt.getTime());
|
|
102
105
|
}
|
|
103
106
|
|
|
107
|
+
function hasStoryItems(item: NewsArticle | null | undefined): boolean {
|
|
108
|
+
return (item?.items?.length ?? 0) > 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function markDetailCapableArticle(source: NewsCapability, item: NewsArticle): NewsArticle {
|
|
112
|
+
if (!source.provider.fetchNewsStory) return item;
|
|
113
|
+
Object.defineProperty(item, DETAIL_CAPABLE_ARTICLE, {
|
|
114
|
+
value: true,
|
|
115
|
+
configurable: true,
|
|
116
|
+
});
|
|
117
|
+
return item;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isDetailCapableArticle(item: NewsArticle): boolean {
|
|
121
|
+
return (item as DetailCapableArticle)[DETAIL_CAPABLE_ARTICLE] === true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function shouldReplaceDuplicate(existing: NewsArticle, item: NewsArticle): boolean {
|
|
125
|
+
return (
|
|
126
|
+
item.importance > existing.importance ||
|
|
127
|
+
(item.importance === existing.importance && item.publishedAt > existing.publishedAt)
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function selectDetailArticle(
|
|
132
|
+
existing: NewsArticle,
|
|
133
|
+
item: NewsArticle,
|
|
134
|
+
winner: NewsArticle,
|
|
135
|
+
): NewsArticle | null {
|
|
136
|
+
if (isDetailCapableArticle(winner)) return winner;
|
|
137
|
+
if (isDetailCapableArticle(item)) return item;
|
|
138
|
+
if (isDetailCapableArticle(existing)) return existing;
|
|
139
|
+
if (hasStoryItems(winner)) return winner;
|
|
140
|
+
if (hasStoryItems(item)) return item;
|
|
141
|
+
if (hasStoryItems(existing)) return existing;
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function mergeDuplicateArticle(existing: NewsArticle, item: NewsArticle): NewsArticle {
|
|
146
|
+
const winner = shouldReplaceDuplicate(existing, item)
|
|
147
|
+
? { ...existing, ...item }
|
|
148
|
+
: existing;
|
|
149
|
+
const detailArticle = selectDetailArticle(existing, item, winner);
|
|
150
|
+
if (!detailArticle || detailArticle.id === winner.id) return winner;
|
|
151
|
+
return {
|
|
152
|
+
...winner,
|
|
153
|
+
id: detailArticle.id,
|
|
154
|
+
items: hasStoryItems(detailArticle) ? detailArticle.items : winner.items,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
104
158
|
function dedupeArticles(items: NewsArticle[]): NewsArticle[] {
|
|
105
159
|
const byKey = new Map<string, NewsArticle>();
|
|
106
160
|
for (const item of items) {
|
|
@@ -110,12 +164,7 @@ function dedupeArticles(items: NewsArticle[]): NewsArticle[] {
|
|
|
110
164
|
byKey.set(key, item);
|
|
111
165
|
continue;
|
|
112
166
|
}
|
|
113
|
-
|
|
114
|
-
item.importance > existing.importance ||
|
|
115
|
-
(item.importance === existing.importance && item.publishedAt > existing.publishedAt)
|
|
116
|
-
) {
|
|
117
|
-
byKey.set(key, { ...existing, ...item });
|
|
118
|
-
}
|
|
167
|
+
byKey.set(key, mergeDuplicateArticle(existing, item));
|
|
119
168
|
}
|
|
120
169
|
return sortByPublishedAt([...byKey.values()]).slice(0, MAX_ARTICLES);
|
|
121
170
|
}
|
|
@@ -382,7 +431,8 @@ export class NewsService {
|
|
|
382
431
|
let firstEmpty: SourceFetchResult | null = null;
|
|
383
432
|
for (const source of sources) {
|
|
384
433
|
try {
|
|
385
|
-
const articles = await source.provider.fetchNews(query)
|
|
434
|
+
const articles = (await source.provider.fetchNews(query))
|
|
435
|
+
.map((article) => markDetailCapableArticle(source, article));
|
|
386
436
|
const result = { articles, sourceIds: [newsCapabilitySourceId(source)] };
|
|
387
437
|
if (articles.length > 0) return result;
|
|
388
438
|
firstEmpty ??= result;
|
|
@@ -397,7 +447,8 @@ export class NewsService {
|
|
|
397
447
|
const settled = await Promise.allSettled(
|
|
398
448
|
sources.map(async (source) => ({
|
|
399
449
|
source,
|
|
400
|
-
articles: await source.provider.fetchNews(query)
|
|
450
|
+
articles: (await source.provider.fetchNews(query))
|
|
451
|
+
.map((article) => markDetailCapableArticle(source, article)),
|
|
401
452
|
})),
|
|
402
453
|
);
|
|
403
454
|
const articles: NewsArticle[] = [];
|
|
@@ -418,7 +469,8 @@ export class NewsService {
|
|
|
418
469
|
let changed = false;
|
|
419
470
|
for (const query of queries) {
|
|
420
471
|
if (source.isEnabled?.() === false || news.supports?.(query) === false) continue;
|
|
421
|
-
const cached = news.getCachedNews?.(query) ?? []
|
|
472
|
+
const cached = (news.getCachedNews?.(query) ?? [])
|
|
473
|
+
.map((article) => markDetailCapableArticle(source, article));
|
|
422
474
|
if (cached.length === 0) continue;
|
|
423
475
|
const key = buildNewsQueryKey(query);
|
|
424
476
|
const current = this.queryStates.get(key) ?? createIdleState();
|
|
@@ -300,13 +300,32 @@ export function AskAiDetailTab({ width, height, focused, onCapture }: DetailTabP
|
|
|
300
300
|
nativePaneChrome,
|
|
301
301
|
terminalBottomInset: terminalFooterClearance,
|
|
302
302
|
});
|
|
303
|
-
const chatHeight = Math.max(height - composerBlockHeight, 0);
|
|
303
|
+
const chatHeight = nativePaneChrome ? undefined : Math.max(height - composerBlockHeight, 0);
|
|
304
|
+
const layoutHeight = nativePaneChrome ? "100%" : height;
|
|
305
|
+
const nativeFillStyle = nativePaneChrome ? { minHeight: 0 } : undefined;
|
|
304
306
|
|
|
305
307
|
return (
|
|
306
|
-
<Box
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
308
|
+
<Box
|
|
309
|
+
flexDirection="column"
|
|
310
|
+
paddingX={nativePaneChrome ? 0 : 1}
|
|
311
|
+
height={layoutHeight}
|
|
312
|
+
flexGrow={nativePaneChrome ? 1 : undefined}
|
|
313
|
+
overflow="hidden"
|
|
314
|
+
style={nativeFillStyle}
|
|
315
|
+
>
|
|
316
|
+
{(nativePaneChrome || (chatHeight ?? 0) > 0) && (
|
|
317
|
+
<ScrollBox
|
|
318
|
+
ref={scrollRef}
|
|
319
|
+
height={nativePaneChrome ? undefined : chatHeight}
|
|
320
|
+
flexGrow={nativePaneChrome ? 1 : undefined}
|
|
321
|
+
scrollY
|
|
322
|
+
style={nativeFillStyle}
|
|
323
|
+
>
|
|
324
|
+
<Box
|
|
325
|
+
flexDirection="column"
|
|
326
|
+
paddingX={nativePaneChrome ? 1 : 0}
|
|
327
|
+
style={nativeFillStyle}
|
|
328
|
+
>
|
|
310
329
|
{messages.length === 0 ? (
|
|
311
330
|
<Box paddingTop={1}>
|
|
312
331
|
<Text fg={colors.textDim}>
|
|
@@ -354,7 +373,7 @@ export function AskAiDetailTab({ width, height, focused, onCapture }: DetailTabP
|
|
|
354
373
|
placeholder="Ask a question..."
|
|
355
374
|
terminalPrefix=" > "
|
|
356
375
|
terminalBottomInset={terminalFooterClearance}
|
|
357
|
-
width={nativePaneChrome ?
|
|
376
|
+
width={nativePaneChrome ? "100%" : contentWidth}
|
|
358
377
|
height={composerHeight}
|
|
359
378
|
onFocusRequest={focusInput}
|
|
360
379
|
onInput={(value) => setInputValue(value)}
|
|
@@ -60,6 +60,9 @@ export function resolveVisibleAiScreenerColumns(columnIds: string[]): ColumnConf
|
|
|
60
60
|
export function buildAiScreenerPaneSettingsDef(settings: AiScreenerPaneSettings): PaneSettingsDef {
|
|
61
61
|
return {
|
|
62
62
|
title: "AI Screener Settings",
|
|
63
|
+
values: {
|
|
64
|
+
columnIds: [...settings.columnIds],
|
|
65
|
+
},
|
|
63
66
|
fields: [
|
|
64
67
|
{
|
|
65
68
|
key: "columnIds",
|