gloomberb 0.11.2 → 0.11.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/src/api-client/data.ts +5 -0
  4. package/src/api-client/index.ts +5 -0
  5. package/src/api-client/types.ts +24 -0
  6. package/src/capabilities/chart-series.ts +3 -2
  7. package/src/cli/commands/market.ts +2 -2
  8. package/src/cli/pane-functions/capabilities.ts +3 -3
  9. package/src/cli/registry.ts +0 -22
  10. package/src/cli/result.ts +7 -10
  11. package/src/components/data-table/export.test.ts +21 -0
  12. package/src/components/data-table/export.ts +21 -0
  13. package/src/components/ui/data-table/index.tsx +19 -2
  14. package/src/data/config/chart-settings.ts +4 -4
  15. package/src/layout-marketplace/gallery-terminal.tsx +126 -96
  16. package/src/layout-marketplace/gallery.test.tsx +65 -1
  17. package/src/plugins/builtin/chart-composer/cli-options.ts +12 -9
  18. package/src/plugins/builtin/chart-composer/settings.ts +2 -13
  19. package/src/plugins/builtin/composite-plugins.ts +2 -0
  20. package/src/plugins/builtin/earnings/index.tsx +1 -0
  21. package/src/plugins/builtin/econ/index.tsx +1 -0
  22. package/src/plugins/builtin/futures/index.tsx +1 -0
  23. package/src/plugins/builtin/fx-matrix/index.tsx +1 -0
  24. package/src/plugins/builtin/ipo-calendar/index.tsx +1 -0
  25. package/src/plugins/builtin/market-halts/index.tsx +1 -0
  26. package/src/plugins/builtin/market-movers/index.tsx +1 -0
  27. package/src/plugins/builtin/options/analytics.test.ts +83 -0
  28. package/src/plugins/builtin/options/analytics.ts +110 -0
  29. package/src/plugins/builtin/options/index.tsx +17 -2
  30. package/src/plugins/builtin/options/table.ts +121 -51
  31. package/src/plugins/builtin/options/types.ts +22 -15
  32. package/src/plugins/builtin/options/view.test.tsx +24 -0
  33. package/src/plugins/builtin/options/view.tsx +91 -19
  34. package/src/plugins/builtin/plugin-module.test.ts +2 -4
  35. package/src/plugins/builtin/plugin-module.ts +0 -7
  36. package/src/plugins/builtin/portfolio-list/index.tsx +1 -0
  37. package/src/plugins/builtin/scanner/index.ts +2 -0
  38. package/src/plugins/builtin/sectors/index.tsx +1 -0
  39. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +3 -5
  40. package/src/plugins/builtin/ticker-detail/index.tsx +2 -0
  41. package/src/plugins/builtin/treasury-auctions/index.tsx +1 -0
  42. package/src/plugins/builtin/world-indices/index.tsx +1 -0
  43. package/src/plugins/builtin/world-venue-map/index.ts +27 -0
  44. package/src/plugins/builtin/world-venue-map/map.tsx +337 -0
  45. package/src/plugins/builtin/world-venue-map/model.test.ts +53 -0
  46. package/src/plugins/builtin/world-venue-map/model.ts +147 -0
  47. package/src/plugins/builtin/world-venue-map/pane.tsx +300 -0
  48. package/src/plugins/builtin/world-venue-map/world-outlines.ts +2 -0
  49. package/src/plugins/catalog-backend.ts +6 -44
  50. package/src/plugins/catalog-browser.ts +2 -0
  51. package/src/plugins/registry/pane-settings.test.ts +53 -0
  52. package/src/plugins/registry/pane-settings.ts +36 -3
  53. package/src/renderers/browser/ui-host.tsx +11 -0
  54. package/src/renderers/electrobun/bun/desktop/host-requests.ts +9 -2
  55. package/src/renderers/electrobun/bun/index.ts +1 -0
  56. package/src/renderers/electrobun/shared/protocol.ts +4 -0
  57. package/src/renderers/electrobun/view/ui-host.tsx +3 -0
  58. package/src/renderers/opentui/host.tsx +4 -0
  59. package/src/state/pane-table-export-registry.ts +31 -0
  60. package/src/time-series/range.ts +12 -0
  61. package/src/time-series/resolution.ts +3 -15
  62. package/src/time-series/spec.ts +5 -5
  63. package/src/types/plugin.ts +2 -20
  64. package/src/ui/host.tsx +7 -0
  65. package/src/utils/csv.test.ts +13 -0
  66. package/src/utils/csv.ts +37 -0
  67. package/src/utils/save-text-file.test.ts +24 -0
  68. package/src/utils/save-text-file.ts +36 -0
