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
@@ -10,6 +10,7 @@ export interface CommandContext {
10
10
  export interface Command {
11
11
  id: string;
12
12
  prefix: string; // e.g., "DES", "AW", "RP"
13
+ aliases?: string[];
13
14
  label: string;
14
15
  description: string;
15
16
  hasArg?: boolean; // true if prefix takes an argument (e.g., "DES AMD")
@@ -24,6 +25,7 @@ export const commands: Command[] = [
24
25
  {
25
26
  id: "security-description",
26
27
  prefix: "DES",
28
+ aliases: ["T"],
27
29
  label: "Description",
28
30
  description: "Open security details for a ticker",
29
31
  hasArg: true,
@@ -239,19 +241,27 @@ export const commands: Command[] = [
239
241
  ];
240
242
 
241
243
  /** Find a command whose prefix matches the start of the input */
242
- export function matchPrefix(input: string, commandList: Command[] = commands): { command: Command; arg: string } | null {
244
+ export function matchPrefix(input: string, commandList: Command[] = commands): { command: Command; arg: string; prefix: string } | null {
243
245
  const upper = input.toUpperCase().trim();
244
246
  if (!upper) return null;
245
247
 
246
248
  // Sort by prefix length descending so longer prefixes match first (e.g., "AW" before "A")
247
- const sorted = [...commandList].sort((a, b) => b.prefix.length - a.prefix.length);
249
+ const sorted = commandList
250
+ .flatMap((command) => getCommandPrefixes(command).map((prefix) => ({ command, prefix })))
251
+ .sort((a, b) => b.prefix.length - a.prefix.length);
248
252
 
249
- for (const cmd of sorted) {
250
- if (upper.startsWith(cmd.prefix + " ") || upper === cmd.prefix) {
251
- const arg = input.slice(cmd.prefix.length).trim();
252
- return { command: cmd, arg };
253
+ for (const { command, prefix } of sorted) {
254
+ if (upper.startsWith(`${prefix} `) || upper === prefix) {
255
+ const arg = input.slice(prefix.length).trim();
256
+ return { command, arg, prefix };
253
257
  }
254
258
  }
255
259
 
256
260
  return null;
257
261
  }
262
+
263
+ export function getCommandPrefixes(command: Command): string[] {
264
+ return [command.prefix, ...(command.aliases ?? [])]
265
+ .map((prefix) => prefix.trim().toUpperCase())
266
+ .filter(Boolean);
267
+ }
@@ -1,5 +1,5 @@
1
1
  import type { CommandDef, PaneTemplateDef } from "../../types/plugin";
2
- import type { Command } from "./command-registry";
2
+ import { getCommandPrefixes, type Command } from "./command-registry";
3
3
  import { getPaneTemplateDisplayLabel } from "./pane-template-display";
4
4
 
5
5
  type RootShortcutArgKind = "text" | "ticker" | "ticker-list";
@@ -83,16 +83,15 @@ function buildShortcutCandidates(
83
83
  ): ShortcutParseCandidate[] {
84
84
  return [
85
85
  ...commands
86
- .filter((command) => command.prefix.trim().length > 0)
87
- .map((command) => ({
88
- prefix: normalizeShortcutPrefix(command.prefix),
86
+ .flatMap((command) => getCommandPrefixes(command).map((prefix) => ({
87
+ prefix: normalizeShortcutPrefix(prefix),
89
88
  label: command.label,
90
89
  description: command.description,
91
90
  argKind: getCommandShortcutArgKind(command),
92
91
  argPlaceholder: command.argPlaceholder,
93
92
  source: "command" as const,
94
93
  command,
95
- })),
94
+ }))),
96
95
  ...pluginCommands
97
96
  .filter((command) => command.shortcut?.trim().length)
98
97
  .map((command) => ({
@@ -11,6 +11,7 @@ describe("command bar view model helpers", () => {
11
11
  test("resolves prefix-driven modes", () => {
12
12
  expect(resolveCommandBarMode("")).toMatchObject({ kind: "default", badge: "BROWSE" });
13
13
  expect(resolveCommandBarMode("DES NVDA")).toMatchObject({ kind: "search", badge: "DES" });
14
+ expect(resolveCommandBarMode("T NVDA")).toMatchObject({ kind: "search", badge: "T" });
14
15
  expect(resolveCommandBarMode("TH ")).toMatchObject({ kind: "themes", badge: "THEMES" });
15
16
  expect(resolveCommandBarMode("PL notes")).toMatchObject({ kind: "plugins", badge: "PLUGINS" });
16
17
  expect(resolveCommandBarMode("LAY ")).toMatchObject({ kind: "layout", badge: "LAYOUT" });
@@ -47,7 +47,7 @@ export function resolveCommandBarMode(query: string, commandList?: Command[]): C
47
47
 
48
48
  switch (match.command.id) {
49
49
  case "security-description":
50
- return { kind: "search", badge: "DES", hint: "Open security details for a ticker" };
50
+ return { kind: "search", badge: match.prefix, hint: "Open security details for a ticker" };
51
51
  case "theme":
52
52
  return { kind: "themes", badge: "THEMES", hint: "Preview with arrows, Enter to save, Esc to revert" };
53
53
  case "plugins":
@@ -128,6 +128,7 @@ export function truncateText(text: string, width: number): string {
128
128
 
129
129
  function getCategoryPriority(category: string): number {
130
130
  const normalized = category.trim().toLowerCase();
131
+ if (normalized === "exact match") return -50;
131
132
  if (normalized === "saved") return -40;
132
133
  if (normalized === "primary listing") return -30;
133
134
  if (normalized === "other listings") return -20;
@@ -16,6 +16,7 @@ import {
16
16
  COMPARISON_CHART_PANE_ID,
17
17
  MIN_COMPARISON_CHART_SYMBOLS,
18
18
  } from "../../plugins/builtin/comparison-chart";
19
+ import { buildQuoteMonitorPaneTitle } from "../../plugins/builtin/ticker-detail/settings";
19
20
  import { getPaneTemplateDisplayLabel } from "./pane-template-display";
20
21
 
21
22
  export interface SharedWorkflowDeps {
@@ -366,43 +367,21 @@ export async function applyPaneSettingFieldValue(
366
367
  return;
367
368
  }
368
369
 
369
- if (descriptor.pane.paneId === "quote-monitor" && field.key === "symbol") {
370
+ if (descriptor.pane.paneId === "quote-monitor" && (field.key === "symbol" || field.key === "symbolsText")) {
370
371
  const rawQuery = typeof value === "string" ? value.trim() : "";
371
- const resolvedTicker = await resolveTickerSearch({
372
- query: rawQuery,
373
- activeTicker: null,
374
- tickers: state.tickers,
375
- dataProvider: deps.dataProvider,
376
- });
377
- if (!resolvedTicker) {
378
- throw new Error(`No ticker match found for "${rawQuery}".`);
379
- }
380
-
381
- const symbol = resolvedTicker.kind === "local"
382
- ? resolvedTicker.ticker.metadata.ticker
383
- : resolvedTicker.symbol;
384
-
385
- if (resolvedTicker.kind === "provider") {
386
- const { ticker, created } = await upsertTickerFromSearchResult(
387
- deps.tickerRepository,
388
- resolvedTicker.result,
389
- );
390
- deps.dispatch({ type: "UPDATE_TICKER", ticker });
391
- if (created) {
392
- deps.pluginRegistry.events.emit("ticker:added", {
393
- symbol: ticker.metadata.ticker,
394
- ticker,
395
- });
396
- }
397
- }
372
+ const symbols = await resolveTickerListInput(rawQuery, null, deps);
373
+ const primarySymbol = symbols[0]!;
374
+ const symbolsText = formatTickerListInput(symbols);
398
375
 
399
376
  const nextLayout = updatePaneInstance(state.config.layout, targetId, (instance) => ({
400
377
  ...instance,
401
- title: symbol,
402
- binding: { kind: "fixed", symbol },
378
+ title: buildQuoteMonitorPaneTitle(symbols),
379
+ binding: { kind: "fixed", symbol: primarySymbol },
403
380
  settings: {
404
381
  ...(instance.settings ?? {}),
405
- symbol,
382
+ symbol: primarySymbol,
383
+ symbols,
384
+ symbolsText,
406
385
  },
407
386
  }));
408
387
  deps.persistLayout(nextLayout, { pushHistory: shouldPushHistory });
@@ -433,7 +412,7 @@ export async function applyPaneSettingFieldValue(
433
412
  }
434
413
 
435
414
  let nextSettings: Record<string, unknown> = {
436
- ...descriptor.context.settings,
415
+ ...(descriptor.rawSettings ?? descriptor.context.settings),
437
416
  [field.key]: value,
438
417
  };
439
418
 
@@ -130,7 +130,16 @@ async function renderSettled() {
130
130
  });
131
131
  }
132
132
 
133
- async function emitKeypress(event: { name?: string; sequence?: string }) {
133
+ async function emitKeypress(event: {
134
+ name?: string;
135
+ sequence?: string;
136
+ ctrl?: boolean;
137
+ meta?: boolean;
138
+ shift?: boolean;
139
+ option?: boolean;
140
+ defaultPrevented?: boolean;
141
+ propagationStopped?: boolean;
142
+ }) {
134
143
  await act(async () => {
135
144
  testSetup!.renderer.keyInput.emit("keypress", {
136
145
  ctrl: false,
@@ -139,6 +148,8 @@ async function emitKeypress(event: { name?: string; sequence?: string }) {
139
148
  shift: false,
140
149
  eventType: "press",
141
150
  repeated: false,
151
+ defaultPrevented: false,
152
+ propagationStopped: false,
142
153
  preventDefault: () => {},
143
154
  stopPropagation: () => {},
144
155
  ...event,
@@ -158,6 +169,10 @@ describe("DataTableView", () => {
158
169
  await renderSettled();
159
170
  expect(testSetup.captureCharFrame()).toContain("selected=Second row");
160
171
 
172
+ await emitKeypress({ name: "k", sequence: "k", meta: true });
173
+ await renderSettled();
174
+ expect(testSetup.captureCharFrame()).toContain("selected=Second row");
175
+
161
176
  await emitKeypress({ name: "k", sequence: "k" });
162
177
  await emitKeypress({ name: "k", sequence: "k" });
163
178
  await renderSettled();
@@ -166,6 +181,14 @@ describe("DataTableView", () => {
166
181
  await emitKeypress({ name: "enter", sequence: "\r" });
167
182
  await renderSettled();
168
183
  expect(testSetup.captureCharFrame()).toContain("activated=First row");
184
+
185
+ await emitKeypress({ name: "j", sequence: "j" });
186
+ await renderSettled();
187
+ expect(testSetup.captureCharFrame()).toContain("selected=Second row activated=First row");
188
+
189
+ await emitKeypress({ name: "enter", sequence: "\r", defaultPrevented: true });
190
+ await renderSettled();
191
+ expect(testSetup.captureCharFrame()).toContain("selected=Second row activated=First row");
169
192
  });
170
193
 
171
194
  test("keeps keyboard navigation usable without external row selection", async () => {
@@ -188,18 +188,19 @@ export function DataTableView<
188
188
  }, [activateIndex, effectiveSelectedIndex, navigableIndices]);
189
189
 
190
190
  useShortcut((event) => {
191
+ if (event.defaultPrevented || event.propagationStopped) return;
191
192
  if (!focused || !keyboardNavigation) return;
192
193
 
193
194
  if (onRootKeyDown?.(event)) return;
194
195
  if (tableProps.items.length === 0) return;
195
196
 
196
- if (isNextTableRowKey(event.name)) {
197
+ if (isNextTableRowKey(event)) {
197
198
  stopTableKey(event);
198
199
  selectByOffset(1);
199
200
  return;
200
201
  }
201
202
 
202
- if (isPreviousTableRowKey(event.name)) {
203
+ if (isPreviousTableRowKey(event)) {
203
204
  stopTableKey(event);
204
205
  selectByOffset(-1);
205
206
  return;
@@ -3,6 +3,7 @@ import { TextAttributes, type ScrollBoxRenderable } from "../ui";
3
3
  import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
4
4
  import { colors } from "../theme/colors";
5
5
  import { formatTimeAgo } from "../utils/format";
6
+ import { isPlainKey } from "../utils/keyboard";
6
7
  import { toTimestampMillis } from "../utils/timestamp";
7
8
  import { DataTableStackView } from "./data-table-stack-view";
8
9
  import {
@@ -245,13 +246,13 @@ export function FeedDataTableStackView({
245
246
  preventDefault?: () => void;
246
247
  stopPropagation?: () => void;
247
248
  }) => {
248
- if (event.name === "j" || event.name === "down") {
249
+ if (isPlainKey(event, "j", "down")) {
249
250
  event.stopPropagation?.();
250
251
  event.preventDefault?.();
251
252
  scrollDetailBy(1);
252
253
  return true;
253
254
  }
254
- if (event.name === "k" || event.name === "up") {
255
+ if (isPlainKey(event, "k", "up")) {
255
256
  event.stopPropagation?.();
256
257
  event.preventDefault?.();
257
258
  scrollDetailBy(-1);
@@ -9,7 +9,7 @@ import { colors, floatingPaneBg, floatingPaneTitleBg, paneTitleText } from "../.
9
9
  import { useThemeColors } from "../../theme/theme-context";
10
10
  import { hasPaneFooterContent, PaneFooterBar, PaneFooterProvider } from "./pane-footer";
11
11
  import { PaneContent } from "./pane-content";
12
- import { getPaneBodyWidth, NATIVE_PANE_BODY_LAYOUT_PROPS } from "./pane-sizing";
12
+ import { getNativePaneBodyWidth, getPaneBodyWidth, NATIVE_PANE_BODY_LAYOUT_PROPS } from "./pane-sizing";
13
13
  import { getPaneDisplayTitle } from "./pane-title";
14
14
  import { TITLEBAR_OVERLAY_HEIGHT_PX, TITLEBAR_TRAFFIC_LIGHT_WIDTH } from "./titlebar-overlay";
15
15
  import {
@@ -52,7 +52,7 @@ export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: Detac
52
52
  ? getPaneDisplayTitle(titleState, instance, paneDef)
53
53
  : "Detached Pane";
54
54
  const focused = windowFocused;
55
- const bodyWidth = nativePaneChrome ? Math.max(1, Math.floor(width)) : getPaneBodyWidth(width);
55
+ const bodyWidth = nativePaneChrome ? getNativePaneBodyWidth(width) : getPaneBodyWidth(width);
56
56
 
57
57
  const focusPane = useCallback(() => {
58
58
  setWindowFocused(true);
@@ -3,7 +3,12 @@ import type { ReactNode } from "react";
3
3
  import { colors, floatingPaneBg } from "../../theme/colors";
4
4
  import { PaneHeader } from "./pane-header";
5
5
  import { hasPaneFooterContent, PaneFooterBar, type CombinedPaneFooter } from "./pane-footer";
6
- import { getPaneBodyHeight, NATIVE_PANE_BODY_LAYOUT_PROPS, shouldReservePaneFooter } from "./pane-sizing";
6
+ import {
7
+ getNativePaneBodyHeight,
8
+ getPaneBodyHeight,
9
+ NATIVE_PANE_BODY_LAYOUT_PROPS,
10
+ shouldReservePaneFooter,
11
+ } from "./pane-sizing";
7
12
 
8
13
  interface FloatingPaneWrapperProps {
9
14
  paneId?: string;
@@ -77,7 +82,9 @@ export function FloatingPaneWrapper({
77
82
  const bg = floatingPaneBg(focused);
78
83
  const showFooter = hasPaneFooterContent(footer);
79
84
  const reserveFooter = shouldReservePaneFooter(nativePaneChrome, showFooter);
80
- const bodyHeight = getPaneBodyHeight(height, reserveFooter);
85
+ const bodyHeight = nativePaneChrome
86
+ ? getNativePaneBodyHeight(height, reserveFooter)
87
+ : getPaneBodyHeight(height, reserveFooter);
81
88
  const bodyLayoutProps = nativePaneChrome
82
89
  ? NATIVE_PANE_BODY_LAYOUT_PROPS
83
90
  : { height: bodyHeight };
@@ -393,7 +393,7 @@ export function PaneFooterBar({
393
393
  "--pane-footer-border-color": empty ? "transparent" : topBorderColor,
394
394
  borderTop: `1px solid ${empty ? "transparent" : topBorderColor}`,
395
395
  backgroundColor: nativeBackgroundColor,
396
- boxShadow: empty ? "none" : "inset 0 1px 0 rgba(255,255,255,0.03)",
396
+ boxShadow: empty ? "none" : `inset 0 1px 0 ${blendHex(nativeBackgroundColor, colors.textBright, 0.03)}`,
397
397
  }}
398
398
  >
399
399
  <FooterContent
@@ -1,6 +1,6 @@
1
1
  import { Box, Span, Text, useUiCapabilities } from "../../ui";
2
2
  import type { ReactNode } from "react";
3
- import { colors, floatingPaneTitleBg, paneTitleBg, paneTitleText } from "../../theme/colors";
3
+ import { blendHex, colors, floatingPaneTitleBg, paneTitleBg, paneTitleText } from "../../theme/colors";
4
4
 
5
5
  const PANE_HEADER_HEIGHT = 1;
6
6
  const PANE_HEADER_GRIP = ":: ";
@@ -127,7 +127,9 @@ export function PaneHeader({
127
127
  style={{
128
128
  borderBottom: `1px solid ${focused ? colors.borderFocused : colors.border}`,
129
129
  paddingInline: 6,
130
- boxShadow: focused ? "inset 0 -1px 0 rgba(84, 201, 159, 0.18)" : "inset 0 -1px 0 rgba(255,255,255,0.04)",
130
+ boxShadow: focused
131
+ ? `inset 0 -1px 0 ${blendHex(paneTitleBg(focused), colors.borderFocused, 0.18)}`
132
+ : `inset 0 -1px 0 ${blendHex(paneTitleBg(focused), colors.textBright, 0.04)}`,
131
133
  }}
132
134
  >
133
135
  <Text fg={focused ? colors.borderFocused : colors.textMuted} selectable={false} data-gloom-role="pane-grip">
@@ -20,3 +20,15 @@ export function getPaneBodyHeight(height: number, reserveFooter = true): number
20
20
  export function getPaneBodyWidth(width: number): number {
21
21
  return Math.max(1, Math.floor(width));
22
22
  }
23
+
24
+ export function getNativePaneBodyHeight(height: number, reserveFooter = true): number {
25
+ const preciseHeight = Number.isFinite(height) ? height : 1;
26
+ return Math.max(
27
+ 1,
28
+ preciseHeight - PANE_HEADER_ROWS - (reserveFooter ? PANE_FOOTER_ROWS : 0),
29
+ );
30
+ }
31
+
32
+ export function getNativePaneBodyWidth(width: number): number {
33
+ return Math.max(1, Number.isFinite(width) ? width : 1);
34
+ }
@@ -3,7 +3,12 @@ import type { ReactNode } from "react";
3
3
  import { colors, paneBg } from "../../theme/colors";
4
4
  import { PaneHeader } from "./pane-header";
5
5
  import { hasPaneFooterContent, PaneFooterBar, type CombinedPaneFooter } from "./pane-footer";
6
- import { getPaneBodyHeight, NATIVE_PANE_BODY_LAYOUT_PROPS, shouldReservePaneFooter } from "./pane-sizing";
6
+ import {
7
+ getNativePaneBodyHeight,
8
+ getPaneBodyHeight,
9
+ NATIVE_PANE_BODY_LAYOUT_PROPS,
10
+ shouldReservePaneFooter,
11
+ } from "./pane-sizing";
7
12
 
8
13
  interface PaneWrapperProps {
9
14
  paneId?: string;
@@ -24,6 +29,23 @@ interface PaneWrapperProps {
24
29
  children: ReactNode;
25
30
  }
26
31
 
32
+ function resolvePaneBodyHeight(
33
+ height: number,
34
+ hasTitle: boolean,
35
+ reserveFooter: boolean,
36
+ nativePaneChrome: boolean | undefined,
37
+ ): number {
38
+ if (nativePaneChrome) {
39
+ return hasTitle
40
+ ? getNativePaneBodyHeight(height, reserveFooter)
41
+ : Math.max(1, height);
42
+ }
43
+
44
+ return hasTitle
45
+ ? getPaneBodyHeight(height, reserveFooter)
46
+ : Math.max(1, Math.floor(height));
47
+ }
48
+
27
49
  export function PaneWrapper({
28
50
  paneId,
29
51
  title,
@@ -47,7 +69,7 @@ export function PaneWrapper({
47
69
  const showFooter = hasPaneFooterContent(footer);
48
70
  const reserveFooter = shouldReservePaneFooter(nativePaneChrome, showFooter);
49
71
  const bodyHeight = typeof height === "number"
50
- ? title ? getPaneBodyHeight(height, reserveFooter) : Math.max(1, Math.floor(height))
72
+ ? resolvePaneBodyHeight(height, !!title, reserveFooter, nativePaneChrome)
51
73
  : undefined;
52
74
  const bodyLayoutProps = nativePaneChrome
53
75
  ? NATIVE_PANE_BODY_LAYOUT_PROPS
@@ -33,6 +33,7 @@ import { removePaneInstances, type LayoutConfig } from "../../types/config";
33
33
  import { contextMenuDivider, type ContextMenuItem } from "../../types/context-menu";
34
34
  import {
35
35
  resolveTickerForPane,
36
+ syncConfigActiveLayoutState,
36
37
  useAppDispatch,
37
38
  useAppSelector,
38
39
  } from "../../state/app-context";
@@ -50,7 +51,13 @@ import { FloatingPaneWrapper } from "./floating-pane";
50
51
  import { PaneContent } from "./pane-content";
51
52
  import { PaneWrapper } from "./pane";
52
53
  import { hasPaneFooterContent, PaneFooterProvider } from "./pane-footer";
53
- import { getPaneBodyHeight, getPaneBodyWidth, shouldReservePaneFooter } from "./pane-sizing";
54
+ import {
55
+ getNativePaneBodyHeight,
56
+ getNativePaneBodyWidth,
57
+ getPaneBodyHeight,
58
+ getPaneBodyWidth,
59
+ shouldReservePaneFooter,
60
+ } from "./pane-sizing";
54
61
  import { getPaneDisplayTitle } from "./pane-title";
55
62
  import { TITLEBAR_OVERLAY_HEIGHT_PX } from "./titlebar-overlay";
56
63
  import { capturePaneScreenshotPngBase64 } from "../../utils/dom-screenshot";
@@ -788,6 +795,7 @@ export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview
788
795
  const config = useAppSelector((state) => state.config);
789
796
  const paneState = useAppSelector((state) => state.paneState);
790
797
  const focusedPaneId = useAppSelector(selectFocusedPaneId);
798
+ const activePanel = useAppSelector((state) => state.activePanel);
791
799
  const commandBarOpen = useAppSelector(selectCommandBarOpen);
792
800
  const inputCaptured = useAppSelector((state) => state.inputCaptured);
793
801
  const statusBarVisible = useAppSelector(selectStatusBarVisible);
@@ -870,15 +878,17 @@ export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview
870
878
  );
871
879
 
872
880
  const persistLayout = useCallback((nextLayout: LayoutConfig, options?: { pushHistory?: boolean }) => {
873
- const layouts = config.layouts.map((savedLayout, index) => (
874
- index === config.activeLayoutIndex ? { ...savedLayout, layout: nextLayout } : savedLayout
875
- ));
876
881
  if (options?.pushHistory !== false) {
877
882
  dispatch({ type: "PUSH_LAYOUT_HISTORY" });
878
883
  }
879
884
  dispatch({ type: "UPDATE_LAYOUT", layout: nextLayout });
880
- scheduleConfigSave({ ...config, layout: nextLayout, layouts });
881
- }, [config, dispatch]);
885
+ scheduleConfigSave(syncConfigActiveLayoutState(
886
+ { ...config, layout: nextLayout },
887
+ paneState,
888
+ focusedPaneId,
889
+ activePanel,
890
+ ));
891
+ }, [activePanel, config, dispatch, focusedPaneId, paneState]);
882
892
 
883
893
  const closeFocusedPane = useCallback(() => {
884
894
  if (!focusedPaneId || !isPaneInLayout(visibleLayout, focusedPaneId)) return false;
@@ -1621,7 +1631,7 @@ export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview
1621
1631
  if (!pane) return null;
1622
1632
  const focused = focusedPaneId === leaf.instanceId && (!overlayOpen || menuState?.paneId === leaf.instanceId);
1623
1633
  const showActions = focused || hoveredPaneId === leaf.instanceId || menuState?.paneId === leaf.instanceId;
1624
- const bodyWidth = nativePaneChrome ? Math.max(1, Math.floor(leaf.rect.width)) : getPaneBodyWidth(leaf.rect.width);
1634
+ const bodyWidth = nativePaneChrome ? getNativePaneBodyWidth(leaf.rect.width) : getPaneBodyWidth(leaf.rect.width);
1625
1635
  return (
1626
1636
  <Box
1627
1637
  key={`dock:${leaf.instanceId}`}
@@ -1634,7 +1644,9 @@ export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview
1634
1644
  <PaneFooterProvider>
1635
1645
  {(footer) => {
1636
1646
  const reserveFooter = shouldReservePaneFooter(nativePaneChrome, hasPaneFooterContent(footer));
1637
- const bodyHeight = getPaneBodyHeight(leaf.rect.height, reserveFooter);
1647
+ const bodyHeight = nativePaneChrome
1648
+ ? getNativePaneBodyHeight(leaf.rect.height, reserveFooter)
1649
+ : getPaneBodyHeight(leaf.rect.height, reserveFooter);
1638
1650
  return (
1639
1651
  <PaneWrapper
1640
1652
  paneId={leaf.instanceId}
@@ -1674,12 +1686,14 @@ export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview
1674
1686
  : rect;
1675
1687
  const focused = focusedPaneId === pane.instance.instanceId && (!overlayOpen || menuState?.paneId === pane.instance.instanceId);
1676
1688
  const showActions = focused || hoveredPaneId === pane.instance.instanceId || menuState?.paneId === pane.instance.instanceId;
1677
- const bodyWidth = nativePaneChrome ? Math.max(1, Math.floor(preview.width)) : getPaneBodyWidth(preview.width);
1689
+ const bodyWidth = nativePaneChrome ? getNativePaneBodyWidth(preview.width) : getPaneBodyWidth(preview.width);
1678
1690
  return (
1679
1691
  <PaneFooterProvider key={`float:${pane.instance.instanceId}`}>
1680
1692
  {(footer) => {
1681
1693
  const reserveFooter = shouldReservePaneFooter(nativePaneChrome, hasPaneFooterContent(footer));
1682
- const bodyHeight = getPaneBodyHeight(preview.height, reserveFooter);
1694
+ const bodyHeight = nativePaneChrome
1695
+ ? getNativePaneBodyHeight(preview.height, reserveFooter)
1696
+ : getPaneBodyHeight(preview.height, reserveFooter);
1683
1697
  return (
1684
1698
  <FloatingPaneWrapper
1685
1699
  paneId={pane.instance.instanceId}
@@ -187,7 +187,7 @@ describe("StatusBar", () => {
187
187
  }
188
188
  });
189
189
 
190
- test("omits focused ticker venue and quote source labels", async () => {
190
+ test("omits focused ticker quote details", async () => {
191
191
  const config = createDefaultConfig("/tmp/gloomberb-test");
192
192
  config.layout.instances = config.layout.instances.map((instance) => (
193
193
  instance.instanceId === "ticker-detail:main"
@@ -247,6 +247,9 @@ describe("StatusBar", () => {
247
247
  await testSetup.renderOnce();
248
248
 
249
249
  const frame = testSetup.captureCharFrame();
250
+ expect(frame).not.toContain("AMD");
251
+ expect(frame).not.toContain("Pre");
252
+ expect(frame).not.toContain("101");
250
253
  expect(frame).not.toContain("NASDAQ PRE-MKT");
251
254
  expect(frame).not.toContain("px IBKR live");
252
255
  expect(frame).not.toContain("ses Yahoo");
@@ -1,8 +1,8 @@
1
1
  import { Box, Span, Text, TextAttributes, contextMenuDivider, useContextMenu, useUiCapabilities } from "../../ui";
2
2
  import { useCallback, useEffect, useState } from "react";
3
- import { colors, hoverBg } from "../../theme/colors";
3
+ import { blendHex, colors, hoverBg } from "../../theme/colors";
4
4
  import { useThemeColors } from "../../theme/theme-context";
5
- import { useAppDispatch, useAppSelector, useFocusedTicker } from "../../state/app-context";
5
+ import { useAppDispatch, useAppSelector } from "../../state/app-context";
6
6
  import {
7
7
  selectActiveLayoutIndex,
8
8
  selectGridlockTipSequence,
@@ -10,9 +10,6 @@ import {
10
10
  selectSavedLayouts,
11
11
  selectStatusBarVisible,
12
12
  } from "../../state/selectors-ui";
13
- import {
14
- getExtendedHoursInfo,
15
- } from "../../utils/market-status";
16
13
  import { getSharedRegistry } from "../../plugins/registry";
17
14
  import { gridlockAllPanes } from "../../plugins/pane-manager";
18
15
  import { notifyGridlockComplete } from "../../plugins/gridlock-notification";
@@ -35,8 +32,6 @@ type LayoutTabItem = {
35
32
  type StatusBarViewProps = {
36
33
  activeLayoutIdx: number;
37
34
  dismissGridlockTip: (event?: StatusBarEvent) => void;
38
- extColor: string;
39
- extText: string;
40
35
  handleGridlockTip: (event?: StatusBarEvent) => void;
41
36
  handleLayoutSelect: (value: string) => void;
42
37
  hasMultipleLayouts: boolean;
@@ -47,7 +42,6 @@ type StatusBarViewProps = {
47
42
  openLayoutContextMenu: (index: number, event: any) => void | Promise<unknown>;
48
43
  setHoveredControl: SetHoveredControl;
49
44
  showGridlockTip: boolean;
50
- symbol: string | null | undefined;
51
45
  };
52
46
 
53
47
  function truncate(text: string, width: number): string {
@@ -68,14 +62,8 @@ export function StatusBar() {
68
62
  const statusBarVisible = useAppSelector(selectStatusBarVisible);
69
63
  const gridlockTipVisible = useAppSelector(selectGridlockTipVisible);
70
64
  const gridlockTipSequence = useAppSelector(selectGridlockTipSequence);
71
- const { symbol, financials: focusedFinancials } = useFocusedTicker();
72
65
  const [hoveredControl, setHoveredControl] = useState<string | null>(null);
73
66
 
74
- // Extended hours info for selected ticker
75
- const extInfo = getExtendedHoursInfo(focusedFinancials?.quote);
76
- const extText = extInfo?.text ?? "";
77
- const extColor = extInfo?.color ?? colors.textDim;
78
-
79
67
  const hasMultipleLayouts = layouts.length > 1;
80
68
  const showGridlockTip = gridlockTipVisible && !!registry;
81
69
  const layoutTabs = layouts.map((layout, index) => ({
@@ -211,8 +199,6 @@ export function StatusBar() {
211
199
  const viewProps: StatusBarViewProps = {
212
200
  activeLayoutIdx,
213
201
  dismissGridlockTip,
214
- extColor,
215
- extText,
216
202
  handleGridlockTip,
217
203
  handleLayoutSelect,
218
204
  hasMultipleLayouts,
@@ -223,7 +209,6 @@ export function StatusBar() {
223
209
  openLayoutContextMenu,
224
210
  setHoveredControl,
225
211
  showGridlockTip,
226
- symbol,
227
212
  };
228
213
 
229
214
  if (nativePaneChrome) {
@@ -251,7 +236,7 @@ function NativeStatusBar({
251
236
  }}
252
237
  style={{
253
238
  borderTop: `1px solid ${colors.border}`,
254
- boxShadow: "inset 0 1px 0 rgba(255,255,255,0.03)",
239
+ boxShadow: `inset 0 1px 0 ${blendHex(colors.panel, colors.textBright, 0.03)}`,
255
240
  paddingInline: 8,
256
241
  }}
257
242
  >
@@ -416,15 +401,10 @@ function TerminalGridlockTip({
416
401
  );
417
402
  }
418
403
 
419
- function StatusBarWidgets({ extColor, extText, symbol }: Pick<StatusBarViewProps, "extColor" | "extText" | "symbol">) {
404
+ function StatusBarWidgets() {
420
405
  return (
421
406
  <>
422
407
  <Box flexGrow={1} />
423
- {extText && (
424
- <Box paddingRight={1}>
425
- <Text fg={extColor}>{symbol} {extText}</Text>
426
- </Box>
427
- )}
428
408
  <PluginSlot name="status:widget" />
429
409
  </>
430
410
  );