gloomberb 0.17.4 → 0.17.5

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 (119) hide show
  1. package/package.json +1 -1
  2. package/patches/electrobun@1.18.1.patch +80 -0
  3. package/src/api-client/data.ts +4 -1
  4. package/src/api-client/research-activity.ts +10 -3
  5. package/src/app/pane-runtime/ticker-open-runtime.ts +2 -1
  6. package/src/app/runtime/update.ts +50 -3
  7. package/src/app.tsx +2 -0
  8. package/src/ask-gloom/pane-context.ts +11 -1
  9. package/src/cli/commands/overview.ts +2 -2
  10. package/src/cli/commands/system.ts +19 -1
  11. package/src/cli/glossary.ts +34 -4
  12. package/src/cli/index.ts +6 -0
  13. package/src/cli/pane-functions/function-help.ts +1 -1
  14. package/src/cli/pane-functions/market-session.ts +18 -3
  15. package/src/components/chart/composite/format.ts +7 -0
  16. package/src/components/chart/composite/legend.tsx +8 -11
  17. package/src/components/command-bar/commands/direct/index.ts +47 -0
  18. package/src/components/command-bar/commands/registry.ts +8 -0
  19. package/src/components/command-bar/panel/prompt-binding.ts +8 -0
  20. package/src/components/command-bar/routes/root/command-items.ts +16 -1
  21. package/src/components/command-bar/surface/index.tsx +2 -1
  22. package/src/components/command-bar/workflow/ops.ts +4 -3
  23. package/src/components/form-modal/confirm.tsx +1 -0
  24. package/src/components/form-modal/request.ts +2 -0
  25. package/src/components/layout/detached-pane-shell.tsx +6 -1
  26. package/src/components/layout/pane/sidebar.tsx +24 -11
  27. package/src/components/layout/shell/index.tsx +5 -1
  28. package/src/components/layout/shell/menu.ts +8 -0
  29. package/src/components/layout/shell/pane/capture.ts +29 -12
  30. package/src/components/ui/confirm-dialog.tsx +30 -4
  31. package/src/components/ui/fields.tsx +5 -0
  32. package/src/data/config/store/normalize.ts +8 -0
  33. package/src/i18n/es.ts +2 -0
  34. package/src/i18n/ja.ts +2 -0
  35. package/src/i18n/ko.ts +2 -0
  36. package/src/i18n/zh-cn.ts +2 -0
  37. package/src/i18n/zh-tw.ts +2 -0
  38. package/src/market-data/coordinator/events.ts +5 -1
  39. package/src/market-data/coordinator/index.ts +4 -0
  40. package/src/market-data/coordinator/quotes.ts +4 -0
  41. package/src/market-data/hooks.tsx +168 -29
  42. package/src/plugins/builtin/broker-manager/detail.tsx +2 -2
  43. package/src/plugins/builtin/broker-manager/model.ts +2 -2
  44. package/src/plugins/builtin/chart-composer/headless.ts +2 -1
  45. package/src/plugins/builtin/chart-composer/pane.tsx +20 -2
  46. package/src/plugins/builtin/chat/content/footer.tsx +5 -2
  47. package/src/plugins/builtin/chat/controller/index.ts +20 -0
  48. package/src/plugins/builtin/chat/controller/messages.ts +14 -2
  49. package/src/plugins/builtin/chat/controller/state.ts +15 -0
  50. package/src/plugins/builtin/chat/controller/unread-people.ts +240 -0
  51. package/src/plugins/builtin/chat/controller/view.ts +2 -0
  52. package/src/plugins/builtin/chat/index.tsx +1 -1
  53. package/src/plugins/builtin/chat/share/flow.tsx +18 -9
  54. package/src/plugins/builtin/chat/unread-badge.ts +38 -7
  55. package/src/plugins/builtin/cloud/askg/pane-attachment.ts +2 -3
  56. package/src/plugins/builtin/futures-curve/headless.ts +13 -6
  57. package/src/plugins/builtin/futures-curve/index.tsx +3 -3
  58. package/src/plugins/builtin/futures-curve/model.ts +64 -4
  59. package/src/plugins/builtin/futures-curve/pane.tsx +11 -4
  60. package/src/plugins/builtin/kelly-sizer/headless.ts +1 -1
  61. package/src/plugins/builtin/kelly-sizer/history.ts +5 -3
  62. package/src/plugins/builtin/kelly-sizer/pane.tsx +1 -1
  63. package/src/plugins/builtin/macro-day/release-table.ts +3 -3
  64. package/src/plugins/builtin/mna/model.ts +2 -1
  65. package/src/plugins/builtin/options-positioning/model.ts +14 -0
  66. package/src/plugins/builtin/options-positioning/pane.tsx +17 -10
  67. package/src/plugins/builtin/perps/client.ts +77 -6
  68. package/src/plugins/builtin/perps/headless.ts +25 -14
  69. package/src/plugins/builtin/perps/index.tsx +1 -1
  70. package/src/plugins/builtin/perps/model.ts +32 -0
  71. package/src/plugins/builtin/perps/pane.tsx +5 -4
  72. package/src/plugins/builtin/rate-path/model.ts +1 -1
  73. package/src/plugins/builtin/rate-path/pane.tsx +2 -1
  74. package/src/plugins/builtin/research/event-sources.ts +1 -1
  75. package/src/plugins/builtin/research/relative-valuation-model.ts +6 -0
  76. package/src/plugins/builtin/research/relative-valuation-pane.tsx +3 -3
  77. package/src/plugins/builtin/sectors/client.ts +46 -19
  78. package/src/plugins/builtin/sectors/sector-model.ts +22 -10
  79. package/src/plugins/builtin/thirteenf/api.ts +60 -43
  80. package/src/plugins/builtin/thirteenf/data.ts +76 -35
  81. package/src/plugins/builtin/thirteenf/headless.ts +2 -0
  82. package/src/plugins/builtin/thirteenf/pane.tsx +10 -2
  83. package/src/plugins/builtin/yield-curve/curves.ts +15 -1
  84. package/src/plugins/builtin/yield-curve/headless.ts +17 -7
  85. package/src/plugins/builtin/yield-curve/history.ts +2 -0
  86. package/src/plugins/builtin/yield-curve/index.tsx +23 -18
  87. package/src/plugins/builtin/yield-curve/series.ts +19 -9
  88. package/src/plugins/builtin/yield-curve/treasury-data.ts +2 -0
  89. package/src/plugins/builtin/yield-curve/world.ts +4 -0
  90. package/src/plugins/registry/host-actions.ts +3 -0
  91. package/src/renderers/browser/app-services.ts +7 -1
  92. package/src/renderers/browser/config-host.ts +55 -2
  93. package/src/renderers/browser/first-screen.ts +193 -0
  94. package/src/renderers/browser/main.tsx +59 -7
  95. package/src/renderers/dom/desktop/controls.tsx +2 -0
  96. package/src/renderers/electrobun/bun/application-menu/index.ts +1 -0
  97. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +3 -0
  98. package/src/renderers/electrobun/bun/desktop/update.ts +115 -12
  99. package/src/renderers/electrobun/shared/protocol.ts +3 -2
  100. package/src/renderers/electrobun/view/update-host.ts +2 -2
  101. package/src/shares/bug-report-dialog.tsx +162 -0
  102. package/src/shares/bug-report-flow.tsx +84 -0
  103. package/src/shares/bug-report.ts +42 -0
  104. package/src/shares/capture-flow.ts +19 -0
  105. package/src/shares/capture-preview.tsx +129 -0
  106. package/src/shares/chat.ts +10 -1
  107. package/src/shares/dialog.tsx +16 -91
  108. package/src/shares/flow.tsx +12 -16
  109. package/src/shares/personal-pane.ts +35 -1
  110. package/src/telemetry/usage-counts.ts +16 -0
  111. package/src/tickers/open-target.ts +61 -11
  112. package/src/time-series/resolve.ts +21 -6
  113. package/src/time-series/spec.ts +8 -0
  114. package/src/types/config.ts +14 -0
  115. package/src/updater/channel.ts +51 -0
  116. package/src/updater/github-releases.ts +85 -16
  117. package/src/updater/index.ts +125 -16
  118. package/src/utils/datetime-format.ts +7 -1
  119. package/src/utils/semver.ts +47 -2
