@gloom-sh/terminal 0.19.1 → 0.19.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 (138) hide show
  1. package/package.json +2 -1
  2. package/src/api-client/errors.ts +38 -0
  3. package/src/app/runtime/startup.ts +3 -1
  4. package/src/capabilities/chart-series.ts +2 -1
  5. package/src/cli/commands/market.ts +9 -4
  6. package/src/cli/commands/overview.ts +5 -6
  7. package/src/cli/commands/search.ts +13 -13
  8. package/src/cli/context.ts +1 -0
  9. package/src/cli/glossary.ts +2 -1
  10. package/src/cli/history-rows.ts +35 -7
  11. package/src/cli/known-gaps.ts +11 -0
  12. package/src/cli/listing-arg.ts +17 -1
  13. package/src/cli/quote-answer.ts +29 -6
  14. package/src/components/command-bar/routes/root/runtime.ts +27 -5
  15. package/src/components/command-bar/selection-runtime.ts +12 -29
  16. package/src/components/layout/detached-pane-shell.tsx +2 -2
  17. package/src/components/layout/header.tsx +2 -2
  18. package/src/components/layout/pane/footer/index.tsx +1 -0
  19. package/src/components/layout/pane/footer/model.ts +33 -1
  20. package/src/components/layout/pane/header.tsx +2 -2
  21. package/src/components/layout/pane/status-footer.ts +2 -1
  22. package/src/components/onboarding/onboarding-frame.tsx +2 -1
  23. package/src/components/onboarding/onboarding-wizard.tsx +6 -3
  24. package/src/components/ui/display.tsx +16 -5
  25. package/src/components/ui/icon.tsx +8 -2
  26. package/src/core/app-services.ts +11 -2
  27. package/src/data/app-persistence.ts +9 -7
  28. package/src/data/plugin-cache.ts +15 -0
  29. package/src/data/plugin-state-store.ts +81 -24
  30. package/src/data/resource-store.ts +130 -58
  31. package/src/data/session-store.ts +31 -8
  32. package/src/data/sqlite/access.ts +313 -0
  33. package/src/data/sqlite/checkpointer.ts +198 -0
  34. package/src/data/sqlite/database.ts +47 -4
  35. package/src/data/sqlite/retry.ts +50 -9
  36. package/src/data/ticker-store.ts +57 -23
  37. package/src/market-data/realized-volatility.ts +4 -2
  38. package/src/plugins/auto-update-node.ts +10 -6
  39. package/src/plugins/builtin/central-bank-rates/series.ts +2 -3
  40. package/src/plugins/builtin/chart-composer/cli-options.ts +16 -0
  41. package/src/plugins/builtin/chart-composer/headless-schema.ts +5 -0
  42. package/src/plugins/builtin/chart-composer/pane.tsx +5 -1
  43. package/src/plugins/builtin/chart-composer/session-range.ts +5 -2
  44. package/src/plugins/builtin/commodity-balances/client.ts +4 -1
  45. package/src/plugins/builtin/cot/client.ts +28 -2
  46. package/src/plugins/builtin/cot/headless.ts +4 -1
  47. package/src/plugins/builtin/cot/index.tsx +4 -2
  48. package/src/plugins/builtin/cot/model.ts +58 -7
  49. package/src/plugins/builtin/cot/pane.tsx +6 -2
  50. package/src/plugins/builtin/cpi/client.ts +6 -5
  51. package/src/plugins/builtin/credit-boards/series.ts +3 -4
  52. package/src/plugins/builtin/credit-conditions/client.ts +22 -14
  53. package/src/plugins/builtin/crypto-board/client.ts +5 -5
  54. package/src/plugins/builtin/crypto-board/series.ts +4 -4
  55. package/src/plugins/builtin/debt-maturities/series.ts +3 -3
  56. package/src/plugins/builtin/doe/client.ts +6 -5
  57. package/src/plugins/builtin/doe/prices.ts +2 -3
  58. package/src/plugins/builtin/doe/series.ts +4 -4
  59. package/src/plugins/builtin/earnings-calls/calendar.tsx +10 -2
  60. package/src/plugins/builtin/earnings-calls/data.ts +19 -11
  61. package/src/plugins/builtin/earnings-calls/pane.tsx +27 -14
  62. package/src/plugins/builtin/econ/detail-view.tsx +16 -2
  63. package/src/plugins/builtin/executives/data.ts +6 -1
  64. package/src/plugins/builtin/executives/pane.tsx +9 -21
  65. package/src/plugins/builtin/fear-greed/cache.ts +4 -0
  66. package/src/plugins/builtin/fear-greed/series.ts +6 -4
  67. package/src/plugins/builtin/gpu/client.ts +4 -1
  68. package/src/plugins/builtin/gpu/series.ts +2 -3
  69. package/src/plugins/builtin/holders/beneficial-view.tsx +1 -2
  70. package/src/plugins/builtin/jodi/client.ts +4 -1
  71. package/src/plugins/builtin/market-movers/client.ts +1 -0
  72. package/src/plugins/builtin/market-movers/index.tsx +10 -5
  73. package/src/plugins/builtin/market-movers/screener.ts +23 -6
  74. package/src/plugins/builtin/market-movers/session-body.tsx +2 -1
  75. package/src/plugins/builtin/money-markets/series.ts +3 -5
  76. package/src/plugins/builtin/power/gas-client.ts +2 -1
  77. package/src/plugins/builtin/power/prices-client.ts +3 -2
  78. package/src/plugins/builtin/risk-factors/data.ts +8 -4
  79. package/src/plugins/builtin/risk-factors/index.tsx +3 -1
  80. package/src/plugins/builtin/risk-factors/pane.tsx +5 -8
  81. package/src/plugins/builtin/scanner/lock-note.ts +26 -3
  82. package/src/plugins/builtin/shared/cloud-resource.ts +13 -8
  83. package/src/plugins/builtin/shared/fred-points.ts +6 -1
  84. package/src/plugins/builtin/shared/pro-lock-note.ts +23 -8
  85. package/src/plugins/builtin/shared/series-warning.ts +13 -4
  86. package/src/plugins/builtin/short-volume/series.ts +2 -3
  87. package/src/plugins/builtin/social-mentions/client.ts +15 -10
  88. package/src/plugins/builtin/social-mentions/series.ts +2 -3
  89. package/src/plugins/builtin/thirteenf/api.ts +11 -16
  90. package/src/plugins/builtin/thirteenf/data.ts +34 -6
  91. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +5 -1
  92. package/src/plugins/builtin/thirteenf/pane.tsx +21 -5
  93. package/src/plugins/builtin/thirteenf/types.ts +2 -0
  94. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -2
  95. package/src/plugins/builtin/ticker-detail/financials/model.ts +35 -2
  96. package/src/plugins/builtin/ticker-detail/financials/ratios.ts +53 -18
  97. package/src/plugins/builtin/ticker-detail/headless.ts +59 -8
  98. package/src/plugins/builtin/treasury-auctions/cache.ts +20 -8
  99. package/src/plugins/builtin/treasury-auctions/client.ts +3 -3
  100. package/src/plugins/builtin/treasury-auctions/headless.ts +3 -1
  101. package/src/plugins/builtin/treasury-auctions/pane.tsx +9 -3
  102. package/src/plugins/builtin/volatility/client.ts +43 -16
  103. package/src/plugins/builtin/volatility/headless.ts +5 -2
  104. package/src/plugins/builtin/volatility/model.ts +17 -6
  105. package/src/plugins/builtin/volatility/pane.tsx +6 -2
  106. package/src/plugins/builtin/volatility/series.ts +2 -1
  107. package/src/plugins/builtin/yield-curve/client.ts +4 -6
  108. package/src/plugins/installer.ts +50 -39
  109. package/src/renderers/dom/desktop/icons.tsx +3 -2
  110. package/src/renderers/electrobun/bun/desktop/detached-windows.ts +23 -1
  111. package/src/renderers/electrobun/bun/desktop/initialization.ts +9 -1
  112. package/src/renderers/electrobun/bun/window/attention.ts +54 -0
  113. package/src/renderers/electrobun/bun/window/create.ts +2 -0
  114. package/src/renderers/electrobun/launcher/preload.ts +13 -0
  115. package/src/renderers/electrobun/launcher/windows-emulation.ts +50 -0
  116. package/src/renderers/electrobun/view/ui-host.tsx +9 -2
  117. package/src/series/resolve.ts +2 -1
  118. package/src/series/types.ts +5 -0
  119. package/src/sources/gloomberb-cloud/fred-series.ts +5 -3
  120. package/src/telemetry/stall-reports-node.ts +13 -2
  121. package/src/telemetry/stall-reports.ts +189 -13
  122. package/src/test-support/outage.ts +8 -0
  123. package/src/tickers/company-name-listing.ts +59 -29
  124. package/src/tickers/company-name.ts +65 -1
  125. package/src/tickers/search/home-listing.ts +93 -8
  126. package/src/tickers/search/index.ts +1 -1
  127. package/src/time-series/market.ts +5 -3
  128. package/src/time-series/price-comparison.ts +20 -2
  129. package/src/time-series/resolve.ts +20 -1
  130. package/src/time-series/types.ts +7 -0
  131. package/src/types/financials.ts +18 -3
  132. package/src/ui/host.tsx +6 -1
  133. package/src/utils/financial-statements.ts +73 -16
  134. package/src/utils/fundamentals.ts +17 -5
  135. package/src/utils/lease-liabilities.ts +54 -0
  136. package/src/utils/main-thread-stall.ts +47 -2
  137. package/src/utils/perf-marks.ts +23 -11
  138. package/src/utils/price-history-integrity.ts +47 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gloom-sh/terminal",
