gloomberb 0.7.0 → 0.7.2

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 (126) hide show
  1. package/package.json +1 -1
  2. package/src/app.tsx +149 -51
  3. package/src/capabilities/factories.ts +3 -0
  4. package/src/components/chart/chart-renderer.ts +24 -12
  5. package/src/components/chart/chart.test.ts +43 -0
  6. package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
  7. package/src/components/chart/comparison-chart-renderer.ts +13 -6
  8. package/src/components/chart/comparison-stock-chart.tsx +15 -12
  9. package/src/components/chart/price-axis-labels.tsx +114 -0
  10. package/src/components/chart/static-chart-surface.tsx +2 -0
  11. package/src/components/chart/stock-chart.tsx +31 -20
  12. package/src/components/chart/time-axis-label.tsx +78 -1
  13. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
  14. package/src/components/command-bar/command-bar.test.tsx +114 -31
  15. package/src/components/command-bar/command-bar.tsx +587 -335
  16. package/src/components/command-bar/command-registry.ts +0 -10
  17. package/src/components/command-bar/helpers.ts +2 -5
  18. package/src/components/command-bar/list-model.ts +1 -2
  19. package/src/components/command-bar/theme-picker.tsx +254 -0
  20. package/src/components/command-bar/view-model.test.ts +4 -4
  21. package/src/components/command-bar/view-model.ts +1 -1
  22. package/src/components/command-bar/workflow-types.ts +1 -2
  23. package/src/components/layout/detached-pane-shell.tsx +29 -10
  24. package/src/components/layout/floating-pane.tsx +5 -2
  25. package/src/components/layout/header.tsx +2 -0
  26. package/src/components/layout/pane-content.test.tsx +64 -0
  27. package/src/components/layout/pane-content.tsx +2 -0
  28. package/src/components/layout/pane-sizing.ts +9 -2
  29. package/src/components/layout/pane-title.ts +4 -1
  30. package/src/components/layout/pane.tsx +11 -5
  31. package/src/components/layout/shell.test.tsx +198 -0
  32. package/src/components/layout/shell.tsx +33 -2
  33. package/src/components/layout/status-bar.tsx +2 -0
  34. package/src/components/speedometer-gauge.tsx +29 -25
  35. package/src/components/table-view-shared.tsx +85 -3
  36. package/src/components/ticker-list-table-view.tsx +16 -2
  37. package/src/components/ticker-list-table.test.tsx +82 -1
  38. package/src/components/ticker-list-table.tsx +22 -9
  39. package/src/components/ui/data-table.tsx +12 -9
  40. package/src/components/ui/frame.tsx +2 -0
  41. package/src/components/ui/table-layout.test.ts +27 -1
  42. package/src/components/ui/table-layout.ts +39 -1
  43. package/src/components/ui/tabs.tsx +34 -1
  44. package/src/components/ui/ui.test.tsx +68 -1
  45. package/src/core/state/app-state.ts +4 -14
  46. package/src/market-data/coordinator.test.ts +75 -0
  47. package/src/market-data/coordinator.ts +218 -48
  48. package/src/news/aggregator.test.ts +49 -1
  49. package/src/news/aggregator.ts +48 -0
  50. package/src/news/hooks.ts +6 -2
  51. package/src/plugins/builtin/ai/screener-pane.tsx +3 -2
  52. package/src/plugins/builtin/analytics/index.tsx +1 -18
  53. package/src/plugins/builtin/chat-controller.test.ts +83 -1
  54. package/src/plugins/builtin/chat-controller.ts +491 -243
  55. package/src/plugins/builtin/chat.test.tsx +425 -7
  56. package/src/plugins/builtin/chat.tsx +1109 -211
  57. package/src/plugins/builtin/cloud-tweets.tsx +1 -0
  58. package/src/plugins/builtin/fear-greed/index.tsx +148 -39
  59. package/src/plugins/builtin/holders/index.test.ts +13 -1
  60. package/src/plugins/builtin/holders/index.tsx +54 -14
  61. package/src/plugins/builtin/market-movers/index.tsx +12 -18
  62. package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
  63. package/src/plugins/builtin/market-movers/screener.ts +70 -17
  64. package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
  65. package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
  66. package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
  67. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
  68. package/src/plugins/builtin/notes.tsx +1 -0
  69. package/src/plugins/builtin/options.tsx +2 -16
  70. package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
  71. package/src/plugins/builtin/research/index.test.ts +94 -0
  72. package/src/plugins/builtin/research/index.tsx +159 -12
  73. package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
  74. package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
  75. package/src/plugins/plugin-runtime.test.tsx +34 -0
  76. package/src/plugins/plugin-runtime.tsx +7 -2
  77. package/src/plugins/prediction-markets/controller.ts +0 -15
  78. package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
  79. package/src/plugins/registry.ts +3 -3
  80. package/src/plugins/ticker-navigation.test.ts +66 -0
  81. package/src/plugins/ticker-navigation.ts +44 -0
  82. package/src/react/input.ts +1 -1
  83. package/src/renderers/electrobun/bun/index.ts +32 -11
  84. package/src/renderers/electrobun/bun/window-focus.ts +37 -0
  85. package/src/renderers/electrobun/shared/protocol.ts +7 -1
  86. package/src/renderers/electrobun/view/app-services.ts +88 -31
  87. package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
  88. package/src/renderers/electrobun/view/data-table.tsx +111 -96
  89. package/src/renderers/electrobun/view/desktop-controls.tsx +8 -0
  90. package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
  91. package/src/renderers/electrobun/view/dialog-host.tsx +2 -0
  92. package/src/renderers/electrobun/view/input-host.tsx +1 -1
  93. package/src/renderers/electrobun/view/main.tsx +18 -1
  94. package/src/renderers/electrobun/view/styles.css +6 -8
  95. package/src/renderers/electrobun/view/toast-host.tsx +29 -3
  96. package/src/renderers/electrobun/view/ui-host.tsx +101 -5
  97. package/src/renderers/opentui/input-host.tsx +1 -1
  98. package/src/sources/gloomberb-cloud.test.ts +86 -1
  99. package/src/sources/gloomberb-cloud.ts +117 -21
  100. package/src/sources/provider-router.test.ts +122 -0
  101. package/src/sources/provider-router.ts +198 -11
  102. package/src/sources/yahoo-finance.test.ts +44 -0
  103. package/src/sources/yahoo-finance.ts +6 -0
  104. package/src/state/app-bootstrap.ts +20 -7
  105. package/src/state/app-context-selector.test.tsx +189 -4
  106. package/src/state/app-context.test.ts +20 -0
  107. package/src/state/app-context.tsx +48 -16
  108. package/src/state/use-inline-tickers.ts +13 -10
  109. package/src/theme/colors.test.ts +75 -0
  110. package/src/theme/colors.ts +64 -3
  111. package/src/theme/theme-context.tsx +26 -0
  112. package/src/types/capability-route-source.ts +1 -0
  113. package/src/types/data-provider.ts +15 -1
  114. package/src/types/desktop-window.ts +6 -0
  115. package/src/types/financials.ts +2 -0
  116. package/src/types/plugin.ts +1 -1
  117. package/src/ui/host.tsx +14 -2
  118. package/src/ui/index.tsx +1 -0
  119. package/src/utils/api-client.test.ts +32 -0
  120. package/src/utils/api-client.ts +119 -18
  121. package/src/utils/double-escape-close.test.ts +33 -0
  122. package/src/utils/double-escape-close.ts +40 -0
  123. package/src/utils/throttled-fetch.test.ts +22 -0
  124. package/src/utils/throttled-fetch.ts +3 -1
  125. package/src/utils/ticker-search.ts +53 -9
  126. package/src/version.ts +1 -1
