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.
Files changed (131) hide show
  1. package/README.md +79 -132
  2. package/package.json +1 -1
  3. package/src/app.tsx +73 -67
  4. package/src/components/chart/chart-data.ts +7 -2
  5. package/src/components/chart/chart-pane-settings.ts +7 -4
  6. package/src/components/chart/chart-renderer.ts +49 -15
  7. package/src/components/chart/chart-types.ts +2 -2
  8. package/src/components/chart/chart.test.ts +27 -8
  9. package/src/components/chart/comparison-stock-chart.tsx +84 -121
  10. package/src/components/chart/native/chart-rasterizer.ts +43 -17
  11. package/src/components/chart/price-axis-labels.tsx +25 -9
  12. package/src/components/chart/stock-chart-renderer-switch.test.tsx +1 -1
  13. package/src/components/chart/stock-chart.tsx +59 -41
  14. package/src/components/command-bar/command-bar.test.tsx +446 -120
  15. package/src/components/command-bar/command-bar.tsx +528 -383
  16. package/src/components/command-bar/command-registry.ts +16 -6
  17. package/src/components/command-bar/root-shortcuts.ts +4 -5
  18. package/src/components/command-bar/view-model.test.ts +1 -0
  19. package/src/components/command-bar/view-model.ts +2 -1
  20. package/src/components/command-bar/workflow-ops.ts +11 -32
  21. package/src/components/data-table-view.test.tsx +24 -1
  22. package/src/components/data-table-view.tsx +3 -2
  23. package/src/components/feed-data-table-stack-view.tsx +3 -2
  24. package/src/components/layout/detached-pane-shell.tsx +2 -2
  25. package/src/components/layout/floating-pane.tsx +9 -2
  26. package/src/components/layout/pane-footer.tsx +1 -1
  27. package/src/components/layout/pane-header.tsx +4 -2
  28. package/src/components/layout/pane-sizing.ts +12 -0
  29. package/src/components/layout/pane.tsx +24 -2
  30. package/src/components/layout/shell.tsx +24 -10
  31. package/src/components/layout/status-bar.test.tsx +4 -1
  32. package/src/components/layout/status-bar.tsx +4 -24
  33. package/src/components/markdown-text.tsx +53 -8
  34. package/src/components/onboarding/onboarding-wizard.tsx +7 -6
  35. package/src/components/pane-settings-dialog.tsx +9 -6
  36. package/src/components/pane-template-wizard.tsx +3 -2
  37. package/src/components/price-selector-dialog.tsx +3 -2
  38. package/src/components/price-sparkline-view.tsx +463 -0
  39. package/src/components/price-sparkline.ts +46 -0
  40. package/src/components/table-view-shared.tsx +47 -7
  41. package/src/components/ticker-list-table-view.tsx +3 -2
  42. package/src/components/ticker-list-table.tsx +11 -51
  43. package/src/components/toggle-list.tsx +3 -3
  44. package/src/components/ui/choice-dialog.tsx +3 -2
  45. package/src/components/ui/multi-select-dialog.tsx +3 -2
  46. package/src/components/ui/native-select.tsx +3 -3
  47. package/src/core/state/app-state.ts +210 -80
  48. package/src/data/config-store-node.ts +113 -25
  49. package/src/data/config-store.test.ts +65 -3
  50. package/src/data/plugin-state-store.ts +39 -13
  51. package/src/data/sqlite-cache.test.ts +13 -0
  52. package/src/market-data/coordinator.ts +8 -1
  53. package/src/news/aggregator.test.ts +41 -0
  54. package/src/news/aggregator.ts +61 -9
  55. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
  56. package/src/plugins/builtin/ai/settings.ts +3 -0
  57. package/src/plugins/builtin/broker-manager/index.tsx +4 -3
  58. package/src/plugins/builtin/chat-controller.test.ts +398 -17
  59. package/src/plugins/builtin/chat-controller.ts +258 -44
  60. package/src/plugins/builtin/chat.test.tsx +257 -6
  61. package/src/plugins/builtin/chat.tsx +225 -60
  62. package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
  63. package/src/plugins/builtin/correlation/colors.test.ts +39 -0
  64. package/src/plugins/builtin/correlation/colors.ts +87 -0
  65. package/src/plugins/builtin/correlation/index.test.tsx +151 -0
  66. package/src/plugins/builtin/correlation/index.tsx +92 -21
  67. package/src/plugins/builtin/debug.tsx +4 -3
  68. package/src/plugins/builtin/econ/index.tsx +3 -2
  69. package/src/plugins/builtin/help.test.tsx +2 -2
  70. package/src/plugins/builtin/holders/index.tsx +25 -13
  71. package/src/plugins/builtin/layout-manager.tsx +1 -7
  72. package/src/plugins/builtin/news-wire/index.tsx +1 -1
  73. package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
  74. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
  75. package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
  76. package/src/plugins/builtin/options.tsx +9 -17
  77. package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
  78. package/src/plugins/builtin/portfolio-list/metrics.ts +11 -0
  79. package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
  80. package/src/plugins/builtin/portfolio-list/pane.tsx +117 -18
  81. package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
  82. package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
  83. package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
  84. package/src/plugins/builtin/portfolio-list.test.tsx +282 -2
  85. package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
  86. package/src/plugins/builtin/sectors/index.tsx +252 -41
  87. package/src/plugins/builtin/sectors/sector-data.ts +41 -1
  88. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
  89. package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
  90. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
  91. package/src/plugins/builtin/ticker-detail/pane.tsx +0 -6
  92. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +442 -63
  93. package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
  94. package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
  95. package/src/plugins/ibkr/trading-pane.tsx +3 -2
  96. package/src/plugins/plugin-runtime.tsx +2 -2
  97. package/src/plugins/prediction-markets/settings.ts +4 -0
  98. package/src/plugins/registry.test.ts +55 -0
  99. package/src/plugins/registry.ts +11 -4
  100. package/src/plugins/ticker-navigation.test.ts +35 -5
  101. package/src/plugins/ticker-navigation.ts +16 -22
  102. package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
  103. package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
  104. package/src/renderers/electrobun/bun/application-menu.ts +6 -1
  105. package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
  106. package/src/renderers/electrobun/bun/index.ts +52 -13
  107. package/src/renderers/electrobun/view/app-services.ts +51 -9
  108. package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
  109. package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
  110. package/src/renderers/electrobun/view/main.tsx +0 -16
  111. package/src/renderers/electrobun/view/styles.css +23 -21
  112. package/src/renderers/electrobun/view/ui-host.tsx +67 -11
  113. package/src/renderers/opentui/host.tsx +2 -1
  114. package/src/renderers/opentui/start.tsx +1 -1
  115. package/src/renderers/opentui/test-utils.tsx +6 -5
  116. package/src/state/app-bootstrap.ts +3 -3
  117. package/src/state/app-context.test.ts +113 -0
  118. package/src/state/app-context.tsx +8 -4
  119. package/src/theme/colors.ts +13 -14
  120. package/src/types/config.ts +9 -4
  121. package/src/types/plugin.ts +8 -1
  122. package/src/ui/context-menu.tsx +4 -4
  123. package/src/ui/host.tsx +8 -2
  124. package/src/ui/index.tsx +1 -0
  125. package/src/utils/api-client.test.ts +128 -0
  126. package/src/utils/api-client.ts +112 -7
  127. package/src/utils/fuzzy-search.ts +12 -1
  128. package/src/utils/keyboard.ts +31 -0
  129. package/src/utils/ticker-search.test.ts +2 -2
  130. package/src/utils/ticker-search.ts +48 -1
  131. 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(process.env.HOME || "~", ".gloomberb");
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 ? { ...entry, layout: cloneLayout(layout) } : entry
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 ? { ...entry, layout: cloneLayout(layout) } : entry
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
- await writeFile(configPath, JSON.stringify(persisted, null, 2), "utf-8");
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 instanceIndex = layout.instances.findIndex((instance) =>
349
- instance.instanceId === "portfolio-list:main"
350
- && instance.paneId === "portfolio-list"
351
- && hasExactColumnIds(instance.settings?.columnIds, LEGACY_MAIN_PORTFOLIO_COLUMN_IDS)
352
- );
353
- if (instanceIndex < 0) return layout;
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
- nextLayout.instances[instanceIndex] = {
357
- ...nextLayout.instances[instanceIndex]!,
358
- settings: {
359
- ...(nextLayout.instances[instanceIndex]?.settings ?? {}),
360
- columnIds: [...DEFAULT_PORTFOLIO_COLUMN_IDS],
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
- name: entry.name,
379
- layout: sanitizeLayout(entry.layout, fallbackLayout),
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: "line",
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: "line",
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
- const rawValue = serializeJson(value);
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
- .query(
72
- `INSERT OR REPLACE INTO plugin_state (plugin_id, key, schema_version, value, updated_at)
73
- VALUES (?, ?, ?, ?, ?)`,
74
- )
75
- .run(pluginId, key, schemaVersion, rawValue, updatedAt);
76
- });
77
- this.cache.set(`${pluginId}:${key}`, {
78
- value,
79
- schemaVersion,
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
  });
@@ -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
- if (
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 flexDirection="column" paddingX={nativePaneChrome ? 0 : 1} height={height} overflow="hidden">
307
- {chatHeight > 0 && (
308
- <ScrollBox ref={scrollRef} height={chatHeight} scrollY>
309
- <Box flexDirection="column" paddingX={nativePaneChrome ? 1 : 0}>
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 ? width : contentWidth}
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",