3
- "version": "0.19.1",
3
+ "version": "0.19.2",
4
4
  "packageManager": "bun@1.3.11",
5
5
  "description": "Market research and portfolio tracker for the terminal",
6
6
  "module": "src/public/index.ts",
@@ -91,6 +91,7 @@
91
91
  "check:pane-layout": "bun run scripts/pane-chrome/layout-sweep.ts",
92
92
  "check:ui-tokens": "bun run scripts/pane-chrome/ui-tokens.ts",
93
93
  "ui:benchmark": "bun run scripts/pane-chrome/ui-benchmark.ts",
94
+ "ui:badge-ink": "bun run scripts/pane-chrome/badge-ink.ts",
94
95
  "audit:ui": "bun run scripts/pane-chrome/ui-audit.ts",
95
96
  "check:call-player": "bun run scripts/pane-chrome/call-player.ts",
96
97
  "check:chart-axis-zoom": "bun run scripts/pane-chrome/chart-axis-zoom.ts",
@@ -1,3 +1,5 @@
1
+ import { isNetworkFailure } from "../utils/network-failure";
2
+
1
3
  const HARD_SESSION_INVALID_PATTERNS = [
2
4
  /\b(user|account)\b.*\b(not found|deleted|removed|disabled|deactivated|suspended)\b/i,
3
5
  /\b(user|account)\b.*\bdoes(?:\s+not|n't)\s+exist\b/i,
@@ -61,6 +63,42 @@ export function isRetryableApiError(error: unknown): boolean {
61
63
  return error.code === "retryable_error" || status === 408 || status === 429 || (status !== undefined && status >= 500);
62
64
  }
63
65
 
66
+ /**
67
+ * A 200 reply saying its data is unavailable this time (a board's `unavailable`
68
+ * status, a market answer's `retryable_error`): the server's own source did
69
+ * not answer. Like a 5xx it may pass, so a saved copy answers it with its note.
70
+ * A permanent reason (a route not served yet, an unsupported symbol or series,
71
+ * sign-in or entitlement) comes as its status, or as a plain error, and stays
72
+ * the error.
73
+ */
74
+ export function sourceDidNotAnswer(message: string): ApiRequestError {
75
+ return new ApiRequestError(message, undefined, undefined, "retryable_error");
76
+ }
77
+
78
+ /** Codes of a connection that dropped or stalled after it opened: Node's and the system's, then Bun's. */
79
+ const INTERRUPTED_CODES = new Set(["ECONNRESET", "ECONNABORTED", "EPIPE", "ETIMEDOUT", "ConnectionClosed"]);
80
+
81
+ /**
82
+ * A failure asking again later may get past, which a copy saved earlier may
83
+ * answer for: no connection, a connection that dropped, a timeout, or a server
84
+ * that was rate limited or overloaded (408, 429, 5xx). Anything else the
85
+ * server answered (another 4xx) and an answer that failed validation are
86
+ * refusals to show as errors, saved copy or not. A bare throw with nothing to
87
+ * classify is not taken for a refusal.
88
+ */
89
+ export function isTransientFailure(error: unknown): boolean {
90
+ if (!error || typeof error !== "object") return typeof error !== "string" || isNetworkFailure(error);
91
+ if (isNetworkFailure(error) || isRetryableApiError(error)) return true;
92
+ for (let current: unknown = error, depth = 0; current && typeof current === "object" && depth < 4; depth += 1) {
93
+ if (current instanceof ApiRequestError) return false;
94
+ const { name, code } = current as { name?: unknown; code?: unknown };
95
+ if (name === "AbortError" || name === "TimeoutError") return true;
96
+ if (typeof code === "string" && INTERRUPTED_CODES.has(code)) return true;
97
+ current = (current as { cause?: unknown }).cause;
98
+ }
99
+ return false;
100
+ }
101
+
64
102
  /** Reads `Retry-After` as milliseconds, accepting seconds or an HTTP date. */
65
103
  export function parseRetryAfterMs(
66
104
  header: string | null,
@@ -32,7 +32,7 @@ import {
32
32
  type UsageFunction,
33
33
  } from "../../telemetry/usage-counts";
34
34
  import { debugLog } from "../../utils/debug-log";
35
- import { startMainThreadStallMonitor } from "../../utils/main-thread-stall";
35
+ import { markStartupComplete, startMainThreadStallMonitor } from "../../utils/main-thread-stall";
36
36
  import { measurePerfAsync } from "../../utils/perf-marks";
37
37
 
38
38
  const appLog = debugLog.createLogger("app");
@@ -161,6 +161,8 @@ export function useAppStartupRuntime({
161
161
  } catch (err) {
162
162
  logFailure("app.runtime.startup", "Workspace startup hydration failed")(err);
163
163
  }
164
+ // The workspace has its data (or gave up on it): stalls from here on are not startup ones.
165
+ markStartupComplete();
164
166
  })();
165
167
  }, [
166
168
  autoImportBrokerPositions,
@@ -203,7 +203,7 @@ function resolvedOutput(value: unknown): ResolvedSeries {
203
203
  const input = object(value, "resolve output");
204
204
  onlyKeys(input, "resolve output", [
205
205
  "id", "label", "color", "unit", "unitGroup", "nativeFrequency", "timestampMode", "dataShape", "style", "transform",
206
- "axis", "panelId", "interpolation", "timeBasis", "latestChangePercent", "points", "warning", "hidden", "attribution",
206
+ "axis", "panelId", "interpolation", "timeBasis", "latestChangePercent", "points", "warning", "savedAt", "hidden", "attribution",
207
207
  "priceOf",
208
208
  ]);
209
209
  if (!Array.isArray(input.points)) fail("resolve output points", "expected an array.");
@@ -247,6 +247,7 @@ function resolvedOutput(value: unknown): ResolvedSeries {
247
247
  : {}),
248
248
  points: input.points.map(point),
249
249
  ...(warning !== undefined ? { warning } : {}),
250
+ ...(input.savedAt !== undefined ? { savedAt: finiteNumber(input.savedAt, "resolve output saved copy time") as number } : {}),
250
251
  ...(attribution ? { attribution: {
251
252
  source: boundedString(attribution.source, "resolve output source", 160),
252
253
  ...(publisher !== undefined ? { publisher } : {}),
@@ -40,7 +40,7 @@ import {
40
40
  quoteChangeText,
41
41
  } from "../helpers";
42
42
  import { cliArrow, cliStyles, colorBySign, renderSection, renderStats } from "../../utils/cli-output";
43
- import { filledPeriodsNote, stalePeriodNote, statementHistoryNotes } from "../../utils/financial-statements";
43
+ import { filledPeriodsNote, hasOlderStatements, stalePeriodNote, statementHistoryNotes } from "../../utils/financial-statements";
44
44
  import { formatPerShareNumber } from "../../utils/reported-money";
45
45
  import { isCurrencyPairListing } from "../../utils/fx-market-hours";
46
46
  import {
@@ -131,6 +131,7 @@ import {
131
131
  historyCoverageWarning,
132
132
  historyFacts,
133
133
  historyFlagNote,
134
+ historyWickNote,
134
135
  historyGapNote,
135
136
  historyJumpNote,
136
137
  historyUnbrokenFrom,
@@ -1464,7 +1465,7 @@ async function loadListingHistory(
1464
1465
  ...window ? [...windowHistoryNotes(window, data), coverage ?? []]
1465
1466
  : range === "YTD" ? ytdHistoryNotes(data) : [...historyNotes(range, interval, data[0]?.date, Date.now(), coverage != null), coverage ?? []],
1466
1467
  // The status line says how far a bar still forming runs; the note is for the bars it does not name.
1467
- gaps ?? [], historyJumpNote(data) ?? [], historyFlagNote(data) ?? [], historyMissingVolumeNote(data) ?? [],
1468
+ gaps ?? [], historyJumpNote(data) ?? [], historyFlagNote(data) ?? [], historyWickNote(data) ?? [], historyMissingVolumeNote(data) ?? [],
1468
1469
  historyVenueVolumeNote(data) ?? [],
1469
1470
  historyBarStatusNote(forming ? served : data, { tradingDay: venueTradingDay(venue, Date.now()) }) ?? [],
1470
1471
  ].flat();
@@ -1881,7 +1882,11 @@ async function runFinancials(rawArgs: string[], ctx: Parameters<CliCommandDef["e
1881
1882
  // Years filled from the listing's exchange, for the revenue and net income columns.
1882
1883
  const filled = filledPeriodsNote(financials.annualStatements.filter((statement) => rows.some((row) => row.date === statement.date)),
1883
1884
  new Set(["totalRevenue", "netIncome"]));
1884
- const historyNotes = [...stale ? [stale] : [], ...filled ? [filled] : [], ...statementHistoryNotes("annual", rows.map((row) => row.date), listing.key)];
1885
+ // GF is pointed to only when the listing's extended history goes further back than these years.
1886
+ const dates = rows.map((row) => row.date);
1887
+ const older = await hasOlderStatements(() => market.dataProvider.getTickerFinancials(listing.request.symbol, listing.request.exchange,
1888
+ { statementHistory: "extended" }), "annual", dates);
1889
+ const historyNotes = [...stale ? [stale] : [], ...filled ? [filled] : [], ...statementHistoryNotes("annual", dates, listing.key, older)];
1885
1890
  // NDJSON keeps the raw statement rows it always wrote; CSV gets the table and its closing lines.
1886
1891
  const tables = tabular
1887
1892
  ? selectTables({
@@ -3280,7 +3285,7 @@ export const marketDataCliCommands: BuiltinCliCommandDef[] = [
3280
3285
  title: "Bars",
3281
3286
  lines: [
3282
3287
  `Each range comes in one bar size: ${historyIntervalsByRange()}. The line under the heading, and the --json metadata, give the currency, the bar size actually served, the first and last bar and the bar count. A warning says when the bars are coarser, or start later, than the range asked for, and for a coin, which trades every day, names each day it has no bar for.`,
3283
- "A bar whose prices contradict each other (high below the open or close, or low above them) is left blank and flagged, such as high<open, with the count in a warning. --json rows carry currency, unit, interval and flag.",
3288
+ "A bar whose prices contradict each other (high below the open or close, or low above them) is left blank and flagged, such as high<open, with the count in a warning. A low under half the open and close, or a high over twice them, is left blank alone and flagged low<close/2 or high>2*open. --json rows carry currency, unit, interval and flag.",
3284
3289
  "A break in the series flags the bar after it, which keeps its prices: gap after more than three months with no bar (weeks for weekly, daily and intraday bars), jump when the close is over 100 times the one before or under a hundredth of it. A warning names each break, and the heading says the date the series runs unbroken from.",
3285
3290
  "The heading's last line is the max drawdown of the bars shown: the largest fall from a close to a later close, with the peak and the trough, their dates, and the date a close got back to the peak, if one did (maxDrawdown in --json).",
3286
3291
  ],
@@ -1,6 +1,6 @@
1
1
  import { logFailure } from "../../utils/log-failure";
2
2
  import { apiClient } from "../../api-client";
3
- import { ApiRequestError, isPermanentClientError } from "../../api-client/errors";
3
+ import { ApiRequestError } from "../../api-client/errors";
4
4
  import type { CliCommandDef } from "../../types/plugin";
5
5
  import { withCliServices, withConfigData, withMarketData } from "../context";
6
6
  import { CLI_ERROR_CODE, failWithFields } from "../errors";
@@ -646,16 +646,15 @@ function withSavedFredSeries<T>(ctx: Parameters<CliCommandDef["execute"]>[1], ru
646
646
 
647
647
  /**
648
648
  * A FRED series from Gloom Cloud, or the copy an earlier read saved when it
649
- * cannot be refreshed (marked as such). A refusal asking again will not change
650
- * (a series Gloom Cloud no longer serves, access denied) is said, not covered.
649
+ * cannot be refreshed (marked as such). A refusal (a series Gloom Cloud no
650
+ * longer serves, access denied) is said, not covered: `loadCachedFredSeries`
651
+ * throws it.
651
652
  */
652
653
  async function loadFredSeries(
653
654
  seriesId: string,
654
655
  params: { startDate?: string; limit?: number; sortOrder: "asc" | "desc" },
655
656
  ): Promise<CloudFredSeriesPayload> {
656
- const result = await loadCachedFredSeries({ seriesId, ...params }, () => apiClient.getCloudFredSeries(seriesId, params));
657
- if (result.source === "stale-fallback" && isPermanentClientError(result.error)) throw result.error;
658
- return result.data;
657
+ return (await loadCachedFredSeries({ seriesId, ...params }, () => apiClient.getCloudFredSeries(seriesId, params))).data;
659
658
  }
660
659
 
661
660
  async function runFredList(filter: string, ctx: Parameters<CliCommandDef["execute"]>[1]) {
@@ -16,6 +16,7 @@ import {
16
16
  candidateExchange,
17
17
  LISTING_SEARCH_LIMIT,
18
18
  listingDistance,
19
+ primaryCompanyLine,
19
20
  searchTickerCandidates,
20
21
  type TickerSearchCandidate,
21
22
  } from "../../tickers/search";
@@ -34,8 +35,7 @@ import { readBrokerIndexCode } from "../listing-arg";
34
35
  import { knownGapMessage, commandPointer } from "../known-gaps";
35
36
  import { instrumentTypeLabel } from "../../tickers/instrument-kind";
36
37
  import { canonicalExchange, isUsListingExchange } from "../../utils/exchanges";
37
- import { companyNameExtraWords, groupByCompany, sameCompany } from "../../tickers/company-name";
38
- import { namedByService } from "../../tickers/company-name-listing";
38
+ import { companyLines, companyNameExtraWords, groupByCompany, namedByService, sameCompany } from "../../tickers/company-name";
39
39
  import { findCatalogSeries, renderSeriesMatches, type SeriesMatch } from "../series-search";
40
40
  import { centralBankAskedAbout, centralBankCiting, type CentralBank } from "../../utils/central-banks";
41
41
  import { createPaneCatalog } from "../pane-functions/discovery";
@@ -283,17 +283,17 @@ function withHomeListingPrimary(query: string, candidates: TickerSearchCandidate
283
283
  || primary.symbol.toUpperCase() === query.toUpperCase()) return candidates;
284
284
  const company = resolveSearchName(primary);
285
285
  const marked = !!primary.result && namedByService(query, primary.result);
286
- const lines = candidates.filter((candidate) => candidate.kind === "search" && candidate.category !== "Funds & Derivatives"
287
- && (candidate.instrumentClass === "equity" || candidate.instrumentClass === "other") && sameCompany(resolveSearchName(candidate), company)
288
- && (!marked || companyNameExtraWords(resolveSearchName(candidate), company) != null));
289
- const score = (candidate: TickerSearchCandidate) => candidate.result?.popularity ?? candidate.popularity ?? null;
290
- const [home] = [...lines].sort((a, b) => listingDistance(a) - listingDistance(b) || (score(b) ?? -1) - (score(a) ?? -1) || candidates.indexOf(a) - candidates.indexOf(b));
291
- if (!home || home === primary) return candidates;
292
- const closer = listingDistance(home) < listingDistance(primary);
293
- const [homeScore, primaryScore] = [score(home), score(primary)];
294
- const moreSearched = !marked && listingDistance(home) === listingDistance(primary) && homeScore != null
295
- && (primaryScore == null || homeScore >= primaryScore * 2);
296
- if (!closer && !moreSearched) return candidates;
286
+ // Never a line of another business its name is in (`companyLines`): Siemens Energy AG is not Siemens AG's.
287
+ const lines = companyLines(primary, candidates.filter((candidate) => candidate === primary || (candidate.kind === "search"
288
+ && candidate.category !== "Funds & Derivatives" && (candidate.instrumentClass === "equity" || candidate.instrumentClass === "other"))),
289
+ (candidate) => ({
290
+ name: resolveSearchName(candidate), exchange: candidateExchange(candidate),
291
+ receipt: listingDistance(candidate) === 2, home: candidate.result?.homeListing === true,
292
+ })).filter((candidate) => candidate === primary || !marked || companyNameExtraWords(resolveSearchName(candidate), company) != null);
293
+ const home = primaryCompanyLine(primary, lines, (candidate) => ({
294
+ distance: listingDistance(candidate), score: candidate.result?.popularity ?? candidate.popularity ?? null,
295
+ }), { named: marked });
296
+ if (home === primary) return candidates;
297
297
  const rest = candidates.filter((candidate) => candidate !== home);
298
298
  const at = rest.indexOf(primary);
299
299
  return [...rest.slice(0, at), { ...home, category: "Primary Listing" as const }, { ...primary, category: "Other Listings" as const }, ...rest.slice(at + 1)];
@@ -141,6 +141,7 @@ export async function initCliServices(options: CliServicesOptions = {}) {
141
141
  config,
142
142
  plugins: getLoadablePlugins(options.externalPlugins ?? []),
143
143
  externalPlugins: options.externalPlugins ?? [],
144
+ interactive: false,
144
145
  });
145
146
  services.providerRouter.setConfigAccessor(() => config);
146
147
  services.providerRouter.setBackgroundRevalidation(false);
@@ -404,7 +404,8 @@ export const GLOSSARY: readonly GlossaryEntry[] = [
404
404
  },
405
405
  {
406
406
  term: "Net debt", aliases: ["net debt/ebitda", "net debt to ebitda", "net leverage"], functions: ["FA"], shown: ["Net Debt"],
407
- definition: "Total debt less cash and short-term investments. Negative means more cash than debt. "
407
+ definition: "Total debt less cash and short-term investments, with the balance sheet's lease liabilities under IFRS (\"incl. leases\"); "
408
+ + "a company reporting under US GAAP (a 10-K, or a 20-F such as Alibaba's) keeps its operating leases out (\"excl. op. leases\"). Negative means more cash than debt. "
408
409
  + "Net debt / EBITDA is how many years of EBITDA would repay it; FA divides by a year of EBITDA, the trailing four quarters in a quarterly column.",
409
410
  },
410
411
  {
@@ -14,7 +14,9 @@ import { isRoundTheClockCoin } from "../utils/crypto-pair";
14
14
  import { currencyPairCode } from "../utils/currency-pair";
15
15
  import { currencyUnitLabel, resolveCurrencyUnit } from "../utils/currency-units";
16
16
  import { getPricePointTimestamp } from "../utils/price-history";
17
- import { latestTradeIndex, missingVolumeNotice, pricePointIntegrityFlags, pricePointValues } from "../utils/price-history-integrity";
17
+ import {
18
+ isWickFlag, latestTradeIndex, missingVolumeNotice, pricePointIntegrityFlags, pricePointValues, pricePointWickFlags,
19
+ } from "../utils/price-history-integrity";
18
20
  import { formatUtcTime } from "../utils/utc-time";
19
21
  import { historyCoverageNotice } from "../sources/history-coverage";
20
22
  import { barLastSession, barPeriodToDate, type BarPeriodToDate } from "../market-data/market/bar-period";
@@ -253,10 +255,17 @@ function flagCodes(row: Pick<HistoryRow, "flag">): string[] {
253
255
  return row.flag ? row.flag.split(",") : [];
254
256
  }
255
257
 
258
+ /** The codes of a bar left blank for prices that contradict each other: not a break before it, nor a withheld wick. */
259
+ function blankingCodes(row: Pick<HistoryRow, "flag">): string[] {
260
+ return flagCodes(row).filter((code) => !BREAK_FLAGS.has(code) && !isWickFlag(code));
261
+ }
262
+
256
263
  /**
257
264
  * Intraday bars keep their UTC time; daily and longer bars are trading dates.
258
265
  * A bar whose prices contradict each other is blanked exactly as HP blanks it,
259
- * and says why in `flag`. A bar after a gap or a jump in the series keeps its
266
+ * and says why in `flag`. A low or high too far from the open and close to
267
+ * have traded is left blank, the rest of the bar kept, flagged `low<close/2`
268
+ * or `high>2*open`. A bar after a gap or a jump in the series keeps its
260
269
  * prices and is flagged `gap` or `jump`.
261
270
  */
262
271
  export function historyRows(
@@ -277,7 +286,7 @@ export function historyRows(
277
286
  const time = times[index]!;
278
287
  const iso = Number.isFinite(time) ? new Date(time).toISOString() : "";
279
288
  const values = pricePointValues(point);
280
- const flags = pricePointIntegrityFlags(point);
289
+ const flags = [...pricePointIntegrityFlags(point), ...pricePointWickFlags(point)];
281
290
  const price = (value: number | null) => value == null ? null : cleanBarPrice(value, priceDivisor);
282
291
  return {
283
292
  date: intraday ? iso.replace(".000Z", "Z") : iso.slice(0, 10),
@@ -448,7 +457,7 @@ export function historyMissingVolumeNote(
448
457
  if (!historyReportsVolume(rows)) return null;
449
458
  const latestTrade = latestTradeIndex(rows.map((row) => Date.parse(row.date)));
450
459
  const missing = rows.filter((row, index) => row.volume == null && row.venueVolume == null && !row.partial && !row.filled
451
- && !flagCodes(row).some((code) => !BREAK_FLAGS.has(code)) && index !== latestTrade).length;
460
+ && blankingCodes(row).length === 0 && index !== latestTrade).length;
452
461
  return missingVolumeNotice(missing, rows.length) ?? null;
453
462
  }
454
463
 
@@ -833,10 +842,9 @@ export function historyGapNote(
833
842
  * keep their prices and are not counted.
834
843
  */
835
844
  export function historyFlagNote(rows: readonly Pick<HistoryRow, "flag">[]): string | null {
836
- const contradictions = (row: Pick<HistoryRow, "flag">) => flagCodes(row).filter((code) => !BREAK_FLAGS.has(code));
837
- const flagged = rows.filter((row) => contradictions(row).length > 0);
845
+ const flagged = rows.filter((row) => blankingCodes(row).length > 0);
838
846
  if (flagged.length === 0) return null;
839
- const codes = new Set(flagged.flatMap(contradictions));
847
+ const codes = new Set(flagged.flatMap(blankingCodes));
840
848
  const kinds = [
841
849
  codes.has("high<open") || codes.has("high<close") ? "high below open or close" : "",
842
850
  codes.has("low>open") || codes.has("low>close") ? "low above open or close" : "",
@@ -845,3 +853,23 @@ export function historyFlagNote(rows: readonly Pick<HistoryRow, "flag">[]): stri
845
853
  const count = `${flagged.length} of ${rows.length} ${flagged.length === 1 && rows.length === 1 ? "bar" : "bars"}`;
846
854
  return `${count} ${flagged.length === 1 ? "has" : "have"} ${kinds.join(", or ")} in the source data; their prices are left blank`;
847
855
  }
856
+
857
+ /**
858
+ * "The low of 2021-11-29 is under half its open and close, too far to have
859
+ * traded; left blank and out of ranges", or null when no bar has such a wick
860
+ * (pricePointWickFlags). Three or more bars are counted.
861
+ */
862
+ export function historyWickNote(rows: readonly Pick<HistoryRow, "date" | "flag">[]): string | null {
863
+ const flagged = rows.filter((row) => flagCodes(row).some(isWickFlag));
864
+ if (flagged.length === 0) return null;
865
+ const sides = new Set(flagged.flatMap((row) => flagCodes(row).filter(isWickFlag).map((code) => code.slice(0, code.indexOf(code.startsWith("low") ? "<" : ">")))));
866
+ const reason = "too far to have traded; left blank and out of ranges";
867
+ const only = flagged[0]!;
868
+ if (flagged.length === 1 && sides.size === 1) {
869
+ return sides.has("low") ? `The low of ${only.date} is under half its open and close, ${reason}`
870
+ : `The high of ${only.date} is over twice its open and close, ${reason}`;
871
+ }
872
+ const kinds = [sides.has("low") ? "a low under half" : "", sides.has("high") ? "a high over twice" : ""].filter(Boolean).join(" or ");
873
+ const dates = flagged.length <= LISTED_GAPS ? ` (${listItems(flagged.map((row) => row.date))})` : "";
874
+ return `${flagged.length} of ${rows.length} bars have ${kinds} their open and close${dates}, ${reason}`;
875
+ }
@@ -222,6 +222,17 @@ const TOPIC_POINTERS: ReadonlyArray<{
222
222
  closestLabel: "COMEX curves",
223
223
  closest: [["CTM", "gloom fn CTM HG for copper, gloom fn CTM ALI for aluminium"]],
224
224
  },
225
+ {
226
+ // No source serves a jet fuel (kerosene) price. Heating oil, NY Harbor ULSD, is what airlines hedge it with and
227
+ // analysts read it by; the EIA's weekly US stocks are a catalog series (checked 2026-10-29: quote HO=F,
228
+ // fn COT HO and fn G CAP:doe:jet all answer).
229
+ topic: "Jet fuel (kerosene)",
230
+ availability: "no price series; heating oil (NY Harbor ULSD) is the usual proxy, its price: gloom quote HO=F. "
231
+ + "US jet fuel stocks (EIA, weekly): gloom fn G CAP:doe:jet",
232
+ phrases: ["jet fuel", "jet fuels", "kerosene", "kerosine", "jet kerosene", "aviation fuel", "aviation fuels", "avtur"],
233
+ closestLabel: "positioning",
234
+ closest: [["COT", "heating oil, the usual jet fuel proxy: gloom fn COT HO"]],
235
+ },
225
236
  {
226
237
  // "exchange reserves" alone reads as crypto's; "foreign exchange reserves" is a central bank's, so only whole.
227
238
  topic: "Crypto exchange reserves and exchange flows",
@@ -15,7 +15,7 @@ import {
15
15
  type ListingVenue,
16
16
  } from "../tickers/listing-venues";
17
17
  import { findUsListingOfCompany, type UsListing } from "../tickers/us-listing";
18
- import { findListingOfCompanyName, isCompanyNameInput, readTerminalCodeArg, type CompanyNameListing } from "../tickers/company-name-listing";
18
+ import { findListingOfCompanyName, isCompanyNameInput, marksAnotherListing, readTerminalCodeArg, type CompanyNameListing } from "../tickers/company-name-listing";
19
19
  import { movedAwayChange, startTickerChangeLookup, type MovedTickerChange } from "../tickers/ticker-changes";
20
20
  import { lineMayHaveMoved, loadListingFate, MovedSymbolError, type ListingFate } from "../tickers/listing-fate";
21
21
  import { quotedListingName } from "../tickers/listing-name";
@@ -1338,6 +1338,22 @@ export async function oneWordCompanyName(typed: string, deps: Pick<ListingDeps,
1338
1338
  return found ? companyListingArg(typed, found) : null;
1339
1339
  }
1340
1340
 
1341
+ /**
1342
+ * A word the service marks as a company's name (`searchAliases`), read as
1343
+ * that company's listing as `oneWordCompanyName` reads a word, for a bare
1344
+ * symbol no source quotes, before it is read on the symbol's listings abroad:
1345
+ * "siemens" is SIE on XETRA (Siemens AG), not SIEMENS on NSE (Siemens Ltd.).
1346
+ * Null when the service marks no listing with the word, or marks the word's
1347
+ * own symbol, and when search names no company or is too busy to say.
1348
+ */
1349
+ export async function serviceNamedCompany(typed: string, deps: Pick<ListingDeps, "dataProvider">): Promise<ListingArg | null> {
1350
+ // Most symbols are no company's name: one search says so before any listing is weighed or quoted.
1351
+ if (!ONE_WORD_NAME.test(typed.trim()) || !await marksAnotherListing(deps.dataProvider, typed)) return null;
1352
+ const found = await findOneWordCompany(typed, deps);
1353
+ if (!found?.named || found.symbol.toUpperCase() === typed.trim().toUpperCase()) return null;
1354
+ return companyListingArg(typed, found);
1355
+ }
1356
+
1341
1357
  /** The company `oneWordCompanyName` reads a word as, before it is read as a listing. */
1342
1358
  async function findOneWordCompany(typed: string, deps: Pick<ListingDeps, "dataProvider">): Promise<CompanyNameListing | null> {
1343
1359
  const word = typed.trim();
@@ -21,6 +21,8 @@ import {
21
21
  notTradedMessage,
22
22
  oneWordCompanyName,
23
23
  quoteBareListing,
24
+ serviceNamedCompany,
25
+ type ListingArg,
24
26
  requireCliListing,
25
27
  resolveCliListing,
26
28
  tradingBoardNote,
@@ -64,7 +66,9 @@ export interface QuoteAnswer {
64
66
  /**
65
67
  * One symbol's row of `quote` and `compare`. A listing on an exchange its symbol
66
68
  * is not listed on says so. A bare symbol the data service read on its home
67
- * listing abroad names that venue (SXR8 -> XETRA). One no source quotes is
69
+ * listing abroad names that venue (SXR8 -> XETRA). One no source quotes that
70
+ * the service marks as another listing's company name is that company
71
+ * (`serviceNamedCompany`: "siemens" is SIE:XETRA); else it is
68
72
  * quoted on its listings (`quoteBareListing`), else names them, or asks for an
69
73
  * exchange when several companies list it (2222); a bare index root (VIX) also
70
74
  * names its index (^VIX), and a futures board code a security keeps (GC) its
@@ -98,6 +102,10 @@ export async function answerQuote(
98
102
  const notTraded = await notTradedMessage(listing, market);
99
103
  if (notTraded) return { result, listing, failure: { message: notTraded, oneLine: notTraded }, notes: [] };
100
104
  if (!isNoProviderError(result.error)) return { result, listing, notes: [] };
105
+ // A word the service marks as a company's name is that company, before the symbol is read on its listings abroad.
106
+ const named = !listing.exchange && listing.typed ? await serviceNamedCompany(listing.typed, market) : null;
107
+ const meant = named ? await answerNamedListing(named, market, request, refresh, datesChangeBasis) : null;
108
+ if (meant) return meant;
101
109
  const other = await quoteBareListing(listing, market, request, { refresh, datesChangeBasis });
102
110
  if (other?.kind === "quoted") {
103
111
  return {
@@ -155,17 +163,32 @@ async function answerCompanyName(
155
163
  const named = await oneWordCompanyName(listing.typed, market);
156
164
  // A real ticker a source failed to quote is not a company's name, even when the company carries the word.
157
165
  if (!named || (unsettled && named.symbol.toUpperCase() === listing.symbol.toUpperCase())) return null;
158
- // A listing on a venue the app cannot read is no answer: the word keeps its own "Not a ticker".
166
+ const answer = await answerNamedListing(named, market, request, refresh, datesChangeBasis);
167
+ if (answer) return answer;
168
+ if (!unsettled) return null;
169
+ const sentence = await notATickerWords(listing.typed, market);
170
+ return { result: { ...result, error: new Error(sentence) }, listing, notes: [] };
171
+ }
172
+
173
+ /**
174
+ * The row of the listing a company's name was read as (`named`), saying so;
175
+ * null when the app cannot read its venue or it has no quote.
176
+ */
177
+ async function answerNamedListing(
178
+ named: ListingArg,
179
+ market: MarketContext,
180
+ request: ListingDataRequest,
181
+ refresh: boolean,
182
+ datesChangeBasis: boolean,
183
+ ): Promise<QuoteAnswer | null> {
184
+ // A listing on a venue the app cannot read is no answer: the word keeps its own.
159
185
  const resolved = await resolveCliListing(named.key, null, market).catch((error: unknown) => {
160
186
  if (error instanceof ListingArgError) return null;
161
187
  throw error;
162
188
  });
163
189
  const found = resolved ? { ...resolved, readAs: named.readAs, ...named.meant ? { meant: named.meant } : {}, ...named.readFromName ? { readFromName: named.readFromName } : {} } : null;
164
190
  const [quoted] = found ? await market.dataProvider.getQuotesBatch([found.request], { forceRefresh: refresh, revalidate: true, datesChangeBasis }) : [];
165
- if (found && quoted?.quote) return answerQuote(quoted, found, market, request, refresh, null, { datesChangeBasis });
166
- if (!unsettled) return null;
167
- const sentence = await notATickerWords(listing.typed, market);
168
- return { result: { ...result, error: new Error(sentence) }, listing, notes: [] };
191
+ return found && quoted?.quote ? answerQuote(quoted, found, market, request, refresh, null, { datesChangeBasis }) : null;
169
192
  }
170
193
 
171
194
  /** A listing a command found a quote for, with what `quote` says beside it. */
@@ -154,6 +154,13 @@ export function useCommandBarRootRuntime({
154
154
  } {
155
155
  const previousRootSelectionContextRef = useRef<{ query: string; mode: string } | null>(null);
156
156
  const previousRootResultIdsRef = useRef<string[]>([]);
157
+ /**
158
+ * The row the untouched highlight rests on once the query's answer is in,
159
+ * and the answer it was on. A later answer that reorders the rows (a
160
+ * fresher search over a cached one, a broker's) moves the highlight with
161
+ * its row, so Enter opens the row that was highlighted.
162
+ */
163
+ const pinnedRootRowRef = useRef<{ id: string; answer: string | null } | null>(null);
157
164
  const activeMatch = matchPrefix(rootQuery, availableCommands);
158
165
 
159
166
  const tickerSearchRouteQuery = currentRoute?.kind === "mode" && currentRoute.screen === "ticker-search"
@@ -326,6 +333,7 @@ export function useCommandBarRootRuntime({
326
333
  previousRootSelectionContextRef.current = { query: rootQuery, mode: rootModeKind };
327
334
  if (selectionContextChanged) {
328
335
  rootSelectionNavigatedRef.current = false;
336
+ pinnedRootRowRef.current = null;
329
337
  }
330
338
 
331
339
  setRootSelectedIdx((current) => {
@@ -338,15 +346,23 @@ export function useCommandBarRootRuntime({
338
346
  if (shiftedIdx >= 0) return shiftedIdx;
339
347
  return clampIndex(current, resultIds.length);
340
348
  }
341
- // Untouched, the selection follows the best row on offer. When every row
342
- // is an offer or a placeholder, none of them is the answer and none is
343
- // selected: Enter must not take one of them for the ticker still being
344
- // searched.
349
+ const pinnedIdx = pinnedRootRowRef.current ? resultIds.indexOf(pinnedRootRowRef.current.id) : -1;
350
+ if (pinnedIdx >= 0) return pinnedIdx;
351
+ // Untouched, the selection follows the best row on offer until the
352
+ // answer is in. When every row is an offer or a placeholder, none of
353
+ // them is the answer and none is selected: Enter must not take one of
354
+ // them for the ticker still being searched.
345
355
  const defaultIdx = orderedRootResults.findIndex(isDefaultSelectable);
346
356
  if (defaultIdx < 0) return -1;
347
- return clampIndex(Math.max(rootResultModel.initialIdx, defaultIdx), resultIds.length);
357
+ const selected = clampIndex(Math.max(rootResultModel.initialIdx, defaultIdx), resultIds.length);
358
+ pinnedRootRowRef.current = rootResultsCurrent && resultIds[selected]
359
+ ? { id: resultIds[selected]!, answer: activeRootProviderResultsKey }
360
+ : null;
361
+ return selected;
348
362
  });
349
363
  }, [
364
+ activeRootProviderResultsKey,
365
+ rootResultsCurrent,
350
366
  activeMatch?.command.id,
351
367
  currentRoute,
352
368
  orderedRootResults,
@@ -360,7 +376,13 @@ export function useCommandBarRootRuntime({
360
376
 
361
377
  useEffect(() => {
362
378
  if (!activeRootProviderResultsKey) return;
379
+ // A later answer that only reorders the rows leaves the highlight on its row.
380
+ const pinned = pinnedRootRowRef.current;
381
+ if (pinned && pinned.answer !== activeRootProviderResultsKey
382
+ && orderedRootResults.some((item) => item.id === pinned.id)) return;
363
383
  setRootSelectedIdx(0);
384
+ const top = orderedRootResults[0];
385
+ pinnedRootRowRef.current = top ? { id: top.id, answer: activeRootProviderResultsKey } : null;
364
386
  setRootHoveredIdx(null);
365
387
  nativeListScrollRef.current?.scrollTo(0);
366
388
  }, [activeRootProviderResultsKey, nativeListScrollRef, setRootHoveredIdx, setRootSelectedIdx]);
@@ -2,8 +2,8 @@ import { useCallback, useEffect, useRef, type MutableRefObject } from "react";
2
2
  import type { AppState } from "../../state/app/context";
3
3
  import type { TickerRecord } from "../../types/ticker";
4
4
  import { t } from "../../i18n";
5
- import { findExactTickerSearchMatch, listingChoiceQuery } from "../../tickers/search";
6
- import { isKnownExchangeCode, parsePublicTickerKey } from "../../utils/exchanges";
5
+ import { listingChoiceQuery } from "../../tickers/search";
6
+ import { isKnownExchangeCode } from "../../utils/exchanges";
7
7
  import type { PluginRegistry } from "../../plugins/registry";
8
8
  import type { Command } from "./commands/registry";
9
9
  import type {
@@ -294,37 +294,21 @@ export function useCommandBarSelectionRuntime({
294
294
  const activateListSelection = useCallback((options?: { secondary?: boolean; item?: ResultItem }) => {
295
295
  const listState = visibleListStateRef.current;
296
296
  if (!listState) return;
297
- // Enter submits the typed command until a row is deliberately chosen.
298
- // Search ranks suggestions by relevance; its first namesake must not
299
- // replace the listing the shared ticker resolver would open.
297
+ // Enter opens exactly the highlighted row. The typed command runs as typed
298
+ // only while no listing for it is highlighted: before its search answers
299
+ // (`DES HFC` then opens the listing `quote HFC` reads), or while the rows
300
+ // on screen are an earlier query's.
300
301
  const queryChanged = rootQueryRef.current !== rootQuery;
301
302
  const tickerArgument = rootShortcutIntent.kind !== "none" && rootShortcutIntent.argKind === "ticker"
302
303
  ? rootShortcutIntent.argText : null;
303
304
  const listingQuery = listingChoiceQuery(tickerArgument ?? rootQueryRef.current);
304
305
  const choosingVenue = listingQuery && !isKnownExchangeCode(listingQuery.suffix);
305
- const submitTyped = !options?.item && !currentRoute
306
+ const highlighted = listState.results[listState.selectedIdx];
307
+ const listingHighlighted = !queryChanged && (highlighted?.kind === "ticker" || highlighted?.kind === "search");
308
+ const submitTyped = !options?.item && !currentRoute && !listingHighlighted
306
309
  && (queryChanged || (!rootSelectionNavigatedRef.current && !choosingVenue));
307
- let typed = submitTyped && (queryChanged || tickerArgument !== null)
310
+ const typed = submitTyped && (queryChanged || tickerArgument !== null)
308
311
  ? resolveImmediateRootSelection(rootQueryRef.current) : null;
309
- const first = listState.results[listState.selectedIdx];
310
- if (typed && !queryChanged && rootShortcutIntent.kind !== "none" && rootShortcutIntent.argKind === "ticker"
311
- && first && (first.kind === "ticker" || first.kind === "search")) {
312
- const argument = rootShortcutIntent.argText;
313
- // Company names and partial symbols still accept the suggested match.
314
- // An exact symbol or named venue goes through listing resolution.
315
- if (!parsePublicTickerKey(argument).exchange && !findExactTickerSearchMatch(
316
- listState.results.filter((item) => item.kind === "ticker" || item.kind === "search"), argument,
317
- )) typed = null;
318
- }
319
- // A plain symbol follows the same path once it is the default ticker row.
320
- // Commands and company-name searches keep their own default actions.
321
- const plainSymbol = submitTyped && !typed && first && (first.kind === "ticker" || first.kind === "search")
322
- && findExactTickerSearchMatch([first], rootQueryRef.current);
323
- if (plainSymbol && first) {
324
- const symbol = rootQueryRef.current;
325
- typed = { ...first, action: () => runSecurityDescriptionShortcut(symbol),
326
- secondaryAction: () => runSecurityDescriptionShortcut(symbol, { forceNewPane: true }) };
327
- }
328
312
  const selected = options?.item ?? typed ?? listState.results[listState.selectedIdx];
329
313
  if (!selected) {
330
314
  if (searchPending && !currentRoute && !options?.item && !rootSelectionNavigatedRef.current) {
@@ -399,8 +383,8 @@ export function useCommandBarSelectionRuntime({
399
383
  item: selected,
400
384
  query,
401
385
  rank: selected === typed ? 0 : Math.max(0, listState.results.indexOf(selected)),
402
- // A plain symbol stays a ticker choice; a submitted command is the shortcut itself.
403
- isShortcut: () => !plainSymbol && (selected === typed || resolveImmediateRootSelection(query)?.id === selected.id),
386
+ // A listing row stays a ticker choice; a submitted command is the shortcut itself.
387
+ isShortcut: () => !listingHighlighted && (selected === typed || resolveImmediateRootSelection(query)?.id === selected.id),
404
388
  }, run);
405
389
  }, [
406
390
  closeAll,
@@ -416,7 +400,6 @@ export function useCommandBarSelectionRuntime({
416
400
  rootSelectionNavigatedRef,
417
401
  rootShortcutIntent,
418
402
  runRootRow,
419
- runSecurityDescriptionShortcut,
420
403
  searchPending,
421
404
  stateConfigLayout,
422
405
  stateRef,
@@ -170,9 +170,9 @@ export function DetachedPaneShell({ pluginRegistry, desktopWindowBridge }: Detac
170
170
  fireAndForget(desktopWindowBridge.closeDetachedPane?.(desktopWindowBridge.paneId), "desktop.closeDetachedPane");
171
171
  }, [desktopWindowBridge, locked, pluginRegistry]);
172
172
 
173
- const startWindowDrag = useCallback(() => {
173
+ const startWindowDrag = useCallback((press?: { detail?: number }) => {
174
174
  focusPane();
175
- if (nativeWindowChrome) void rendererHost.startWindowDrag?.();
175
+ if (nativeWindowChrome) void rendererHost.startWindowDrag?.(press);
176
176
  }, [focusPane, nativeWindowChrome, rendererHost]);
177
177
 
178
178
  const sharePane = useCallback(() => {
@@ -870,9 +870,9 @@ export function Header({
870
870
  // bar itself wears the prompt's fill and reads as a single strip.
871
871
  const headerBg = nativePaneChrome ? colors.header : headerSurface(colors);
872
872
 
873
- const startWindowDrag = useCallback(() => {
873
+ const startWindowDrag = useCallback((press?: { detail?: number }) => {
874
874
  if (!titleBarOverlay || !nativeWindowChrome) return;
875
- void rendererHost.startWindowDrag?.();
875
+ void rendererHost.startWindowDrag?.(press);
876
876
  }, [nativeWindowChrome, rendererHost, titleBarOverlay]);
877
877
 
878
878
  const openCommandBar = useCallback((event?: HeaderActionEvent) => {