package/README.md CHANGED
@@ -280,6 +280,7 @@ The toolbar controls preset or exact date ranges, intervals from one minute thro
280
280
  | `TBO` | TheBuildout infrastructure intelligence |
281
281
  | `CG` | Congress trading disclosures |
282
282
  | `WEI` | Global equity indices |
283
+ | `MAP` | Live world venue map with local market status and clocks |
283
284
  | `FUT` | Front-month futures across index, rates, energy, metals, grains, and FX |
284
285
  | `ECON` | Economic events and releases |
285
286
  | `GC` | Yield curve |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.11.2",
3
+ "version": "0.11.3",
4
4
  "packageManager": "bun@1.3.11",
5
5
  "description": "Market research and portfolio tracker for the terminal",
6
6
  "module": "src/index.tsx",
@@ -55,6 +55,7 @@ import type {
55
55
  CloudPricePointPayload,
56
56
  CloudQuotePayload,
57
57
  CloudTweetSearchResponse,
58
+ CloudWorldVenueMapPayload,
58
59
  CloudYieldPointPayload,
59
60
  } from "./types";
60
61
 
@@ -94,6 +95,10 @@ export class CloudDataApi {
94
95
  return this.postMarketBatch("/market/quotes/batch", targets, mode);
95
96
  }
96
97
 