@@ -1,5 +1,4 @@
1
1
  import type { AppAction } from "../../state/app-context";
2
- import { themes, getThemeIds } from "../../theme/themes";
3
2
 
4
3
  export type CommandExecutor = (dispatch: React.Dispatch<AppAction>, context: CommandContext) => void | Promise<void>;
5
4
 
@@ -239,15 +238,6 @@ export const commands: Command[] = [
239
238
  },
240
239
  ];
241
240
 
242
- /** Get theme options for the command bar */
243
- export function getThemeOptions(): Array<{ id: string; name: string; description: string }> {
244
- return getThemeIds().map((id) => ({
245
- id,
246
- name: themes[id]!.name,
247
- description: themes[id]!.description,
248
- }));
249
- }
250
-
251
241
  /** Find a command whose prefix matches the start of the input */
252
242
  export function matchPrefix(input: string, commandList: Command[] = commands): { command: Command; arg: string } | null {
253
243
  const upper = input.toUpperCase().trim();
@@ -14,7 +14,7 @@ import type {
14
14
  } from "./workflow-types";
15
15
  import type { CollectionKind, CollectionMembershipAction } from "./workflow-ops";
16
16
 
17
- export type RouteCommandId = "security-description" | "theme" | "plugins" | "layout";
17
+ export type RouteCommandId = "security-description" | "plugins" | "layout";
18
18
  export type CollectionCommandId = "add-watchlist" | "add-portfolio" | "remove-watchlist" | "remove-portfolio";
19
19
 
20
20
  export function summarizeError(error: unknown): Record<string, string> {
@@ -30,7 +30,6 @@ export function summarizeError(error: unknown): Record<string, string> {
30
30
 
31
31
  export function isRouteCommandId(commandId: string): commandId is RouteCommandId {
32
32
  return commandId === "security-description"
33
- || commandId === "theme"
34
33
  || commandId === "plugins"
35
34
  || commandId === "layout";
36
35
  }
@@ -237,12 +236,10 @@ export function normalizeWizardFields(steps: WizardStep[]): {
237
236
  return { fields, description, initialValues, pendingLabel, successLabel };
238
237
  }
239
238
 
240
- export function routeCommandIdToScreen(commandId: RouteCommandId): "ticker-search" | "themes" | "plugins" | "layout" {
239
+ export function routeCommandIdToScreen(commandId: RouteCommandId): "ticker-search" | "plugins" | "layout" {
241
240
  switch (commandId) {
242
241
  case "security-description":
243
242
  return "ticker-search";
244
- case "theme":
245
- return "themes";
246
243
  case "plugins":
247
244
  return "plugins";
248
245
  case "layout":
@@ -5,10 +5,9 @@ export interface ResultItem {
5
5
  label: string;
6
6
  detail: string;
7
7
  category: string;
8
- kind: "command" | "ticker" | "search" | "theme" | "plugin" | "action" | "info";
8
+ kind: "command" | "ticker" | "search" | "plugin" | "action" | "info";
9
9
  right?: string;
10
10
  searchText?: string;
11
- themeId?: string;
12
11
  pluginToggle?: () => void | Promise<void>;
13
12
  secondaryAction?: () => void | Promise<void>;
14
13
  checked?: boolean;
@@ -0,0 +1,254 @@
1
+ import {
2
+ forwardRef,
3
+ memo,
4
+ useCallback,
5
+ useEffect,
6
+ useImperativeHandle,
7
+ useLayoutEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from "react";
12
+ import { Box, ScrollBox, Text } from "../../ui";
13
+ import { TextAttributes, type ScrollBoxRenderable } from "../../ui";
14
+ import { getThemeIds, themes as themeRegistry } from "../../theme/themes";
15
+ import { truncateText } from "./view-model";
16
+
17
+ const THEME_PREVIEW_DEBOUNCE_MS = 120;
18
+ const THEME_OPTIONS = getThemeIds().map((id) => ({
19
+ id,
20
+ name: themeRegistry[id]!.name,
21
+ }));
22
+
23
+ interface ThemePickerScrollEvent {
24
+ stopPropagation: () => void;
25
+ preventDefault: () => void;
26
+ scroll?: { direction?: string; delta?: number };
27
+ }
28
+
29
+ interface ThemePickerProps {
30
+ filter: string;
31
+ committedThemeId: string;
32
+ height: number;
33
+ contentPadding: number;
34
+ labelWidth: number;
35
+ trailingWidth: number;
36
+ queryDisplayWidth: number;
37
+ nativePaneChrome: boolean;
38
+ paletteBg: string;
39
+ paletteHoverBg: string;
40
+ paletteSelectedBg: string;
41
+ paletteSelectedText: string;
42
+ paletteSubtleText: string;
43
+ paletteText: string;
44
+ panelBg: string;
45
+ onPreview: (themeId: string | null) => void;
46
+ onCommit: (themeId: string) => void;
47
+ }
48
+
49
+ export interface ThemePickerHandle {
50
+ move: (delta: number) => boolean;
51
+ commit: () => boolean;
52
+ cancelPreview: () => void;
53
+ }
54
+
55
+ function clampIndex(index: number, length: number): number {
56
+ return Math.max(0, Math.min(index, Math.max(0, length - 1)));
57
+ }
58
+
59
+ export const ThemePicker = memo(forwardRef<ThemePickerHandle, ThemePickerProps>(function ThemePicker({
60
+ filter,
61
+ committedThemeId,
62
+ height,
63
+ contentPadding,
64
+ labelWidth,
65
+ trailingWidth,
66
+ queryDisplayWidth,
67
+ nativePaneChrome,
68
+ paletteBg,
69
+ paletteHoverBg,
70
+ paletteSelectedBg,
71
+ paletteSelectedText,
72
+ paletteSubtleText,
73
+ paletteText,
74
+ panelBg,
75
+ onPreview,
76
+ onCommit,
77
+ }: ThemePickerProps, ref) {
78
+ const scrollRef = useRef<ScrollBoxRenderable | null>(null);
79
+ const previewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
80
+ const pendingPreviewIdRef = useRef<string | null>(null);
81
+ const committedThemeIdRef = useRef(committedThemeId);
82
+ const onPreviewRef = useRef(onPreview);
83
+ const onCommitRef = useRef(onCommit);
84
+ const normalizedFilter = filter.trim().toLowerCase();
85
+ const themes = useMemo(() => {
86
+ if (!normalizedFilter) return THEME_OPTIONS;
87
+ return THEME_OPTIONS.filter((theme) => (
88
+ theme.name.toLowerCase().includes(normalizedFilter)
89
+ || theme.id.toLowerCase().includes(normalizedFilter)
90
+ ));
91
+ }, [normalizedFilter]);
92
+ const [selectedIndex, setSelectedIndex] = useState(() => (
93
+ Math.max(0, themes.findIndex((theme) => theme.id === committedThemeId))
94
+ ));
95
+ const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
96
+ const themesRef = useRef(themes);
97
+ const selectedIndexRef = useRef(selectedIndex);
98
+
99
+ themesRef.current = themes;
100
+ selectedIndexRef.current = selectedIndex;
101
+ committedThemeIdRef.current = committedThemeId;
102
+ onPreviewRef.current = onPreview;
103
+ onCommitRef.current = onCommit;
104
+
105
+ const cancelPreview = useCallback(() => {
106
+ if (previewTimerRef.current) {
107
+ clearTimeout(previewTimerRef.current);
108
+ previewTimerRef.current = null;
109
+ }
110
+ pendingPreviewIdRef.current = null;
111
+ }, []);
112
+
113
+ const requestPreview = useCallback((themeId: string) => {
114
+ pendingPreviewIdRef.current = themeId;
115
+ if (previewTimerRef.current) {
116
+ clearTimeout(previewTimerRef.current);
117
+ }
118
+ previewTimerRef.current = setTimeout(() => {
119
+ previewTimerRef.current = null;
120
+ const nextThemeId = pendingPreviewIdRef.current;
121
+ pendingPreviewIdRef.current = null;
122
+ if (!nextThemeId) return;
123
+ onPreviewRef.current(nextThemeId === committedThemeIdRef.current ? null : nextThemeId);
124
+ }, THEME_PREVIEW_DEBOUNCE_MS);
125
+ }, []);
126
+
127
+ const move = useCallback((delta: number): boolean => {
128
+ const options = themesRef.current;
129
+ if (options.length === 0 || delta === 0) return false;
130
+ const nextIndex = clampIndex(selectedIndexRef.current + delta, options.length);
131
+ if (nextIndex === selectedIndexRef.current) return false;
132
+ selectedIndexRef.current = nextIndex;
133
+ setSelectedIndex(nextIndex);
134
+ setHoveredIndex(null);
135
+ requestPreview(options[nextIndex]!.id);
136
+ return true;
137
+ }, [requestPreview]);
138
+
139
+ const commit = useCallback((): boolean => {
140
+ const selected = themesRef.current[selectedIndexRef.current];
141
+ if (!selected) return false;
142
+ cancelPreview();
143
+ onCommitRef.current(selected.id);
144
+ return true;
145
+ }, [cancelPreview]);
146
+
147
+ useImperativeHandle(ref, () => ({
148
+ move,
149
+ commit,
150
+ cancelPreview,
151
+ }), [cancelPreview, commit, move]);
152
+
153
+ useEffect(() => {
154
+ const preferredIndex = themes.findIndex((theme) => theme.id === committedThemeId);
155
+ const nextIndex = preferredIndex >= 0 ? preferredIndex : 0;
156
+ selectedIndexRef.current = nextIndex;
157
+ setSelectedIndex(nextIndex);
158
+ setHoveredIndex(null);
159
+ }, [committedThemeId, themes]);
160
+
161
+ useEffect(() => cancelPreview, [cancelPreview]);
162
+
163
+ useLayoutEffect(() => {
164
+ const scrollBox = scrollRef.current;
165
+ if (!scrollBox) return;
166
+ if (scrollBox.verticalScrollBar) scrollBox.verticalScrollBar.visible = false;
167
+ if (themes.length === 0) return;
168
+ const viewportHeight = Math.max(1, scrollBox.viewport?.height ?? height);
169
+ if (selectedIndex < scrollBox.scrollTop) {
170
+ scrollBox.scrollTo(selectedIndex);
171
+ } else if (selectedIndex >= scrollBox.scrollTop + viewportHeight) {
172
+ scrollBox.scrollTo(selectedIndex - viewportHeight + 1);
173
+ }
174
+ }, [height, selectedIndex, themes.length]);
175
+
176
+ const handleScroll = useCallback((event: ThemePickerScrollEvent) => {
177
+ event.stopPropagation();
178
+ event.preventDefault();
179
+ const delta = Math.max(1, Math.round(event.scroll?.delta ?? 1));
180
+ const direction = event.scroll?.direction;
181
+ if (direction === "down" || direction === "right") {
182
+ move(delta);
183
+ } else if (direction === "up" || direction === "left") {
184
+ move(-delta);
185
+ }
186
+ }, [move]);
187
+
188
+ if (themes.length === 0) {
189
+ return (
190
+ <Box height={height} paddingX={contentPadding}>
191
+ <Text fg={paletteSubtleText}>{truncateText("No themes match", queryDisplayWidth)}</Text>
192
+ </Box>
193
+ );
194
+ }
195
+
196
+ return (
197
+ <ScrollBox
198
+ ref={scrollRef}
199
+ flexDirection="column"
200
+ height={height}
201
+ scrollY
202
+ focusable={false}
203
+ {...(!nativePaneChrome ? { onMouseScroll: handleScroll } : {})}
204
+ >
205
+ {themes.map((theme, index) => {
206
+ const selected = index === selectedIndex;
207
+ const hovered = index === hoveredIndex && !selected;
208
+ const current = theme.id === committedThemeId;
209
+ const label = truncateText(theme.name, labelWidth);
210
+ const trailing = current ? "current" : "";
211
+ return (
212
+ <Box
213
+ key={theme.id}
214
+ flexDirection="row"
215
+ height={1}
216
+ paddingX={contentPadding}
217
+ backgroundColor={selected
218
+ ? paletteSelectedBg
219
+ : hovered
220
+ ? paletteHoverBg
221
+ : (nativePaneChrome ? panelBg : paletteBg)}
222
+ onMouseMove={() => setHoveredIndex(index)}
223
+ onMouseOut={() => setHoveredIndex(null)}
224
+ onMouseDown={(event: any) => {
225
+ event.stopPropagation?.();
226
+ event.preventDefault?.();
227
+ selectedIndexRef.current = index;
228
+ setSelectedIndex(index);
229
+ cancelPreview();
230
+ onCommitRef.current(theme.id);
231
+ }}
232
+ {...(!nativePaneChrome ? { onMouseScroll: handleScroll } : {})}
233
+ data-command-bar-row-selected={nativePaneChrome && selected ? "true" : undefined}
234
+ style={nativePaneChrome ? { borderRadius: 6 } : undefined}
235
+ >
236
+ <Box width={labelWidth}>
237
+ <Text
238
+ fg={selected ? paletteSelectedText : paletteText}
239
+ attributes={current ? TextAttributes.BOLD : undefined}
240
+ >
241
+ {label}
242
+ </Text>
243
+ </Box>
244
+ <Box width={trailingWidth}>
245
+ <Text fg={selected ? paletteSelectedText : paletteSubtleText}>
246
+ {truncateText(trailing, trailingWidth)}
247
+ </Text>
248
+ </Box>
249
+ </Box>
250
+ );
251
+ })}
252
+ </ScrollBox>
253
+ );
254
+ }));
@@ -46,7 +46,7 @@ describe("command bar view model helpers", () => {
46
46
  expect(sections.map((section) => section.category)).toEqual(["Tickers", "Config", "Danger", "Debug"]);
47
47
  });
48
48
 
49
- test("derives row presentation for toggles and current theme rows", () => {
49
+ test("derives row presentation for toggles and current rows", () => {
50
50
  expect(getRowPresentation({
51
51
  id: "plugin:news",
52
52
  label: "News",
@@ -61,11 +61,11 @@ describe("command bar view model helpers", () => {
61
61
  });
62
62
 
63
63
  expect(getRowPresentation({
64
- id: "theme:amber",
64
+ id: "current:amber",
65
65
  label: "Amber",
66
66
  detail: "Warm terminal palette",
67
- category: "Themes",
68
- kind: "theme",
67
+ category: "Config",
68
+ kind: "command",
69
69
  right: "amber",
70
70
  current: true,
71
71
  }, false, true)).toMatchObject({
@@ -20,7 +20,7 @@ export interface CommandBarItemView {
20
20
  label: string;
21
21
  detail: string;
22
22
  category: string;
23
- kind: "command" | "ticker" | "search" | "theme" | "plugin" | "action" | "info";
23
+ kind: "command" | "ticker" | "search" | "plugin" | "action" | "info";
24
24
  right?: string;
25
25
  checked?: boolean;
26
26
  current?: boolean;
@@ -38,11 +38,10 @@ export interface CommandBarRouteBase {
38
38
 
39
39
  export interface CommandBarModeRoute extends CommandBarRouteBase {
40
40
  kind: "mode";
41
- screen: "ticker-search" | "themes" | "plugins" | "layout";
41
+ screen: "ticker-search" | "plugins" | "layout";
42
42
  query: string;
43
43
  selectedIdx: number;
44
44
  hoveredIdx: number | null;
45
- themeBaseId?: string;
46
45
  payload?: Record<string, unknown>;
47
46
  }
48
47
 
@@ -1,16 +1,22 @@
1
1
  import { Box, Text, useRendererHost, useUiCapabilities } from "../../ui";
2
- import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { useShortcut, useViewport } from "../../react/input";
4
4
  import { useAppDispatch, useAppSelector } from "../../state/app-context";
5
5
  import type { DesktopWindowBridge } from "../../types/desktop-window";
6
6
  import { findPaneInstance } from "../../types/config";
7
7
  import type { PluginRegistry } from "../../plugins/registry";
8
8
  import { colors, floatingPaneBg, floatingPaneTitleBg, paneTitleText } from "../../theme/colors";
9
+ import { useThemeColors } from "../../theme/theme-context";
9
10
  import { hasPaneFooterContent, PaneFooterBar, PaneFooterProvider } from "./pane-footer";
10
11
  import { PaneContent } from "./pane-content";
11
- import { getPaneBodyWidth } from "./pane-sizing";
12
+ import { getPaneBodyWidth, NATIVE_PANE_BODY_LAYOUT_PROPS } from "./pane-sizing";
12
13
  import { getPaneDisplayTitle } from "./pane-title";
13
14
  import { TITLEBAR_OVERLAY_HEIGHT_PX, TITLEBAR_TRAFFIC_LIGHT_WIDTH } from "./titlebar-overlay";
15
+ import {
16
+ createDoubleEscapeCloseState,
17
+ recordDoubleEscapeClose,
18
+ resetDoubleEscapeClose,
19
+ } from "../../utils/double-escape-close";
14
20
 
15
21
  interface DetachedPaneShellProps {
16
22
  pluginRegistry: PluginRegistry;
@@ -23,12 +29,13 @@ function stopMouse(event?: { stopPropagation?: () => void; preventDefault?: () =
23
29
  }
24
30
 
25
31
  export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: DetachedPaneShellProps) {
32
+ useThemeColors();
26
33
  const dispatch = useAppDispatch();
27
34
  const rendererHost = useRendererHost();
28
35
  const config = useAppSelector((state) => state.config);
29
36
  const paneState = useAppSelector((state) => state.paneState);
30
- const focusedPaneId = useAppSelector((state) => state.focusedPaneId);
31
37
  const inputCaptured = useAppSelector((state) => state.inputCaptured);
38
+ const doubleEscapeCloseRef = useRef(createDoubleEscapeCloseState());
32
39
  const [windowFocused, setWindowFocused] = useState(() => (
33
40
  typeof document === "undefined" ? true : document.hasFocus()
34
41
  ));
@@ -52,12 +59,6 @@ export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: Detac
52
59
  dispatch({ type: "FOCUS_PANE", paneId: desktopWindowBridge.paneId });
53
60
  }, [desktopWindowBridge.paneId, dispatch]);
54
61
 
55
- useEffect(() => {
56
- if (windowFocused && focusedPaneId !== desktopWindowBridge.paneId) {
57
- focusPane();
58
- }
59
- }, [desktopWindowBridge.paneId, focusPane, focusedPaneId, windowFocused]);
60
-
61
62
  useEffect(() => {
62
63
  if (typeof window === "undefined" || typeof document === "undefined") return;
63
64
 
@@ -81,6 +82,21 @@ export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: Detac
81
82
  };
82
83
  }, [focusPane]);
83
84
 
85
+ useShortcut((event) => {
86
+ const doubleEscapeState = doubleEscapeCloseRef.current;
87
+ const isEscape = event.name === "escape" || event.name === "esc";
88
+ if (isEscape) {
89
+ if (recordDoubleEscapeClose(doubleEscapeState, desktopWindowBridge.paneId, Date.now())) {
90
+ event.preventDefault();
91
+ event.stopPropagation();
92
+ void desktopWindowBridge.closeDetachedPane?.(desktopWindowBridge.paneId);
93
+ }
94
+ return;
95
+ }
96
+
97
+ resetDoubleEscapeClose(doubleEscapeState);
98
+ }, { phase: "before" });
99
+
84
100
  useShortcut((event) => {
85
101
  if (event.name !== "w" || (!event.ctrl && !event.meta && !event.super)) return;
86
102
  if (inputCaptured && event.ctrl && !event.meta && !event.super) return;
@@ -116,6 +132,9 @@ export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: Detac
116
132
  const bodyHeight = Math.max(1, height - headerHeightRows - footerHeightRows);
117
133
  const background = floatingPaneBg(focused);
118
134
  const titleBackground = floatingPaneTitleBg(focused);
135
+ const bodyLayoutProps = nativePaneChrome
136
+ ? NATIVE_PANE_BODY_LAYOUT_PROPS
137
+ : { height: bodyHeight };
119
138
 
120
139
  return (
121
140
  <Box
@@ -166,7 +185,7 @@ export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: Detac
166
185
  )}
167
186
  </Box>
168
187
  </Box>
169
- <Box height={bodyHeight} overflow="hidden" backgroundColor={background}>
188
+ <Box {...bodyLayoutProps} overflow="hidden" backgroundColor={background}>
170
189
  <PaneContent
171
190
  component={paneDef.component}
172
191
  paneId={instance.instanceId}
@@ -3,7 +3,7 @@ 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, shouldReservePaneFooter } from "./pane-sizing";
6
+ import { getPaneBodyHeight, NATIVE_PANE_BODY_LAYOUT_PROPS, shouldReservePaneFooter } from "./pane-sizing";
7
7
 
8
8
  interface FloatingPaneWrapperProps {
9
9
  paneId?: string;
@@ -78,6 +78,9 @@ export function FloatingPaneWrapper({
78
78
  const showFooter = hasPaneFooterContent(footer);
79
79
  const reserveFooter = shouldReservePaneFooter(nativePaneChrome, showFooter);
80
80
  const bodyHeight = getPaneBodyHeight(height, reserveFooter);
81
+ const bodyLayoutProps = nativePaneChrome
82
+ ? NATIVE_PANE_BODY_LAYOUT_PROPS
83
+ : { height: bodyHeight };
81
84
 
82
85
  return (
83
86
  <Box
@@ -115,7 +118,7 @@ export function FloatingPaneWrapper({
115
118
  />
116
119
 
117
120
  {/* Content */}
118
- <Box height={bodyHeight} overflow="hidden">
121
+ <Box {...bodyLayoutProps} overflow="hidden" backgroundColor={bg}>
119
122
  {children}
120
123
  </Box>
121
124
 
@@ -1,6 +1,7 @@
1
1
  import { Box, SpinnerMark, Text, TextAttributes, useUiCapabilities } from "../../ui";
2
2
  import { useEffect, type ReactNode } from "react";
3
3
  import { blendHex, colors, priceColor } from "../../theme/colors";
4
+ import { useThemeColors } from "../../theme/theme-context";
4
5
  import { useAppActive } from "../../state/app-activity";
5
6
  import { useAppDispatch, useAppSelector } from "../../state/app-context";
6
7
  import {
@@ -121,6 +122,7 @@ function UpdateStatus() {
121
122
  }
122
123
 
123
124
  export function Header() {
125
+ useThemeColors();
124
126
  const baseCurrency = useAppSelector(selectBaseCurrency);
125
127
  const appActive = useAppActive();
126
128
  const { titleBarOverlay } = useUiCapabilities();
@@ -0,0 +1,64 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { act, type Dispatch } from "react";
3
+ import { testRender } from "../../renderers/opentui/test-utils";
4
+ import { AppProvider, useAppDispatch, type AppAction } from "../../state/app-context";
5
+ import { applyTheme, colors } from "../../theme/colors";
6
+ import { getTheme } from "../../theme/themes";
7
+ import { createDefaultConfig } from "../../types/config";
8
+ import { PaneContent } from "./pane-content";
9
+
10
+ let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
11
+ let capturedDispatch: Dispatch<AppAction> | null = null;
12
+
13
+ function DispatchCapture() {
14
+ capturedDispatch = useAppDispatch();
15
+ return null;
16
+ }
17
+
18
+ function ThemeColorProbe() {
19
+ return <text>{colors.textBright}</text>;
20
+ }
21
+
22
+ describe("PaneContent", () => {
23
+ afterEach(() => {
24
+ testSetup?.renderer.destroy();
25
+ testSetup = undefined;
26
+ capturedDispatch = null;
27
+ applyTheme("amber");
28
+ });
29
+
30
+ test("rerenders memoized pane bodies when the theme preview changes", async () => {
31
+ const config = {
32
+ ...createDefaultConfig("/tmp/gloomberb-test"),
33
+ theme: "amber",
34
+ };
35
+
36
+ testSetup = await testRender(
37
+ <AppProvider config={config}>
38
+ <DispatchCapture />
39
+ <PaneContent
40
+ component={ThemeColorProbe}
41
+ paneId="theme-preview:test"
42
+ paneType="test"
43
+ focused
44
+ width={24}
45
+ height={4}
46
+ />
47
+ </AppProvider>,
48
+ { width: 32, height: 6 },
49
+ );
50
+
51
+ await testSetup.renderOnce();
52
+ expect(testSetup.captureCharFrame()).toContain(getTheme("amber").textBright);
53
+
54
+ await act(async () => {
55
+ capturedDispatch?.({ type: "PREVIEW_THEME", theme: "green" });
56
+ await testSetup!.renderOnce();
57
+ });
58
+ await testSetup.renderOnce();
59
+
60
+ const frame = testSetup.captureCharFrame();
61
+ expect(frame).toContain(getTheme("green").textBright);
62
+ expect(frame).not.toContain(getTheme("amber").textBright);
63
+ });
64
+ });
@@ -1,6 +1,7 @@
1
1
  import { memo, useCallback } from "react";
2
2
  import { PaneInstanceProvider } from "../../state/app-context";
3
3
  import { PaneKeyboardScrollController } from "../../state/pane-scroll-registry";
4
+ import { useThemeColors } from "../../theme/theme-context";
4
5
  import type { PaneDef } from "../../types/plugin";
5
6
 
6
7
  interface PaneContentProps {
@@ -22,6 +23,7 @@ export const PaneContent = memo(function PaneContent({
22
23
  height,
23
24
  onClose,
24
25
  }: PaneContentProps) {
26
+ useThemeColors();
25
27
  const close = useCallback(() => {
26
28
  onClose?.(paneId);
27
29
  }, [onClose, paneId]);
@@ -2,14 +2,21 @@
2
2
  const PANE_HEADER_ROWS = 1;
3
3
  const PANE_FOOTER_ROWS = 1;
4
4
 
5
+ export const NATIVE_PANE_BODY_LAYOUT_PROPS = {
6
+ flexGrow: 1,
7
+ flexBasis: 0,
8
+ minHeight: 0,
9
+ } as const;
10
+
5
11
  export function shouldReservePaneFooter(nativePaneChrome: boolean | undefined, showFooter: boolean): boolean {
6
12
  return !nativePaneChrome || showFooter;
7
13
  }
8
14
 
9
15
  export function getPaneBodyHeight(height: number, reserveFooter = true): number {
10
- return Math.max(1, height - PANE_HEADER_ROWS - (reserveFooter ? PANE_FOOTER_ROWS : 0));
16
+ const cellHeight = Math.max(1, Math.floor(height));
17
+ return Math.max(1, cellHeight - PANE_HEADER_ROWS - (reserveFooter ? PANE_FOOTER_ROWS : 0));
11
18
  }
12
19
 
13
20
  export function getPaneBodyWidth(width: number): number {
14
- return Math.max(1, width);
21
+ return Math.max(1, Math.floor(width));
15
22
  }
@@ -8,7 +8,10 @@ export function getPaneDisplayTitle(
8
8
  paneDef: PaneDef,
9
9
  ): string {
10
10
  if (instance.paneId === "chat") {
11
- return instance.title ?? "#everyone";
11
+ const channelId = typeof instance.settings?.channelId === "string" && instance.settings.channelId.trim()
12
+ ? instance.settings.channelId.trim()
13
+ : "everyone";
14
+ return instance.title ?? `#${channelId}`;
12
15
  }
13
16
 
14
17
  if (instance.paneId === "ticker-detail") {
@@ -3,7 +3,7 @@ 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, shouldReservePaneFooter } from "./pane-sizing";
6
+ import { getPaneBodyHeight, NATIVE_PANE_BODY_LAYOUT_PROPS, shouldReservePaneFooter } from "./pane-sizing";
7
7
 
8
8
  interface PaneWrapperProps {
9
9
  paneId?: string;
@@ -47,8 +47,15 @@ export function PaneWrapper({
47
47
  const showFooter = hasPaneFooterContent(footer);
48
48
  const reserveFooter = shouldReservePaneFooter(nativePaneChrome, showFooter);
49
49
  const bodyHeight = typeof height === "number"
50
- ? title ? getPaneBodyHeight(height, reserveFooter) : height
50
+ ? title ? getPaneBodyHeight(height, reserveFooter) : Math.max(1, Math.floor(height))
51
51
  : undefined;
52
+ const bodyLayoutProps = nativePaneChrome
53
+ ? NATIVE_PANE_BODY_LAYOUT_PROPS
54
+ : {
55
+ height: bodyHeight,
56
+ flexGrow: bodyHeight == null ? 1 : 0,
57
+ flexBasis: bodyHeight == null ? 0 : undefined,
58
+ };
52
59
 
53
60
  return (
54
61
  <Box
@@ -82,10 +89,9 @@ export function PaneWrapper({
82
89
  />
83
90
  )}
84
91
  <Box
85
- height={bodyHeight}
86
- flexGrow={bodyHeight == null ? 1 : 0}
87
- flexBasis={bodyHeight == null ? 0 : undefined}
92
+ {...bodyLayoutProps}
88
93
  overflow="hidden"
94
+ backgroundColor={bg}
89
95
  >
90
96
  {children}
91
97
  </Box>