gloomberb 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +9 -7
  2. package/package.json +14 -13
  3. package/patches/{@opentui%2Fcore@0.1.90.patch → @opentui%2Fcore@0.3.2.patch} +175 -90
  4. package/src/app/global-shortcuts.test.tsx +54 -4
  5. package/src/app/global-shortcuts.ts +11 -5
  6. package/src/components/command-bar/action-runtime.ts +1 -0
  7. package/src/components/command-bar/layout-items/current-layout.test.ts +115 -0
  8. package/src/components/command-bar/layout-items/current-layout.ts +33 -0
  9. package/src/components/command-bar/list/view.tsx +3 -1
  10. package/src/components/command-bar/pane-templates/items.ts +4 -1
  11. package/src/components/command-bar/panel/body.tsx +1 -0
  12. package/src/components/command-bar/routes/root/results.ts +0 -30
  13. package/src/components/command-bar/routes/root/runtime.ts +2 -20
  14. package/src/components/command-bar/routes/ticker-search/actions.ts +42 -4
  15. package/src/components/command-bar/routes/ticker-search/route.ts +4 -1
  16. package/src/components/command-bar/routing/launch-request.ts +14 -7
  17. package/src/components/command-bar/routing/list-state.ts +1 -3
  18. package/src/components/command-bar/surface/index.test.tsx +76 -4
  19. package/src/components/command-bar/surface/index.tsx +0 -2
  20. package/src/components/command-bar/surface/pane.test.tsx +1 -0
  21. package/src/components/command-bar/surface/test-harness.tsx +1 -0
  22. package/src/components/command-bar/view-model.ts +3 -3
  23. package/src/components/layout/shell/index.test.tsx +120 -2
  24. package/src/components/layout/shell/index.tsx +2 -0
  25. package/src/components/layout/shell/menu.ts +1 -7
  26. package/src/components/layout/shell/pane/actions.ts +8 -0
  27. package/src/components/layout/shell/pane/management-shortcuts.ts +12 -2
  28. package/src/components/layout/shell/shortcuts.ts +14 -6
  29. package/src/components/layout/shell/window-mode/index.ts +8 -3
  30. package/src/components/layout/window-edit/presentation.ts +4 -4
  31. package/src/components/onboarding/onboarding-steps.tsx +3 -2
  32. package/src/components/ticker/list-table-view.tsx +6 -0
  33. package/src/components/ui/remote-image.tsx +1 -14
  34. package/src/components/ui/tabs.tsx +95 -80
  35. package/src/components/ui/ui.test.tsx +54 -0
  36. package/src/core/state/app/types.ts +12 -4
  37. package/src/plugins/builtin/chat/content/channel-navigation.ts +44 -11
  38. package/src/plugins/builtin/chat/content/index.tsx +5 -4
  39. package/src/plugins/builtin/chat/content/shortcuts.ts +7 -0
  40. package/src/plugins/builtin/chat/sidebar.test.tsx +71 -0
  41. package/src/plugins/builtin/help/components.tsx +1 -1
  42. package/src/plugins/builtin/help/index.test.tsx +105 -30
  43. package/src/plugins/builtin/help/index.tsx +324 -168
  44. package/src/plugins/builtin/help/shortcut-model.ts +5 -6
  45. package/src/plugins/builtin/portfolio-list/column-values.ts +304 -1
  46. package/src/plugins/builtin/portfolio-list/metrics.test.ts +93 -0
  47. package/src/plugins/builtin/portfolio-list/pane/data.ts +7 -0
  48. package/src/plugins/builtin/portfolio-list/pane/index.tsx +25 -2
  49. package/src/plugins/builtin/portfolio-list/pane/supplemental.ts +281 -0
  50. package/src/plugins/builtin/portfolio-list/settings.ts +27 -0
  51. package/src/plugins/builtin/portfolio-list/table.tsx +4 -2
  52. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +10 -68
  53. package/src/plugins/builtin/substack/api/auth.ts +185 -0
  54. package/src/plugins/builtin/substack/api/cache.ts +78 -0
  55. package/src/plugins/builtin/substack/api/loaders.ts +146 -0
  56. package/src/plugins/builtin/substack/api/store.ts +186 -0
  57. package/src/plugins/builtin/substack/api/types.ts +40 -0
  58. package/src/plugins/builtin/substack/api.test.ts +298 -0
  59. package/src/plugins/builtin/substack/article-detail.tsx +359 -0
  60. package/src/plugins/builtin/substack/article-stack.tsx +129 -0
  61. package/src/plugins/builtin/substack/content.ts +478 -0
  62. package/src/plugins/builtin/substack/feed-tabs.tsx +41 -0
  63. package/src/plugins/builtin/substack/index.tsx +49 -0
  64. package/src/plugins/builtin/substack/login-view.tsx +126 -0
  65. package/src/plugins/builtin/substack/model.test.ts +155 -0
  66. package/src/plugins/builtin/substack/normalize.ts +343 -0
  67. package/src/plugins/builtin/substack/pane-footer.ts +71 -0
  68. package/src/plugins/builtin/substack/pane-state.ts +170 -0
  69. package/src/plugins/builtin/substack/pane.tsx +500 -0
  70. package/src/plugins/builtin/substack/table.ts +111 -0
  71. package/src/plugins/builtin/substack/types.ts +71 -0
  72. package/src/plugins/builtin/substack/utils.ts +131 -0
  73. package/src/plugins/builtin/ticker-detail/index.test.tsx +16 -2
  74. package/src/plugins/builtin/ticker-detail/index.tsx +1 -0
  75. package/src/plugins/builtin/ticker-detail/pane.tsx +13 -1
  76. package/src/plugins/catalog-ui.ts +2 -0
  77. package/src/plugins/pane-manager/layout-state.ts +11 -0
  78. package/src/plugins/pane-manager.ts +1 -1
  79. package/src/react/use-throttled-commit-value.ts +116 -0
  80. package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +42 -0
  81. package/src/renderers/electrobun/bun/desktop/http-fetch.ts +5 -0
  82. package/src/renderers/electrobun/view/http-fetch.ts +10 -3
  83. package/src/state/app/context/index.test.tsx +16 -0
  84. package/src/tickers/search/index.ts +1 -0
  85. package/src/version.ts +1 -1
