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,5 @@
1
1
  import { Box, Input, ScrollBox, Text, Textarea, useUiCapabilities } from "../../ui";
2
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3
3
  import type { RefObject } from "react";
4
4
  import { TextAttributes, type InputRenderable, type ScrollBoxRenderable, type TextareaRenderable } from "../../ui";
5
5
  import { useNativeRenderer } from "../../ui";
@@ -8,7 +8,8 @@ import { Button, NumberField, Spinner, TextField } from "../ui";
8
8
  import { NativeSelect, openNativeSelect, type NativeSelectElement } from "../ui/native-select";
9
9
  import { ToggleList } from "../toggle-list";
10
10
  import {
11
- colors,
11
+ applyTheme,
12
+ clearTransientThemePreview,
12
13
  commandBarBg,
13
14
  commandBarHeadingText,
14
15
  commandBarHoverBg,
@@ -18,9 +19,10 @@ import {
18
19
  commandBarSelectedText,
19
20
  commandBarSubtleText,
20
21
  commandBarText,
22
+ getCurrentThemeId,
23
+ previewTheme,
21
24
  } from "../../theme/colors";
22
25
  import {
23
- getEffectiveThemeId,
24
26
  getFocusedCollectionId,
25
27
  useAppDispatch,
26
28
  useAppSelector,
@@ -28,8 +30,9 @@ import {
28
30
  type AppState,
29
31
  } from "../../state/app-context";
30
32
  import { fuzzyFilter } from "../../utils/fuzzy-search";
31
- import { commands, getThemeOptions, matchPrefix, type Command } from "./command-registry";
32
- import { applyTheme, getCurrentThemeId } from "../../theme/colors";
33
+ import { commands, matchPrefix, type Command } from "./command-registry";
34
+ import { useThemeColors } from "../../theme/theme-context";
35
+ import { ThemePicker, type ThemePickerHandle } from "./theme-picker";
33
36
  import { exportConfig, importConfig, resetAllData, saveConfig } from "../../data/config-store";
34
37
  import type { DataProvider } from "../../types/data-provider";
35
38
  import type { TickerRepository } from "../../data/ticker-repository";
@@ -180,6 +183,7 @@ const COMMAND_BAR_OVERLAY_Z_INDEX = 2_147_483_646;
180
183
  const COMMAND_BAR_PANEL_Z_INDEX = 2_147_483_647;
181
184
  const NATIVE_COMMAND_BAR_PADDING_X_PX = 14;
182
185
  const NATIVE_COMMAND_BAR_PADDING_Y_PX = 14;
186
+ const QUICK_LOOK_TICKER_SEARCH_OPTIONS = { includeOptionContracts: false } as const;
183
187
 
184
188
  function getDefaultConfigBackupPath(): string {
185
189
  const home = typeof process !== "undefined" ? process.env.HOME : undefined;
@@ -297,6 +301,309 @@ function getVisibleMultiSelectPickerOptions(
297
301
  });
298
302
  }
299
303
 
304
+ type CommandBarListScrollEvent = {
305
+ stopPropagation: () => void;
306
+ preventDefault: () => void;
307
+ scroll?: { direction?: string; delta?: number };
308
+ };
309
+
310
+ interface CommandBarListHeaderProps {
311
+ kind: ListScreenState["kind"];
312
+ query: string;
313
+ queryDisplayWidth: number;
314
+ nativePaneChrome: boolean;
315
+ inputBg: string;
316
+ paletteBg: string;
317
+ paletteText: string;
318
+ paletteSubtleText: string;
319
+ cursorColor: string;
320
+ contentPadding: number;
321
+ rootGhostSuffix: string | null;
322
+ rootQueryLength: number;
323
+ rootShortcutFeedback: string | null;
324
+ onQueryChange: (query: string) => void;
325
+ }
326
+
327
+ const CommandBarListHeader = memo(function CommandBarListHeader({
328
+ kind,
329
+ query,
330
+ queryDisplayWidth,
331
+ nativePaneChrome,
332
+ inputBg,
333
+ paletteBg,
334
+ paletteText,
335
+ paletteSubtleText,
336
+ cursorColor,
337
+ contentPadding,
338
+ rootGhostSuffix,
339
+ rootQueryLength,
340
+ rootShortcutFeedback,
341
+ onQueryChange,
342
+ }: CommandBarListHeaderProps) {
343
+ return (
344
+ <>
345
+ <Box height={1} paddingX={contentPadding}>
346
+ <Box
347
+ width={queryDisplayWidth}
348
+ height={1}
349
+ position="relative"
350
+ backgroundColor={nativePaneChrome ? undefined : inputBg}
351
+ style={nativePaneChrome ? undefined : {
352
+ overflow: "hidden",
353
+ }}
354
+ >
355
+ <Input
356
+ value={query}
357
+ onInput={onQueryChange}
358
+ onChange={onQueryChange}
359
+ placeholder={kind === "root" ? "Search" : "Filter"}
360
+ focused
361
+ width={nativePaneChrome ? "100%" : queryDisplayWidth}
362
+ backgroundColor={nativePaneChrome ? "transparent" : paletteBg}
363
+ focusedBackgroundColor={nativePaneChrome ? "transparent" : paletteBg}
364
+ textColor={paletteText}
365
+ focusedTextColor={paletteText}
366
+ placeholderColor={paletteSubtleText}
367
+ cursorColor={cursorColor}
368
+ />
369
+ {kind === "root" && rootGhostSuffix && (
370
+ <Box
371
+ position="absolute"
372
+ top={0}
373
+ left={Math.max(0, Math.min(rootQueryLength, queryDisplayWidth - 1))}
374
+ width={Math.max(0, queryDisplayWidth - Math.min(rootQueryLength, queryDisplayWidth - 1))}
375
+ height={1}
376
+ >
377
+ <Text fg={paletteSubtleText}>
378
+ {truncateText(
379
+ rootGhostSuffix,
380
+ Math.max(0, queryDisplayWidth - Math.min(rootQueryLength, queryDisplayWidth - 1)),
381
+ )}
382
+ </Text>
383
+ </Box>
384
+ )}
385
+ </Box>
386
+ </Box>
387
+ <Box height={1} paddingX={contentPadding}>
388
+ {kind === "root" && rootShortcutFeedback
389
+ ? (
390
+ <Text fg={paletteSubtleText}>
391
+ {truncateText(rootShortcutFeedback, queryDisplayWidth)}
392
+ </Text>
393
+ )
394
+ : null}
395
+ </Box>
396
+ </>
397
+ );
398
+ });
399
+
400
+ interface CommandBarListItemRowProps {
401
+ item: ResultItem;
402
+ globalIdx: number;
403
+ isSelected: boolean;
404
+ isHovered: boolean;
405
+ contentPadding: number;
406
+ labelWidth: number;
407
+ trailingWidth: number;
408
+ nativePaneChrome: boolean;
409
+ paletteBg: string;
410
+ paletteHoverBg: string;
411
+ paletteSelectedBg: string;
412
+ paletteSelectedText: string;
413
+ paletteSubtleText: string;
414
+ paletteText: string;
415
+ panelBg: string;
416
+ onHoverIndex: (index: number | null) => void;
417
+ onListScroll: (event: CommandBarListScrollEvent) => void;
418
+ onRowMouseDown: (event: any, item: ResultItem, globalIdx: number) => void;
419
+ }
420
+
421
+ const CommandBarListItemRow = memo(function CommandBarListItemRow({
422
+ item,
423
+ globalIdx,
424
+ isSelected,
425
+ isHovered,
426
+ contentPadding,
427
+ labelWidth,
428
+ trailingWidth,
429
+ nativePaneChrome,
430
+ paletteBg,
431
+ paletteHoverBg,
432
+ paletteSelectedBg,
433
+ paletteSelectedText,
434
+ paletteSubtleText,
435
+ paletteText,
436
+ panelBg,
437
+ onHoverIndex,
438
+ onListScroll,
439
+ onRowMouseDown,
440
+ }: CommandBarListItemRowProps) {
441
+ const presentation = getRowPresentation(item, isSelected, trailingWidth > 0);
442
+ const label = truncateText(presentation.label, labelWidth);
443
+ const trailing = truncateText(presentation.trailing, trailingWidth);
444
+
445
+ return (
446
+ <Box
447
+ key={item.id}
448
+ flexDirection="row"
449
+ height={1}
450
+ paddingX={contentPadding}
451
+ backgroundColor={isSelected
452
+ ? paletteSelectedBg
453
+ : isHovered
454
+ ? paletteHoverBg
455
+ : (nativePaneChrome ? panelBg : paletteBg)}
456
+ onMouseMove={() => onHoverIndex(globalIdx)}
457
+ onMouseOut={() => onHoverIndex(null)}
458
+ {...(!nativePaneChrome ? { onMouseScroll: onListScroll } : {})}
459
+ onMouseDown={(event: any) => onRowMouseDown(event, item, globalIdx)}
460
+ data-command-bar-row-selected={nativePaneChrome && isSelected ? "true" : undefined}
461
+ style={nativePaneChrome ? { borderRadius: 6 } : undefined}
462
+ >
463
+ <Box width={labelWidth}>
464
+ <Text fg={isSelected ? paletteSelectedText : presentation.primaryMuted ? paletteSubtleText : paletteText}>
465
+ {label}
466
+ </Text>
467
+ </Box>
468
+ <Box width={trailingWidth}>
469
+ <Text fg={isSelected ? paletteSelectedText : paletteSubtleText}>{trailing}</Text>
470
+ </Box>
471
+ </Box>
472
+ );
473
+ });
474
+
475
+ interface CommandBarListBodyProps {
476
+ visibleListState: ListScreenState;
477
+ nativeListRows: CommandBarListRow[];
478
+ listBodyHeight: number;
479
+ contentPadding: number;
480
+ labelWidth: number;
481
+ nativePaneChrome: boolean;
482
+ nativeListScrollRef: RefObject<ScrollBoxRenderable | null>;
483
+ paletteBg: string;
484
+ paletteHeadingText: string;
485
+ paletteHoverBg: string;
486
+ paletteSelectedBg: string;
487
+ paletteSelectedText: string;
488
+ paletteSubtleText: string;
489
+ paletteText: string;
490
+ panelBg: string;
491
+ queryDisplayWidth: number;
492
+ trailingWidth: number;
493
+ onHoverIndex: (index: number | null) => void;
494
+ onListScroll: (event: CommandBarListScrollEvent) => void;
495
+ onRowMouseDown: (event: any, item: ResultItem, globalIdx: number) => void;
496
+ }
497
+
498
+ const CommandBarListBody = memo(function CommandBarListBody({
499
+ visibleListState,
500
+ nativeListRows,
501
+ listBodyHeight,
502
+ contentPadding,
503
+ labelWidth,
504
+ nativePaneChrome,
505
+ nativeListScrollRef,
506
+ paletteBg,
507
+ paletteHeadingText,
508
+ paletteHoverBg,
509
+ paletteSelectedBg,
510
+ paletteSelectedText,
511
+ paletteSubtleText,
512
+ paletteText,
513
+ panelBg,
514
+ queryDisplayWidth,
515
+ trailingWidth,
516
+ onHoverIndex,
517
+ onListScroll,
518
+ onRowMouseDown,
519
+ }: CommandBarListBodyProps) {
520
+ const visibleRows = useMemo(() => {
521
+ const rows = nativeListRows;
522
+ if (nativePaneChrome) return rows;
523
+ const paddedRows = [...rows];
524
+ while (paddedRows.length < listBodyHeight) {
525
+ paddedRows.push({ kind: "filler", id: `filler:${paddedRows.length}` });
526
+ }
527
+ return paddedRows;
528
+ }, [
529
+ listBodyHeight,
530
+ nativeListRows,
531
+ nativePaneChrome,
532
+ ]);
533
+
534
+ const renderedRows = (
535
+ <>
536
+ {visibleRows.map((row) => {
537
+ if (row.kind === "filler" || row.kind === "spacer") {
538
+ return <Box key={row.id} height={1} />;
539
+ }
540
+ if (row.kind === "spinner") {
541
+ return (
542
+ <Box key={row.id} height={1} paddingX={contentPadding} {...(!nativePaneChrome ? { onMouseScroll: onListScroll } : {})}>
543
+ <Spinner label={row.label} />
544
+ </Box>
545
+ );
546
+ }
547
+ if (row.kind === "message") {
548
+ return (
549
+ <Box key={row.id} height={1} paddingX={contentPadding} {...(!nativePaneChrome ? { onMouseScroll: onListScroll } : {})}>
550
+ <Text fg={paletteText}>{truncateText(row.label, queryDisplayWidth)}</Text>
551
+ </Box>
552
+ );
553
+ }
554
+ if (row.kind === "heading") {
555
+ return (
556
+ <Box key={row.id} height={1} paddingX={contentPadding} {...(!nativePaneChrome ? { onMouseScroll: onListScroll } : {})}>
557
+ <Text attributes={TextAttributes.BOLD} fg={paletteHeadingText}>
558
+ {truncateText(row.label, queryDisplayWidth)}
559
+ </Text>
560
+ </Box>
561
+ );
562
+ }
563
+
564
+ const isSelected = row.globalIdx === visibleListState.selectedIdx;
565
+ const isHovered = row.globalIdx === visibleListState.hoveredIdx && !isSelected;
566
+ return (
567
+ <CommandBarListItemRow
568
+ key={row.item.id}
569
+ item={row.item}
570
+ globalIdx={row.globalIdx}
571
+ isSelected={isSelected}
572
+ isHovered={isHovered}
573
+ contentPadding={contentPadding}
574
+ labelWidth={labelWidth}
575
+ trailingWidth={trailingWidth}
576
+ nativePaneChrome={nativePaneChrome}
577
+ paletteBg={paletteBg}
578
+ paletteHoverBg={paletteHoverBg}
579
+ paletteSelectedBg={paletteSelectedBg}
580
+ paletteSelectedText={paletteSelectedText}
581
+ paletteSubtleText={paletteSubtleText}
582
+ paletteText={paletteText}
583
+ panelBg={panelBg}
584
+ onHoverIndex={onHoverIndex}
585
+ onListScroll={onListScroll}
586
+ onRowMouseDown={onRowMouseDown}
587
+ />
588
+ );
589
+ })}
590
+ </>
591
+ );
592
+
593
+ return (
594
+ <ScrollBox
595
+ ref={nativeListScrollRef}
596
+ flexDirection="column"
597
+ height={listBodyHeight}
598
+ scrollY
599
+ focusable={false}
600
+ {...(!nativePaneChrome ? { onMouseScroll: onListScroll } : {})}
601
+ >
602
+ {renderedRows}
603
+ </ScrollBox>
604
+ );
605
+ });
606
+
300
607
  export function CommandBar({
301
608
  dataProvider,
302
609
  tickerRepository,
@@ -305,6 +612,7 @@ export function CommandBar({
305
612
  onCheckForUpdates,
306
613
  }: CommandBarProps) {
307
614
  const dispatch = useAppDispatch();
615
+ const themeColors = useThemeColors();
308
616
  const config = useAppSelector((state) => state.config);
309
617
  const paneState = useAppSelector((state) => state.paneState);
310
618
  const tickers = useAppSelector((state) => state.tickers);
@@ -366,6 +674,8 @@ export function CommandBar({
366
674
  const rootQueryRef = useRef(rootQuery);
367
675
  rootQueryRef.current = rootQuery;
368
676
  const rootModeInfo = resolveCommandBarMode(rootQuery, availableCommands);
677
+ const rootModeKindRef = useRef(rootModeInfo.kind);
678
+ rootModeKindRef.current = rootModeInfo.kind;
369
679
  const [rootSelectedIdx, setRootSelectedIdx] = useState(0);
370
680
  const [rootHoveredIdx, setRootHoveredIdx] = useState<number | null>(null);
371
681
  const [rootSearching, setRootSearching] = useState(false);
@@ -390,32 +700,50 @@ export function CommandBar({
390
700
  const workflowNativeSelectRefs = useRef(new Map<string, NativeSelectElement>());
391
701
  const workflowScrollRef = useRef<ScrollBoxRenderable | null>(null);
392
702
  const nativeListScrollRef = useRef<ScrollBoxRenderable | null>(null);
703
+ const themePickerRef = useRef<ThemePickerHandle | null>(null);
393
704
  const visibleListStateRef = useRef<ListScreenState | null>(null);
394
705
  const processedLaunchSequenceRef = useRef<number | null>(null);
706
+ const currentThemePreviewRef = useRef<string | null>(null);
395
707
  const currentRoute = routeStack[routeStack.length - 1] ?? null;
396
708
  const currentRouteRef = useRef<CommandBarRoute | null>(currentRoute);
397
709
  currentRouteRef.current = currentRoute;
398
710
  const activeCollectionId = getFocusedCollectionId(state);
399
711
  const activePortfolio = state.config.portfolios.find((portfolio) => portfolio.id === activeCollectionId);
400
712
 
713
+ const applyThemePreview = useCallback((themeId: string | null) => {
714
+ const committedThemeId = stateRef.current.config.theme;
715
+ const preview = themeId && themeId !== committedThemeId ? themeId : null;
716
+ if (preview) {
717
+ previewTheme(preview);
718
+ } else {
719
+ clearTransientThemePreview();
720
+ if (getCurrentThemeId() !== committedThemeId) {
721
+ applyTheme(committedThemeId);
722
+ }
723
+ }
724
+ if (currentThemePreviewRef.current !== preview) {
725
+ currentThemePreviewRef.current = preview;
726
+ dispatch({ type: "PREVIEW_THEME", theme: preview });
727
+ }
728
+ }, [dispatch]);
729
+
401
730
  const clearThemePreview = useCallback((themeId: string | null | undefined) => {
402
- if (!themeId) return;
403
- if (getCurrentThemeId() !== themeId) {
404
- applyTheme(themeId);
731
+ themePickerRef.current?.cancelPreview();
732
+ const targetThemeId = themeId ?? stateRef.current.config.theme;
733
+ if (!targetThemeId) return;
734
+ clearTransientThemePreview();
735
+ if (getCurrentThemeId() !== targetThemeId) {
736
+ applyTheme(targetThemeId);
737
+ }
738
+ if (currentThemePreviewRef.current !== null) {
739
+ currentThemePreviewRef.current = null;
740
+ dispatch({ type: "PREVIEW_THEME", theme: null });
405
741
  }
406
- dispatch({ type: "PREVIEW_THEME", theme: null });
407
742
  }, [dispatch]);
408
743
 
409
744
  const restoreThemePreview = useCallback(() => {
410
- const themeRoute = [...routeStack].reverse().find((route) => (
411
- route.kind === "mode" && route.screen === "themes"
412
- ));
413
- if (themeRoute?.kind === "mode" && themeRoute.themeBaseId) {
414
- clearThemePreview(themeRoute.themeBaseId);
415
- return;
416
- }
417
745
  clearThemePreview(rootThemeBaseIdRef.current);
418
- }, [clearThemePreview, routeStack]);
746
+ }, [clearThemePreview]);
419
747
 
420
748
  const closeAll = useCallback((options?: { revertThemePreview?: boolean }) => {
421
749
  if (options?.revertThemePreview !== false) {
@@ -462,10 +790,6 @@ export function CommandBar({
462
790
  return;
463
791
  }
464
792
 
465
- if (currentRoute.kind === "mode" && currentRoute.screen === "themes" && currentRoute.themeBaseId) {
466
- clearThemePreview(currentRoute.themeBaseId);
467
- }
468
-
469
793
  if (routeStack.length === 1 && currentRoute.restoreMain) {
470
794
  setRootQuery(currentRoute.restoreMain.query);
471
795
  setRootSelectedIdx(currentRoute.restoreMain.selectedIdx);
@@ -525,20 +849,22 @@ export function CommandBar({
525
849
  pluginRegistry.pinTicker(symbol, { floating: true, paneType: "ticker-detail" });
526
850
  }, [pluginRegistry]);
527
851
 
528
- const previewThemeId = useCallback((themeId: string | undefined) => {
529
- if (!themeId) return;
852
+ const commitTheme = useCallback((themeId: string) => {
853
+ themePickerRef.current?.cancelPreview();
854
+ clearTransientThemePreview();
530
855
  if (getCurrentThemeId() !== themeId) {
531
856
  applyTheme(themeId);
532
857
  }
533
- const previewTheme = themeId === stateRef.current.config.theme ? null : themeId;
534
- if (stateRef.current.themePreview !== previewTheme) {
535
- dispatch({ type: "PREVIEW_THEME", theme: previewTheme });
536
- }
858
+ currentThemePreviewRef.current = null;
859
+ dispatch({ type: "SET_THEME", theme: themeId });
537
860
  }, [dispatch]);
538
861
 
539
- const previewThemeSelection = useCallback((listState: ListScreenState, selectedIdx: number) => {
540
- previewThemeId(listState.results[selectedIdx]?.themeId);
541
- }, [previewThemeId]);
862
+ useEffect(() => {
863
+ return () => {
864
+ themePickerRef.current?.cancelPreview();
865
+ clearTransientThemePreview();
866
+ };
867
+ }, []);
542
868
 
543
869
  const focusTicker = useCallback((symbol: string, options?: { forceNewPane?: boolean }) => {
544
870
  const currentState = stateRef.current;
@@ -591,7 +917,7 @@ export function CommandBar({
591
917
  }, [dispatch, persistLayoutChange, pluginRegistry]);
592
918
 
593
919
  const openModeRoute = useCallback((
594
- screen: "ticker-search" | "themes" | "plugins" | "layout",
920
+ screen: "ticker-search" | "plugins" | "layout",
595
921
  initialQuery = "",
596
922
  payload?: Record<string, unknown>,
597
923
  ) => {
@@ -604,7 +930,6 @@ export function CommandBar({
604
930
  query: initialQuery,
605
931
  selectedIdx: 0,
606
932
  hoveredIdx: null,
607
- themeBaseId: screen === "themes" ? stateRef.current.config.theme : undefined,
608
933
  payload,
609
934
  });
610
935
  }, [pushRoute]);
@@ -1161,44 +1486,6 @@ export function CommandBar({
1161
1486
  });
1162
1487
  }, [openConfirmRoute]);
1163
1488
 
1164
- const buildThemeItems = useCallback((
1165
- query: string,
1166
- currentThemeId: string | null | undefined,
1167
- options?: { onSelectTheme?: (themeId: string) => void },
1168
- ): { items: ResultItem[]; selectedIdx: number } => {
1169
- let selectedIdx = 0;
1170
- const filtered = query
1171
- ? getThemeOptions().filter((theme) => (
1172
- theme.name.toLowerCase().includes(query.toLowerCase())
1173
- || theme.id.includes(query.toLowerCase())
1174
- ))
1175
- : getThemeOptions();
1176
- const items = filtered.map((theme, index): ResultItem => {
1177
- const current = theme.id === currentThemeId;
1178
- if (current) selectedIdx = index;
1179
- return {
1180
- id: `theme:${theme.id}`,
1181
- label: theme.name,
1182
- detail: theme.description,
1183
- category: "Themes",
1184
- kind: "theme",
1185
- current,
1186
- themeId: theme.id,
1187
- action: () => {
1188
- options?.onSelectTheme?.(theme.id);
1189
- const nextConfig = {
1190
- ...state.config,
1191
- theme: theme.id,
1192
- };
1193
- dispatch({ type: "SET_THEME", theme: theme.id });
1194
- void saveConfig(nextConfig);
1195
- closeAll({ revertThemePreview: false });
1196
- },
1197
- };
1198
- });
1199
- return { items, selectedIdx };
1200
- }, [closeAll, dispatch, state.config]);
1201
-
1202
1489
  const buildPluginItems = useCallback((query: string): ResultItem[] => {
1203
1490
  const disabledPlugins = state.config.disabledPlugins || [];
1204
1491
  const toggleable = [...pluginRegistry.allPlugins.values()].filter((plugin) => plugin.toggleable);
@@ -2379,20 +2666,24 @@ export function CommandBar({
2379
2666
  tickerSearchCacheRef.current.clear();
2380
2667
  }, [state.tickers]);
2381
2668
 
2669
+ const createQuickLookLocalTickerCandidates = useCallback((tickers: Iterable<TickerRecord>) => (
2670
+ createLocalTickerSearchCandidates(tickers, new Map(), QUICK_LOOK_TICKER_SEARCH_OPTIONS)
2671
+ ), []);
2672
+
2382
2673
  const localTickerSearchResultItems = useCallback((query?: string, options?: {
2383
2674
  category?: string;
2384
2675
  limit?: number;
2385
2676
  }): ResultItem[] => {
2386
2677
  const items = query
2387
- ? rankTickerSearchItems(createLocalTickerSearchCandidates(state.tickers.values()), query)
2388
- : createLocalTickerSearchCandidates(state.tickers.values());
2678
+ ? rankTickerSearchItems(createQuickLookLocalTickerCandidates(state.tickers.values()), query)
2679
+ : createQuickLookLocalTickerCandidates(state.tickers.values());
2389
2680
  return items
2390
2681
  .slice(0, options?.limit)
2391
2682
  .map((candidate) => ({
2392
2683
  ...mapTickerSearchCandidateToResultItem(candidate),
2393
2684
  category: options?.category ?? candidate.category,
2394
2685
  }));
2395
- }, [mapTickerSearchCandidateToResultItem, state.tickers]);
2686
+ }, [createQuickLookLocalTickerCandidates, mapTickerSearchCandidateToResultItem, state.tickers]);
2396
2687
 
2397
2688
  const adaptTickerSearchRouteResult = useCallback((
2398
2689
  item: ResultItem,
@@ -2812,6 +3103,8 @@ export function CommandBar({
2812
3103
  const importPath = getDefaultConfigBackupPath();
2813
3104
  void importConfig(state.config.dataDir, importPath)
2814
3105
  .then((imported) => {
3106
+ themePickerRef.current?.cancelPreview();
3107
+ clearTransientThemePreview();
2815
3108
  dispatch({ type: "SET_CONFIG", config: imported });
2816
3109
  applyTheme(imported.theme);
2817
3110
  dispatch({ type: "SET_THEME", theme: imported.theme });
@@ -2843,6 +3136,10 @@ export function CommandBar({
2843
3136
  void onCheckForUpdates?.();
2844
3137
  closeAll({ revertThemePreview: false });
2845
3138
  return;
3139
+ case "theme":
3140
+ rootThemeBaseIdRef.current = stateRef.current.config.theme;
3141
+ setRootQuery(arg ? `TH ${arg}` : "TH ");
3142
+ return;
2846
3143
  case "security-description":
2847
3144
  void runSecurityDescriptionShortcut(arg);
2848
3145
  return;
@@ -2879,9 +3176,12 @@ export function CommandBar({
2879
3176
  quitApp,
2880
3177
  runSecurityDescriptionShortcut,
2881
3178
  executeCollectionCommand,
3179
+ setRootQuery,
2882
3180
  ]);
2883
3181
 
2884
3182
  const activeMatch = matchPrefix(rootQuery, availableCommands);
3183
+ const themePickerActive = !currentRoute && activeMatch?.command.id === "theme";
3184
+ const themePickerFilter = themePickerActive ? activeMatch.arg : "";
2885
3185
  const rootSecurityDescriptionArg = activeMatch?.command.id === "security-description" && activeMatch.arg.length >= 1
2886
3186
  ? activeMatch.arg
2887
3187
  : null;
@@ -2903,13 +3203,13 @@ export function CommandBar({
2903
3203
  rootThemeBaseIdRef.current = state.config.theme;
2904
3204
  } else if (rootModeInfo.kind !== "themes" && previousMode === "themes") {
2905
3205
  const rootThemeBaseId = rootThemeBaseIdRef.current;
2906
- if (rootThemeBaseId && getEffectiveThemeId(state) !== rootThemeBaseId) {
3206
+ if (rootThemeBaseId) {
2907
3207
  clearThemePreview(rootThemeBaseId);
2908
3208
  }
2909
3209
  rootThemeBaseIdRef.current = null;
2910
3210
  }
2911
3211
  previousRootModeRef.current = rootModeInfo.kind;
2912
- }, [clearThemePreview, currentRoute, rootModeInfo.kind, state.config.theme, state.themePreview]);
3212
+ }, [clearThemePreview, currentRoute, rootModeInfo.kind, state.config.theme]);
2913
3213
 
2914
3214
  const tickerSearchRouteQuery = currentRoute?.kind === "mode" && currentRoute.screen === "ticker-search"
2915
3215
  ? currentRoute.query
@@ -2957,6 +3257,7 @@ export function CommandBar({
2957
3257
  brokerId: activePortfolio?.brokerId,
2958
3258
  brokerInstanceId: activePortfolio?.brokerInstanceId,
2959
3259
  },
3260
+ ...QUICK_LOOK_TICKER_SEARCH_OPTIONS,
2960
3261
  });
2961
3262
  if (requestId !== searchRequestIdRef.current) return;
2962
3263
  writeTickerSearchCache(
@@ -3247,14 +3548,7 @@ export function CommandBar({
3247
3548
  } else if (match && match.command.id === "layout") {
3248
3549
  items.push(...buildLayoutItems(match.arg, { confirmDangerousActions: true }));
3249
3550
  } else if (match && match.command.id === "theme") {
3250
- const savedThemeId = rootThemeBaseIdRef.current ?? state.config.theme;
3251
- const themeResult = buildThemeItems(match.arg, savedThemeId, {
3252
- onSelectTheme: (themeId) => {
3253
- rootThemeBaseIdRef.current = themeId;
3254
- },
3255
- });
3256
- initialIdx = themeResult.selectedIdx;
3257
- items.push(...themeResult.items);
3551
+ initialIdx = 0;
3258
3552
  } else if (match && match.command.id === "security-description") {
3259
3553
  if (shortcutItem) {
3260
3554
  items.push(shortcutItem);
@@ -3281,18 +3575,26 @@ export function CommandBar({
3281
3575
  const recentSymbols = state.recentTickers.slice(0, maxDefaultTickers);
3282
3576
  const recentTickers = recentSymbols
3283
3577
  .map((symbol) => state.tickers.get(symbol))
3284
- .filter((ticker): ticker is NonNullable<typeof ticker> => ticker != null);
3578
+ .filter((ticker): ticker is NonNullable<typeof ticker> => (
3579
+ ticker != null && createQuickLookLocalTickerCandidates([ticker]).length > 0
3580
+ ));
3285
3581
  if (recentTickers.length < maxDefaultTickers) {
3286
3582
  const seen = new Set(recentSymbols);
3287
3583
  for (const ticker of state.tickers.values()) {
3288
3584
  if (recentTickers.length >= maxDefaultTickers) break;
3585
+ if (createQuickLookLocalTickerCandidates([ticker]).length === 0) continue;
3289
3586
  if (!seen.has(ticker.metadata.ticker)) recentTickers.push(ticker);
3290
3587
  }
3291
3588
  }
3292
- items.push(...recentTickers.map((ticker) => ({
3293
- ...mapTickerSearchCandidateToResultItem(createLocalTickerSearchCandidates([ticker])[0]!),
3294
- category: "Tickers",
3295
- })));
3589
+ items.push(...recentTickers.flatMap((ticker) => {
3590
+ const candidate = createQuickLookLocalTickerCandidates([ticker])[0];
3591
+ return candidate
3592
+ ? [{
3593
+ ...mapTickerSearchCandidateToResultItem(candidate),
3594
+ category: "Tickers",
3595
+ }]
3596
+ : [];
3597
+ }));
3296
3598
  items.push(...paneShortcutItems());
3297
3599
  for (const command of availableCommands) {
3298
3600
  const item = commandToItem(command);
@@ -3324,7 +3626,7 @@ export function CommandBar({
3324
3626
  availableCommands,
3325
3627
  buildLayoutItems,
3326
3628
  buildPluginItems,
3327
- buildThemeItems,
3629
+ createQuickLookLocalTickerCandidates,
3328
3630
  createPluginCommandItem,
3329
3631
  currentRoute,
3330
3632
  executeCollectionCommand,
@@ -3412,6 +3714,7 @@ export function CommandBar({
3412
3714
  brokerId: activeSearchPortfolio?.brokerId,
3413
3715
  brokerInstanceId: activeSearchPortfolio?.brokerInstanceId,
3414
3716
  },
3717
+ ...QUICK_LOOK_TICKER_SEARCH_OPTIONS,
3415
3718
  });
3416
3719
  if (requestId !== rootSearchRequestIdRef.current) return;
3417
3720
  writeTickerSearchCache(
@@ -3461,6 +3764,7 @@ export function CommandBar({
3461
3764
  }
3462
3765
  return rootResultModel.items;
3463
3766
  }, [rootProviderResults, rootProviderResultsQuery, rootResultModel.items, rootSecurityDescriptionArg]);
3767
+ const orderedRootResults = useMemo(() => orderListResults(rootResults), [rootResults]);
3464
3768
 
3465
3769
  const rootGhostCompletion = !currentRoute && rootShortcutIntent.kind === "inferred-complete"
3466
3770
  ? rootShortcutIntent.completionQuery
@@ -3666,12 +3970,15 @@ export function CommandBar({
3666
3970
 
3667
3971
  if (match.command.id === "theme") {
3668
3972
  return {
3669
- id: "theme-route",
3973
+ id: "theme-picker",
3670
3974
  label: "Change Theme",
3671
3975
  detail: "Preview and apply themes",
3672
3976
  category: "Themes",
3673
3977
  kind: "command",
3674
- action: () => openModeRoute("themes", match.arg),
3978
+ action: () => {
3979
+ rootThemeBaseIdRef.current = stateRef.current.config.theme;
3980
+ setRootQuery(match.arg ? `TH ${match.arg}` : "TH ");
3981
+ },
3675
3982
  };
3676
3983
  }
3677
3984
 
@@ -3737,6 +4044,7 @@ export function CommandBar({
3737
4044
  openModeRoute,
3738
4045
  runDirectCommand,
3739
4046
  runSecurityDescriptionShortcut,
4047
+ setRootQuery,
3740
4048
  ]);
3741
4049
 
3742
4050
  const routeListState = useMemo<ListScreenState | null>(() => {
@@ -3752,7 +4060,7 @@ export function CommandBar({
3752
4060
  query: rootQuery,
3753
4061
  selectedIdx: rootSelectedIdx,
3754
4062
  hoveredIdx: rootHoveredIdx,
3755
- results: orderListResults(rootResults),
4063
+ results: orderedRootResults,
3756
4064
  searching: rootSearching,
3757
4065
  emptyLabel: emptyState.label,
3758
4066
  emptyDetail: emptyState.detail,
@@ -3763,23 +4071,6 @@ export function CommandBar({
3763
4071
 
3764
4072
  if (currentRoute.kind === "mode") {
3765
4073
  switch (currentRoute.screen) {
3766
- case "themes": {
3767
- const results = buildThemeItems(currentRoute.query, currentRoute.themeBaseId).items;
3768
- return {
3769
- kind: "mode",
3770
- title: "Change Theme",
3771
- subtitle: "Preview themes with the keyboard, then save.",
3772
- query: currentRoute.query,
3773
- selectedIdx: currentRoute.selectedIdx,
3774
- hoveredIdx: currentRoute.hoveredIdx,
3775
- results: orderListResults(results),
3776
- searching: false,
3777
- emptyLabel: getEmptyState("themes", currentRoute.query).label,
3778
- emptyDetail: getEmptyState("themes", currentRoute.query).detail,
3779
- footerLeft: getScreenFooterLeft(currentRoute),
3780
- footerRight: getScreenFooterRight(currentRoute),
3781
- };
3782
- }
3783
4074
  case "plugins": {
3784
4075
  const results = buildPluginItems(currentRoute.query);
3785
4076
  return {
@@ -3911,7 +4202,6 @@ export function CommandBar({
3911
4202
  activeTickerSymbol,
3912
4203
  buildLayoutItems,
3913
4204
  buildPluginItems,
3914
- buildThemeItems,
3915
4205
  closeAll,
3916
4206
  createPaneTemplateItem,
3917
4207
  currentRoute,
@@ -3935,7 +4225,7 @@ export function CommandBar({
3935
4225
  rootHoveredIdx,
3936
4226
  rootModeInfo.kind,
3937
4227
  rootQuery,
3938
- rootResults,
4228
+ orderedRootResults,
3939
4229
  rootSearching,
3940
4230
  rootSelectedIdx,
3941
4231
  rootShortcutIntent,
@@ -3972,8 +4262,9 @@ export function CommandBar({
3972
4262
  }, [currentRoute, routeListState, updateTopRoute]);
3973
4263
 
3974
4264
  const setActiveListQuery = useCallback((nextQuery: string) => {
3975
- if (!currentRoute) {
3976
- if (rootModeInfo.kind === "themes" && resolveCommandBarMode(nextQuery, availableCommands).kind !== "themes") {
4265
+ const route = currentRouteRef.current;
4266
+ if (!route) {
4267
+ if (rootModeKindRef.current === "themes" && resolveCommandBarMode(nextQuery, availableCommands).kind !== "themes") {
3977
4268
  clearThemePreview(rootThemeBaseIdRef.current ?? stateRef.current.config.theme);
3978
4269
  rootThemeBaseIdRef.current = null;
3979
4270
  }
@@ -3981,7 +4272,7 @@ export function CommandBar({
3981
4272
  return;
3982
4273
  }
3983
4274
 
3984
- if (currentRoute.kind === "mode" || currentRoute.kind === "picker" || currentRoute.kind === "pane-settings") {
4275
+ if (route.kind === "mode" || route.kind === "picker" || route.kind === "pane-settings") {
3985
4276
  updateTopRoute((route) => {
3986
4277
  if (route.kind === "mode" || route.kind === "picker" || route.kind === "pane-settings") {
3987
4278
  return { ...route, query: nextQuery, selectedIdx: 0, hoveredIdx: null };
@@ -3989,19 +4280,21 @@ export function CommandBar({
3989
4280
  return route;
3990
4281
  });
3991
4282
  }
3992
- }, [availableCommands, clearThemePreview, currentRoute, rootModeInfo.kind, setRootQuery, updateTopRoute]);
4283
+ }, [availableCommands, clearThemePreview, setRootQuery, updateTopRoute]);
3993
4284
 
3994
- const moveListSelection = useCallback((delta: number) => {
4285
+ const applyListSelectionDelta = useCallback((delta: number) => {
3995
4286
  const listState = visibleListStateRef.current;
3996
4287
  if (!listState || listState.results.length === 0 || delta === 0) return;
3997
4288
  const nextIndex = clampListIndex(listState.selectedIdx + delta, listState.results.length);
4289
+ const selectionChanged = nextIndex !== listState.selectedIdx;
4290
+ const hoverChanged = listState.hoveredIdx !== null;
4291
+ if (!selectionChanged && !hoverChanged) return;
3998
4292
  const nextListState = { ...listState, selectedIdx: nextIndex, hoveredIdx: null };
3999
4293
  visibleListStateRef.current = nextListState;
4000
- previewThemeSelection(nextListState, nextIndex);
4001
4294
 
4002
4295
  if (!currentRouteRef.current) {
4003
- setRootSelectedIdx(nextIndex);
4004
- setRootHoveredIdx(null);
4296
+ setRootSelectedIdx((current) => (current === nextIndex ? current : nextIndex));
4297
+ setRootHoveredIdx((current) => (current === null ? current : null));
4005
4298
  return;
4006
4299
  }
4007
4300
  setRouteStack((current) => {
@@ -4011,24 +4304,34 @@ export function CommandBar({
4011
4304
  if (!top || (top.kind !== "mode" && top.kind !== "picker" && top.kind !== "pane-settings")) {
4012
4305
  return current;
4013
4306
  }
4307
+ if (top.selectedIdx === nextIndex && top.hoveredIdx === null) return current;
4014
4308
  next[next.length - 1] = { ...top, selectedIdx: nextIndex, hoveredIdx: null };
4015
4309
  return next;
4016
4310
  });
4017
- }, [previewThemeSelection]);
4311
+ }, []);
4312
+
4313
+ const moveListSelection = useCallback((delta: number) => {
4314
+ applyListSelectionDelta(delta);
4315
+ }, [applyListSelectionDelta]);
4018
4316
 
4019
4317
  const setHoveredIndex = useCallback((index: number | null) => {
4020
- if (!currentRoute) {
4318
+ if (!currentRouteRef.current) {
4021
4319
  setRootHoveredIdx((current) => (current === index ? current : index));
4022
4320
  return;
4023
4321
  }
4024
- updateTopRoute((route) => {
4322
+ setRouteStack((current) => {
4323
+ if (current.length === 0) return current;
4324
+ const next = [...current];
4325
+ const route = next[next.length - 1];
4326
+ if (!route) return current;
4025
4327
  if (route.kind === "mode" || route.kind === "picker" || route.kind === "pane-settings") {
4026
- if (route.hoveredIdx === index) return route;
4027
- return { ...route, hoveredIdx: index };
4328
+ if (route.hoveredIdx === index) return current;
4329
+ next[next.length - 1] = { ...route, hoveredIdx: index };
4330
+ return next;
4028
4331
  }
4029
- return route;
4332
+ return current;
4030
4333
  });
4031
- }, [currentRoute, updateTopRoute]);
4334
+ }, []);
4032
4335
 
4033
4336
  const handleListScroll = useCallback((event: {
4034
4337
  stopPropagation: () => void;
@@ -4402,6 +4705,29 @@ export function CommandBar({
4402
4705
  disconnectBrokerInstance,
4403
4706
  executeCollectionCommand,
4404
4707
  ]);
4708
+ const activateListSelectionRef = useRef(activateListSelection);
4709
+ activateListSelectionRef.current = activateListSelection;
4710
+
4711
+ const handleListRowMouseDown = useCallback((event: any, item: ResultItem, globalIdx: number) => {
4712
+ event.stopPropagation?.();
4713
+ event.preventDefault?.();
4714
+ if (!currentRouteRef.current) {
4715
+ setRootSelectedIdx((current) => (current === globalIdx ? current : globalIdx));
4716
+ } else {
4717
+ setRouteStack((current) => {
4718
+ if (current.length === 0) return current;
4719
+ const next = [...current];
4720
+ const route = next[next.length - 1];
4721
+ if (!route || (route.kind !== "mode" && route.kind !== "picker" && route.kind !== "pane-settings")) {
4722
+ return current;
4723
+ }
4724
+ if (route.selectedIdx === globalIdx && route.hoveredIdx === globalIdx) return current;
4725
+ next[next.length - 1] = { ...route, selectedIdx: globalIdx, hoveredIdx: globalIdx };
4726
+ return next;
4727
+ });
4728
+ }
4729
+ activateListSelectionRef.current({ item });
4730
+ }, []);
4405
4731
 
4406
4732
  const confirmCurrentRoute = useCallback(async () => {
4407
4733
  if (currentRoute?.kind !== "confirm") return;
@@ -4539,6 +4865,7 @@ export function CommandBar({
4539
4865
  preventDefault: () => void;
4540
4866
  }) => void) => void;
4541
4867
  };
4868
+ if (!keyInput) return undefined;
4542
4869
  const handleKeyPress = (event: {
4543
4870
  name: string;
4544
4871
  sequence?: string;
@@ -4735,6 +5062,27 @@ export function CommandBar({
4735
5062
  return;
4736
5063
  }
4737
5064
 
5065
+ if (themePickerActive) {
5066
+ if (event.name === "up" || (event.ctrl && event.name === "p")) {
5067
+ event.stopPropagation();
5068
+ event.preventDefault();
5069
+ themePickerRef.current?.move(-1);
5070
+ return;
5071
+ }
5072
+ if (event.name === "down" || (event.ctrl && event.name === "n")) {
5073
+ event.stopPropagation();
5074
+ event.preventDefault();
5075
+ themePickerRef.current?.move(1);
5076
+ return;
5077
+ }
5078
+ if (event.name === "return" || event.name === "enter") {
5079
+ event.stopPropagation();
5080
+ event.preventDefault();
5081
+ themePickerRef.current?.commit();
5082
+ return;
5083
+ }
5084
+ }
5085
+
4738
5086
  const activeListState = visibleListStateRef.current;
4739
5087
  if (!activeListState) return;
4740
5088
 
@@ -4802,13 +5150,13 @@ export function CommandBar({
4802
5150
  if (keyInput.onInternal) {
4803
5151
  keyInput.onInternal("keypress", handleKeyPress);
4804
5152
  } else {
4805
- renderer.keyInput.on("keypress", handleKeyPress);
5153
+ keyInput.on("keypress", handleKeyPress);
4806
5154
  }
4807
5155
  return () => {
4808
5156
  if (keyInput.offInternal) {
4809
5157
  keyInput.offInternal("keypress", handleKeyPress);
4810
5158
  } else {
4811
- renderer.keyInput.off("keypress", handleKeyPress);
5159
+ keyInput.off("keypress", handleKeyPress);
4812
5160
  }
4813
5161
  };
4814
5162
  }, [
@@ -4828,6 +5176,7 @@ export function CommandBar({
4828
5176
  renderer,
4829
5177
  rootModeInfo.kind,
4830
5178
  setActiveListQuery,
5179
+ themePickerActive,
4831
5180
  updateWorkflowValue,
4832
5181
  ]);
4833
5182
 
@@ -4849,8 +5198,23 @@ export function CommandBar({
4849
5198
  ? routeListState
4850
5199
  : null;
4851
5200
  const showCustomMultiSelectPicker = currentRoute?.kind === "picker" && currentRoute.pickerId === "field-multi-select";
4852
- const listRows = visibleListState ? buildListRows(visibleListState) : [];
4853
- const nativeListRows = visibleListState ? buildNativeListRows(visibleListState, listRows) : [];
5201
+ const listRows = useMemo(
5202
+ () => (visibleListState ? buildListRows(visibleListState) : []),
5203
+ [visibleListState?.results],
5204
+ );
5205
+ const nativeListRows = useMemo(
5206
+ () => (visibleListState ? buildNativeListRows(visibleListState, listRows) : []),
5207
+ [listRows, visibleListState?.emptyLabel, visibleListState?.searching],
5208
+ );
5209
+ const listRowIndexByGlobalIndex = useMemo(() => {
5210
+ const indexByGlobalIndex = new Map<number, number>();
5211
+ listRows.forEach((row, index) => {
5212
+ if (row.kind === "item") {
5213
+ indexByGlobalIndex.set(row.globalIdx, index);
5214
+ }
5215
+ });
5216
+ return indexByGlobalIndex;
5217
+ }, [listRows]);
4854
5218
  const workflowBodyHeight = currentRoute?.kind === "workflow"
4855
5219
  ? Math.min(
4856
5220
  Math.max(9, termHeight - (nativePaneChrome ? 7 : 9)),
@@ -4879,26 +5243,30 @@ export function CommandBar({
4879
5243
  const trailingWidth = Math.max(8, Math.min(12, Math.floor(resultsInnerWidth * 0.18)));
4880
5244
  const labelWidth = Math.max(10, resultsInnerWidth - trailingWidth);
4881
5245
  const queryDisplayWidth = Math.max(8, resultsInnerWidth);
4882
- const nativeSelectedRowIndex = nativePaneChrome && visibleListState
4883
- ? nativeListRows.findIndex((row) => row.kind === "item" && row.globalIdx === visibleListState.selectedIdx)
5246
+ const selectedListRowIndex = visibleListState
5247
+ ? listRowIndexByGlobalIndex.get(visibleListState.selectedIdx) ?? -1
4884
5248
  : -1;
5249
+ const selectedScrollRowIndex = selectedListRowIndex;
4885
5250
  const bodySlotKey = showCustomMultiSelectPicker
4886
5251
  ? "picker:field-multi-select"
4887
- : currentRoute?.kind === "picker"
4888
- ? `picker:${currentRoute.pickerId}`
4889
- : currentRoute?.kind ?? "root";
5252
+ : themePickerActive
5253
+ ? "theme-picker"
5254
+ : currentRoute?.kind === "picker"
5255
+ ? `picker:${currentRoute.pickerId}`
5256
+ : currentRoute?.kind ?? "root";
4890
5257
 
4891
- useEffect(() => {
4892
- if (!nativePaneChrome || nativeSelectedRowIndex < 0) return;
5258
+ useLayoutEffect(() => {
4893
5259
  const scrollBox = nativeListScrollRef.current;
4894
5260
  if (!scrollBox) return;
5261
+ if (scrollBox.verticalScrollBar) scrollBox.verticalScrollBar.visible = false;
5262
+ if (selectedScrollRowIndex < 0) return;
4895
5263
  const viewportHeight = Math.max(1, scrollBox.viewport?.height ?? listBodyHeight);
4896
- if (nativeSelectedRowIndex < scrollBox.scrollTop) {
4897
- scrollBox.scrollTo(nativeSelectedRowIndex);
4898
- } else if (nativeSelectedRowIndex >= scrollBox.scrollTop + viewportHeight) {
4899
- scrollBox.scrollTo(nativeSelectedRowIndex - viewportHeight + 1);
5264
+ if (selectedScrollRowIndex < scrollBox.scrollTop) {
5265
+ scrollBox.scrollTo(selectedScrollRowIndex);
5266
+ } else if (selectedScrollRowIndex >= scrollBox.scrollTop + viewportHeight) {
5267
+ scrollBox.scrollTo(selectedScrollRowIndex - viewportHeight + 1);
4900
5268
  }
4901
- }, [listBodyHeight, nativePaneChrome, nativeSelectedRowIndex, visibleListState?.kind, visibleListState?.query]);
5269
+ }, [listBodyHeight, selectedScrollRowIndex, visibleListState?.kind, visibleListState?.query]);
4902
5270
 
4903
5271
  useEffect(() => {
4904
5272
  if (!nativePaneChrome || currentRoute?.kind !== "workflow") return;
@@ -4919,140 +5287,6 @@ export function CommandBar({
4919
5287
  }
4920
5288
  }, [bodyHeight, currentRoute, nativePaneChrome]);
4921
5289
 
4922
- const renderListBody = () => {
4923
- if (!visibleListState) return null;
4924
-
4925
- const allRows = listRows;
4926
- let visibleRows: CommandBarListRow[];
4927
- if (nativePaneChrome) {
4928
- visibleRows = nativeListRows;
4929
- } else if (visibleListState.searching && allRows.length === 0) {
4930
- visibleRows = [{ kind: "spinner", id: "searching", label: "Searching…" }];
4931
- } else if (allRows.length === 0) {
4932
- visibleRows = [{ kind: "message", id: "empty", label: visibleListState.emptyLabel }];
4933
- } else {
4934
- const selectedRowIdx = allRows.findIndex((row) => row.kind === "item" && row.globalIdx === visibleListState.selectedIdx);
4935
- const halfWindow = Math.floor(listBodyHeight / 2);
4936
- let windowStart = Math.max(0, Math.min(selectedRowIdx - halfWindow, allRows.length - listBodyHeight));
4937
- if (windowStart < 0) windowStart = 0;
4938
- visibleRows = allRows.slice(windowStart, windowStart + listBodyHeight);
4939
- if (visibleListState.searching) {
4940
- if (visibleRows.length >= listBodyHeight) {
4941
- visibleRows = visibleRows.slice(0, listBodyHeight - 1);
4942
- }
4943
- visibleRows.push({ kind: "spinner", id: "searching", label: "Searching…" });
4944
- }
4945
- }
4946
-
4947
- while (!nativePaneChrome && visibleRows.length < listBodyHeight) {
4948
- visibleRows.push({ kind: "filler", id: `filler:${visibleRows.length}` });
4949
- }
4950
-
4951
- const renderedRows = (
4952
- <>
4953
- {visibleRows.map((row) => {
4954
- if (row.kind === "filler" || row.kind === "spacer") {
4955
- return <Box key={row.id} height={1} />;
4956
- }
4957
- if (row.kind === "spinner") {
4958
- return (
4959
- <Box key={row.id} height={1} paddingX={contentPadding} {...(!nativePaneChrome ? { onMouseScroll: handleListScroll } : {})}>
4960
- <Spinner label={row.label} />
4961
- </Box>
4962
- );
4963
- }
4964
- if (row.kind === "message") {
4965
- return (
4966
- <Box key={row.id} height={1} paddingX={contentPadding} {...(!nativePaneChrome ? { onMouseScroll: handleListScroll } : {})}>
4967
- <Text fg={paletteText}>{truncateText(row.label, queryDisplayWidth)}</Text>
4968
- </Box>
4969
- );
4970
- }
4971
- if (row.kind === "heading") {
4972
- return (
4973
- <Box key={row.id} height={1} paddingX={contentPadding} {...(!nativePaneChrome ? { onMouseScroll: handleListScroll } : {})}>
4974
- <Text attributes={TextAttributes.BOLD} fg={paletteHeadingText}>
4975
- {truncateText(row.label, queryDisplayWidth)}
4976
- </Text>
4977
- </Box>
4978
- );
4979
- }
4980
-
4981
- const isSelected = row.globalIdx === visibleListState.selectedIdx;
4982
- const isHovered = row.globalIdx === visibleListState.hoveredIdx && !isSelected;
4983
- const presentation = getRowPresentation(row.item, isSelected, trailingWidth > 0);
4984
- const label = truncateText(presentation.label, labelWidth);
4985
- const trailing = truncateText(presentation.trailing, trailingWidth);
4986
- return (
4987
- <Box
4988
- key={row.item.id}
4989
- flexDirection="row"
4990
- height={1}
4991
- paddingX={contentPadding}
4992
- backgroundColor={isSelected
4993
- ? paletteSelectedBg
4994
- : isHovered
4995
- ? paletteHoverBg
4996
- : (nativePaneChrome ? panelBg : paletteBg)}
4997
- onMouseMove={() => setHoveredIndex(row.globalIdx)}
4998
- onMouseOut={() => setHoveredIndex(null)}
4999
- {...(!nativePaneChrome ? { onMouseScroll: handleListScroll } : {})}
5000
- onMouseDown={(event: any) => {
5001
- event.stopPropagation?.();
5002
- event.preventDefault?.();
5003
- if (!currentRoute) {
5004
- setRootSelectedIdx(row.globalIdx);
5005
- } else {
5006
- updateTopRoute((route) => {
5007
- if (route.kind === "mode" || route.kind === "picker" || route.kind === "pane-settings") {
5008
- return { ...route, selectedIdx: row.globalIdx, hoveredIdx: row.globalIdx };
5009
- }
5010
- return route;
5011
- });
5012
- }
5013
- activateListSelection({ item: row.item });
5014
- }}
5015
- data-command-bar-row-selected={nativePaneChrome && isSelected ? "true" : undefined}
5016
- style={nativePaneChrome ? { borderRadius: 6 } : undefined}
5017
- >
5018
- <Box width={labelWidth}>
5019
- <Text fg={isSelected ? paletteSelectedText : presentation.primaryMuted ? paletteSubtleText : paletteText}>
5020
- {label}
5021
- </Text>
5022
- </Box>
5023
- <Box width={trailingWidth}>
5024
- <Text fg={isSelected ? paletteSelectedText : paletteSubtleText}>{trailing}</Text>
5025
- </Box>
5026
- </Box>
5027
- );
5028
- })}
5029
- </>
5030
- );
5031
-
5032
- if (nativePaneChrome) {
5033
- return (
5034
- <ScrollBox
5035
- ref={nativeListScrollRef}
5036
- flexDirection="column"
5037
- height={listBodyHeight}
5038
- scrollY
5039
- >
5040
- {renderedRows}
5041
- </ScrollBox>
5042
- );
5043
- }
5044
-
5045
- return (
5046
- <Box
5047
- flexDirection="column"
5048
- height={listBodyHeight}
5049
- onMouseScroll={handleListScroll}
5050
- >
5051
- {renderedRows}
5052
- </Box>
5053
- );
5054
- };
5055
-
5056
5290
  const renderWorkflowBody = () => {
5057
5291
  if (currentRoute?.kind !== "workflow") return null;
5058
5292
  const visibleFields = getVisibleWorkflowFields(currentRoute.fields, currentRoute.values);
@@ -5130,7 +5364,7 @@ export function CommandBar({
5130
5364
  borderColor={active ? paletteSelectedBg : paletteBg}
5131
5365
  backgroundColor={nativePaneChrome ? inputBg : fieldBg}
5132
5366
  style={nativePaneChrome ? {
5133
- border: `1px solid ${active ? colors.borderFocused : colors.border}`,
5367
+ border: `1px solid ${active ? themeColors.borderFocused : themeColors.border}`,
5134
5368
  borderRadius: 6,
5135
5369
  overflow: "hidden",
5136
5370
  } : undefined}
@@ -5144,7 +5378,7 @@ export function CommandBar({
5144
5378
  focused={!currentRoute.pending}
5145
5379
  textColor={paletteText}
5146
5380
  placeholderColor={paletteSubtleText}
5147
- backgroundColor={nativePaneChrome ? inputBg : colors.panel}
5381
+ backgroundColor={nativePaneChrome ? inputBg : themeColors.panel}
5148
5382
  flexGrow={1}
5149
5383
  wrapText
5150
5384
  />
@@ -5225,7 +5459,7 @@ export function CommandBar({
5225
5459
  })}
5226
5460
  {currentRoute.error && (
5227
5461
  <Box height={1}>
5228
- <Text fg={colors.negative}>{truncateText(currentRoute.error, queryDisplayWidth)}</Text>
5462
+ <Text fg={themeColors.negative}>{truncateText(currentRoute.error, queryDisplayWidth)}</Text>
5229
5463
  </Box>
5230
5464
  )}
5231
5465
  {currentRoute.pendingLabel && currentRoute.pending && (
@@ -5366,10 +5600,9 @@ export function CommandBar({
5366
5600
  <Box flexGrow={1}>
5367
5601
  <Text fg={paletteText} attributes={TextAttributes.BOLD}>
5368
5602
  {currentRoute?.kind === "mode"
5369
- ? currentRoute.screen === "themes" ? "Change Theme"
5370
- : currentRoute.screen === "plugins" ? "Manage Plugins"
5371
- : currentRoute.screen === "layout" ? "Layout Actions"
5372
- : "Security Description"
5603
+ ? currentRoute.screen === "plugins" ? "Manage Plugins"
5604
+ : currentRoute.screen === "layout" ? "Layout Actions"
5605
+ : "Security Description"
5373
5606
  : currentRoute?.kind === "picker" ? currentRoute.title
5374
5607
  : currentRoute?.kind === "pane-settings" ? "Pane Settings"
5375
5608
  : currentRoute?.kind === "workflow" ? currentRoute.title
@@ -5398,61 +5631,80 @@ export function CommandBar({
5398
5631
  )}
5399
5632
  {(visibleListState || currentRoute?.kind === "picker") && visibleListState && (
5400
5633
  <>
5401
- <Box height={1} paddingX={contentPadding}>
5402
- <Box
5403
- width={queryDisplayWidth}
5404
- height={1}
5405
- position="relative"
5406
- backgroundColor={nativePaneChrome ? undefined : inputBg}
5407
- style={nativePaneChrome ? undefined : {
5408
- overflow: "hidden",
5409
- }}
5410
- >
5411
- <Input
5412
- value={visibleListState.query}
5413
- onInput={setActiveListQuery}
5414
- onChange={setActiveListQuery}
5415
- placeholder={visibleListState.kind === "root" ? "Search" : "Filter"}
5416
- focused
5417
- width={nativePaneChrome ? "100%" : queryDisplayWidth}
5418
- backgroundColor={nativePaneChrome ? "transparent" : paletteBg}
5419
- focusedBackgroundColor={nativePaneChrome ? "transparent" : paletteBg}
5420
- textColor={paletteText}
5421
- focusedTextColor={paletteText}
5422
- placeholderColor={paletteSubtleText}
5423
- cursorColor={colors.textBright}
5424
- />
5425
- {visibleListState.kind === "root" && rootGhostSuffix && (
5426
- <Box
5427
- position="absolute"
5428
- top={0}
5429
- left={Math.max(0, Math.min(rootQuery.length, queryDisplayWidth - 1))}
5430
- width={Math.max(0, queryDisplayWidth - Math.min(rootQuery.length, queryDisplayWidth - 1))}
5431
- height={1}
5432
- >
5433
- <Text fg={paletteSubtleText}>
5434
- {truncateText(
5435
- rootGhostSuffix,
5436
- Math.max(0, queryDisplayWidth - Math.min(rootQuery.length, queryDisplayWidth - 1)),
5437
- )}
5438
- </Text>
5439
- </Box>
5440
- )}
5441
- </Box>
5442
- </Box>
5443
- <Box height={1} paddingX={contentPadding}>
5444
- {visibleListState.kind === "root" && rootShortcutFeedback
5445
- ? (
5446
- <Text fg={paletteSubtleText}>
5447
- {truncateText(rootShortcutFeedback, queryDisplayWidth)}
5448
- </Text>
5449
- )
5450
- : null}
5451
- </Box>
5634
+ <CommandBarListHeader
5635
+ kind={visibleListState.kind}
5636
+ query={visibleListState.query}
5637
+ queryDisplayWidth={queryDisplayWidth}
5638
+ nativePaneChrome={nativePaneChrome}
5639
+ inputBg={inputBg}
5640
+ paletteBg={paletteBg}
5641
+ paletteText={paletteText}
5642
+ paletteSubtleText={paletteSubtleText}
5643
+ cursorColor={themeColors.textBright}
5644
+ contentPadding={contentPadding}
5645
+ rootGhostSuffix={rootGhostSuffix}
5646
+ rootQueryLength={rootQuery.length}
5647
+ rootShortcutFeedback={rootShortcutFeedback}
5648
+ onQueryChange={setActiveListQuery}
5649
+ />
5452
5650
  </>
5453
5651
  )}
5454
5652
 
5455
- {visibleListState && !showCustomMultiSelectPicker && renderListBody()}
5653
+ {themePickerActive && (
5654
+ <ThemePicker
5655
+ ref={themePickerRef}
5656
+ filter={themePickerFilter}
5657
+ committedThemeId={state.config.theme}
5658
+ height={listBodyHeight}
5659
+ contentPadding={contentPadding}
5660
+ labelWidth={labelWidth}
5661
+ trailingWidth={trailingWidth}
5662
+ queryDisplayWidth={queryDisplayWidth}
5663
+ nativePaneChrome={nativePaneChrome}
5664
+ paletteBg={paletteBg}
5665
+ paletteHoverBg={paletteHoverBg}
5666
+ paletteSelectedBg={paletteSelectedBg}
5667
+ paletteSelectedText={paletteSelectedText}
5668
+ paletteSubtleText={paletteSubtleText}
5669
+ paletteText={paletteText}
5670
+ panelBg={panelBg}
5671
+ onPreview={applyThemePreview}
5672
+ onCommit={(themeId) => {
5673
+ const nextConfig = {
5674
+ ...stateRef.current.config,
5675
+ theme: themeId,
5676
+ };
5677
+ commitTheme(themeId);
5678
+ void saveConfig(nextConfig);
5679
+ closeAll({ revertThemePreview: false });
5680
+ }}
5681
+ />
5682
+ )}
5683
+
5684
+ {visibleListState && !themePickerActive && !showCustomMultiSelectPicker && (
5685
+ <CommandBarListBody
5686
+ visibleListState={visibleListState}
5687
+ nativeListRows={nativeListRows}
5688
+ listBodyHeight={listBodyHeight}
5689
+ contentPadding={contentPadding}
5690
+ labelWidth={labelWidth}
5691
+ nativePaneChrome={nativePaneChrome}
5692
+ nativeListScrollRef={nativeListScrollRef}
5693
+ paletteBg={paletteBg}
5694
+ paletteHeadingText={paletteHeadingText}
5695
+ paletteHoverBg={paletteHoverBg}
5696
+ paletteSelectedBg={paletteSelectedBg}
5697
+ paletteSelectedText={paletteSelectedText}
5698
+ paletteSubtleText={paletteSubtleText}
5699
+ paletteText={paletteText}
5700
+ panelBg={panelBg}
5701
+ queryDisplayWidth={queryDisplayWidth}
5702
+ trailingWidth={trailingWidth}
5703
+ onHoverIndex={setHoveredIndex}
5704
+ onListScroll={handleListScroll}
5705
+ onRowMouseDown={handleListRowMouseDown}
5706
+ />
5707
+ )}
5456
5708
  {currentRoute?.kind === "workflow" && renderWorkflowBody()}
5457
5709
  {currentRoute?.kind === "confirm" && renderConfirmBody()}
5458
5710
  {showCustomMultiSelectPicker && renderMultiSelectBody()}