gloomberb 0.6.1 → 0.7.1

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 (208) hide show
  1. package/package.json +1 -1
  2. package/patches/@opentui%2Fcore@0.1.90.patch +48 -24
  3. package/src/app.tsx +158 -57
  4. package/src/capabilities/factories.ts +3 -0
  5. package/src/cli/registry.test.ts +0 -1
  6. package/src/components/chart/chart-controller.ts +0 -1
  7. package/src/components/chart/chart-data.ts +1 -31
  8. package/src/components/chart/chart-renderer.ts +94 -12
  9. package/src/components/chart/chart-resolution.test.ts +0 -36
  10. package/src/components/chart/chart-resolution.ts +0 -27
  11. package/src/components/chart/chart.test.ts +60 -45
  12. package/src/components/chart/comparison-chart-data.test.ts +1 -5
  13. package/src/components/chart/comparison-chart-data.ts +1 -4
  14. package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
  15. package/src/components/chart/comparison-chart-renderer.ts +13 -6
  16. package/src/components/chart/comparison-stock-chart.tsx +63 -39
  17. package/src/components/chart/price-axis-labels.tsx +114 -0
  18. package/src/components/chart/static-chart-surface.tsx +30 -4
  19. package/src/components/chart/stock-chart.test.ts +0 -16
  20. package/src/components/chart/stock-chart.tsx +69 -41
  21. package/src/components/chart/time-axis-label.tsx +120 -0
  22. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
  23. package/src/components/command-bar/command-bar.test.tsx +182 -36
  24. package/src/components/command-bar/command-bar.tsx +943 -1016
  25. package/src/components/command-bar/command-registry.ts +0 -10
  26. package/src/components/command-bar/confirm-body.tsx +51 -0
  27. package/src/components/command-bar/helpers.ts +2 -5
  28. package/src/components/command-bar/list-model.ts +76 -0
  29. package/src/components/command-bar/theme-picker.tsx +254 -0
  30. package/src/components/command-bar/view-model.test.ts +4 -4
  31. package/src/components/command-bar/view-model.ts +1 -20
  32. package/src/components/command-bar/workflow-ops.ts +4 -0
  33. package/src/components/command-bar/workflow-types.ts +1 -2
  34. package/src/components/data-table-stack-view.test.tsx +39 -4
  35. package/src/components/data-table-stack-view.tsx +11 -104
  36. package/src/components/data-table-view.tsx +43 -79
  37. package/src/components/feed-data-table-stack-view.tsx +10 -17
  38. package/src/components/feed-stack-controller.ts +39 -0
  39. package/src/components/index.ts +5 -1
  40. package/src/components/layout/detached-pane-shell.tsx +29 -10
  41. package/src/components/layout/floating-pane.tsx +35 -4
  42. package/src/components/layout/header.tsx +12 -7
  43. package/src/components/layout/pane-content.test.tsx +64 -0
  44. package/src/components/layout/pane-content.tsx +2 -0
  45. package/src/components/layout/pane-cycle-order.test.ts +30 -0
  46. package/src/components/layout/pane-cycle-order.ts +32 -0
  47. package/src/components/layout/pane-footer.tsx +11 -4
  48. package/src/components/layout/pane-header.tsx +5 -5
  49. package/src/components/layout/pane-sizing.ts +9 -2
  50. package/src/components/layout/pane-title.ts +4 -1
  51. package/src/components/layout/pane.tsx +11 -5
  52. package/src/components/layout/shell.test.tsx +264 -22
  53. package/src/components/layout/shell.tsx +46 -8
  54. package/src/components/layout/status-bar.tsx +2 -0
  55. package/src/components/onboarding/onboarding-wizard.tsx +1 -2
  56. package/src/components/pane-settings-dialog.tsx +12 -4
  57. package/src/components/pane-template-wizard.test.tsx +3 -4
  58. package/src/components/speedometer-gauge.test.tsx +45 -0
  59. package/src/components/speedometer-gauge.tsx +562 -0
  60. package/src/components/table-view-shared.tsx +218 -0
  61. package/src/components/ticker-list-table-view.tsx +57 -73
  62. package/src/components/ticker-list-table.test.tsx +82 -1
  63. package/src/components/ticker-list-table.tsx +22 -9
  64. package/src/components/ui/confirm-dialog.tsx +66 -0
  65. package/src/components/ui/data-table.tsx +30 -19
  66. package/src/components/ui/frame.tsx +2 -0
  67. package/src/components/ui/index.ts +0 -1
  68. package/src/components/ui/page-stack-view.tsx +39 -10
  69. package/src/components/ui/table-layout.test.ts +42 -0
  70. package/src/components/ui/table-layout.ts +49 -4
  71. package/src/components/ui/tabs.tsx +34 -1
  72. package/src/components/ui/ui.test.tsx +168 -21
  73. package/src/core/state/app-state.ts +10 -17
  74. package/src/market-data/coordinator.test.ts +76 -1
  75. package/src/market-data/coordinator.ts +218 -49
  76. package/src/news/aggregator.test.ts +49 -1
  77. package/src/news/aggregator.ts +48 -3
  78. package/src/news/hooks.ts +6 -2
  79. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +46 -16
  80. package/src/plugins/builtin/ai/providers.ts +0 -17
  81. package/src/plugins/builtin/ai/screener-pane.test.tsx +3 -4
  82. package/src/plugins/builtin/ai/screener-pane.tsx +3 -5
  83. package/src/plugins/builtin/analytics/index.tsx +1 -18
  84. package/src/plugins/builtin/ask-ai.test.tsx +89 -2
  85. package/src/plugins/builtin/broker-manager/index.test.tsx +2 -2
  86. package/src/plugins/builtin/broker-manager/index.tsx +19 -36
  87. package/src/plugins/builtin/chat-controller.test.ts +188 -1
  88. package/src/plugins/builtin/chat-controller.ts +537 -220
  89. package/src/plugins/builtin/chat.test.tsx +431 -13
  90. package/src/plugins/builtin/chat.tsx +1134 -227
  91. package/src/plugins/builtin/cloud-tweets.tsx +1 -1
  92. package/src/plugins/builtin/comparison-chart.test.tsx +53 -2
  93. package/src/plugins/builtin/comparison-chart.tsx +11 -5
  94. package/src/plugins/builtin/correlation/index.tsx +1 -6
  95. package/src/plugins/builtin/debug.tsx +1 -3
  96. package/src/plugins/builtin/econ/index.tsx +1 -2
  97. package/src/plugins/builtin/fear-greed/fear-greed-data.test.ts +72 -0
  98. package/src/plugins/builtin/fear-greed/fear-greed-data.ts +366 -0
  99. package/src/plugins/builtin/fear-greed/index.tsx +587 -0
  100. package/src/plugins/builtin/fx-matrix/index.tsx +2 -2
  101. package/src/plugins/builtin/help.test.tsx +2 -2
  102. package/src/plugins/builtin/help.tsx +2 -2
  103. package/src/plugins/builtin/holders/index.test.ts +13 -1
  104. package/src/plugins/builtin/holders/index.tsx +64 -29
  105. package/src/plugins/builtin/insider/index.tsx +22 -89
  106. package/src/plugins/builtin/market-movers/index.tsx +13 -19
  107. package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
  108. package/src/plugins/builtin/market-movers/screener.ts +70 -17
  109. package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
  110. package/src/plugins/builtin/news-wire/index.tsx +41 -2
  111. package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
  112. package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
  113. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
  114. package/src/plugins/builtin/news-wire/news-table.tsx +11 -11
  115. package/src/plugins/builtin/news.tsx +4 -16
  116. package/src/plugins/builtin/notes.tsx +19 -55
  117. package/src/plugins/builtin/options.test.tsx +58 -2
  118. package/src/plugins/builtin/options.tsx +110 -65
  119. package/src/plugins/builtin/plugin-groups.ts +2 -0
  120. package/src/plugins/builtin/portfolio-list/cli/commands.ts +1 -1
  121. package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
  122. package/src/plugins/builtin/research/index.test.ts +94 -0
  123. package/src/plugins/builtin/research/index.tsx +164 -33
  124. package/src/plugins/builtin/sec-filing-display.tsx +84 -0
  125. package/src/plugins/builtin/sec.tsx +13 -66
  126. package/src/plugins/builtin/sectors/index.tsx +1 -1
  127. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +0 -1
  128. package/src/plugins/builtin/ticker-detail/index.tsx +0 -1
  129. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +0 -1
  130. package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
  131. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +3 -4
  132. package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
  133. package/src/plugins/builtin/world-indices/index.tsx +1 -1
  134. package/src/plugins/builtin/yield-curve/index.tsx +1 -1
  135. package/src/plugins/ibkr/broker-adapter.ts +1 -3
  136. package/src/plugins/ibkr/gateway-service-native.ts +1 -5
  137. package/src/plugins/ibkr/gateway-service.ts +1 -1
  138. package/src/plugins/ibkr/index.test.tsx +0 -2
  139. package/src/plugins/ibkr/trade-tab.test.tsx +3 -4
  140. package/src/plugins/pane-manager.ts +0 -13
  141. package/src/plugins/plugin-runtime.test.tsx +40 -6
  142. package/src/plugins/plugin-runtime.tsx +20 -10
  143. package/src/plugins/prediction-markets/cli.ts +0 -1
  144. package/src/plugins/prediction-markets/controller-data.ts +0 -2
  145. package/src/plugins/prediction-markets/controller.ts +0 -15
  146. package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
  147. package/src/plugins/prediction-markets/pane.test.tsx +3 -11
  148. package/src/plugins/prediction-markets/test-helpers.tsx +1 -1
  149. package/src/plugins/registry.ts +13 -42
  150. package/src/plugins/ticker-navigation.test.ts +66 -0
  151. package/src/plugins/ticker-navigation.ts +44 -0
  152. package/src/react/input.ts +2 -1
  153. package/src/renderers/electrobun/bun/index.ts +32 -11
  154. package/src/renderers/electrobun/bun/window-focus.ts +37 -0
  155. package/src/renderers/electrobun/shared/protocol.ts +7 -1
  156. package/src/renderers/electrobun/view/app-services.ts +88 -31
  157. package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
  158. package/src/renderers/electrobun/view/data-table.tsx +138 -101
  159. package/src/renderers/electrobun/view/desktop-controls.tsx +42 -20
  160. package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
  161. package/src/renderers/electrobun/view/dialog-host.tsx +13 -1
  162. package/src/renderers/electrobun/view/input-host.tsx +74 -10
  163. package/src/renderers/electrobun/view/key-event.ts +1 -1
  164. package/src/renderers/electrobun/view/main.tsx +18 -1
  165. package/src/renderers/electrobun/view/styles.css +329 -0
  166. package/src/renderers/electrobun/view/toast-host.tsx +29 -3
  167. package/src/renderers/electrobun/view/ui-host.tsx +42 -0
  168. package/src/renderers/opentui/chart-surface.tsx +179 -0
  169. package/src/renderers/opentui/input-host.tsx +1 -1
  170. package/src/renderers/opentui/start.tsx +48 -30
  171. package/src/renderers/opentui/test-utils.tsx +17 -0
  172. package/src/renderers/opentui/ui-host.tsx +5 -2
  173. package/src/sources/gloomberb-cloud.test.ts +86 -1
  174. package/src/sources/gloomberb-cloud.ts +117 -21
  175. package/src/sources/provider-router.test.ts +122 -0
  176. package/src/sources/provider-router.ts +198 -15
  177. package/src/sources/yahoo-finance.test.ts +44 -0
  178. package/src/sources/yahoo-finance.ts +6 -0
  179. package/src/state/app-bootstrap.ts +20 -7
  180. package/src/state/app-context-selector.test.tsx +189 -4
  181. package/src/state/app-context.test.ts +65 -0
  182. package/src/state/app-context.tsx +48 -16
  183. package/src/state/use-inline-tickers.test.tsx +73 -0
  184. package/src/state/use-inline-tickers.ts +19 -10
  185. package/src/test-support/plugin-runtime.ts +3 -2
  186. package/src/theme/colors.test.ts +75 -0
  187. package/src/theme/colors.ts +76 -6
  188. package/src/theme/theme-context.tsx +26 -0
  189. package/src/theme/themes.test.ts +23 -1
  190. package/src/theme/themes.ts +115 -0
  191. package/src/types/broker.ts +1 -1
  192. package/src/types/capability-route-source.ts +1 -0
  193. package/src/types/data-provider.ts +15 -2
  194. package/src/types/desktop-window.ts +6 -0
  195. package/src/types/financials.ts +2 -0
  196. package/src/types/plugin.ts +1 -3
  197. package/src/ui/host.tsx +7 -0
  198. package/src/utils/api-client.test.ts +32 -0
  199. package/src/utils/api-client.ts +119 -18
  200. package/src/utils/back-navigation.ts +24 -1
  201. package/src/utils/double-escape-close.test.ts +33 -0
  202. package/src/utils/double-escape-close.ts +40 -0
  203. package/src/utils/throttled-fetch.test.ts +22 -0
  204. package/src/utils/throttled-fetch.ts +3 -1
  205. package/src/utils/ticker-search.ts +53 -9
  206. package/src/version.ts +1 -1
  207. package/src/plugins/builtin/news-wire/feed-pane.tsx +0 -21
  208. package/src/plugins/builtin/news-wire/top-pane.tsx +0 -21