@@ -44,26 +44,38 @@ function createRegistry(shortcutExecute?: () => void): PluginRegistry {
44
44
 
45
45
  function ShortcutHarness({
46
46
  dispatch,
47
+ focusedTickerSymbol = null,
47
48
  pluginRegistry,
49
+ refreshTicker = () => {},
48
50
  state,
49
51
  }: {
50
52
  dispatch: (action: AppAction) => void;
53
+ focusedTickerSymbol?: string | null;
51
54
  pluginRegistry: PluginRegistry;
55
+ refreshTicker?: (symbol: string, exchange?: string, tickerOverride?: any, priority?: number) => void;
52
56
  state: AppState;
53
57
  }) {
54
58
  useAppGlobalShortcuts({
55
59
  dispatch,
56
- focusedTickerSymbol: null,
60
+ focusedTickerSymbol,
57
61
  isDetachedWindow: false,
58
62
  pluginRegistry,
59
- refreshTicker: () => {},
63
+ refreshTicker,
60
64
  startUpdate: () => {},
61
65
  state,
62
66
  });
63
67
  return <text>ready</text>;
64
68
  }
65
69
 
66
- async function renderHarness(state: AppState, registry: PluginRegistry, dispatch: (action: AppAction) => void) {
70
+ async function renderHarness(
71
+ state: AppState,
72
+ registry: PluginRegistry,
73
+ dispatch: (action: AppAction) => void,
74
+ options: {
75
+ focusedTickerSymbol?: string | null;
76
+ refreshTicker?: (symbol: string, exchange?: string, tickerOverride?: any, priority?: number) => void;
77
+ } = {},
78
+ ) {
67
79
  testSetup = await createTestRenderer({ width: 40, height: 8 });
68
80
  root = createRoot(testSetup.renderer);
69
81
  act(() => {
@@ -71,14 +83,16 @@ async function renderHarness(state: AppState, registry: PluginRegistry, dispatch
71
83
  <TestDialogProvider>
72
84
  <ShortcutHarness
73
85
  dispatch={dispatch}
86
+ focusedTickerSymbol={options.focusedTickerSymbol}
74
87
  pluginRegistry={registry}
88
+ refreshTicker={options.refreshTicker}
75
89
  state={state}
76
90
  />
77
91
  </TestDialogProvider>,
78
92
  );
79
93
  });
80
94
  await act(async () => {
81
- await testSetup.renderOnce();
95
+ await testSetup!.renderOnce();
82
96
  });
83
97
  }
84
98
 
@@ -121,6 +135,23 @@ describe("useAppGlobalShortcuts", () => {
121
135
  expect(event.propagationStopped).toBe(true);
122
136
  });
123
137
 
138
+ test("opens ticker search with backtick", async () => {
139
+ const actions: AppAction[] = [];
140
+ const state = createInitialState(createDefaultConfig("/tmp/gloomberb-global-shortcuts"));
141
+ await renderHarness(state, createRegistry(), (action) => actions.push(action));
142
+
143
+ const event = await emitKeypress({ name: "`" });
144
+
145
+ expect(actions).toEqual([{
146
+ type: "SET_COMMAND_BAR",
147
+ open: true,
148
+ query: "",
149
+ launch: { kind: "ticker-search", query: "" },
150
+ }]);
151
+ expect(event.defaultPrevented).toBe(true);
152
+ expect(event.propagationStopped).toBe(true);
153
+ });
154
+
124
155
  test("does not run plain plugin shortcuts while input is captured", async () => {
125
156
  let executed = 0;
126
157
  const actions: AppAction[] = [];
@@ -163,4 +194,23 @@ describe("useAppGlobalShortcuts", () => {
163
194
  expect(event.defaultPrevented).toBe(true);
164
195
  expect(event.propagationStopped).toBe(true);
165
196
  });
197
+
198
+ test("does not treat modified Shift-R as force refresh", async () => {
199
+ const refreshes: Array<{ symbol: string; priority?: number }> = [];
200
+ const state = createInitialState(createDefaultConfig("/tmp/gloomberb-global-shortcuts-resize"));
201
+ state.tickers.set("AAPL", {
202
+ metadata: { ticker: "AAPL", exchange: "NASDAQ" },
203
+ } as any);
204
+ await renderHarness(state, createRegistry(), () => {}, {
205
+ refreshTicker: (symbol, _exchange, _ticker, priority) => {
206
+ refreshes.push({ symbol, priority });
207
+ },
208
+ });
209
+
210
+ const event = await emitKeypress({ name: "r", ctrl: true, shift: true });
211
+
212
+ expect(refreshes).toEqual([]);
213
+ expect(event.defaultPrevented).toBe(false);
214
+ expect(event.propagationStopped).toBe(false);
215
+ });
166
216
  });
@@ -62,7 +62,12 @@ export function useAppGlobalShortcuts({
62
62
  if (!isDetachedWindow && event.name === "`" && !state.commandBarOpen) {
63
63
  event.preventDefault();
64
64
  event.stopPropagation();
65
- dispatch({ type: "SET_COMMAND_BAR", open: true, query: "" });
65
+ dispatch({
66
+ type: "SET_COMMAND_BAR",
67
+ open: true,
68
+ query: "",
69
+ launch: { kind: "ticker-search", query: "" },
70
+ });
66
71
  return;
67
72
  }
68
73
  if (!isDetachedWindow && /^[1-9]$/.test(event.name ?? "") && event.ctrl && (state.config.layouts ?? []).length > 1) {
@@ -96,18 +101,19 @@ export function useAppGlobalShortcuts({
96
101
 
97
102
  if (state.inputCaptured) return;
98
103
 
99
- if (!isDetachedWindow && event.name === "q") {
104
+ const hasShortcutModifier = event.ctrl || event.meta || event.super || event.alt;
105
+ if (!hasShortcutModifier && !isDetachedWindow && event.name === "q") {
100
106
  rendererHost.requestExit();
101
- } else if (event.name === "r") {
107
+ } else if (!hasShortcutModifier && event.name === "r") {
102
108
  if (focusedTickerSymbol) {
103
109
  const ticker = state.tickers.get(focusedTickerSymbol);
104
110
  if (ticker) refreshTicker(ticker.metadata.ticker, ticker.metadata.exchange, ticker, 0);
105
111
  }
106
- } else if (event.name === "R" || (event.name === "r" && event.shift)) {
112
+ } else if (!hasShortcutModifier && (event.name === "R" || (event.name === "r" && event.shift))) {
107
113
  for (const ticker of state.tickers.values()) {
108
114
  refreshTicker(ticker.metadata.ticker, ticker.metadata.exchange, ticker, 1);
109
115
  }
110
- } else if (event.name === "u" && state.updateAvailable && !state.updateProgress && !state.updateCheckInProgress && canSelfUpdate(state.updateAvailable)) {
116
+ } else if (!hasShortcutModifier && event.name === "u" && state.updateAvailable && !state.updateProgress && !state.updateCheckInProgress && canSelfUpdate(state.updateAvailable)) {
111
117
  startUpdate(state.updateAvailable);
112
118
  } else {
113
119
  const disabledPlugins = new Set(state.config.disabledPlugins || []);
@@ -229,6 +229,7 @@ export function useCommandBarActionRuntime({
229
229
  activeTickerSymbol,
230
230
  commandBarLaunchRequest: state.commandBarLaunchRequest,
231
231
  commandBarOpen: state.commandBarOpen,
232
+ openModeRoute,
232
233
  openPluginCommandWorkflow,
233
234
  pluginRegistry,
234
235
  });
@@ -0,0 +1,115 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createInitialState, type AppAction, type AppState } from "../../../state/app/context";
3
+ import { cloneLayout, createDefaultConfig, type LayoutConfig } from "../../../types/config";
4
+ import type { PluginRegistry } from "../../../plugins/registry";
5
+ import type { CommandBarRoute } from "../workflow/types";
6
+ import type { LayoutItemsContext } from "./types";
7
+ import { buildCurrentLayoutItems } from "./current-layout";
8
+
9
+ type InlineConfirmOptions = Parameters<LayoutItemsContext["openInlineConfirm"]>[0];
10
+
11
+ function createFloatingLayoutFixture(): { layout: LayoutConfig; state: AppState } {
12
+ const config = createDefaultConfig("/tmp/gloomberb-layout-actions-test");
13
+ const mainPane = config.layout.instances.find((instance) => instance.instanceId === "portfolio-list:main");
14
+ const firstDetailPane = config.layout.instances.find((instance) => instance.instanceId === "ticker-detail:main");
15
+ if (!mainPane || !firstDetailPane) throw new Error("missing default panes");
16
+
17
+ const secondDetailPane = {
18
+ ...firstDetailPane,
19
+ instanceId: "ticker-detail:secondary",
20
+ };
21
+ const layout: LayoutConfig = {
22
+ dockRoot: { kind: "pane", instanceId: "portfolio-list:main" },
23
+ instances: [{ ...mainPane }, { ...firstDetailPane }, secondDetailPane],
24
+ floating: [
25
+ { instanceId: "ticker-detail:main", x: 4, y: 2, width: 30, height: 8 },
26
+ { instanceId: "ticker-detail:secondary", x: 8, y: 3, width: 30, height: 8 },
27
+ ],
28
+ detached: [],
29
+ };
30
+ const state = createInitialState({
31
+ ...config,
32
+ layout: cloneLayout(layout),
33
+ layouts: [{ name: "Default", layout: cloneLayout(layout) }],
34
+ });
35
+ return { layout, state };
36
+ }
37
+
38
+ function createLayoutItemsContext(
39
+ options: {
40
+ confirmDangerousActions?: boolean;
41
+ layouts: LayoutConfig[];
42
+ confirmations: InlineConfirmOptions[];
43
+ },
44
+ ): LayoutItemsContext {
45
+ const { layout, state } = createFloatingLayoutFixture();
46
+ return {
47
+ closeAll: () => {},
48
+ currentLayout: layout,
49
+ dispatch: (_action: AppAction) => {},
50
+ duplicatePane: () => {},
51
+ focusedPaneId: state.focusedPaneId,
52
+ notifyGridlockRevert: () => {},
53
+ openBuiltInWorkflow: () => {},
54
+ openInlineConfirm: (confirmOptions) => {
55
+ options.confirmations.push(confirmOptions);
56
+ },
57
+ persistLayoutChange: (nextLayout) => {
58
+ options.layouts.push(nextLayout);
59
+ },
60
+ pluginRegistry: {
61
+ getTermSizeFn: () => ({ width: 80, height: 24 }),
62
+ } as PluginRegistry,
63
+ pushRoute: (_route: CommandBarRoute) => {},
64
+ state,
65
+ ...(options.confirmDangerousActions === undefined ? {} : { confirmDangerousActions: options.confirmDangerousActions }),
66
+ };
67
+ }
68
+
69
+ describe("buildCurrentLayoutItems", () => {
70
+ test("closes all floating panes from the current layout", () => {
71
+ const layouts: LayoutConfig[] = [];
72
+ const confirmations: InlineConfirmOptions[] = [];
73
+ const items = buildCurrentLayoutItems(createLayoutItemsContext({ layouts, confirmations }));
74
+
75
+ const item = items.find((entry) => entry.id === "layout-close-all-floating");
76
+ expect(item).toMatchObject({
77
+ label: "Close All Floating Panes",
78
+ disabled: false,
79
+ });
80
+
81
+ item?.action();
82
+
83
+ expect(confirmations).toEqual([]);
84
+ expect(layouts).toHaveLength(1);
85
+ expect(layouts[0]?.floating).toEqual([]);
86
+ expect(layouts[0]?.instances.map((instance) => instance.instanceId)).toEqual(["portfolio-list:main"]);
87
+ expect(layouts[0]?.dockRoot).toEqual({ kind: "pane", instanceId: "portfolio-list:main" });
88
+ });
89
+
90
+ test("confirms before closing all floating panes when dangerous actions are guarded", () => {
91
+ const layouts: LayoutConfig[] = [];
92
+ const confirmations: InlineConfirmOptions[] = [];
93
+ const items = buildCurrentLayoutItems(createLayoutItemsContext({
94
+ confirmDangerousActions: true,
95
+ layouts,
96
+ confirmations,
97
+ }));
98
+
99
+ items.find((entry) => entry.id === "layout-close-all-floating")?.action();
100
+
101
+ expect(layouts).toEqual([]);
102
+ expect(confirmations).toHaveLength(1);
103
+ expect(confirmations[0]).toMatchObject({
104
+ confirmId: "layout-close-all-floating",
105
+ title: "Close All Floating Panes",
106
+ confirmLabel: "Close Floating Panes",
107
+ tone: "danger",
108
+ });
109
+
110
+ confirmations[0]?.onConfirm();
111
+
112
+ expect(layouts).toHaveLength(1);
113
+ expect(layouts[0]?.floating).toEqual([]);
114
+ });
115
+ });
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  gridlockAllPanes,
3
+ removeFloatingPanes,
3
4
  } from "../../../plugins/pane-manager";
4
5
  import {
5
6
  DEFAULT_LAYOUT,
@@ -21,6 +22,8 @@ export function buildCurrentLayoutItems({
21
22
  state,
22
23
  }: LayoutItemsContext): ResultItem[] {
23
24
  const layoutHistory = state.layoutHistory[state.config.activeLayoutIndex];
25
+ const floatingPaneCount = currentLayout.floating.length;
26
+ const floatingPaneLabel = floatingPaneCount === 1 ? "floating pane" : "floating panes";
24
27
 
25
28
  return [
26
29
  {
@@ -89,6 +92,36 @@ export function buildCurrentLayoutItems({
89
92
  closeAll({ revertThemePreview: false });
90
93
  },
91
94
  },
95
+ {
96
+ id: "layout-close-all-floating",
97
+ label: "Close All Floating Panes",
98
+ detail: floatingPaneCount > 0
99
+ ? `Remove ${floatingPaneCount} ${floatingPaneLabel} from the current layout`
100
+ : "No floating panes in the current layout",
101
+ category: "Current Layout",
102
+ kind: "action",
103
+ disabled: floatingPaneCount === 0,
104
+ action: confirmDangerousActions
105
+ ? () => {
106
+ if (floatingPaneCount === 0) return;
107
+ openInlineConfirm({
108
+ confirmId: "layout-close-all-floating",
109
+ title: "Close All Floating Panes",
110
+ body: [`Close ${floatingPaneCount} ${floatingPaneLabel}?`],
111
+ confirmLabel: "Close Floating Panes",
112
+ cancelLabel: "Back",
113
+ tone: "danger",
114
+ onConfirm: () => {
115
+ persistLayoutChange(removeFloatingPanes(currentLayout));
116
+ },
117
+ });
118
+ }
119
+ : () => {
120
+ if (floatingPaneCount === 0) return;
121
+ persistLayoutChange(removeFloatingPanes(currentLayout));
122
+ closeAll({ revertThemePreview: false });
123
+ },
124
+ },
92
125
  {
93
126
  id: "layout-rename",
94
127
  label: "Rename Layout",
@@ -19,6 +19,7 @@ export type CommandBarListScrollEvent = {
19
19
 
20
20
  interface CommandBarListHeaderProps {
21
21
  kind: ListScreenState["kind"];
22
+ title: string;
22
23
  query: string;
23
24
  queryDisplayWidth: number;
24
25
  nativePaneChrome: boolean;
@@ -36,6 +37,7 @@ interface CommandBarListHeaderProps {
36
37
 
37
38
  export const CommandBarListHeader = memo(function CommandBarListHeader({
38
39
  kind,
40
+ title,
39
41
  query,
40
42
  queryDisplayWidth,
41
43
  nativePaneChrome,
@@ -66,7 +68,7 @@ export const CommandBarListHeader = memo(function CommandBarListHeader({
66
68
  value={query}
67
69
  onInput={onQueryChange}
68
70
  onChange={onQueryChange}
69
- placeholder={kind === "root" ? "Search" : "Filter"}
71
+ placeholder={kind === "root" ? "Search commands" : title === "Security Description" ? "Search tickers" : "Filter"}
70
72
  focused
71
73
  width={nativePaneChrome ? "100%" : queryDisplayWidth}
72
74
  backgroundColor={nativePaneChrome ? "transparent" : paletteBg}
@@ -141,7 +141,10 @@ export function buildPaneTemplateItem(options: {
141
141
  const arg = options.createOptions?.arg;
142
142
 
143
143
  const action = () => {
144
- if (options.shortcutExecution && options.template.shortcut) {
144
+ if (
145
+ options.template.shortcut
146
+ && (options.shortcutExecution || getPaneTemplateArgKind(options.template) === "ticker")
147
+ ) {
145
148
  options.runPaneTemplateShortcut(options.template, arg);
146
149
  return;
147
150
  }
@@ -130,6 +130,7 @@ export function CommandBarPanelBody({
130
130
  {(visibleListState || currentRoute?.kind === "picker") && visibleListState && (
131
131
  <CommandBarListHeader
132
132
  kind={visibleListState.kind}
133
+ title={visibleListState.title}
133
134
  query={visibleListState.query}
134
135
  queryDisplayWidth={queryDisplayWidth}
135
136
  nativePaneChrome={nativePaneChrome}
@@ -6,12 +6,10 @@ import type {
6
6
  } from "../../../../types/plugin";
7
7
  import type { TickerRecord } from "../../../../types/ticker";
8
8
  import { fuzzyFilter } from "../../../../utils/fuzzy-search";
9
- import type { TickerSearchCandidate } from "../../../../tickers/search";
10
9
  import { matchPrefix, type Command } from "../../commands/registry";
11
10
  import { isCollectionCommand } from "../../helpers";
12
11
  import type { ResultItem } from "../../list/model";
13
12
  import type { parseRootShortcutIntent } from "./shortcuts";
14
- import { createQuickLookTickerCandidates } from "../ticker-search/results";
15
13
  import type { CommandBarRoute } from "../../workflow/types";
16
14
  import { createRootCommandItemBuilder } from "./command-items";
17
15
  import { buildRootShortcutItem } from "./shortcut-items";
@@ -55,7 +53,6 @@ export interface RootResultModelOptions {
55
53
  getAvailablePaneShortcutTemplates: (query: string) => PaneTemplateDef[];
56
54
  hasPaneSettings: (paneId: string) => boolean;
57
55
  localTickerSearchResultItems: (query?: string, options?: { category?: string; limit?: number }) => ResultItem[];
58
- mapTickerSearchCandidateToResultItem: (candidate: TickerSearchCandidate) => ResultItem;
59
56
  nonShortcutPaneTemplateItems: (filterQuery?: string) => ResultItem[];
60
57
  openModeRoute: (screen: "ticker-search" | "plugins" | "layout", initialQuery?: string) => void;
61
58
  paneShortcutItems: (options?: PaneShortcutItemsOptions) => ResultItem[];
@@ -86,7 +83,6 @@ export function buildRootResultModel(options: RootResultModelOptions): RootResul
86
83
  getAvailablePaneShortcutTemplates,
87
84
  hasPaneSettings,
88
85
  localTickerSearchResultItems,
89
- mapTickerSearchCandidateToResultItem,
90
86
  nonShortcutPaneTemplateItems,
91
87
  openModeRoute,
92
88
  paneShortcutItems,
@@ -180,30 +176,6 @@ export function buildRootResultModel(options: RootResultModelOptions): RootResul
180
176
  const item = commandToItem(match.command);
181
177
  if (item) items.push(item);
182
178
  } else if (!rootQuery) {
183
- const maxDefaultTickers = 5;
184
- const recentSymbols = state.recentTickers.slice(0, maxDefaultTickers);
185
- const recentTickers = recentSymbols
186
- .map((symbol) => state.tickers.get(symbol))
187
- .filter((ticker): ticker is NonNullable<typeof ticker> => (
188
- ticker != null && createQuickLookTickerCandidates([ticker]).length > 0
189
- ));
190
- if (recentTickers.length < maxDefaultTickers) {
191
- const seen = new Set(recentSymbols);
192
- for (const ticker of state.tickers.values()) {
193
- if (recentTickers.length >= maxDefaultTickers) break;
194
- if (createQuickLookTickerCandidates([ticker]).length === 0) continue;
195
- if (!seen.has(ticker.metadata.ticker)) recentTickers.push(ticker);
196
- }
197
- }
198
- items.push(...recentTickers.flatMap((ticker) => {
199
- const candidate = createQuickLookTickerCandidates([ticker])[0];
200
- return candidate
201
- ? [{
202
- ...mapTickerSearchCandidateToResultItem(candidate),
203
- category: "Tickers",
204
- }]
205
- : [];
206
- }));
207
179
  items.push(...paneShortcutItems());
208
180
  for (const command of availableCommands) {
209
181
  const item = commandToItem(command);
@@ -212,12 +184,10 @@ export function buildRootResultModel(options: RootResultModelOptions): RootResul
212
184
  items.push(...tickerActionItems());
213
185
  items.push(...pluginCommandItems());
214
186
  } else {
215
- const tickerItems = localTickerSearchResultItems(undefined, { category: "Tickers" });
216
187
  const commandItems = availableCommands
217
188
  .map((command) => commandToItem(command))
218
189
  .filter((item): item is ResultItem => item !== null);
219
190
  const allItems = [
220
- ...tickerItems,
221
191
  ...commandItems,
222
192
  ...buildLayoutItems("", { confirmDangerousActions: true }),
223
193
  ...buildPaneSettingItems(state.focusedPaneId, rootQuery),
@@ -8,10 +8,6 @@ import type { TickerSearchCandidate } from "../../../../tickers/search";
8
8
  import { matchPrefix, type Command } from "../../commands/registry";
9
9
  import type { ResultItem } from "../../list/model";
10
10
  import type { CommandBarRoute } from "../../workflow/types";
11
- import {
12
- isLikelyPlainTickerSearch,
13
- normalizeCommandTickerSearchText,
14
- } from "../ticker-search/results";
15
11
  import { useTickerSearchRouteResults } from "../ticker-search/route";
16
12
  import { buildRootResultModel, type RootResultModel } from "./results";
17
13
  import { useRootProviderSearch } from "./provider-search";
@@ -47,7 +43,6 @@ interface UseCommandBarRootRuntimeOptions {
47
43
  getTickers(): AppState["tickers"];
48
44
  hasPaneSettings(paneId: string): boolean;
49
45
  localTickerSearchResultItems(query?: string, options?: { category?: string; limit?: number }): ResultItem[];
50
- mapTickerSearchCandidateToResultItem(candidate: TickerSearchCandidate): ResultItem;
51
46
  nativeListScrollRef: RefObject<ScrollBoxRenderable | null>;
52
47
  nonShortcutPaneTemplateItems(filterQuery?: string): ResultItem[];
53
48
  openModeRoute(screen: "ticker-search" | "plugins" | "layout", initialQuery?: string): void;
@@ -101,7 +96,6 @@ export function useCommandBarRootRuntime({
101
96
  getTickers,
102
97
  hasPaneSettings,
103
98
  localTickerSearchResultItems,
104
- mapTickerSearchCandidateToResultItem,
105
99
  nativeListScrollRef,
106
100
  nonShortcutPaneTemplateItems,
107
101
  openModeRoute,
@@ -178,7 +172,6 @@ export function useCommandBarRootRuntime({
178
172
  getAvailablePaneShortcutTemplates,
179
173
  hasPaneSettings,
180
174
  localTickerSearchResultItems,
181
- mapTickerSearchCandidateToResultItem,
182
175
  nonShortcutPaneTemplateItems,
183
176
  openModeRoute,
184
177
  paneShortcutItems,
@@ -206,7 +199,6 @@ export function useCommandBarRootRuntime({
206
199
  getAvailablePaneShortcutTemplates,
207
200
  hasPaneSettings,
208
201
  localTickerSearchResultItems,
209
- mapTickerSearchCandidateToResultItem,
210
202
  nonShortcutPaneTemplateItems,
211
203
  openModeRoute,
212
204
  paneShortcutItems,
@@ -220,20 +212,10 @@ export function useCommandBarRootRuntime({
220
212
  tickerActionItems,
221
213
  ]);
222
214
 
223
- const rootPlainTickerSearchArg = useMemo(() => {
224
- if (activeMatch || rootShortcutIntent.kind !== "none" || !isLikelyPlainTickerSearch(rootQuery)) return null;
225
- const normalizedQuery = normalizeCommandTickerSearchText(rootQuery);
226
- const hasExactNonTickerResult = rootResultModel.items.some((item) => (
227
- item.kind !== "ticker"
228
- && item.kind !== "search"
229
- && normalizeCommandTickerSearchText(item.label) === normalizedQuery
230
- ));
231
- return hasExactNonTickerResult ? null : rootQuery.trim();
232
- }, [activeMatch, rootQuery, rootResultModel.items, rootShortcutIntent.kind]);
233
215
  const rootSecurityDescriptionArg = activeMatch?.command.id === "security-description" && activeMatch.arg.length >= 1
234
216
  ? activeMatch.arg
235
217
  : null;
236
- const rootTickerSearchArg = rootSecurityDescriptionArg ?? rootPlainTickerSearchArg;
218
+ const rootTickerSearchArg = rootSecurityDescriptionArg;
237
219
 
238
220
  useEffect(() => {
239
221
  if (currentRoute) return;
@@ -272,7 +254,7 @@ export function useCommandBarRootRuntime({
272
254
  localTickerSearchResultItems,
273
255
  portfolios: state.config.portfolios,
274
256
  readTickerSearchCache,
275
- rootPlainTickerSearchArg,
257
+ rootPlainTickerSearchArg: null,
276
258
  rootResultItems: rootResultModel.items,
277
259
  rootTickerSearchArg,
278
260
  tickers: state.tickers,
@@ -6,6 +6,7 @@ import {
6
6
  rankTickerSearchItems,
7
7
  upsertTickerFromSearchResult,
8
8
  type TickerSearchCandidate,
9
+ type TickerSearchInstrumentClass,
9
10
  } from "../../../../tickers/search";
10
11
  import type { ResultItem } from "../../list/model";
11
12
  import {
@@ -45,12 +46,15 @@ export function useCommandBarTickerSearchActions({
45
46
  }, [tickerRepository, dispatch, pluginRegistry.events, focusTicker, closeAll]);
46
47
 
47
48
  const mapTickerSearchCandidateToResultItem = useCallback((candidate: TickerSearchCandidate): ResultItem => {
49
+ const detail = formatTickerSearchDetail(candidate);
50
+ const right = formatTickerSearchRight(candidate);
51
+
48
52
  if (candidate.kind === "ticker" && candidate.ticker) {
49
53
  return {
50
54
  id: candidate.id,
51
55
  label: candidate.label,
52
- detail: candidate.detail,
53
- right: candidate.right,
56
+ detail,
57
+ right,
54
58
  category: candidate.category,
55
59
  kind: "ticker",
56
60
  secondaryAction: () => {
@@ -67,8 +71,8 @@ export function useCommandBarTickerSearchActions({
67
71
  return {
68
72
  id: candidate.id,
69
73
  label: candidate.label,
70
- detail: candidate.detail,
71
- right: candidate.right,
74
+ detail,
75
+ right,
72
76
  category: candidate.category,
73
77
  kind: "search",
74
78
  secondaryAction: () => openTickerResearch(candidate.result!, { forceNewPane: true }),
@@ -140,3 +144,37 @@ export function useCommandBarTickerSearchActions({
140
144
  writeTickerSearchCache,
141
145
  };
142
146
  }
147
+
148
+ function formatTickerSearchRight(candidate: TickerSearchCandidate): string | undefined {
149
+ const assetClass = formatInstrumentClass(candidate.instrumentClass);
150
+ const exchange = candidate.exchangeLabel || candidate.primaryExchangeLabel || candidate.right || "";
151
+ if (!exchange) return assetClass;
152
+ if (assetClass === "Other") return exchange;
153
+ return `${assetClass} ${exchange}`;
154
+ }
155
+
156
+ function formatTickerSearchDetail(candidate: TickerSearchCandidate): string {
157
+ const metadata = [
158
+ formatInstrumentClass(candidate.instrumentClass),
159
+ candidate.exchangeLabel,
160
+ candidate.primaryExchangeLabel && candidate.primaryExchangeLabel !== candidate.exchangeLabel
161
+ ? candidate.primaryExchangeLabel
162
+ : null,
163
+ ].filter((value): value is string => !!value);
164
+ return metadata.length > 0
165
+ ? `${candidate.detail} | ${metadata.join(" | ")}`
166
+ : candidate.detail;
167
+ }
168
+
169
+ function formatInstrumentClass(instrumentClass: TickerSearchInstrumentClass): string {
170
+ switch (instrumentClass) {
171
+ case "equity":
172
+ return "Equity";
173
+ case "fund":
174
+ return "Fund";
175
+ case "derivative":
176
+ return "Derivative";
177
+ case "other":
178
+ return "Other";
179
+ }
180
+ }
@@ -65,7 +65,10 @@ export function useTickerSearchRouteResults(options: {
65
65
 
66
66
  const searchQuery = routeQuery.trim();
67
67
  if (!searchQuery) {
68
- resetTickerSearchRoute();
68
+ searchRequestIdRef.current += 1;
69
+ setTickerSearchPending(false);
70
+ setTickerSearchResults(localTickerSearchResultItems(undefined, { limit: 8 }));
71
+ if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
69
72
  return;
70
73
  }
71
74
 
@@ -1,17 +1,17 @@
1
1
  import { useEffect, useRef } from "react";
2
2
  import type { PluginRegistry } from "../../../plugins/registry";
3
+ import type { AppState } from "../../../state/app/context";
3
4
  import type { CommandDef } from "../../../types/plugin";
4
5
 
5
- interface CommandBarPluginLaunchRequest {
6
- kind: "plugin-command";
7
- commandId: string;
8
- sequence: number;
9
- }
10
-
11
6
  interface UseCommandBarLaunchRequestOptions {
12
7
  activeTickerSymbol: string | null;
13
- commandBarLaunchRequest: CommandBarPluginLaunchRequest | null;
8
+ commandBarLaunchRequest: AppState["commandBarLaunchRequest"];
14
9
  commandBarOpen: boolean;
10
+ openModeRoute: (
11
+ screen: "ticker-search" | "plugins" | "layout",
12
+ initialQuery?: string,
13
+ payload?: Record<string, unknown>,
14
+ ) => void;
15
15
  openPluginCommandWorkflow: (
16
16
  command: CommandDef,
17
17
  options?: { values?: Record<string, string> },
@@ -23,6 +23,7 @@ export function useCommandBarLaunchRequest({
23
23
  activeTickerSymbol,
24
24
  commandBarLaunchRequest,
25
25
  commandBarOpen,
26
+ openModeRoute,
26
27
  openPluginCommandWorkflow,
27
28
  pluginRegistry,
28
29
  }: UseCommandBarLaunchRequestOptions) {
@@ -38,6 +39,11 @@ export function useCommandBarLaunchRequest({
38
39
  if (processedLaunchSequenceRef.current === launch.sequence) return;
39
40
  processedLaunchSequenceRef.current = launch.sequence;
40
41
 
42
+ if (launch.kind === "ticker-search") {
43
+ openModeRoute("ticker-search", launch.query ?? "");
44
+ return;
45
+ }
46
+
41
47
  const command = pluginRegistry.commands.get(launch.commandId);
42
48
  if (!command?.wizard || command.wizard.length === 0) return;
43
49
  let values: Record<string, string> | undefined;
@@ -53,6 +59,7 @@ export function useCommandBarLaunchRequest({
53
59
  activeTickerSymbol,
54
60
  commandBarLaunchRequest,
55
61
  commandBarOpen,
62
+ openModeRoute,
56
63
  openPluginCommandWorkflow,
57
64
  pluginRegistry,
58
65
  ]);
@@ -125,9 +125,7 @@ function buildRouteListState(options: BuildRouteListStateOptions): ListScreenSta
125
125
  };
126
126
  }
127
127
  case "ticker-search": {
128
- const results = currentRoute.query.trim()
129
- ? tickerSearchResults.map((item) => adaptTickerSearchRouteResult(item, currentRoute.payload))
130
- : [];
128
+ const results = tickerSearchResults.map((item) => adaptTickerSearchRouteResult(item, currentRoute.payload));
131
129
  const emptyState = getEmptyState("search", currentRoute.query, currentRoute.query);
132
130
  return {
133
131
  kind: "mode",