@@ -0,0 +1,240 @@
1
+ import { apiClient, type ChatChannel, type ChatMessage } from "../../../../api-client";
2
+ import type { ChannelRuntimeState, ChannelUnreadPeople } from "./state";
3
+ import { chatMessageMentionsUsername, normalizeChatUsername } from "./utils";
4
+
5
+ /** The most messages the server returns for one request. */
6
+ const PAGE_SIZE = 100;
7
+ /**
8
+ * A channel reads at most this many pages of its unread messages, the newest
9
+ * ones: 300. A mention older than that in a channel with more than 300 unread
10
+ * is not counted, which keeps a busy channel left unread for weeks from
11
+ * costing hundreds of requests at every launch.
12
+ */
13
+ const MAX_PAGES = 3;
14
+ const RETRY_AFTER_FAILURE_MS = 60_000;
15
+
16
+ interface Reader {
17
+ id: string;
18
+ username: string | null;
19
+ }
20
+
21
+ /** What the app badge needs of one unread message: who sent it, and whether it mentions you. */
22
+ interface UnreadFact {
23
+ senderId: string;
24
+ mentionsYou: boolean;
25
+ }
26
+
27
+ function unreadFact(message: ChatMessage, reader: Reader): UnreadFact {
28
+ return {
29
+ senderId: message.user.id,
30
+ mentionsYou: !!reader.username && chatMessageMentionsUsername(message.content, reader.username),
31
+ };
32
+ }
33
+
34
+ function peopleOf(facts: Iterable<UnreadFact>): ChannelUnreadPeople {
35
+ const senders = new Set<string>();
36
+ const mentioners = new Set<string>();
37
+ for (const fact of facts) {
38
+ senders.add(fact.senderId);
39
+ if (fact.mentionsYou) mentioners.add(fact.senderId);
40
+ }
41
+ return { senders: [...senders], mentioners: [...mentioners] };
42
+ }
43
+
44
+ /**
45
+ * The people behind unread messages: who sent them and who mentioned
46
+ * `reader`. Your own messages are never unread.
47
+ */
48
+ export function summarizeUnreadMessages(
49
+ messages: readonly ChatMessage[],
50
+ reader: { id: string; username: string | null },
51
+ ): ChannelUnreadPeople {
52
+ return peopleOf(messages.filter((message) => message.user.id !== reader.id).map((message) => unreadFact(message, reader)));
53
+ }
54
+
55
+ /** Messages read from the server for a channel whose unread messages are not all on this device. */
56
+ interface LoadedUnread {
57
+ userId: string;
58
+ username: string | null;
59
+ /** The read marker they follow; a new marker makes them stale. */
60
+ markerId: string;
61
+ facts: Map<string, UnreadFact>;
62
+ /** The newest message read, where the next load picks up. */
63
+ newestId: string | null;
64
+ /** The unread count the load was asked for, so a count that never matches is not asked for again. */
65
+ loadedFor: number;
66
+ failedAt: number | null;
67
+ }
68
+
69
+ interface UnreadPeopleOptions {
70
+ getChannels: () => ChatChannel[];
71
+ getChannelStates: () => Iterable<[string, ChannelRuntimeState]>;
72
+ getUser: () => { id: string; username: string; emailVerified: boolean } | null;
73
+ /** A load finished: the people changed. */
74
+ onChange: () => void;
75
+ }
76
+
77
+ /**
78
+ * Who each channel's unread messages are from, for the app badge. The server
79
+ * keeps a count per channel and a read marker, not the senders, so they come
80
+ * from the messages after the marker: the ones this device holds, and for a
81
+ * channel whose unread messages are not all here, the newest 300 of the rest
82
+ * read from the server once (then only what is new). Nothing is guessed: a
83
+ * channel that has not loaded yet reports the unread messages it does hold.
84
+ *
85
+ * Only a host that keeps an app badge needs this, so it does nothing, and
86
+ * makes no request, until something asks to {@link track} it.
87
+ */
88
+ export class ChatControllerUnreadPeople {
89
+ private readonly loaded = new Map<string, LoadedUnread>();
90
+ private readonly inFlight = new Set<string>();
91
+ private generation = 0;
92
+ private trackers = 0;
93
+
94
+ constructor(private readonly options: UnreadPeopleOptions) {}
95
+
96
+ /** Starts keeping the people current for as long as the returned stop has not been called. */
97
+ track(): () => void {
98
+ this.trackers += 1;
99
+ if (this.trackers === 1) this.options.onChange();
100
+ let stopped = false;
101
+ return () => {
102
+ if (stopped) return;
103
+ stopped = true;
104
+ this.trackers -= 1;
105
+ if (this.trackers === 0) this.clear();
106
+ };
107
+ }
108
+
109
+ snapshot(): Record<string, ChannelUnreadPeople> {
110
+ const reader = this.trackers > 0 ? this.reader() : null;
111
+ if (!reader) return {};
112
+ const channels = new Map(this.options.getChannels().map((channel) => [channel.id, channel]));
113
+ const people: Record<string, ChannelUnreadPeople> = {};
114
+ for (const [channelId, state] of this.options.getChannelStates()) {
115
+ if (state.unreadCount <= 0) continue;
116
+ const otherPerson = directPerson(channels.get(channelId));
117
+ // Everything unread in a DM is from the one person on the other side.
118
+ people[channelId] = otherPerson
119
+ ? { senders: [otherPerson], mentioners: [] }
120
+ : peopleOf(this.knownFacts(channelId, state, reader).values());
121
+ }
122
+ return people;
123
+ }
124
+
125
+ /** Starts reading what is missing for channels whose unread messages are not all here. Cheap when nothing is. */
126
+ sync(): void {
127
+ const reader = this.trackers > 0 ? this.reader() : null;
128
+ if (!reader || !this.options.getUser()?.emailVerified) return;
129
+ const channels = new Map(this.options.getChannels().map((channel) => [channel.id, channel]));
130
+ for (const [channelId, state] of this.options.getChannelStates()) {
131
+ const marker = state.lastViewedMessageId;
132
+ const channel = channels.get(channelId);
133
+ if (state.unreadCount <= 0 || !marker || !channel || directPerson(channel) || this.inFlight.has(channelId)) continue;
134
+ if (this.knownFacts(channelId, state, reader).size >= state.unreadCount) continue;
135
+ const loaded = this.currentLoad(channelId, marker, reader);
136
+ if (loaded && loaded.loadedFor >= state.unreadCount) continue;
137
+ if (loaded?.failedAt && Date.now() - loaded.failedAt < RETRY_AFTER_FAILURE_MS) continue;
138
+ void this.load(channelId, marker, reader, state.unreadCount);
139
+ }
140
+ }
141
+
142
+ /** Drops what was read and lets late answers fall away, as on sign-out. */
143
+ clear(): void {
144
+ this.generation += 1;
145
+ this.loaded.clear();
146
+ this.inFlight.clear();
147
+ }
148
+
149
+ private reader(): Reader | null {
150
+ const user = this.options.getUser();
151
+ return user ? { id: user.id, username: normalizeChatUsername(user.username) } : null;
152
+ }
153
+
154
+ private currentLoad(channelId: string, marker: string, reader: Reader): LoadedUnread | null {
155
+ const loaded = this.loaded.get(channelId);
156
+ return loaded && loaded.markerId === marker && loaded.userId === reader.id && loaded.username === reader.username
157
+ ? loaded
158
+ : null;
159
+ }
160
+
161
+ /**
162
+ * The unread messages known for certain: those read from the server after
163
+ * the read marker, and the cached ones after it. Without the marker nothing
164
+ * can be placed before or after it, so none is claimed (the server counts
165
+ * no unread without one either).
166
+ */
167
+ private knownFacts(channelId: string, state: ChannelRuntimeState, reader: Reader): Map<string, UnreadFact> {
168
+ const facts = new Map<string, UnreadFact>();
169
+ const marker = state.lastViewedMessageId;
170
+ if (!marker) return facts;
171
+ for (const [id, fact] of this.currentLoad(channelId, marker, reader)?.facts ?? []) facts.set(id, fact);
172
+ const markerIndex = state.messages.findIndex((message) => message.id === marker);
173
+ if (markerIndex < 0) return facts;
174
+ for (const message of state.messages.slice(markerIndex + 1)) {
175
+ if (message.user.id === reader.id || message.clientStatus) continue;
176
+ facts.set(message.id, unreadFact(message, reader));
177
+ }
178
+ return facts;
179
+ }
180
+
181
+ private async load(channelId: string, marker: string, reader: Reader, wanted: number): Promise<void> {
182
+ const generation = this.generation;
183
+ this.inFlight.add(channelId);
184
+ const previous = this.currentLoad(channelId, marker, reader);
185
+ try {
186
+ const messages = await readAfter(channelId, previous?.newestId ?? marker);
187
+ if (generation !== this.generation) return;
188
+ const facts = new Map(previous?.facts);
189
+ for (const message of messages) {
190
+ if (message.user.id !== reader.id) facts.set(message.id, unreadFact(message, reader));
191
+ }
192
+ this.loaded.set(channelId, {
193
+ userId: reader.id,
194
+ username: reader.username,
195
+ markerId: marker,
196
+ facts,
197
+ newestId: messages.at(-1)?.id ?? previous?.newestId ?? null,
198
+ loadedFor: wanted,
199
+ failedAt: null,
200
+ });
201
+ } catch {
202
+ if (generation !== this.generation) return;
203
+ this.loaded.set(channelId, {
204
+ userId: reader.id,
205
+ username: reader.username,
206
+ markerId: marker,
207
+ facts: previous?.facts ?? new Map(),
208
+ newestId: previous?.newestId ?? null,
209
+ loadedFor: previous?.loadedFor ?? 0,
210
+ failedAt: Date.now(),
211
+ });
212
+ } finally {
213
+ if (generation === this.generation) this.inFlight.delete(channelId);
214
+ }
215
+ this.options.onChange();
216
+ }
217
+ }
218
+
219
+ /** The one other person in a direct message, when the channel says who. */
220
+ function directPerson(channel: ChatChannel | undefined): string | null {
221
+ return channel?.kind === "direct" ? channel.dmUser?.id ?? null : null;
222
+ }
223
+
224
+ /**
225
+ * Every message after `after`, oldest first. The server answers a request
226
+ * with the newest ones, so a long run reads back from the newest page to the
227
+ * one that reaches `after`.
228
+ */
229
+ async function readAfter(channelId: string, after: string): Promise<ChatMessage[]> {
230
+ const pages: ChatMessage[][] = [];
231
+ let before: string | undefined;
232
+ for (let page = 0; page < MAX_PAGES; page += 1) {
233
+ const messages = await apiClient.getMessages(channelId, { after, before, limit: PAGE_SIZE });
234
+ if (messages.length === 0) break;
235
+ pages.unshift(messages);
236
+ if (messages.length < PAGE_SIZE) break;
237
+ before = messages[0]!.id;
238
+ }
239
+ return pages.flat();
240
+ }
@@ -23,6 +23,7 @@ interface ChatControllerViewOptions {
23
23
  getListenerSnapshot: (channelId: string) => ChatControllerSnapshot;
24
24
  getVisibleMessages: (channelId: string) => ChatMessage[];
25
25
  getUnreadMentionCount: (channelId: string) => number;
26
+ getUnreadPeople: () => ChatControllerSnapshot["unreadPeople"];
26
27
  }
