gloomberb 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +27 -16
  2. package/package.json +1 -1
  3. package/src/app.tsx +29 -65
  4. package/src/cli/base-converter.ts +31 -0
  5. package/src/cli/commands/search.ts +11 -3
  6. package/src/cli/commands/ticker.ts +22 -11
  7. package/src/cli/context.ts +27 -32
  8. package/src/cli/helpers.ts +1 -4
  9. package/src/cli/index.ts +140 -123
  10. package/src/cli/registry.test.ts +226 -0
  11. package/src/cli/registry.ts +201 -0
  12. package/src/cli.test.ts +219 -0
  13. package/src/components/chart/chart-controller.ts +309 -0
  14. package/src/components/chart/chart-data.ts +5 -6
  15. package/src/components/chart/chart-pane-settings.ts +27 -0
  16. package/src/components/chart/chart-renderer.ts +7 -2
  17. package/src/components/chart/chart-resolution.test.ts +56 -0
  18. package/src/components/chart/chart-resolution.ts +362 -0
  19. package/src/components/chart/chart-types.ts +12 -4
  20. package/src/components/chart/chart-viewport.test.ts +25 -0
  21. package/src/components/chart/chart-viewport.ts +28 -0
  22. package/src/components/chart/chart.test.ts +26 -0
  23. package/src/components/chart/comparison-chart-data.ts +20 -27
  24. package/src/components/chart/comparison-stock-chart.tsx +374 -99
  25. package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
  26. package/src/components/chart/stock-chart.tsx +484 -112
  27. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
  28. package/src/components/command-bar/command-bar.test.tsx +650 -5
  29. package/src/components/command-bar/command-bar.tsx +359 -63
  30. package/src/components/command-bar/command-registry.ts +8 -0
  31. package/src/components/command-bar/helpers.test.ts +23 -1
  32. package/src/components/command-bar/helpers.ts +14 -5
  33. package/src/components/command-bar/pane-template-display.ts +12 -0
  34. package/src/components/command-bar/root-shortcuts.ts +2 -1
  35. package/src/components/command-bar/workflow-ops.ts +4 -2
  36. package/src/components/detail-feed-view.test.tsx +124 -49
  37. package/src/components/detail-feed-view.tsx +396 -160
  38. package/src/components/layout/floating-pane.tsx +1 -1
  39. package/src/components/layout/pane-header.tsx +26 -2
  40. package/src/components/layout/shell.tsx +62 -50
  41. package/src/components/layout/status-bar.test.tsx +66 -0
  42. package/src/components/layout/status-bar.tsx +26 -5
  43. package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
  44. package/src/components/onboarding/onboarding-wizard.tsx +5 -6
  45. package/src/components/ticker-list-table.test.tsx +83 -0
  46. package/src/components/ticker-list-table.tsx +174 -97
  47. package/src/components/ui/data-table.tsx +27 -2
  48. package/src/components/ui/index.ts +1 -0
  49. package/src/components/ui/page-stack-view.tsx +60 -0
  50. package/src/components/use-double-click-activation.ts +31 -0
  51. package/src/index.tsx +10 -10
  52. package/src/market-data/coordinator.test.ts +92 -4
  53. package/src/market-data/coordinator.ts +32 -5
  54. package/src/market-data/hooks.test.tsx +120 -0
  55. package/src/market-data/hooks.tsx +53 -16
  56. package/src/market-data/request-types.ts +4 -2
  57. package/src/market-data/selectors.ts +24 -58
  58. package/src/plugins/builtin/chat-controller.test.ts +370 -3
  59. package/src/plugins/builtin/chat-controller.ts +310 -35
  60. package/src/plugins/builtin/chat.test.tsx +118 -6
  61. package/src/plugins/builtin/chat.tsx +175 -116
  62. package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
  63. package/src/plugins/builtin/comparison-chart.tsx +21 -1
  64. package/src/plugins/builtin/news.tsx +1 -11
  65. package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
  66. package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
  67. package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
  68. package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
  69. package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
  70. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  71. package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
  72. package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
  73. package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
  74. package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
  75. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
  76. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
  77. package/src/plugins/builtin/sec.tsx +1 -11
  78. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
  79. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
  80. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
  81. package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
  82. package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
  83. package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
  84. package/src/plugins/catalog.ts +65 -0
  85. package/src/plugins/ibkr/broker-adapter.ts +28 -0
  86. package/src/plugins/ibkr/gateway-service.test.ts +75 -1
  87. package/src/plugins/ibkr/gateway-service.ts +179 -9
  88. package/src/plugins/pane-manager.test.ts +1 -1
  89. package/src/plugins/plugin-runtime.tsx +27 -7
  90. package/src/plugins/prediction-markets/chart.test.tsx +139 -0
  91. package/src/plugins/prediction-markets/chart.tsx +65 -436
  92. package/src/plugins/prediction-markets/cli.ts +28 -0
  93. package/src/plugins/prediction-markets/controller-data.ts +17 -5
  94. package/src/plugins/prediction-markets/controller.ts +141 -121
  95. package/src/plugins/prediction-markets/detail/outcomes.tsx +2 -6
  96. package/src/plugins/prediction-markets/detail/pane.tsx +4 -25
  97. package/src/plugins/prediction-markets/detail/shared.tsx +0 -1
  98. package/src/plugins/prediction-markets/detail.test.tsx +4 -0
  99. package/src/plugins/prediction-markets/index.tsx +2 -0
  100. package/src/plugins/prediction-markets/launch.ts +51 -16
  101. package/src/plugins/prediction-markets/metrics.ts +9 -5
  102. package/src/plugins/prediction-markets/pane.test.tsx +160 -58
  103. package/src/plugins/prediction-markets/pane.tsx +74 -183
  104. package/src/plugins/prediction-markets/table.tsx +6 -3
  105. package/src/plugins/prediction-markets/types.ts +0 -1
  106. package/src/sources/gloomberb-cloud.test.ts +29 -0
  107. package/src/sources/gloomberb-cloud.ts +89 -1
  108. package/src/sources/provider-router.test.ts +308 -3
  109. package/src/sources/provider-router.ts +247 -17
  110. package/src/sources/yahoo-finance.test.ts +26 -0
  111. package/src/sources/yahoo-finance.ts +54 -3
  112. package/src/state/app-context-selector.test.tsx +99 -0
  113. package/src/state/app-context.test.ts +18 -0
  114. package/src/state/app-context.tsx +170 -65
  115. package/src/state/selectors.ts +25 -0
  116. package/src/theme/colors.ts +8 -8
  117. package/src/theme/themes.test.ts +2 -0
  118. package/src/theme/themes.ts +22 -10
  119. package/src/types/broker.ts +21 -0
  120. package/src/types/config.ts +17 -1
  121. package/src/types/data-provider.ts +18 -0
  122. package/src/types/financials.ts +31 -1
  123. package/src/types/plugin.ts +70 -0
  124. package/src/utils/api-client.test.ts +57 -0
  125. package/src/utils/api-client.ts +35 -12
  126. package/src/utils/back-navigation.ts +27 -0
  127. package/src/utils/format.test.ts +14 -0
  128. package/src/utils/format.ts +3 -3
  129. package/src/utils/market-status.ts +46 -1
  130. package/src/utils/price-history.test.ts +25 -0
  131. package/src/utils/price-history.ts +29 -5
  132. package/src/utils/quote-freshness.ts +127 -0
  133. package/src/utils/quote-resolution.test.ts +277 -0
  134. package/src/utils/quote-resolution.ts +641 -0
  135. package/src/utils/slugify.ts +3 -0
  136. package/src/utils/timestamp.ts +25 -0
  137. package/src/version.ts +1 -1
  138. package/src/cli/commands/portfolio.ts +0 -210
  139. package/src/cli/commands/watchlist.ts +0 -210
  140. package/src/plugins/builtin/data-providers.ts +0 -16
  141. package/src/sources/exchange-rates.ts +0 -18
  142. package/src/utils/ascii-chart.ts +0 -172
  143. package/src/utils/ticker-tokenizer.test.ts +0 -48