@@ -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();
@@ -0,0 +1,51 @@
1
+ import { Box, Text } from "../../ui";
2
+ import { colors } from "../../theme/colors";
3
+ import { Button, Spinner } from "../ui";
4
+ import { truncateText } from "./view-model";
5
+ import type { CommandBarConfirmRoute } from "./workflow-types";
6
+
7
+ export function CommandBarConfirmBody({
8
+ route,
9
+ bodyHeight,
10
+ contentPadding,
11
+ paletteText,
12
+ queryDisplayWidth,
13
+ onConfirm,
14
+ }: {
15
+ route: CommandBarConfirmRoute;
16
+ bodyHeight: number;
17
+ contentPadding: number;
18
+ paletteText: string;
19
+ queryDisplayWidth: number;
20
+ onConfirm: () => void;
21
+ }) {
22
+ return (
23
+ <Box flexDirection="column" height={bodyHeight} paddingX={contentPadding}>
24
+ {route.body.map((line, index) => (
25
+ <Box key={`confirm:${index}`} height={1}>
26
+ <Text fg={paletteText}>{truncateText(line, queryDisplayWidth)}</Text>
27
+ </Box>
28
+ ))}
29
+ <Box height={1} />
30
+ {route.error && (
31
+ <Box height={1}>
32
+ <Text fg={colors.negative}>{truncateText(route.error, queryDisplayWidth)}</Text>
33
+ </Box>
34
+ )}
35
+ {route.pending && (
36
+ <Box height={1}>
37
+ <Spinner label="Working…" />
38
+ </Box>
39
+ )}
40
+ <Box flexGrow={1} />
41
+ <Box flexDirection="row" gap={1}>
42
+ <Button
43
+ label={route.confirmLabel}
44
+ variant={route.tone === "danger" ? "danger" : "primary"}
45
+ onPress={onConfirm}
46
+ disabled={route.pending}
47
+ />
48
+ </Box>
49
+ </Box>
50
+ );
51
+ }
@@ -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":
@@ -0,0 +1,76 @@
1
+ import { buildSections } from "./view-model";
2
+
3
+ export interface ResultItem {
4
+ id: string;
5
+ label: string;
6
+ detail: string;
7
+ category: string;
8
+ kind: "command" | "ticker" | "search" | "plugin" | "action" | "info";
9
+ right?: string;
10
+ searchText?: string;
11
+ pluginToggle?: () => void | Promise<void>;
12
+ secondaryAction?: () => void | Promise<void>;
13
+ checked?: boolean;
14
+ current?: boolean;
15
+ disabled?: boolean;
16
+ action: () => void | Promise<void>;
17
+ }
18
+
19
+ type ListScreenKind = "root" | "mode" | "picker" | "pane-settings";
20
+
21
+ export interface ListScreenState {
22
+ kind: ListScreenKind;
23
+ title: string;
24
+ subtitle?: string;
25
+ query: string;
26
+ selectedIdx: number;
27
+ hoveredIdx: number | null;
28
+ results: ResultItem[];
29
+ searching: boolean;
30
+ emptyLabel: string;
31
+ emptyDetail: string;
32
+ footerLeft: string;
33
+ footerRight: string;
34
+ }
35
+
36
+ export type CommandBarListRow =
37
+ | { kind: "spacer"; id: string }
38
+ | { kind: "heading"; id: string; label: string }
39
+ | { kind: "item"; item: ResultItem; globalIdx: number }
40
+ | { kind: "message"; id: string; label: string; dim?: boolean }
41
+ | { kind: "spinner"; id: string; label: string }
42
+ | { kind: "filler"; id: string };
43
+
44
+ export function orderListResults(results: ResultItem[]): ResultItem[] {
45
+ return buildSections(results).flatMap((section) => section.items);
46
+ }
47
+
48
+ export function buildListRows(listState: ListScreenState): CommandBarListRow[] {
49
+ const rows: CommandBarListRow[] = [];
50
+ const sections = buildSections(listState.results);
51
+ let globalIdx = 0;
52
+ sections.forEach((section, sectionIndex) => {
53
+ if (sectionIndex > 0) {
54
+ rows.push({ kind: "spacer", id: `spacer:${sectionIndex}:${section.category}` });
55
+ }
56
+ rows.push({ kind: "heading", id: `heading:${sectionIndex}:${section.category}`, label: section.category });
57
+ for (const item of section.items) {
58
+ rows.push({ kind: "item", item, globalIdx });
59
+ globalIdx += 1;
60
+ }
61
+ });
62
+ return rows;
63
+ }
64
+
65
+ export function buildNativeListRows(listState: ListScreenState, rows: CommandBarListRow[]): CommandBarListRow[] {
66
+ if (listState.searching && rows.length === 0) {
67
+ return [{ kind: "spinner", id: "searching", label: "Searching…" }];
68
+ }
69
+ if (rows.length === 0) {
70
+ return [{ kind: "message", id: "empty", label: listState.emptyLabel }];
71
+ }
72
+ if (listState.searching) {
73
+ return [...rows, { kind: "spinner", id: "searching", label: "Searching…" }];
74
+ }
75
+ return rows;
76
+ }
@@ -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({
@@ -10,11 +10,6 @@ export interface CommandBarModeInfo {
10
10
  hint: string;
11
11
  }
12
12
 
13
- export interface CommandBarFooterHints {
14
- left: string;
15
- right: string;
16
- }
17
-
18
13
  export interface CommandBarSection<T> {
19
14
  category: string;
20
15
  items: T[];
@@ -25,7 +20,7 @@ export interface CommandBarItemView {
25
20
  label: string;
26
21
  detail: string;
27
22
  category: string;
28
- kind: "command" | "ticker" | "search" | "theme" | "plugin" | "action" | "info";
23
+ kind: "command" | "ticker" | "search" | "plugin" | "action" | "info";
29
24
  right?: string;
30
25
  checked?: boolean;
31
26
  current?: boolean;
@@ -83,20 +78,6 @@ export function buildSections<T extends { category: string }>(items: T[]): Array
83
78
  .map(({ section }) => section);
84
79
  }
85
80
 
86
- export function getFooterHints(mode: CommandBarMode, isNarrow: boolean): CommandBarFooterHints {
87
- const moveAndSelect = isNarrow ? "up/down move enter select" : "up/down move enter select";
88
- if (mode === "plugins") {
89
- return {
90
- left: isNarrow ? "space toggle" : `${moveAndSelect} space toggle`,
91
- right: "esc cancel",
92
- };
93
- }
94
- if (mode === "layout") {
95
- return { left: moveAndSelect, right: "esc cancel" };
96
- }
97
- return { left: moveAndSelect, right: "esc cancel" };
98
- }
99
-
100
81
  export function getEmptyState(mode: CommandBarMode, query: string, searchQuery?: string): { label: string; detail: string } {
101
82
  switch (mode) {
102
83
  case "search":
@@ -14,6 +14,7 @@ import { cleanPortfolioPaneSettings, resolvePortfolioPaneCollectionId } from "..
14
14
  import {
15
15
  buildComparisonChartPaneTitle,
16
16
  COMPARISON_CHART_PANE_ID,
17
+ MIN_COMPARISON_CHART_SYMBOLS,
17
18
  } from "../../plugins/builtin/comparison-chart";
18
19
  import { getPaneTemplateDisplayLabel } from "./pane-template-display";
19
20
 
@@ -415,6 +416,9 @@ export async function applyPaneSettingFieldValue(
415
416
  descriptor.context.activeCollectionId,
416
417
  deps,
417
418
  );
419
+ if (symbols.length < MIN_COMPARISON_CHART_SYMBOLS) {
420
+ throw new Error(`Enter at least ${MIN_COMPARISON_CHART_SYMBOLS} tickers.`);
421
+ }
418
422
  const nextLayout = updatePaneInstance(state.config.layout, targetId, (instance) => ({
419
423
  ...instance,
420
424
  title: buildComparisonChartPaneTitle(symbols),
@@ -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
 
@@ -160,6 +160,13 @@ async function emitKeypress(event: { name?: string; sequence?: string }) {
160
160
  });
161
161
  }
162
162
 
163
+ async function emitTerminalMouseBack(x: number, y: number) {
164
+ await act(async () => {
165
+ await testSetup!.mockMouse.emitMouseEvent("down", x, y, 128 as any);
166
+ await testSetup!.renderOnce();
167
+ });
168
+ }
169
+
163
170
  describe("DataTableStackView", () => {
164
171
  test("owns table navigation, detail open, and back navigation", async () => {
165
172
  testSetup = await testRender(<Harness />, { width: 60, height: 12 });
@@ -179,16 +186,44 @@ describe("DataTableStackView", () => {
179
186
 
180
187
  await emitKeypress({ name: "escape", sequence: "\u001b" });
181
188
  await renderSettled();
182
- expect(testSetup.captureCharFrame()).toContain("Second detail");
183
-
184
- await emitKeypress({ name: "backspace", sequence: "\u007f" });
185
- await renderSettled();
186
189
 
187
190
  const rootFrame = testSetup.captureCharFrame();
188
191
  expect(rootFrame).toContain("Second row");
189
192
  expect(rootFrame).not.toContain("Second detail");
190
193
  });
191
194
 
195
+ test("closes detail from backspace", async () => {
196
+ testSetup = await testRender(<Harness />, { width: 60, height: 12 });
197
+
198
+ await renderSettled();
199
+ await emitKeypress({ name: "enter", sequence: "\r" });
200
+ await renderSettled();
201
+ expect(testSetup.captureCharFrame()).toContain("First detail");
202
+
203
+ await emitKeypress({ name: "backspace", sequence: "\u007f" });
204
+ await renderSettled();
205
+
206
+ const frame = testSetup.captureCharFrame();
207
+ expect(frame).toContain("First row");
208
+ expect(frame).not.toContain("First detail");
209
+ });
210
+
211
+ test("closes detail from the terminal mouse back button", async () => {
212
+ testSetup = await testRender(<Harness />, { width: 60, height: 12 });
213
+
214
+ await renderSettled();
215
+ await emitKeypress({ name: "enter", sequence: "\r" });
216
+ await renderSettled();
217
+ expect(testSetup.captureCharFrame()).toContain("First detail");
218
+
219
+ await emitTerminalMouseBack(30, 4);
220
+ await renderSettled();
221
+
222
+ const frame = testSetup.captureCharFrame();
223
+ expect(frame).toContain("First row");
224
+ expect(frame).not.toContain("First detail");
225
+ });
226
+
192
227
  test("skips section rows while navigating through the stack view", async () => {
193
228
  testSetup = await testRender(<GroupedHarness />, { width: 60, height: 12 });
194
229