27
28
 
28
29
  interface ChannelListenerEntry {
@@ -78,6 +79,7 @@ export class ChatControllerView {
78
79
  draftAttachments: channel.draftAttachments.map(publicDraftAttachment),
79
80
  replyToId: channel.replyToId,
80
81
  unreadMentionCount: this.options.getUnreadMentionCount(normalizedChannelId),
82
+ unreadPeople: this.options.getUnreadPeople(),
81
83
  };
82
84
  }
83
85
 
@@ -157,7 +157,7 @@ export const chatModule: PluginModule = {
157
157
  chatController.attachPersistence(ctx.persistence, ctx.resume);
158
158
  chatSidebarStore.attach(ctx.persistence);
159
159
  chatHandlePromptStore.attach(ctx.persistence);
160
- // The desktop app's Dock badge follows the Unread count; other hosts have no badge.
160
+ // The desktop app's Dock badge counts the people who wrote to you or tagged you, plus the missed notices; other hosts have no badge.
161
161
  disposeUnreadBadge?.();
162
162
  const badgeHost = getAppBadgeHost();
163
163
  disposeUnreadBadge = badgeHost
@@ -1,6 +1,6 @@
1
1
  import { apiClient } from "../../../../api-client";
2
2
  import { pngDataUrl } from "../../../../shares/pane-image";
3
- import { registerPaneChatSharing } from "../../../../shares/chat";
3
+ import { registerPaneChatSharing, type ChatPostPurpose } from "../../../../shares/chat";
4
4
  import type { GloomPluginContext } from "../../../../types/plugin";
5
5
  import { requestAuthDialog } from "../../../../account/auth-dialog";
6
6
  import { MAX_CHAT_IMAGE_BYTES } from "../attachments/model";
@@ -11,6 +11,11 @@ import { defaultShareTarget, shareTargets } from "./targets";
11
11
 
12
12
  type ShareContext = Pick<GloomPluginContext, "notify" | "createPaneFromTemplate" | "configState">;
13
13
  const INCLUDE_LINK_KEY = "share.includeLink";
14
+ /** How the sign-in and verify prompts finish "Sign in to ...". */
15
+ const PURPOSE_TEXT: Record<ChatPostPurpose, string> = {
16
+ share: "share to chat",
17
+ "bug-report": "report a bug",
18
+ };
14
19
 
15
20
  function errorText(error: unknown, fallback: string): string {
16
21
  return error instanceof Error && error.message ? error.message : fallback;
@@ -18,12 +23,13 @@ function errorText(error: unknown, fallback: string): string {
18
23
 
19
24
  /**
20
25
  * Whether the account can post, saying in one line what is missing when it
21
- * cannot: a sign-in (which then carries on with the share) or a verified email.
26
+ * cannot: a sign-in (which then carries on with the share or the bug report)
27
+ * or a verified email.
22
28
  */
23
- function readyToPost(ctx: ShareContext, retry: () => void): boolean {
29
+ function readyToPost(ctx: ShareContext, retry: () => void, purpose: ChatPostPurpose): boolean {
24
30
  if (!apiClient.isSignedIn()) {
25
31
  ctx.notify({
26
- body: "Sign in to share to chat.",
32
+ body: `Sign in to ${PURPOSE_TEXT[purpose]}.`,
27
33
  type: "info",
28
34
  action: { label: "Sign in", onClick: () => { requestAuthDialog({ onSignedIn: retry }); } },
29
35
  });
@@ -31,7 +37,7 @@ function readyToPost(ctx: ShareContext, retry: () => void): boolean {
31
37
  }
32
38
  if (!apiClient.isVerified()) {
33
39
  ctx.notify({
34
- body: "Verify your email to share to chat.",
40
+ body: `Verify your email to ${PURPOSE_TEXT[purpose]}.`,
35
41
  type: "info",
36
42
  action: {
37
43
  label: "Resend link",
@@ -55,10 +61,14 @@ async function loadTargets() {
55
61
  return shareTargets(list ? buildChatConversations(list.states, list.userId) : []);
56
62
  }
57
63
 
64
+ function openMessage(ctx: ShareContext, channelId: string, messageId: string): void {
65
+ ctx.createPaneFromTemplate("new-chat-pane", { arg: channelId, values: { messageId } });
66
+ }
67
+
58
68
  /** Chat contributes a destination, never another menu entry, command or key. */
59
69
  export function installPaneChatSharing(ctx: ShareContext): () => void {
60
70
  return registerPaneChatSharing({
61
- ready: (retry) => readyToPost(ctx, retry),
71
+ ready: (retry, purpose = "share") => readyToPost(ctx, retry, purpose),
62
72
  targets: loadTargets,
63
73
  defaultChannel: (targets) => defaultShareTarget(targets, ctx.configState.get<string>(LAST_VISITED_CHAT_CHANNEL_KEY)),
64
74
  includeLink: () => ctx.configState.get<boolean>(INCLUDE_LINK_KEY) !== false,
@@ -78,10 +88,9 @@ export function installPaneChatSharing(ctx: ShareContext): () => void {
78
88
  ctx.notify({
79
89
  body: `Posted to ${label}`,
80
90
  type: "success",
81
- action: { label: "Open", onClick: () => {
82
- ctx.createPaneFromTemplate("new-chat-pane", { arg: result.channelId, values: { messageId: result.messageId } });
83
- } },
91
+ action: { label: "Open", onClick: () => { openMessage(ctx, result.channelId, result.messageId); } },
84
92
  });
85
93
  },
94
+ openMessage: (channelId, messageId) => openMessage(ctx, channelId, messageId),
86
95
  });
87
96
  }
@@ -1,9 +1,11 @@
1
+ import type { ChatChannel } from "../../../api-client";
1
2
  import { appBadgeLabel } from "../../../notifications/app-badge";
2
3
  import type { NotificationLogEntry } from "../../../notifications/notification-log";
3
4
  import { unreadNotificationLogCount } from "../../../notifications/notification-log";
4
5
  import type { ChatControllerSnapshot } from "./controller/state";
5
6
 
6
7
  type UnreadSnapshot = Pick<ChatControllerSnapshot, "channelStates" | "hasSavedSession" | "user">;
8
+ type BadgeSnapshot = UnreadSnapshot & Pick<ChatControllerSnapshot, "channels" | "unreadPeople">;
7
9
 
8
10
  /**
9
11
  * What the Unread list holds as unread, and what the status bar counts: the
@@ -16,9 +18,34 @@ export function unreadTotal(snapshot: UnreadSnapshot, notices: readonly Notifica
16
18
  return chat + unreadNotificationLogCount(notices);
17
19
  }
18
20
 
21
+ /** DMs and group chats: what only the people in them read, and every message in them is for you. */
22
+ function isPeopleConversation(channel: ChatChannel | undefined): boolean {
23
+ return channel?.kind === "direct" || channel?.kind === "group";
24
+ }
25
+
26
+ /**
27
+ * What the app badge counts: people, not messages. Everyone who sent you an
28
+ * unread DM or group message, and everyone whose unread message in any other
29
+ * channel mentions you, each once however many messages they sent or where;
30
+ * then every missed notice not yet read. Untagged channel messages add
31
+ * nothing. Zero signed out. The status bar keeps counting every unread
32
+ * message ({@link unreadTotal}).
33
+ */
34
+ export function unreadBadgeCount(snapshot: BadgeSnapshot, notices: readonly NotificationLogEntry[]): number {
35
+ if (!snapshot.user && !snapshot.hasSavedSession) return 0;
36
+ const channels = new Map(snapshot.channels.map((channel) => [channel.id, channel]));
37
+ const people = new Set<string>();
38
+ for (const [channelId, who] of Object.entries(snapshot.unreadPeople)) {
39
+ for (const id of isPeopleConversation(channels.get(channelId)) ? who.senders : who.mentioners) people.add(id);
40
+ }
41
+ return people.size + unreadNotificationLogCount(notices);
42
+ }
43
+
19
44
  interface UnreadBadgeOptions {
20
45
  controller: {
21
- subscribe(listener: (snapshot: UnreadSnapshot) => void): () => void;
46
+ subscribe(listener: (snapshot: BadgeSnapshot) => void): () => void;
47
+ /** Starts reading who the unread messages are from; the returned stop ends it. */
48
+ trackUnreadPeople(): () => void;
22
49
  };
23
50
  log: {
24
51
  get(): readonly NotificationLogEntry[];
@@ -28,14 +55,16 @@ interface UnreadBadgeOptions {
28
55
  }
29
56
 
30
57
  /**
31
- * Keeps the app badge on the unread count: one `setBadge` per change of the
32
- * label, none while it stays the same (past 99 it reads `99+` however many
33
- * more arrive), and a clear when stopped, as when the plugin is turned off.
34
- * Signing out zeroes the count, which clears it. Returns the stop.
58
+ * Keeps the app badge on {@link unreadBadgeCount}: one `setBadge` per change
59
+ * of the label, none while it stays the same (past 99 it reads `99+` however
60
+ * many more arrive), and a clear when stopped, as when the plugin is turned
61
+ * off. Signing out zeroes the count, which clears it. While it runs the
62
+ * controller reads who the unread messages are from; a host with no badge
63
+ * never starts it, so reads nothing extra. Returns the stop.
35
64
  */
36
65
  export function watchUnreadBadge({ controller, log, setBadge }: UnreadBadgeOptions): () => void {
37
66
  let applied = "";
38
- let snapshot: UnreadSnapshot | null = null;
67
+ let snapshot: BadgeSnapshot | null = null;
39
68
 
40
69
  const apply = (label: string) => {
41
70
  if (label === applied) return;
@@ -47,9 +76,10 @@ export function watchUnreadBadge({ controller, log, setBadge }: UnreadBadgeOptio
47
76
  }
48
77
  };
49
78
  const update = () => {
50
- if (snapshot) apply(appBadgeLabel(unreadTotal(snapshot, log.get())));
79
+ if (snapshot) apply(appBadgeLabel(unreadBadgeCount(snapshot, log.get())));
51
80
  };
52
81
 
82
+ const stopTracking = controller.trackUnreadPeople();
53
83
  const stopController = controller.subscribe((next) => {
54
84
  snapshot = next;
55
85
  update();
@@ -59,6 +89,7 @@ export function watchUnreadBadge({ controller, log, setBadge }: UnreadBadgeOptio
59
89
  return () => {
60
90
  stopController();
61
91
  stopLog();
92
+ stopTracking();
62
93
  snapshot = null;
63
94
  apply("");
64
95
  };
@@ -1,3 +1,4 @@
1
+ import { paneFunctionLabel } from "../../../../ask-gloom/pane-context";
1
2
  import type { AskGloomPaneAttachment } from "../../../../ask-gloom/pane-request";
2
3
  import type { ASKGSessionContext, JsonValue } from "./protocol";
3
4
 
@@ -38,9 +39,7 @@ function sharesPaneContents(attached: ASKGAttachedPane): boolean {
38
39
 
39
40
  /** What the chip says: the function and what it shows, such as `DES AAPL`. */
40
41
  export function attachedPaneLabel(pane: AskGloomPaneAttachment): string {
41
- const title = pane.title.trim() || pane.paneType;
42
- if (!pane.code) return title;
43
- return title.split(/\s+/)[0]?.toUpperCase() === pane.code ? title : `${pane.code} ${title}`;
42
+ return paneFunctionLabel(pane);
44
43
  }
45
44
 
46
45
  /** Drops whole lines from the end, then characters, until `fits` holds. */
@@ -3,7 +3,7 @@ import { futuresSpecLines } from "../../../market-data/futures-contract";
3
3
  import type { HeadlessPaneColumn, HeadlessPaneDefinition, HeadlessPaneEntry, HeadlessPaneFreshness } from "../../../types/plugin";
4
4
  import { newestReportTime, oldestReportTime } from "../../../utils/utc-time";
5
5
  import { fetchFuturesCurve, loadCurveSpot, loadFuturesCurveAsOf } from "./client";
6
- import { basisSpotSymbol, catalogueCurveUnit, contractBasis, curveAsOfDate, curveBasisPercent, curveChangeText, curveContractCode, curvePrice, curveRoundedPrice, curveSpotLabel, curveSpreadLabel, curveTimestamp, curveUnitLabel, normalizeCurveRoot, thinContractCount, unsupportedCurveRootMessage, type ContractBasis } from "./model";
6
+ import { basisSpotSymbol, BASIS_CONVENTION, blankBasisNote, catalogueCurveUnit, curveAsOfDate, curveBasisPercent, curveBasisRows, curveChangeText, curveContractCode, curvePrice, curveRoundedPrice, curveSpotLabel, curveSpotSource, curveSpreadLabel, curveTimestamp, curveUnitLabel, normalizeCurveRoot, shortDatedBasisNote, thinContractCount, unsupportedCurveRootMessage, type ContractBasis } from "./model";
7
7
 
8
8
  /** The root a report shows when none is given. */
9
9
  const DEFAULT_ROOT = "ES";
@@ -73,7 +73,9 @@ function specEntries(data: FuturesCurvePayload): HeadlessPaneEntry[] {
73
73
 
74
74
  export const futuresCurveHeadless: HeadlessPaneDefinition<"bundle"> = {
75
75
  discovery: { aliases: ["CTM"], dataRequirements: ["Gloom Cloud futures curve endpoint"],
76
- limitations: ["Listed-contract catalogues can be incomplete.", "Ghosts and percentiles use the same listed contracts.", "Cboe VIX is daily settlement."] },
76
+ limitations: ["Listed-contract catalogues can be incomplete.", "Ghosts and percentiles use the same listed contracts.", "Cboe VIX is daily settlement.",
77
+ "Ann Basis is simple actual/365 on the latest trade against the spot quote, and noisy under 30 days to expiry."],
78
+ intents: ["futures basis trade", "cash and carry", "basis against spot"] },
77
79
  shape: "bundle", argument: { kind: "free-text", optional: true, placeholder: "root", description: "FUT root such as CL, ES, ZN or VX, or a CME crypto root (BTC, ETH, SOL, XRP). Defaults to ES." },
78
80
  // Only the pane reads the tab; the report always carries both.
79
81
  options: [{ key: "tab", description: "Curve or the full contract table.", type: "enum",
@@ -93,11 +95,15 @@ export const futuresCurveHeadless: HeadlessPaneDefinition<"bundle"> = {
93
95
  date ? loadFuturesCurveAsOf(root, date, ctx.apiClient) : fetchFuturesCurve(root, ctx.apiClient),
94
96
  spotSymbol ? loadCurveSpot(spotSymbol, ctx.marketData, now) : null,
95
97
  ]);
96
- const bases = spot ? data.contracts.map((row) => contractBasis(row, spot)) : [];
98
+ const bases = spot ? curveBasisRows(data.contracts, spot) : null;
97
99
  const unit = curveUnitLabel(data);
98
100
  const terms = specEntries(data);
101
+ const source = spot ? curveSpotSource(spot) : null;
99
102
  const notices = [...args.argument ? [] : [`Showing ${DEFAULT_ROOT}. Try fn CTM ${EXAMPLE_ROOT}.`],
100
- ...spot ? [spot.status === "ok" ? `Basis against ${curveSpotLabel(spot, root, now)}.` : `Basis blank: ${spot.reason}.`] : []];
103
+ ...spot ? [spot.status === "ok" ? `Basis against ${curveSpotLabel(spot, root, now)}${source ? ` (${source})` : ""}; not the CME reference rate.` : `Basis blank: ${spot.reason}.`] : []];
104
+ // The footnotes under the table: the convention, why a cell is blank, and where a short-dated rate is not to be trusted.
105
+ // Against a spot that is not usable every cell is blank for the spot's reason, which the notice above already gives.
106
+ const notes = spot && bases ? [BASIS_CONVENTION, ...spot.status === "ok" ? [blankBasisNote(data.contracts, bases), shortDatedBasisNote(data.contracts, bases, spot)].filter((note): note is string => !!note) : []] : [];
101
107
  // Dated by the contracts' own quotes; the same-contract history rows are a year of context, not observations.
102
108
  const quoted = data.contracts.map((row) => row.asOf);
103
109
  const freshness: HeadlessPaneFreshness = date
@@ -109,7 +115,7 @@ export const futuresCurveHeadless: HeadlessPaneDefinition<"bundle"> = {
109
115
  sections: [
110
116
  { title: "Contracts", columns: contractColumns(root, data.settlementDate ?? null,
111
117
  { basis: !!spot, last: data.source !== "cboe", notice: data.contracts.some((row) => row.firstNotice) }),
112
- rows: data.contracts.map((row, index) => contractRow(row, data, root, !!date, bases[index])) },
118
+ rows: data.contracts.map((row) => contractRow(row, data, root, !!date, bases?.get(row.symbol))) },
113
119
  { title: "Front spread", rows: [{ label: curveSpreadLabel(data.slope), ...data.slope, value: curveRoundedPrice(data.slope.value, root),
114
120
  annualizedRollYield: data.slope.annualizedRollYield == null ? null : Number(data.slope.annualizedRollYield.toFixed(2)),
115
121
  percentile: rank(data.slope.percentile), rollPercentile: rank(data.slope.rollPercentile) }] },
@@ -117,7 +123,8 @@ export const futuresCurveHeadless: HeadlessPaneDefinition<"bundle"> = {
117
123
  ...data.ghosts.map((ghost) => ({ title: `${ghost.label} same-contract history`, rows: ghost.points.map((point) => ({ ...point })) })),
118
124
  ],
119
125
  errors: data.gaps,
120
- metadata: { ...data, quoteUnit: unit, ...(spot ? { spot, basisBlankedThin: thinContractCount(bases) } : {}), ...(args.argument ? {} : { defaultArgument: DEFAULT_ROOT }),
126
+ ...(notes.length ? { notes } : {}),
127
+ metadata: { ...data, quoteUnit: unit, ...(spot && bases ? { spot, basisBlankedThin: thinContractCount(bases.values()) } : {}), ...(args.argument ? {} : { defaultArgument: DEFAULT_ROOT }),
121
128
  ...(notices.length ? { notices } : {}),
122
129
  complete: data.status === "available", percentileBasis: "Same-contract observations within one year; actual sample start/end retained" },
123
130
  };
@@ -1,7 +1,7 @@
1
1
  import type { PluginModule } from "../plugin-module";
2
2
  import { futuresCurveCache } from "./client";
3
3
  import { futuresCurveHeadless } from "./headless";
4
- import { CURVE_HORIZONS, CURVE_ROOTS, curveRootForTicker, DEFAULT_CURVE_HORIZON, normalizeCurveRoot } from "./model";
4
+ import { BASIS_FORMULA, CURVE_HORIZONS, CURVE_ROOTS, curveRootForTicker, DEFAULT_CURVE_HORIZON, normalizeCurveRoot } from "./model";
5
5
  import { FuturesCurvePane } from "./pane";
6
6
 
7
7
  export const futuresCurveModule: PluginModule = {
@@ -17,8 +17,8 @@ export const futuresCurveModule: PluginModule = {
17
17
  }),
18
18
  }],
19
19
  paneTemplates: [{ id: "futures-curve-pane", paneId: "futures-curve", label: "Futures Curve",
20
- description: "Listed futures contracts, historical curves, roll yield and open interest including VIX and CME crypto futures.",
21
- keywords: ["ctm", "futures", "curve", "term structure", "contango", "backwardation", "roll", "vix", "crude oil", "natural gas"],
20
+ description: `Listed futures contracts, historical curves, roll yield and open interest including VIX and CME crypto futures, which add basis against spot: Ann Basis = ${BASIS_FORMULA}, simple actual/365.`,
21
+ keywords: ["ctm", "futures", "curve", "term structure", "contango", "backwardation", "roll", "basis", "basis trade", "carry", "annualised basis", "vix", "crude oil", "natural gas"],
22
22
  shortcut: { prefix: "CTM", aliases: ["CT"], argKind: "text", argPlaceholder: "root", argOptional: true,
23
23
  argOptions: () => CURVE_ROOTS },
24
24
  headless: futuresCurveHeadless,
@@ -282,6 +282,10 @@ export interface CurveSpot {
282
282
  asOf: string | null;
283
283
  status: "ok" | "stale" | "missing";
284
284
  reason: string | null;
285
+ /** The listing the quote path reports for the pair (`CCC`), when it reports one. */
286
+ venue?: string;
287
+ /** Whether the quote is real-time or held back, as the quote path reports it. */
288
+ feed?: "real-time" | "delayed";
285
289
  }
286
290
 
287
291
  export interface SpotQuote {
@@ -289,6 +293,8 @@ export interface SpotQuote {
289
293
  lastUpdated: number;
290
294
  stale?: boolean;
291
295
  dataSource?: string;
296
+ listingExchangeName?: string;
297
+ exchangeName?: string;
292
298
  }
293
299
 
294
300
  const positive = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value > 0;
@@ -303,16 +309,20 @@ function quoteAge(ms: number): string {
303
309
  /** Judges one spot quote at `now`: usable, older than the rule allows, or absent. */
304
310
  export function curveSpot(symbol: string, quote: SpotQuote | null | undefined, now: number): CurveSpot {
305
311
  if (!quote || !positive(quote.price)) return { symbol, price: null, asOf: null, status: "missing", reason: `no ${symbol} quote` };
312
+ // What the quote path says about the quote itself, kept beside the price so the footer can name it.
313
+ const venue = (quote.listingExchangeName || quote.exchangeName)?.trim();
314
+ const feed: CurveSpot["feed"] = quote.dataSource === "live" ? "real-time" : quote.dataSource === "delayed" ? "delayed" : undefined;
315
+ const source = { ...venue ? { venue } : {}, ...feed ? { feed } : {} };
306
316
  const time = quote.lastUpdated;
307
317
  if (!Number.isFinite(time) || time <= 0) {
308
- return { symbol, price: quote.price, asOf: null, status: "stale", reason: `${symbol} quote has no time` };
318
+ return { symbol, price: quote.price, asOf: null, status: "stale", reason: `${symbol} quote has no time`, ...source };
309
319
  }
310
320
  const asOf = new Date(time).toISOString().replace(".000Z", "Z");
311
321
  const age = now - time;
312
322
  const limit = SPOT_STALE_MS + (quote.dataSource === "delayed" ? DELAYED_SPOT_MS : 0);
313
- if (age > limit) return { symbol, price: quote.price, asOf, status: "stale", reason: `${symbol} quote is ${quoteAge(age)} old` };
314
- if (quote.stale === true) return { symbol, price: quote.price, asOf, status: "stale", reason: `${symbol} quote is stale` };
315
- return { symbol, price: quote.price, asOf, status: "ok", reason: null };
323
+ if (age > limit) return { symbol, price: quote.price, asOf, status: "stale", reason: `${symbol} quote is ${quoteAge(age)} old`, ...source };
324
+ if (quote.stale === true) return { symbol, price: quote.price, asOf, status: "stale", reason: `${symbol} quote is stale`, ...source };
325
+ return { symbol, price: quote.price, asOf, status: "ok", reason: null, ...source };
316
326
  }
317
327
 
318
328
  /** How far the contract trades over (positive) or under spot, in percent. */
@@ -400,6 +410,56 @@ export function thinContractsNotice(count: number): string {
400
410
  return `Basis blank on ${count} thin contract${count === 1 ? "" : "s"} (last print over 1h before spot)`;
401
411
  }
402
412
 
413
+ /** What the quote path reports of the spot itself, as the footer names it: `CCC, real-time`. Null when it reports neither. */
414
+ export function curveSpotSource(spot: Pick<CurveSpot, "venue" | "feed">): string | null {
415
+ const parts = [spot.venue, spot.feed].filter((part): part is string => !!part);
416
+ return parts.length ? parts.join(", ") : null;
417
+ }
418
+
419
+ /** What Ann Basis is: Vs Spot is the same ratio before it is annualised. */
420
+ export const BASIS_FORMULA = "(Last / spot - 1) × 365 / days to expiry";
421
+ /** The two basis columns' convention in one line. */
422
+ export const BASIS_CONVENTION = `Ann Basis = ${BASIS_FORMULA}: simple actual/365, the latest trade against the spot quote.`;
423
+
424
+ /** Why one priced-or-not contract has no basis against a usable spot, in the order `contractBasis` decides it. */
425
+ function blankReason(row: Pick<FuturesContract, "price" | "stale">, basis: ContractBasis): string | null {
426
+ if (basis.vsSpotPct != null) return null;
427
+ if (!positive(row.price)) return "no price";
428
+ if (row.stale) return "price over two sessions old";
429
+ return basis.thin ? "last trade over 1h before the spot quote" : null;
430
+ }
431
+
432
+ /**
433
+ * The contracts whose basis cells are blank against a good spot, grouped by cause and named, so a blank is never
434
+ * unexplained: the far months of a curve print rarely, and a print an hour before the spot is not read against it.
435
+ */
436
+ export function blankBasisNote(rows: readonly Pick<FuturesContract, "symbol" | "price" | "stale">[], basis: CurveBasisRows): string | null {
437
+ const groups = new Map<string, string[]>();
438
+ for (const row of rows) {
439
+ const cell = basis.get(row.symbol);
440
+ const reason = cell ? blankReason(row, cell) : null;
441
+ if (reason) groups.set(reason, [...groups.get(reason) ?? [], curveContractCode(row.symbol)]);
442
+ }
443
+ return groups.size ? `Basis blank for ${[...groups].map(([reason, codes]) => `${codes.join(", ")} (${reason})`).join("; ")}.` : null;
444
+ }
445
+
446
+ /** Under this many days to expiry a few basis points of spot move the annualised rate by whole points. */
447
+ const SHORT_DATED_DAYS = 30;
448
+ /** The noise the note sizes the swing against: ten basis points of spot, a quote's usual gap to the contract's print. */
449
+ const SPOT_NOISE_PCT = 0.1;
450
+
451
+ /** The note for contracts showing an annualised basis inside `SHORT_DATED_DAYS`: what 10 bp of spot noise does to each. */
452
+ export function shortDatedBasisNote(rows: readonly Pick<FuturesContract, "symbol" | "expiration">[], basis: CurveBasisRows, spot: CurveSpot | null): string | null {
453
+ if (!spot || spot.status !== "ok" || spot.asOf == null) return null;
454
+ const spotTime = Date.parse(spot.asOf);
455
+ const near = rows.flatMap((row) => {
456
+ if (basis.get(row.symbol)?.annualisedBasisPct == null) return [];
457
+ const days = daysToExpiry(spotTime, row.expiration);
458
+ return days != null && days > 0 && days < SHORT_DATED_DAYS ? [`${curveContractCode(row.symbol)} (${days}d) by ±${(SPOT_NOISE_PCT * 365 / days).toFixed(1)} points`] : [];
459
+ });
460
+ return near.length ? `Ann Basis is noisy under ${SHORT_DATED_DAYS} days to expiry: 10 bp of spot noise moves ${near.join(", ")}.` : null;
461
+ }
462
+
403
463
  /** A past date for the as-of view: empty (or "latest") for the live curve, else YYYY-MM-DD no later than today. */
404
464
  export function curveAsOfDate(value: unknown, now = new Date()): string {
405
465
  const date = typeof value === "string" ? value.trim() : "";