98
+ async getCloudWorldVenues(): Promise<CloudMarketResponse<CloudWorldVenueMapPayload>> {
99
+ return this.request<CloudMarketResponse<CloudWorldVenueMapPayload>>("/market/venues");
100
+ }
101
+
97
102
  async getCloudMarketScreener(
98
103
  category: CloudMarketScreenerCategory,
99
104
  count = 25,
@@ -61,6 +61,7 @@ import type {
61
61
  CloudMarketBatchPayload,
62
62
  CloudMarketScreenerCategory,
63
63
  CloudMarketScreenerPayload,
64
+ CloudWorldVenueMapPayload,
64
65
  CloudVerificationResponse,
65
66
  DeviceAuthStartResponse,
66
67
  DeviceAuthTokenResponse,
@@ -545,6 +546,10 @@ class GloomApiClient {
545
546
  return this.data.getCloudQuotesBatch(targets, mode);
546
547
  }
547
548
 
549
+ async getCloudWorldVenues(): Promise<CloudMarketResponse<CloudWorldVenueMapPayload>> {
550
+ return this.data.getCloudWorldVenues();
551
+ }
552
+
548
553
  async getCloudMarketScreener(
549
554
  category: CloudMarketScreenerCategory,
550
555
  count = 25,
@@ -253,6 +253,30 @@ export interface CloudQuotePayload extends Quote {
253
253
  dataSource: "live" | "delayed";
254
254
  }
255
255
 
256
+ export interface CloudWorldVenuePayload {
257
+ mic: string;
258
+ name: string;
259
+ title: string;
260
+ country: string;
261
+ countryCode: string;
262
+ city: string;
263
+ timezone: string;
264
+ latitude: number;
265
+ longitude: number;
266
+ isOpen: boolean;
267
+ timeAfterOpenSeconds?: number;
268
+ timeToOpenSeconds?: number;
269
+ timeToCloseSeconds?: number;
270
+ }
271
+
272
+ export interface CloudWorldVenueMapPayload {
273
+ providerId: "gloomberb-cloud";
274
+ checkedAt: number;
275
+ refreshAt: number;
276
+ stale?: boolean;
277
+ venues: CloudWorldVenuePayload[];
278
+ }
279
+
256
280
  export interface CloudOptionsChainPayload extends OptionsChain {
257
281
  providerId: "gloomberb-cloud";
258
282
  }
@@ -9,6 +9,7 @@ import type {
9
9
  SeriesTransform,
10
10
  TimeSeriesPoint,
11
11
  } from "../time-series/types";
12
+ import { CHART_RESOLUTIONS, TIME_RANGES } from "../time-series/range";
12
13
  import type {
13
14
  CapabilityInvoker,
14
15
  CapabilitySchema,
@@ -24,8 +25,8 @@ export const MAX_CHART_SERIES_POINTS = 20_000;
24
25
  const CAPABILITY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
25
26
  const SERIES_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._~%:/-]{0,239}$/;
26
27
  const chartSeriesLog = debugLog.createLogger("chart-series");
27
- const ranges = new Set(["1D", "1W", "1M", "3M", "6M", "1Y", "5Y", "ALL"]);
28
- const resolutions = new Set(["auto", "1m", "5m", "15m", "30m", "45m", "1h", "1d", "1wk", "1mo"]);
28
+ const ranges = new Set<string>(TIME_RANGES);
29
+ const resolutions = new Set<string>(CHART_RESOLUTIONS);
29
30
  const periods = new Set(["auto", "daily", "weekly", "monthly", "quarterly", "annual", "ttm"]);
30
31
  const styles = new Set(["line", "area", "step", "columns", "points", "candles", "ohlc", "hlc"]);
31
32
  const transforms = new Set(["raw", "percent", "index100", "yoy", "qoq", "log"]);
@@ -1,5 +1,5 @@
1
1
  import type { CliCommandDef } from "../../types/plugin";
2
- import type { TimeRange } from "../../time-series/range";
2
+ import { TIME_RANGES, type TimeRange } from "../../time-series/range";
3
3
  import type { EarningsEvent, QuoteBatchResult, SecFilingItem } from "../../types/data-provider";
4
4
  import type { NewsArticle, NewsFeed, NewsQuery } from "../../news/types";
5
5
  import type {
@@ -15,7 +15,7 @@ import { formatCompact } from "../../utils/format";
15
15
  import { withCliServices, withMarketData } from "../context";
16
16
  import { isoDate, parsePositiveInt, requireArg, takeOption } from "./command-utils";
17
17
 
18
- const VALID_RANGES = new Set<TimeRange>(["1D", "1W", "1M", "3M", "6M", "1Y", "5Y", "ALL"]);
18
+ const VALID_RANGES = new Set<TimeRange>(TIME_RANGES);
19
19
  const VALID_NEWS_FEEDS = new Set<NewsFeed>(["latest", "top", "breaking", "ticker", "sector", "topic"]);
20
20
 
21
21
  type QuoteCliRecord = Omit<QuoteBatchResult, "error"> & { error: string | null };
@@ -1,3 +1,4 @@
1
+ import { CHART_RESOLUTIONS } from "../../time-series/range";
1
2
  import type { PaneDef, PaneTemplateDef } from "../../types/plugin";
2
3
 
3
4
  export type PaneFunctionReadiness = "ready" | "partial" | "unsupported";
@@ -128,8 +129,7 @@ const CAPABILITIES: Record<string, PaneFunctionCapability> = {
128
129
  description: "Market-price sampling resolution.",
129
130
  type: "enum",
130
131
  aliases: ["resolution"],
131
- values: ["auto", "1m", "5m", "15m", "30m", "45m", "1h", "1d", "1wk", "1mo"]
132
- .map((value) => ({ value })),
132
+ values: CHART_RESOLUTIONS.map((value) => ({ value })),
133
133
  },
134
134
  ],
135
135
  },
@@ -251,7 +251,7 @@ const CAPABILITIES: Record<string, PaneFunctionCapability> = {
251
251
  description: "Price sampling resolution.",
252
252
  type: "enum",
253
253
  aliases: ["resolution"],
254
- values: ["1m", "5m", "15m", "30m", "45m", "1h", "1d", "1wk", "1mo"].map((value) => ({ value })),
254
+ values: CHART_RESOLUTIONS.filter((value) => value !== "auto").map((value) => ({ value })),
255
255
  defaultValue: "1d",
256
256
  },
257
257
  ],
@@ -4,7 +4,6 @@ import type {
4
4
  CliCommandDef,
5
5
  CliDispatchResult,
6
6
  GloomPlugin,
7
- PluginCliCommandDescriptor,
8
7
  } from "../types/plugin";
9
8
  import type { LoadedExternalPlugin } from "../plugins/loader";
10
9
  import { getPluginCatalog } from "../plugins/catalog";
@@ -35,7 +34,6 @@ export interface CliCommandRegistry {
35
34
  config: AppConfig | null;
36
35
  plugins: GloomPlugin[];
37
36
  externalPlugins: LoadedExternalPlugin[];
38
- descriptors: Array<{ pluginId: string; descriptor: PluginCliCommandDescriptor }>;
39
37
  }
40
38
 
41
39
  interface BuildCliCommandRegistryOptions {
@@ -71,19 +69,6 @@ function getCommandUsageLabel(command: CliCommandDef): string {
71
69
  return command.help?.usage?.[0] ?? command.name;
72
70
  }
73
71
 
74
- function descriptorToCommand(descriptor: PluginCliCommandDescriptor): CliCommandDef | null {
75
- if (!descriptor.execute) return null;
76
- return {
77
- name: descriptor.name,
78
- aliases: descriptor.aliases,
79
- description: descriptor.summary,
80
- help: descriptor.examples?.length
81
- ? { usage: descriptor.examples }
82
- : undefined,
83
- execute: descriptor.execute,
84
- };
85
- }
86
-
87
72
  function renderHelpSections(registry: CliCommandRegistry): string[] {
88
73
  const lines: string[] = [];
89
74
 
@@ -172,7 +157,6 @@ export function buildCliCommandRegistry({
172
157
  }
173
158
 
174
159
  const loadablePlugins: GloomPlugin[] = [];
175
- const descriptors: Array<{ pluginId: string; descriptor: PluginCliCommandDescriptor }> = [];
176
160
  for (const entry of catalog) {
177
161
  if (entry.error) {
178
162
  registryLog.warn(`Skipping external plugin "${entry.plugin.id}" for CLI registration.`, {
@@ -185,11 +169,6 @@ export function buildCliCommandRegistry({
185
169
  for (const command of entry.plugin.cliCommands ?? []) {
186
170
  registerCommand(command, entry.plugin.id, "plugin");
187
171
  }
188
- for (const descriptor of entry.plugin.cli?.commands ?? []) {
189
- descriptors.push({ pluginId: entry.plugin.id, descriptor });
190
- const command = descriptorToCommand(descriptor);
191
- if (command) registerCommand(command, entry.plugin.id, "plugin");
192
- }
193
172
  }
194
173
 
195
174
  const commands = allCommands.filter((entry) => (
@@ -210,7 +189,6 @@ export function buildCliCommandRegistry({
210
189
  config,
211
190
  plugins: loadablePlugins.filter((plugin) => !disabledPlugins.has(plugin.id)),
212
191
  externalPlugins,
213
- descriptors,
214
192
  };
215
193
  }
216
194
 
package/src/cli/result.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { CliGlobalOptions } from "./options";
2
2
  import { renderTable, type CliTableColumn } from "../utils/cli-output";
3
+ import { serializeCsv } from "../utils/csv";
3
4
 
4
5
  export interface CliResult<T = unknown> {
5
6
  data: T;
@@ -57,11 +58,6 @@ function inferColumns(rows: Record<string, unknown>[]): CliResultColumn<Record<s
57
58
  return [...keys].map((key) => ({ key, header: key }));
58
59
  }
59
60
 
60
- function csvEscape(value: string): string {
61
- if (!/[",\n\r]/.test(value)) return value;
62
- return `"${value.replace(/"/g, '""')}"`;
63
- }
64
-
65
61
  function serializeColumns<Row>(columns?: CliResultColumn<Row>[]) {
66
62
  return columns?.map((column) => ({
67
63
  key: column.key,
@@ -78,11 +74,12 @@ function renderCsv<Row extends Record<string, unknown>>(
78
74
  const resolvedColumns = columns && columns.length > 0
79
75
  ? columns
80
76
  : inferColumns(rows as Record<string, unknown>[]) as CliResultColumn<Row>[];
81
- const header = resolvedColumns.map((column) => csvEscape(column.header)).join(",");
82
- const body = rows.map((row) => resolvedColumns
83
- .map((column) => csvEscape(normalizeCell(column.value ? column.value(row) : row[column.key])))
84
- .join(","));
85
- return [header, ...body].join("\n");
77
+ return serializeCsv([
78
+ resolvedColumns.map((column) => column.header),
79
+ ...rows.map((row) => resolvedColumns.map((column) => (
80
+ column.value ? column.value(row) : row[column.key]
81
+ ))),
82
+ ]);
86
83
  }
87
84
 
88
85
  function renderTextTable<Row extends Record<string, unknown>>(
@@ -0,0 +1,21 @@
1
+ import { expect, test } from "bun:test";
2
+ import { EXCEL_CSV_BOM } from "../../utils/csv";
3
+ import { createDataTableCsv } from "./export";
4
+
5
+ test("exports displayed table cells and skips section headers", () => {
6
+ const items = [
7
+ { type: "section", name: "Americas", value: "" },
8
+ { type: "row", name: "S&P 500", value: "+1.25%" },
9
+ ];
10
+ const csv = createDataTableCsv({
11
+ columns: [
12
+ { id: "name", label: "Name", width: 20, align: "left" },
13
+ { id: "value", label: "Change", width: 10, align: "right" },
14
+ ],
15
+ items,
16
+ renderSectionHeader: (item) => item.type === "section" ? { text: item.name } : null,
17
+ renderCell: (item, column) => ({ text: item[column.id as "name" | "value"] }),
18
+ });
19
+
20
+ expect(csv).toBe(`${EXCEL_CSV_BOM}Name,Change\nS&P 500,'+1.25%`);
21
+ });
@@ -0,0 +1,21 @@
1
+ import type { DataTableColumn, DataTableProps } from "../ui";
2
+ import { serializeCsv } from "../../utils/csv";
3
+
4
+ export function createDataTableCsv<
5
+ T,
6
+ C extends DataTableColumn = DataTableColumn,
7
+ >({
8
+ columns,
9
+ items,
10
+ renderCell,
11
+ renderSectionHeader,
12
+ }: Pick<DataTableProps<T, C>, "columns" | "items" | "renderCell" | "renderSectionHeader">): string {
13
+ const rows = items.flatMap((item, index) => {
14
+ if (renderSectionHeader?.(item, index)) return [];
15
+ return [columns.map((column) => renderCell(item, column, index, { selected: false }).text)];
16
+ });
17
+ return serializeCsv([
18
+ columns.map((column) => column.label),
19
+ ...rows,
20
+ ], { excelCompatible: true });
21
+ }
@@ -1,5 +1,5 @@
1
- import { type ComponentType } from "react";
2
- import { useUiHost } from "../../../ui";
1
+ import { type ComponentType, useEffect, useRef } from "react";
2
+ import { useRendererHost, useUiHost } from "../../../ui";
3
3
  import { OpenTuiDataTable } from "./opentui";
4
4
  import type {
5
5
  DataTableColumn,
@@ -7,6 +7,9 @@ import type {
7
7
  } from "./types";
8
8
  import { useRemoteUiNode } from "../../../remote/semantic-tree";
9
9
  import { remoteNumberValue, resolveRemoteItemIndex } from "../../../remote/semantic-helpers";
10
+ import { useOptionalPaneInstanceId } from "../../../state/app/context";
11
+ import { registerPaneTableExporter } from "../../../state/pane-table-export-registry";
12
+ import { createDataTableCsv } from "../../data-table/export";
10
13
 
11
14
  export type {
12
15
  DataTableCell,
@@ -19,6 +22,20 @@ export type {
19
22
  export function DataTable<T, C extends DataTableColumn = DataTableColumn>(
20
23
  props: DataTableProps<T, C>,
21
24
  ) {
25
+ const paneId = useOptionalPaneInstanceId();
26
+ const renderer = useRendererHost();
27
+ const propsRef = useRef(props);
28
+ propsRef.current = props;
29
+
30
+ useEffect(() => {
31
+ if (!paneId || !renderer.saveTextFile) return;
32
+ return registerPaneTableExporter(paneId, (filename) => renderer.saveTextFile!({
33
+ name: filename,
34
+ text: createDataTableCsv(propsRef.current),
35
+ mimeType: "text/csv;charset=utf-8",
36
+ }));
37
+ }, [paneId, renderer]);
38
+
22
39
  useRemoteUiNode({
23
40
  role: "table",
24
41
  label: "Data table",
@@ -1,4 +1,4 @@
1
- import type { ChartResolution, TimeRange } from "../../time-series/range";
1
+ import { CHART_RESOLUTIONS, TIME_RANGES, type ChartResolution, type TimeRange } from "../../time-series/range";
2
2
  import type { LayoutConfig, PaneBinding } from "../../types/config";
3
3
  import type {
4
4
  ChartPanelSpec,
@@ -16,8 +16,8 @@ import {
16
16
  validateChartSpec,
17
17
  } from "../../time-series/spec";
18
18
 
19
- const CHART_RANGES = new Set<TimeRange>(["1D", "1W", "1M", "3M", "6M", "1Y", "5Y", "ALL"]);
20
- const CHART_RESOLUTIONS = new Set<ChartResolution>(["auto", "1m", "5m", "15m", "30m", "45m", "1h", "1d", "1wk", "1mo"]);
19
+ const CHART_RANGES = new Set<TimeRange>(TIME_RANGES);
20
+ const CHART_RESOLUTION_SET = new Set<ChartResolution>(CHART_RESOLUTIONS);
21
21
  const PRICE_RENDER_MODES = new Set<SeriesStyle>(["area", "line", "candles", "ohlc", "hlc"]);
22
22
  const LEGACY_INDICATOR_IDS = new Set<LegacyChartIndicatorId>([
23
23
  "volume",
@@ -94,7 +94,7 @@ function range(value: unknown, fallback: TimeRange): TimeRange {
94
94
  }
95
95
 
96
96
  function resolution(value: unknown, fallback: ChartResolution): ChartResolution {
97
- return CHART_RESOLUTIONS.has(value as ChartResolution) ? value as ChartResolution : fallback;
97
+ return CHART_RESOLUTION_SET.has(value as ChartResolution) ? value as ChartResolution : fallback;
98
98
  }
99
99
 
100
100
  function makeSpec(
@@ -1,11 +1,11 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
- import { Box, Input, Text, TextAttributes, type InputRenderable } from "../ui";
3
- import { Button } from "../components/ui/button";
2
+ import { Box, Text, TextAttributes, type InputRenderable } from "../ui";
3
+ import { InputSearchBar, usePaneFooter } from "../components";
4
4
  import { ListView, type ListViewItem } from "../components/ui/list-view";
5
5
  import { useShortcut, useViewport } from "../react/input";
6
- import { useAppInputCapture } from "../state/app/input-capture";
7
6
  import { useThemeColors } from "../theme/theme-context";
8
7
  import { truncateToDisplayWidth } from "../utils/format";
8
+ import { isPlainKey } from "../utils/keyboard";
9
9
  import { t, tf } from "../i18n";
10
10
  import type { LayoutGalleryController } from "./gallery";
11
11
  import {
@@ -71,9 +71,23 @@ export function LayoutGalleryTerminal({
71
71
  const detailsWidth = Math.min(DETAILS_WIDTH, Math.max(28, Math.floor(paneWidth * 0.42)));
72
72
  const inputRef = useRef<InputRenderable | null>(null);
73
73
  const [searchFocused, setSearchFocused] = useState(false);
74
+ const [searchFocusToken, setSearchFocusToken] = useState(0);
74
75
  const [selectedIndex, setSelectedIndex] = useState(0);
75
- useAppInputCapture(searchFocused && !dialogOpen);
76
- const { community, owned, select } = controller;
76
+ const {
77
+ activate: activateLayout,
78
+ canDelete,
79
+ community,
80
+ copyLink,
81
+ deleteLayout,
82
+ duplicateLayout,
83
+ install: installLayout,
84
+ newLayout,
85
+ owned,
86
+ publishCurrent,
87
+ publishing,
88
+ renameLayout,
89
+ select,
90
+ } = controller;
77
91
  const discoverStatus = discoverStatusRow(controller);
78
92
 
79
93
  const rows = useMemo<GalleryRow[]>(() => {
@@ -138,6 +152,12 @@ export function LayoutGalleryTerminal({
138
152
  setSelectedIndex(selectableIndexes[nextPosition]!);
139
153
  }, [selectableIndexes, selectedIndex]);
140
154
 
155
+ const focusSearch = useCallback(() => {
156
+ setSearchFocused(true);
157
+ setSearchFocusToken((current) => current + 1);
158
+ }, []);
159
+ const blurSearch = useCallback(() => setSearchFocused(false), []);
160
+
141
161
  // The details panel is always on screen here, so Enter runs the action it shows
142
162
  // instead of stepping through a separate detail state.
143
163
  const activate = useCallback((row: GalleryRow | null) => {
@@ -147,70 +167,120 @@ export function LayoutGalleryTerminal({
147
167
  return;
148
168
  }
149
169
  if (!row.entry) return;
150
- if (row.entry.kind === "community") controller.install(row.entry);
151
- else controller.activate(row.entry);
152
- }, [controller]);
170
+ if (row.entry.kind === "community") installLayout(row.entry);
171
+ else activateLayout(row.entry);
172
+ }, [activateLayout, installLayout]);
173
+
174
+ const selectedRowRef = useRef<GalleryRow | null>(selectedRow);
175
+ selectedRowRef.current = selectedRow;
176
+ const activateSelected = useCallback(() => activate(selectedRowRef.current), [activate]);
177
+ const renameSelected = useCallback(() => {
178
+ const entry = selectedRowRef.current?.entry;
179
+ if (entry?.kind === "owned") renameLayout(entry);
180
+ }, [renameLayout]);
181
+ const copySelected = useCallback(() => {
182
+ const entry = selectedRowRef.current?.entry;
183
+ if (entry?.kind === "community") copyLink(entry);
184
+ else if (entry?.kind === "owned") duplicateLayout(entry);
185
+ }, [copyLink, duplicateLayout]);
186
+ const deleteSelected = useCallback(() => {
187
+ const entry = selectedRowRef.current?.entry;
188
+ if (entry?.kind === "owned") deleteLayout(entry);
189
+ }, [deleteLayout]);
190
+
191
+ usePaneFooter("layout-marketplace", () => ({
192
+ info: publishing
193
+ ? [{ id: "publishing", parts: [{ text: "publishing", tone: "muted" as const }] }]
194
+ : [],
195
+ hints: [
196
+ { id: "search", key: "/", label: "search", onPress: focusSearch },
197
+ { id: "new", key: "n", label: "ew", onPress: newLayout },
198
+ ...(selectedEntry?.kind === "owned"
199
+ ? [
200
+ { id: "open", key: "o", label: "pen", onPress: activateSelected },
201
+ { id: "rename", key: "r", label: "ename", onPress: renameSelected },
202
+ { id: "copy", key: "c", label: "opy", onPress: copySelected },
203
+ { id: "delete", key: "d", label: "elete", onPress: deleteSelected, disabled: !canDelete },
204
+ ]
205
+ : selectedEntry?.kind === "community"
206
+ ? [
207
+ { id: "add", key: "a", label: "dd layout", onPress: activateSelected },
208
+ { id: "copy-link", key: "c", label: "opy link", onPress: copySelected },
209
+ ]
210
+ : selectedRow?.id === "discover:retry"
211
+ ? [{ id: "retry", key: "r", label: "etry", onPress: activateSelected }]
212
+ : selectedRow?.action
213
+ ? [{ id: "open", key: "o", label: "pen", onPress: activateSelected }]
214
+ : []),
215
+ { id: "publish", key: "p", label: "ublish", onPress: publishCurrent, disabled: publishing },
216
+ ],
217
+ }), [
218
+ activateSelected,
219
+ canDelete,
220
+ copySelected,
221
+ deleteSelected,
222
+ focusSearch,
223
+ newLayout,
224
+ publishCurrent,
225
+ publishing,
226
+ renameSelected,
227
+ selectedEntry?.kind,
228
+ selectedRow?.action,
229
+ selectedRow?.id,
230
+ ]);
153
231
 
154
232
  useShortcut((event) => {
155
233
  if (dialogOpen) return;
156
- const editable = event.targetEditable === true;
157
234
  // Leaving the search field is the first Escape; the gallery closes on the next.
158
- if (event.name === "escape" && searchFocused) {
159
- event.preventDefault();
160
- event.stopPropagation();
161
- setSearchFocused(false);
162
- return;
163
- }
164
- if (event.name === "down" || (!editable && event.name === "j")) {
235
+ if (isPlainKey(event, "escape", "esc") && searchFocused) {
165
236
  event.preventDefault();
166
237
  event.stopPropagation();
167
- move(1);
238
+ blurSearch();
168
239
  return;
169
240
  }
170
- if (event.name === "up" || (!editable && event.name === "k")) {
171
- event.preventDefault();
172
- event.stopPropagation();
173
- move(-1);
174
- return;
175
- }
176
- if (event.name === "enter" || event.name === "return") {
177
- event.preventDefault();
178
- event.stopPropagation();
179
- activate(selectedRow);
180
- return;
181
- }
182
- if (!editable && (event.name === "/" || event.sequence === "/")) {
241
+ if (event.targetEditable) return;
242
+
243
+ const run = (action: () => void) => {
183
244
  event.preventDefault();
184
245
  event.stopPropagation();
185
- setSearchFocused(true);
186
- inputRef.current?.focus?.();
187
- }
246
+ action();
247
+ };
248
+ if (isPlainKey(event, "down", "j")) run(() => move(1));
249
+ else if (isPlainKey(event, "up", "k")) {
250
+ run(() => {
251
+ if (event.name === "up" && selectedIndex === selectableIndexes[0]) focusSearch();
252
+ else move(-1);
253
+ });
254
+ } else if (isPlainKey(event, "enter", "return")) run(activateSelected);
255
+ else if (isPlainKey(event, "/")) run(focusSearch);
256
+ else if (isPlainKey(event, "n")) run(newLayout);
257
+ else if (isPlainKey(event, "p")) run(publishCurrent);
258
+ else if (isPlainKey(event, "o") && (selectedEntry?.kind === "owned" || selectedRow?.action)) run(activateSelected);
259
+ else if (isPlainKey(event, "a") && selectedEntry?.kind === "community") run(activateSelected);
260
+ else if (isPlainKey(event, "r") && selectedRow?.id === "discover:retry") run(activateSelected);
261
+ else if (isPlainKey(event, "r") && selectedEntry?.kind === "owned") run(renameSelected);
262
+ else if (isPlainKey(event, "c") && selectedEntry) run(copySelected);
263
+ else if (isPlainKey(event, "d") && selectedEntry?.kind === "owned" && canDelete) run(deleteSelected);
188
264
  }, { allowEditable: true, enabled: focused && !dialogOpen, phase: "before", scope: "layout-gallery" });
189
265
 
190
266
  const bodyHeight = Math.max(4, paneHeight - 1);
191
267
 
192
268
  return (
193
269
  <Box flexGrow={1} flexDirection="column" backgroundColor={colors.bg}>
194
- <Box height={1} flexDirection="row" paddingX={1} backgroundColor={colors.panel}>
195
- <Text fg={searchFocused ? colors.textBright : colors.textDim}>{"/ "}</Text>
196
- <Input
197
- ref={inputRef}
198
- value={controller.query}
199
- focused={searchFocused && !dialogOpen}
200
- placeholder={t("Search layouts and panes")}
201
- placeholderColor={colors.textDim}
202
- textColor={colors.text}
203
- backgroundColor={colors.panel}
204
- focusedBackgroundColor={colors.panel}
205
- cursorColor={colors.textBright}
206
- flexGrow={1}
207
- onInput={controller.setQuery}
208
- onChange={controller.setQuery}
209
- onFocus={() => setSearchFocused(true)}
210
- onBlur={() => setSearchFocused(false)}
211
- onSubmit={() => setSearchFocused(false)}
212
- />
213
- </Box>
270
+ <InputSearchBar
271
+ value={controller.query}
272
+ focused={focused && !dialogOpen}
273
+ active={searchFocused}
274
+ width={paneWidth}
275
+ focusToken={searchFocusToken}
276
+ inputRef={inputRef}
277
+ placeholder={t("Search layouts and panes")}
278
+ debounceMs={80}
279
+ onFocus={focusSearch}
280
+ onBlur={blurSearch}
281
+ onNavigateDown={blurSearch}
282
+ onQueryChange={controller.setQuery}
283
+ />
214
284
 
215
285
  <Box flexDirection="row" flexGrow={1}>
216
286
  <Box flexGrow={1} flexDirection="column" paddingX={1}>
@@ -257,17 +327,12 @@ export function LayoutGalleryTerminal({
257
327
  controller={controller}
258
328
  entry={selectedEntry}
259
329
  width={detailsWidth}
260
- height={paneHeight}
330
+ height={bodyHeight}
261
331
  />
262
332
  ) : selectedRow?.action ? (
263
333
  <Box flexDirection="column" gap={1}>
264
334
  <Text fg={colors.textBright} attributes={TextAttributes.BOLD}>{selectedRow.label}</Text>
265
335
  {selectedRow.detail && <Text fg={colors.textMuted}>{selectedRow.detail}</Text>}
266
- <Button
267
- label={selectedRow.id === "discover:login" ? "Log in" : "Retry"}
268
- variant="primary"
269
- onPress={selectedRow.action}
270
- />
271
336
  </Box>
272
337
  ) : (
273
338
  <Text fg={colors.textDim}>{t("No layout selected.")}</Text>
@@ -292,8 +357,8 @@ function LayoutDetails({
292
357
  const colors = useThemeColors();
293
358
  const allPanes = summarizeLayoutPanes(entry.layout, controller.panes);
294
359
  const missing = allPanes.filter((pane) => pane.missing);
295
- // Leave room for the title block, the warning line, and both action rows.
296
- const paneBudget = Math.max(3, height - 13);
360
+ // Leave room for the title block and the optional warning.
361
+ const paneBudget = Math.max(3, height - 8);
297
362
  const overflow = Math.max(0, allPanes.length - paneBudget);
298
363
  const panes = overflow > 0 ? allPanes.slice(0, paneBudget - 1) : allPanes;
299
364
 
@@ -336,41 +401,6 @@ function LayoutDetails({
336
401
  </Text>
337
402
  </>
338
403
  )}
339
- <Box height={1} />
340
- <Box flexDirection="row" gap={1}>
341
- <Button
342
- label={entry.kind === "community" ? "Add Layout" : "Open"}
343
- variant="primary"
344
- onPress={() => (entry.kind === "community"
345
- ? controller.install(entry)
346
- : controller.activate(entry))}
347
- />
348
- {entry.kind === "community" && (
349
- <Button label="Copy Link" variant="secondary" onPress={() => controller.copyLink(entry)} />
350
- )}
351
- {entry.kind === "owned" && (
352
- <>
353
- <Button label="Rename" variant="secondary" onPress={() => controller.renameLayout(entry)} />
354
- <Button label="Copy" variant="secondary" onPress={() => controller.duplicateLayout(entry)} />
355
- <Button
356
- label="Delete"
357
- variant="secondary"
358
- disabled={!controller.canDelete}
359
- onPress={() => controller.deleteLayout(entry)}
360
- />
361
- </>
362
- )}
363
- </Box>
364
- <Box height={1} />
365
- <Box flexDirection="row" gap={1}>
366
- <Button label="New" variant="ghost" onPress={controller.newLayout} />
367
- <Button
368
- label={controller.publishing ? "Publishing" : "Publish"}
369
- variant="ghost"
370
- disabled={controller.publishing}
371
- onPress={controller.publishCurrent}
372
- />
373
- </Box>
374
404
  </Box>
375
405
  );
376
406
  }