@@ -1,4 +1,4 @@
1
- import { floatingPaneTitleBg, paneTitleBg, paneTitleText } from "../../theme/colors";
1
+ import { colors, floatingPaneTitleBg, paneTitleBg, paneTitleText } from "../../theme/colors";
2
2
 
3
3
  export const PANE_HEADER_HEIGHT = 1;
4
4
  export const PANE_HEADER_GRIP = ":: ";
@@ -30,13 +30,37 @@ export function PaneHeader({
30
30
  const backgroundColor = floating ? floatingPaneTitleBg(focused) : paneTitleBg(focused);
31
31
  const actionText = showActions ? PANE_HEADER_ACTION : " ";
32
32
  const closeText = floating ? PANE_HEADER_CLOSE : "";
33
+ const bc = colors.borderFocused;
34
+ const textColor = paneTitleText(focused, floating);
35
+
36
+ if (focused) {
37
+ // Build: ┌─:: Title ─────────── ... x─┐
38
+ // Reserve 2 for corners, 1 for ─ after ┌, 1 for ─ before ┐
39
+ const innerWidth = Math.max(0, width - 4);
40
+ const contentWidth = PANE_HEADER_GRIP.length + closeText.length + actionText.length;
41
+ const titleWidth = Math.max(0, innerWidth - contentWidth);
42
+ const clippedTitle = truncateTitle(title, titleWidth);
43
+ const fillLen = Math.max(0, innerWidth - PANE_HEADER_GRIP.length - clippedTitle.length - actionText.length - closeText.length);
44
+ const fill = "─".repeat(fillLen);
45
+
46
+ return (
47
+ <box height={PANE_HEADER_HEIGHT} width={width} backgroundColor={backgroundColor} flexDirection="row">
48
+ <text fg={bc} selectable={false}>{"┌─"}</text>
49
+ <text fg={textColor} selectable={false}>{`${PANE_HEADER_GRIP}${clippedTitle}`}</text>
50
+ <text fg={bc} selectable={false}>{fill}</text>
51
+ <text fg={textColor} selectable={false}>{`${actionText}${closeText}`}</text>
52
+ <text fg={bc} selectable={false}>{"─┐"}</text>
53
+ </box>
54
+ );
55
+ }
56
+
33
57
  const titleWidth = Math.max(0, width - PANE_HEADER_GRIP.length - actionText.length - closeText.length);
34
58
  const clippedTitle = truncateTitle(title, titleWidth);
35
59
  const padding = " ".repeat(Math.max(0, titleWidth - clippedTitle.length));
36
60
 
37
61
  return (
38
62
  <box height={PANE_HEADER_HEIGHT} width={width} backgroundColor={backgroundColor} flexDirection="row">
39
- <text fg={paneTitleText(focused, floating)} selectable={false}>
63
+ <text fg={textColor} selectable={false}>
40
64
  {`${PANE_HEADER_GRIP}${clippedTitle}${padding}${actionText}${closeText}`}
41
65
  </text>
42
66
  </box>
@@ -5,10 +5,7 @@ import { saveConfig } from "../../data/config-store";
5
5
  import {
6
6
  MIN_FLOAT_HEIGHT,
7
7
  MIN_FLOAT_WIDTH,
8
- addPaneFloating,
9
- addPaneToLayout,
10
8
  applyDrop,
11
- bringToFront,
12
9
  findDockLeaf,
13
10
  floatAtRect,
14
11
  floatPane,
@@ -29,7 +26,7 @@ import {
29
26
  type ResolvedPane,
30
27
  } from "../../plugins/pane-manager";
31
28
  import type { PluginRegistry } from "../../plugins/registry";
32
- import { removePaneInstances, createPaneInstance, type LayoutConfig } from "../../types/config";
29
+ import { removePaneInstances, type LayoutConfig } from "../../types/config";
33
30
  import {
34
31
  PaneInstanceProvider,
35
32
  resolveCollectionForPane,
@@ -435,12 +432,13 @@ function resolveSnapGuide(x: number, y: number, guides: SnapGuide[]): SnapGuide
435
432
 
436
433
  function resolveHeaderHitAreas(
437
434
  width: number,
438
- options: { floating: boolean },
435
+ options: { floating: boolean; focused: boolean },
439
436
  ): {
440
437
  actionStart: number | null;
441
438
  closeStart: number | null;
442
439
  } {
443
- let rightEdge = width;
440
+ // When focused, the header renders ┌─...─┐ adding 2 chars on each side
441
+ let rightEdge = options.focused ? width - 2 : width;
444
442
  let closeStart: number | null = null;
445
443
  let actionStart: number | null = null;
446
444
 
@@ -463,7 +461,6 @@ function menuForPane(
463
461
  persistLayout: (nextLayout: LayoutConfig, options?: { pushHistory?: boolean }) => void,
464
462
  focusPane: (paneId: string) => void,
465
463
  openPaneSettings: (paneId: string) => void,
466
- openLayoutMenu: () => void,
467
464
  ) {
468
465
  const baseActions: Array<{ id: string; label: string; action: () => void }> = [];
469
466
  if (pluginRegistry.hasPaneSettings(pane.instance.instanceId)) {
@@ -483,11 +480,6 @@ function menuForPane(
483
480
  focusPane(pane.instance.instanceId);
484
481
  },
485
482
  });
486
- baseActions.push({
487
- id: "front",
488
- label: "Bring To Front",
489
- action: () => persistLayout(bringToFront(layout, pane.instance.instanceId), { pushHistory: false }),
490
- });
491
483
  } else {
492
484
  baseActions.push({
493
485
  id: "float",
@@ -499,37 +491,6 @@ function menuForPane(
499
491
  });
500
492
  }
501
493
 
502
- baseActions.push({
503
- id: "duplicate",
504
- label: "Duplicate",
505
- action: () => {
506
- const duplicate = createPaneInstance(pane.instance.paneId, {
507
- title: pane.instance.title,
508
- binding: pane.instance.binding,
509
- params: pane.instance.params,
510
- settings: pane.instance.settings,
511
- });
512
- const nextLayout = pane.floating
513
- ? floatAtRect(addPaneFloating(layout, duplicate, width, contentHeight, pane.def), duplicate.instanceId, {
514
- x: Math.max(0, pane.floating.x + 2),
515
- y: Math.max(0, pane.floating.y + 1),
516
- width: pane.floating.width,
517
- height: pane.floating.height,
518
- })
519
- : addPaneToLayout(layout, duplicate, { relativeTo: pane.instance.instanceId, position: "right" });
520
- persistLayout(nextLayout);
521
- focusPane(duplicate.instanceId);
522
- },
523
- });
524
- baseActions.push({ id: "swap", label: "Swap With...", action: openLayoutMenu });
525
- baseActions.push({
526
- id: "close",
527
- label: "Close",
528
- action: () => {
529
- persistLayout(removePane(layout, pane.instance.instanceId));
530
- },
531
- });
532
-
533
494
  return baseActions;
534
495
  }
535
496
 
@@ -739,10 +700,9 @@ export function Shell({ pluginRegistry }: ShellProps) {
739
700
  persistLayout,
740
701
  focusPane,
741
702
  openPaneSettings,
742
- openLayoutMenu,
743
703
  ),
744
704
  });
745
- }, [contentHeight, focusPane, openLayoutMenu, openPaneSettings, paneMap, persistLayout, pluginRegistry, visibleLayout, width]);
705
+ }, [contentHeight, focusPane, openPaneSettings, paneMap, persistLayout, pluginRegistry, visibleLayout, width]);
746
706
 
747
707
  const handleFloatingClose = useCallback((paneId: string) => {
748
708
  persistLayout(removePane(visibleLayout, paneId));
@@ -768,13 +728,12 @@ export function Shell({ pluginRegistry }: ShellProps) {
768
728
  if (!pointInRect({ x: rect.x, y: rect.y, width: rect.width, height: rect.height }, event.x, shellY)) continue;
769
729
  const relativeX = event.x - rect.x;
770
730
  const relativeY = shellY - rect.y;
731
+ const isFocused = state.focusedPaneId === pane.instance.instanceId;
771
732
  const headerAreas = resolveHeaderHitAreas(rect.width, {
772
733
  floating: true,
734
+ focused: isFocused,
773
735
  });
774
736
  focusPane(pane.instance.instanceId);
775
- if ((pane.floating?.zIndex ?? 50) < 999) {
776
- persistLayout(bringToFront(visibleLayout, pane.instance.instanceId), { pushHistory: false });
777
- }
778
737
  if (relativeY === 0 && headerAreas.closeStart != null && relativeX >= headerAreas.closeStart && relativeX < rect.width) {
779
738
  handleFloatingClose(pane.instance.instanceId);
780
739
  event.stopPropagation();
@@ -850,8 +809,10 @@ export function Shell({ pluginRegistry }: ShellProps) {
850
809
  if (!pane) continue;
851
810
  const relativeX = event.x - leaf.rect.x;
852
811
  const relativeY = shellY - leaf.rect.y;
812
+ const isFocused = state.focusedPaneId === leaf.instanceId;
853
813
  const headerAreas = resolveHeaderHitAreas(leaf.rect.width, {
854
814
  floating: false,
815
+ focused: isFocused,
855
816
  });
856
817
  focusPane(leaf.instanceId);
857
818
  if (relativeY === 0
@@ -1021,7 +982,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
1021
982
  {dockLeafLayouts.map((leaf) => {
1022
983
  const pane = paneMap.get(leaf.instanceId);
1023
984
  if (!pane) return null;
1024
- const focused = state.focusedPaneId === leaf.instanceId && !overlayOpen;
985
+ const focused = state.focusedPaneId === leaf.instanceId && (!overlayOpen || menuState?.paneId === leaf.instanceId);
1025
986
  const showActions = focused || hoveredPaneId === leaf.instanceId || menuState?.paneId === leaf.instanceId;
1026
987
  return (
1027
988
  <box
@@ -1056,7 +1017,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
1056
1017
 
1057
1018
  {floatingPanes.map((pane) => {
1058
1019
  const preview = dragFloatingRect?.paneId === pane.instance.instanceId ? dragFloatingRect.rect : pane.floating!;
1059
- const focused = state.focusedPaneId === pane.instance.instanceId && !overlayOpen;
1020
+ const focused = state.focusedPaneId === pane.instance.instanceId && (!overlayOpen || menuState?.paneId === pane.instance.instanceId);
1060
1021
  const showActions = focused || hoveredPaneId === pane.instance.instanceId || menuState?.paneId === pane.instance.instanceId;
1061
1022
  return (
1062
1023
  <FloatingPaneWrapper
@@ -1108,6 +1069,57 @@ export function Shell({ pluginRegistry }: ShellProps) {
1108
1069
  );
1109
1070
  })}
1110
1071
 
1072
+ {/* Focus border overlay — rendered on top of the focused pane */}
1073
+ {(() => {
1074
+ if (!state.focusedPaneId) return null;
1075
+ // Hide border when command bar or dialog is open, but keep it when just the pane menu is open
1076
+ if (overlayOpen && !menuState) return null;
1077
+ let rect: { x: number; y: number; width: number; height: number } | null = null;
1078
+ let z = 3;
1079
+ // Check floating panes first (they render on top of docked panes)
1080
+ const floatingPane = floatingPanes.find((p) => p.instance.instanceId === state.focusedPaneId);
1081
+ if (floatingPane) {
1082
+ rect = dragFloatingRect?.paneId === floatingPane.instance.instanceId
1083
+ ? dragFloatingRect.rect
1084
+ : floatingPane.floating!;
1085
+ z = (floatingPane.floating?.zIndex ?? 50) + 1;
1086
+ } else {
1087
+ // Check docked panes
1088
+ const dockedLeaf = dockLeafLayouts.find((l) => l.instanceId === state.focusedPaneId);
1089
+ if (dockedLeaf) {
1090
+ rect = dockedLeaf.rect;
1091
+ }
1092
+ }
1093
+ if (!rect || rect.height < 2) return null;
1094
+ const bc = colors.borderFocused;
1095
+ const isFloating = !!floatingPane;
1096
+ const bodyTop = rect.y + 1; // below header (header renders its own border edges)
1097
+ const bodyH = rect.height - 2; // rows between header and bottom edge
1098
+ return (
1099
+ <>
1100
+ {/* Left edge — body only */}
1101
+ {bodyH > 0 && (
1102
+ <box key="focus-l" position="absolute" left={rect.x} top={bodyTop} width={1} height={bodyH} zIndex={z}>
1103
+ <text fg={bc} selectable={false}>{"│".repeat(bodyH)}</text>
1104
+ </box>
1105
+ )}
1106
+ {/* Right edge — body only */}
1107
+ {bodyH > 0 && (
1108
+ <box key="focus-r" position="absolute" left={rect.x + rect.width - 1} top={bodyTop} width={1} height={bodyH} zIndex={z}>
1109
+ <text fg={bc} selectable={false}>{"│".repeat(bodyH)}</text>
1110
+ </box>
1111
+ )}
1112
+ {/* Bottom edge — for floating, leave last 2 chars for resize handle (rendered by FloatingPaneWrapper) */}
1113
+ <box key="focus-b" position="absolute" left={rect.x} top={rect.y + rect.height - 1} width={isFloating ? Math.max(0, rect.width - 2) : rect.width} height={1} zIndex={z}>
1114
+ <text fg={bc} selectable={false}>{isFloating
1115
+ ? `└${"─".repeat(Math.max(0, rect.width - 4))}─`
1116
+ : `└${"─".repeat(Math.max(0, rect.width - 2))}┘`
1117
+ }</text>
1118
+ </box>
1119
+ </>
1120
+ );
1121
+ })()}
1122
+
1111
1123
  {activeHoverOverlay && activeHoverOverlay.cells.map((cell) => {
1112
1124
  const active = dockPreview?.kind === "dock"
1113
1125
  && dockPreview.target.kind === "leaf"
@@ -170,4 +170,70 @@ describe("StatusBar", () => {
170
170
  globalThis.clearTimeout = originalClearTimeout;
171
171
  }
172
172
  });
173
+
174
+ test("separates listing venue, session, and source labels", async () => {
175
+ const config = createDefaultConfig("/tmp/gloomberb-test");
176
+ config.layout.instances = config.layout.instances.map((instance) => (
177
+ instance.instanceId === "ticker-detail:main"
178
+ ? { ...instance, binding: { kind: "fixed" as const, symbol: "AMD" } }
179
+ : instance
180
+ ));
181
+ const state = createInitialState(config);
182
+ state.statusBarVisible = true;
183
+ state.focusedPaneId = "ticker-detail:main";
184
+ state.tickers = new Map([["AMD", {
185
+ metadata: {
186
+ ticker: "AMD",
187
+ exchange: "NASDAQ",
188
+ currency: "USD",
189
+ name: "Advanced Micro Devices",
190
+ portfolios: [],
191
+ watchlists: [],
192
+ positions: [],
193
+ custom: {},
194
+ tags: [],
195
+ },
196
+ }]]);
197
+ state.financials = new Map([["AMD", {
198
+ annualStatements: [],
199
+ quarterlyStatements: [],
200
+ priceHistory: [],
201
+ quote: {
202
+ symbol: "AMD",
203
+ providerId: "ibkr",
204
+ dataSource: "live",
205
+ price: 100,
206
+ currency: "USD",
207
+ change: 1,
208
+ changePercent: 1,
209
+ lastUpdated: Date.now(),
210
+ listingExchangeName: "NASDAQ",
211
+ routingExchangeName: "SMART",
212
+ marketState: "PRE",
213
+ sessionConfidence: "derived",
214
+ preMarketPrice: 101,
215
+ preMarketChange: 2,
216
+ preMarketChangePercent: 2,
217
+ provenance: {
218
+ price: { providerId: "ibkr", dataSource: "live" },
219
+ session: { providerId: "yahoo", dataSource: "yahoo" },
220
+ },
221
+ },
222
+ }]]);
223
+
224
+ testSetup = await testRender(
225
+ <AppContext value={{ state, dispatch: () => {} }}>
226
+ <StatusBar />
227
+ </AppContext>,
228
+ { width: 120, height: 1 },
229
+ );
230
+
231
+ await testSetup.renderOnce();
232
+
233
+ const frame = testSetup.captureCharFrame();
234
+ expect(frame).toContain("NASDAQ PRE-MKT");
235
+ expect(frame).toContain("px IBKR live");
236
+ expect(frame).toContain("ses Yahoo");
237
+ expect(frame).not.toContain("SMART OPEN");
238
+ });
173
239
  });
@@ -1,7 +1,13 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import { colors, hoverBg } from "../../theme/colors";
3
3
  import { useAppState, useFocusedTicker } from "../../state/app-context";
4
- import { marketStateLabel, marketStateColor, exchangeShortName, getExtendedHoursInfo } from "../../utils/market-status";
4
+ import {
5
+ marketStateLabel,
6
+ marketStateColor,
7
+ exchangeShortName,
8
+ getExtendedHoursInfo,
9
+ quoteSourceLabel,
10
+ } from "../../utils/market-status";
5
11
  import { getSharedRegistry } from "../../plugins/registry";
6
12
  import { gridlockAllPanes } from "../../plugins/pane-manager";
7
13
 
@@ -23,7 +29,12 @@ export function StatusBar() {
23
29
 
24
30
  const q = focusedFinancials?.quote;
25
31
  const mktState = q?.marketState;
26
- const exchName = q ? exchangeShortName(q.exchangeName, q.fullExchangeName) : "";
32
+ const exchName = q
33
+ ? exchangeShortName(q.listingExchangeName ?? q.exchangeName, q.listingExchangeFullName ?? q.fullExchangeName)
34
+ : "";
35
+ const sessionLabel = mktState ? marketStateLabel(mktState) : "?";
36
+ const priceSourceLabel = q?.provenance?.price ? quoteSourceLabel(q.provenance.price, "price") : "";
37
+ const sessionSourceLabel = q?.provenance?.session ? quoteSourceLabel(q.provenance.session, "session") : "";
27
38
 
28
39
  // Extended hours info for selected ticker
29
40
  const extInfo = getExtendedHoursInfo(focusedFinancials?.quote);
@@ -124,13 +135,23 @@ export function StatusBar() {
124
135
  <text fg={extColor}>{symbol} {extText}</text>
125
136
  </box>
126
137
  )}
127
- {mktState && (
138
+ {(exchName || mktState || q?.provenance?.session) && (
128
139
  <box paddingRight={1}>
129
- <text fg={marketStateColor(mktState)}>
130
- {exchName ? `${exchName} ` : ""}{marketStateLabel(mktState)}
140
+ <text fg={mktState ? marketStateColor(mktState) : colors.textDim}>
141
+ {exchName ? `${exchName} ` : ""}{sessionLabel}
131
142
  </text>
132
143
  </box>
133
144
  )}
145
+ {priceSourceLabel && (
146
+ <box paddingRight={1}>
147
+ <text fg={colors.textDim}>px {priceSourceLabel}</text>
148
+ </box>
149
+ )}
150
+ {sessionSourceLabel && sessionSourceLabel !== priceSourceLabel && (
151
+ <box paddingRight={1}>
152
+ <text fg={colors.textDim}>ses {sessionSourceLabel}</text>
153
+ </box>
154
+ )}
134
155
  {/* Plugin status widgets */}
135
156
  {registry && <registry.Slot name="status:widget" />}
136
157
  </box>
@@ -65,6 +65,20 @@ async function emitKeypress(
65
65
  });
66
66
  }
67
67
 
68
+ async function waitForFrameToContain(text: string, attempts = 8): Promise<string> {
69
+ for (let index = 0; index < attempts; index += 1) {
70
+ const frame = testSetup!.captureCharFrame();
71
+ if (frame.includes(text)) {
72
+ return frame;
73
+ }
74
+ await act(async () => {
75
+ await Promise.resolve();
76
+ await testSetup!.renderOnce();
77
+ });
78
+ }
79
+ throw new Error(`Timed out waiting for "${text}".`);
80
+ }
81
+
68
82
  afterEach(async () => {
69
83
  if (testSetup) {
70
84
  await act(async () => {
@@ -79,6 +93,57 @@ afterEach(async () => {
79
93
  });
80
94
 
81
95
  describe("OnboardingWizard", () => {
96
+ test("uses backspace to go back when not editing and keeps backspace for text deletion while editing", async () => {
97
+ const pluginRegistry = {
98
+ allPlugins: new Map(),
99
+ brokers: new Map(),
100
+ paneTemplates: new Map(),
101
+ getPaneTemplatePluginId: () => undefined,
102
+ tickerRepository: createTickerRepository(),
103
+ persistence: { resources: undefined },
104
+ } as unknown as PluginRegistry;
105
+
106
+ testSetup = await testRender(
107
+ <OnboardingWizard
108
+ config={createDefaultConfig("/tmp/gloomberb-onboarding-backspace")}
109
+ pluginRegistry={pluginRegistry}
110
+ onComplete={() => {}}
111
+ />,
112
+ { width: 90, height: 28 },
113
+ );
114
+ await testSetup.renderOnce();
115
+
116
+ await emitKeypress(testSetup, { name: "return", sequence: "\r" });
117
+ let frame = await waitForFrameToContain("Theme");
118
+ expect(frame).toContain("Theme");
119
+
120
+ await act(async () => {
121
+ testSetup!.mockInput.pressBackspace();
122
+ await testSetup!.renderOnce();
123
+ await testSetup!.renderOnce();
124
+ });
125
+ frame = await waitForFrameToContain("Let's set things up (~30s).");
126
+ expect(frame).toContain("Let's set things up (~30s).");
127
+
128
+ await emitKeypress(testSetup, { name: "return", sequence: "\r" });
129
+ frame = await waitForFrameToContain("Theme");
130
+ await emitKeypress(testSetup, { name: "return", sequence: "\r" });
131
+ frame = await waitForFrameToContain("Create Manual Portfolio");
132
+ expect(frame).toContain("Create Manual Portfolio");
133
+
134
+ await emitKeypress(testSetup, { name: "return", sequence: "\r" });
135
+ frame = await waitForFrameToContain("Main Portfolio");
136
+ expect(frame).toContain("Main Portfolio");
137
+
138
+ await act(async () => {
139
+ testSetup!.mockInput.pressBackspace();
140
+ await testSetup!.renderOnce();
141
+ });
142
+ frame = testSetup.captureCharFrame();
143
+ expect(frame).toContain("Main Portfoli");
144
+ expect(frame).not.toContain("Create Manual Portfolio");
145
+ });
146
+
82
147
  test("waits for broker positions to import before completing onboarding", async () => {
83
148
  tempDataDir = await mkdtemp(join(tmpdir(), "gloomberb-onboarding-"));
84
149
  const importDeferred = createDeferred<any[]>();
@@ -10,6 +10,7 @@ import type { PluginRegistry } from "../../plugins/registry";
10
10
  import { resolveBrokerConfigFields, type BrokerAdapter, type BrokerConfigField } from "../../types/broker";
11
11
  import { buildIbkrConfigFromValues } from "../../plugins/ibkr/config";
12
12
  import { createBrokerInstanceId } from "../../utils/broker-instances";
13
+ import { isBackNavigationKey, isPlainEscape } from "../../utils/back-navigation";
13
14
  import { syncBrokerInstance } from "../../brokers/sync-broker-instance";
14
15
  import { debugLog } from "../../utils/debug-log";
15
16
  import { ToggleList, type ToggleListItem } from "../toggle-list";
@@ -415,7 +416,7 @@ export function OnboardingWizard({ config, pluginRegistry, onComplete }: Onboard
415
416
  }
416
417
  }
417
418
  }
418
- } else if (event.name === "escape") {
419
+ } else if (isPlainEscape(event)) {
419
420
  setEditingField(false);
420
421
  if (portfolioSub === "broker-fields") {
421
422
  setPortfolioSub("broker-setup");
@@ -506,7 +507,7 @@ export function OnboardingWizard({ config, pluginRegistry, onComplete }: Onboard
506
507
  }
507
508
  }
508
509
  nextStep();
509
- } else if (event.name === "escape") {
510
+ } else if (isBackNavigationKey(event)) {
510
511
  if (step === "portfolio" && portfolioSub === "broker-sync") {
511
512
  resetBrokerSync();
512
513
  setPortfolioSub("broker-fields");
@@ -693,9 +694,7 @@ export function OnboardingWizard({ config, pluginRegistry, onComplete }: Onboard
693
694
  </box>
694
695
  </box>
695
696
  <box height={1} flexDirection="row" width={contentWidth}>
696
- <box flexGrow={1}>
697
- {stepIdx > 0 && <text fg={colors.textMuted}>{"<- back"}</text>}
698
- </box>
697
+ <box flexGrow={1} />
699
698
  <box>
700
699
  <text fg={colors.textMuted}>{hintText}</text>
701
700
  </box>
@@ -1024,7 +1023,7 @@ function PortfolioStep({
1024
1023
  <text fg={colors.textDim}>
1025
1024
  {brokerSyncing
1026
1025
  ? "This happens now so your portfolio is ready before onboarding finishes."
1027
- : "Press Enter to retry, or Esc to edit the broker settings."}
1026
+ : "Press Enter to retry, or Backspace to edit the broker settings."}
1028
1027
  </text>
1029
1028
  </box>
1030
1029
  </box>
@@ -0,0 +1,83 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { act, useState } from "react";
3
+ import { testRender } from "@opentui/react/test-utils";
4
+ import type { ColumnConfig } from "../types/config";
5
+ import type { TickerFinancials } from "../types/financials";
6
+ import type { TickerRecord } from "../types/ticker";
7
+ import { TickerListTable, type TickerTableCell } from "./ticker-list-table";
8
+
9
+ let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
10
+ let setHarnessCursorSymbol: ((symbol: string) => void) | null = null;
11
+ let resolveCellCallCount = 0;
12
+
13
+ const columns: ColumnConfig[] = [
14
+ { id: "ticker", label: "Ticker", width: 6, align: "left" },
15
+ ];
16
+ const financialsMap = new Map<string, TickerFinancials>();
17
+ const tickers: TickerRecord[] = [
18
+ { metadata: { ticker: "AAPL", exchange: "NASDAQ", currency: "USD", name: "Apple", portfolios: [], watchlists: [], positions: [], custom: {}, tags: [] } },
19
+ { metadata: { ticker: "MSFT", exchange: "NASDAQ", currency: "USD", name: "Microsoft", portfolios: [], watchlists: [], positions: [], custom: {}, tags: [] } },
20
+ { metadata: { ticker: "NVDA", exchange: "NASDAQ", currency: "USD", name: "NVIDIA", portfolios: [], watchlists: [], positions: [], custom: {}, tags: [] } },
21
+ ];
22
+
23
+ function noop(_index: number | null): void {}
24
+
25
+ function resolveCell(_column: ColumnConfig, ticker: TickerRecord, _financials: TickerFinancials | undefined): TickerTableCell {
26
+ resolveCellCallCount += 1;
27
+ return { text: ticker.metadata.ticker };
28
+ }
29
+
30
+ function TickerListTableHarness() {
31
+ const [cursorSymbol, setCursorSymbol] = useState("AAPL");
32
+ setHarnessCursorSymbol = (symbol: string) => setCursorSymbol(symbol);
33
+
34
+ return (
35
+ <TickerListTable
36
+ columns={columns}
37
+ tickers={tickers}
38
+ cursorSymbol={cursorSymbol}
39
+ hoveredIdx={null}
40
+ setHoveredIdx={noop}
41
+ setCursorSymbol={setCursorSymbol}
42
+ resolveCell={resolveCell}
43
+ financialsMap={financialsMap}
44
+ />
45
+ );
46
+ }
47
+
48
+ afterEach(async () => {
49
+ if (testSetup) {
50
+ await act(async () => {
51
+ testSetup!.renderer.destroy();
52
+ });
53
+ testSetup = undefined;
54
+ }
55
+ resolveCellCallCount = 0;
56
+ setHarnessCursorSymbol = null;
57
+ });
58
+
59
+ describe("TickerListTable", () => {
60
+ test("recomputes cells only for rows whose selection state changed", async () => {
61
+ testSetup = await testRender(
62
+ <TickerListTableHarness />,
63
+ { width: 20, height: 6 },
64
+ );
65
+
66
+ await act(async () => {
67
+ await testSetup!.renderOnce();
68
+ });
69
+
70
+ const initialCallCount = resolveCellCallCount;
71
+
72
+ await act(async () => {
73
+ setHarnessCursorSymbol?.("MSFT");
74
+ await Promise.resolve();
75
+ });
76
+ await act(async () => {
77
+ await testSetup!.renderOnce();
78
+ });
79
+
80
+ expect(initialCallCount).toBeGreaterThan(0);
81
+ expect(resolveCellCallCount - initialCallCount).toBe(2);
82
+ });
83
+ });