@gloom-sh/terminal 0.19.0 → 0.19.1

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 (203) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/package.json +1 -1
  4. package/patches/electrobun@1.18.1.patch +23 -1
  5. package/src/api-client/app-attention.ts +3 -0
  6. package/src/api-client/beneficial-owners.ts +3 -0
  7. package/src/api-client/company-kpis.ts +3 -0
  8. package/src/api-client/data.ts +2 -1
  9. package/src/api-client/hiring.ts +3 -0
  10. package/src/api-client/index.ts +4 -0
  11. package/src/api-client/ipo.ts +24 -1
  12. package/src/api-client/mna.ts +3 -0
  13. package/src/api-client/paths.ts +9 -1
  14. package/src/api-client/roadmap.ts +85 -0
  15. package/src/api-client/types.ts +12 -0
  16. package/src/architecture/core-builtin-allowlist.json +2 -2
  17. package/src/cli/commands/market.ts +35 -12
  18. package/src/cli/commands/news-search.ts +22 -0
  19. package/src/cli/commands/overview.ts +86 -20
  20. package/src/cli/commands/ticker.ts +32 -2
  21. package/src/cli/errors.ts +17 -2
  22. package/src/cli/glossary.ts +6 -0
  23. package/src/cli/index-roots.ts +0 -7
  24. package/src/cli/listing-arg.ts +57 -10
  25. package/src/cli/pane-functions/capabilities.ts +1 -0
  26. package/src/cli/pane-functions/discovery.ts +21 -1
  27. package/src/cli/pane-functions/freshness.ts +7 -1
  28. package/src/cli/pane-functions/function-help.ts +7 -0
  29. package/src/cli/pane-functions/index.ts +6 -3
  30. package/src/cli/quote-answer.ts +1 -1
  31. package/src/cli/saved-copy-persistence.ts +88 -0
  32. package/src/components/command-bar/list/model.ts +5 -0
  33. package/src/components/command-bar/routes/root/runtime.ts +6 -7
  34. package/src/components/command-bar/selection-runtime.ts +52 -3
  35. package/src/components/command-bar/surface/index.tsx +2 -0
  36. package/src/components/layout/pane/capture-stamp.ts +2 -2
  37. package/src/components/layout/pane/footer/index.tsx +22 -6
  38. package/src/components/layout/pane/footer/model.ts +83 -15
  39. package/src/components/layout/pane/status-footer.ts +35 -2
  40. package/src/components/layout/status-widget-space.ts +9 -0
  41. package/src/components/ticker/list-table-view.tsx +4 -20
  42. package/src/components/ui/display.tsx +18 -3
  43. package/src/components/ui/icon.tsx +4 -1
  44. package/src/data/plugin-cache.ts +5 -3
  45. package/src/data/saved-copies.ts +8 -0
  46. package/src/docs/snapshot.json +32 -5
  47. package/src/i18n/es.ts +2 -4
  48. package/src/i18n/ja.ts +2 -4
  49. package/src/i18n/ko.ts +2 -4
  50. package/src/i18n/zh-cn.ts +2 -4
  51. package/src/i18n/zh-tw.ts +2 -4
  52. package/src/market-data/depositary-pair.ts +25 -1
  53. package/src/market-data/quotes/metadata.ts +7 -0
  54. package/src/news/aggregator.ts +7 -1
  55. package/src/news/types.ts +6 -0
  56. package/src/plugins/builtin/alerts/calendar-events.ts +10 -4
  57. package/src/plugins/builtin/alerts/cli.ts +34 -11
  58. package/src/plugins/builtin/alerts/event-rows.ts +24 -3
  59. package/src/plugins/builtin/alerts/events-pane.tsx +15 -6
  60. package/src/plugins/builtin/alerts/headless.ts +10 -6
  61. package/src/plugins/builtin/cds/client.ts +142 -30
  62. package/src/plugins/builtin/cds/headless.ts +35 -8
  63. package/src/plugins/builtin/cds/model.ts +145 -18
  64. package/src/plugins/builtin/cds/pane.tsx +16 -5
  65. package/src/plugins/builtin/chart-composer/follow-binding.ts +14 -2
  66. package/src/plugins/builtin/chart-composer/index.tsx +4 -5
  67. package/src/plugins/builtin/chat/controller/index.ts +5 -1
  68. package/src/plugins/builtin/chat/controller/notifications.ts +33 -3
  69. package/src/plugins/builtin/chat/controller/state.ts +25 -2
  70. package/src/plugins/builtin/chat/controller/unread-people.ts +89 -23
  71. package/src/plugins/builtin/chat/controller/utils.ts +7 -2
  72. package/src/plugins/builtin/chat/status-preview.ts +79 -0
  73. package/src/plugins/builtin/chat/status-widget.tsx +147 -10
  74. package/src/plugins/builtin/chat/unread-badge.ts +51 -10
  75. package/src/plugins/builtin/clinical-trials/client.ts +49 -4
  76. package/src/plugins/builtin/clinical-trials/model.ts +86 -22
  77. package/src/plugins/builtin/clinical-trials/pane.tsx +46 -27
  78. package/src/plugins/builtin/clinical-trials/sponsor-listing.ts +90 -0
  79. package/src/plugins/builtin/clinical-trials/sponsor-tickers.ts +87 -9
  80. package/src/plugins/builtin/clinical-trials/trial-pages.ts +50 -0
  81. package/src/plugins/builtin/company-attention/headless.ts +2 -1
  82. package/src/plugins/builtin/company-attention/pane.tsx +3 -0
  83. package/src/plugins/builtin/company-kpis/headless.ts +8 -3
  84. package/src/plugins/builtin/company-kpis/model.ts +73 -5
  85. package/src/plugins/builtin/company-kpis/pane.tsx +15 -7
  86. package/src/plugins/builtin/composite-plugins.ts +2 -1
  87. package/src/plugins/builtin/credit-documents/headless.ts +3 -3
  88. package/src/plugins/builtin/doe/headless.ts +3 -2
  89. package/src/plugins/builtin/doe/pane.tsx +13 -3
  90. package/src/plugins/builtin/earnings-calls/data.ts +18 -6
  91. package/src/plugins/builtin/earnings-calls/headless.ts +13 -3
  92. package/src/plugins/builtin/earnings-calls/pane.tsx +7 -0
  93. package/src/plugins/builtin/econ/calendar-model.ts +48 -1
  94. package/src/plugins/builtin/econ/calendar-source.ts +4 -2
  95. package/src/plugins/builtin/econ/detail-view.tsx +2 -2
  96. package/src/plugins/builtin/econ/figures.ts +48 -0
  97. package/src/plugins/builtin/econ/index.tsx +14 -5
  98. package/src/plugins/builtin/econ/print-watch.ts +3 -1
  99. package/src/plugins/builtin/econ-statistics/client.ts +11 -0
  100. package/src/plugins/builtin/econ-statistics/headless.ts +10 -2
  101. package/src/plugins/builtin/econ-statistics/pane.tsx +8 -4
  102. package/src/plugins/builtin/econ-statistics/stats.ts +3 -0
  103. package/src/plugins/builtin/help/index.tsx +61 -59
  104. package/src/plugins/builtin/help/shortcut-table.tsx +5 -6
  105. package/src/plugins/builtin/help/table-cells.tsx +13 -2
  106. package/src/plugins/builtin/holders/beneficial-client.ts +5 -1
  107. package/src/plugins/builtin/holders/beneficial-view.tsx +3 -0
  108. package/src/plugins/builtin/holders/headless.ts +4 -2
  109. package/src/plugins/builtin/holders/pane.tsx +3 -0
  110. package/src/plugins/builtin/holders/report-rows.ts +4 -1
  111. package/src/plugins/builtin/insider/headless.ts +2 -2
  112. package/src/plugins/builtin/ipo-calendar/client.ts +18 -2
  113. package/src/plugins/builtin/ipo-calendar/headless.ts +75 -11
  114. package/src/plugins/builtin/ipo-calendar/listing-ipo.ts +160 -0
  115. package/src/plugins/builtin/ipo-calendar/model.ts +98 -2
  116. package/src/plugins/builtin/macro-day/headless.ts +12 -5
  117. package/src/plugins/builtin/macro-day/index.tsx +1 -1
  118. package/src/plugins/builtin/macro-day/model.ts +39 -5
  119. package/src/plugins/builtin/macro-day/pane.tsx +27 -19
  120. package/src/plugins/builtin/market-movers/client.ts +2 -0
  121. package/src/plugins/builtin/market-movers/headless.ts +3 -1
  122. package/src/plugins/builtin/market-movers/screener.ts +8 -4
  123. package/src/plugins/builtin/mna/headless.ts +2 -0
  124. package/src/plugins/builtin/mna/pane.tsx +3 -1
  125. package/src/plugins/builtin/news/headless.ts +3 -1
  126. package/src/plugins/builtin/news/index.tsx +3 -0
  127. package/src/plugins/builtin/portfolio-list/pane/index.tsx +2 -6
  128. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +0 -14
  129. package/src/plugins/builtin/portfolio-list/quick-add/resolution.ts +2 -1
  130. package/src/plugins/builtin/portfolio-list/table.tsx +4 -6
  131. package/src/plugins/builtin/realized-vol/client.ts +13 -5
  132. package/src/plugins/builtin/realized-vol/evidence.ts +29 -4
  133. package/src/plugins/builtin/realized-vol/headless.ts +8 -4
  134. package/src/plugins/builtin/realized-vol/model.ts +37 -6
  135. package/src/plugins/builtin/realized-vol/pane.tsx +9 -7
  136. package/src/plugins/builtin/research/event-model.ts +30 -4
  137. package/src/plugins/builtin/research/index.tsx +7 -3
  138. package/src/plugins/builtin/research/relative-valuation-headless.ts +8 -25
  139. package/src/plugins/builtin/research/relative-valuation-pane.tsx +52 -15
  140. package/src/plugins/builtin/research/relative-valuation-peers.ts +217 -20
  141. package/src/plugins/builtin/research/relative-valuation-settings.ts +44 -0
  142. package/src/plugins/builtin/risk-factors/data.ts +4 -2
  143. package/src/plugins/builtin/risk-factors/headless.ts +5 -2
  144. package/src/plugins/builtin/roadmap/index.tsx +28 -0
  145. package/src/plugins/builtin/roadmap/model.ts +67 -0
  146. package/src/plugins/builtin/roadmap/pane.tsx +244 -0
  147. package/src/plugins/builtin/scanner/live-headless.ts +21 -2
  148. package/src/plugins/builtin/sec/headless.ts +2 -1
  149. package/src/plugins/builtin/sec/index.tsx +3 -0
  150. package/src/plugins/builtin/shared/cloud-resource.ts +13 -5
  151. package/src/plugins/builtin/shared/headless-market-data.ts +2 -2
  152. package/src/plugins/builtin/shared/report-freshness.ts +8 -0
  153. package/src/plugins/builtin/shared/research-cells.tsx +1 -1
  154. package/src/plugins/builtin/shared/series-cache.ts +4 -4
  155. package/src/plugins/builtin/thirteenf/api.ts +12 -1
  156. package/src/plugins/builtin/thirteenf/data.ts +7 -7
  157. package/src/plugins/builtin/thirteenf/headless.ts +31 -4
  158. package/src/plugins/builtin/ticker-detail/overview/model.ts +10 -2
  159. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +2 -1
  160. package/src/plugins/builtin/ticker-detail/pane.tsx +11 -6
  161. package/src/plugins/builtin/treasury-auctions/headless.ts +21 -12
  162. package/src/plugins/builtin/treasury-auctions/pane.tsx +9 -3
  163. package/src/plugins/builtin/volatility/pane.tsx +5 -2
  164. package/src/plugins/builtin/yield-curve/client.ts +61 -1
  165. package/src/plugins/builtin/yield-curve/headless.ts +9 -6
  166. package/src/plugins/builtin/yield-curve/index.tsx +34 -10
  167. package/src/plugins/builtin/yield-curve/swaps.tsx +9 -5
  168. package/src/plugins/catalog-browser.ts +2 -1
  169. package/src/plugins/host-link.ts +1 -1
  170. package/src/portfolio/column-values.ts +11 -7
  171. package/src/renderers/dom/desktop/icons.tsx +7 -0
  172. package/src/renderers/electrobun/bun/desktop/backend.ts +9 -10
  173. package/src/renderers/electrobun/bun/desktop/host-requests.ts +8 -0
  174. package/src/renderers/electrobun/bun/desktop/installer-folder.ts +731 -0
  175. package/src/renderers/electrobun/bun/desktop/relaunch.ts +102 -10
  176. package/src/renderers/electrobun/bun/desktop/windows-script.ts +92 -0
  177. package/src/renderers/electrobun/bun/index.ts +26 -2
  178. package/src/renderers/electrobun/bun/window/create.ts +43 -4
  179. package/src/renderers/electrobun/bun/window/events.ts +20 -0
  180. package/src/renderers/electrobun/bun/window/windows-custom-chrome.ts +9 -2
  181. package/src/renderers/electrobun/bun/window/windows-native.ts +78 -0
  182. package/src/renderers/electrobun/bun/window/windows-quit.ts +57 -0
  183. package/src/renderers/electrobun/shared/protocol.ts +2 -0
  184. package/src/renderers/electrobun/view/news-source.ts +2 -0
  185. package/src/renderers/electrobun/view/ui-host.tsx +8 -0
  186. package/src/sources/gloomberb-cloud/fred-series.ts +3 -1
  187. package/src/sources/gloomberb-cloud/index.ts +42 -8
  188. package/src/tickers/company-name-listing.ts +53 -13
  189. package/src/tickers/company-name.ts +6 -1
  190. package/src/tickers/index-roots.ts +7 -0
  191. package/src/tickers/listing-fate.ts +2 -2
  192. package/src/tickers/search/index.ts +6 -2
  193. package/src/tickers/search/ranking.ts +5 -1
  194. package/src/tickers/search/upsert.ts +13 -0
  195. package/src/tickers/ticker-changes.ts +8 -4
  196. package/src/types/company-from.ts +40 -0
  197. package/src/types/data-provider.ts +7 -0
  198. package/src/types/financials.ts +27 -0
  199. package/src/types/headless.ts +6 -0
  200. package/src/utils/crypto-pair.ts +121 -7
  201. package/src/utils/declared-dividends.ts +1 -1
  202. package/src/utils/fundamentals.ts +36 -5
  203. package/src/version.ts +2 -2
@@ -1,5 +1,5 @@
1
1
  import { apiClient, type ChatChannel, type ChatMessage } from "../../../../api-client";
2
- import type { ChannelRuntimeState, ChannelUnreadPeople } from "./state";
2
+ import type { ChannelRuntimeState, ChannelUnreadPeople, UnreadMessageForYou } from "./state";
3
3
  import { chatMessageMentionsUsername, normalizeChatUsername } from "./utils";
4
4
 
5
5
  /** The most messages the server returns for one request. */
@@ -18,38 +18,73 @@ interface Reader {
18
18
  username: string | null;
19
19
  }
20
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;
21
+ /** What the app badge and the status bar need of one unread message. */
22
+ interface UnreadFact extends Omit<UnreadMessageForYou, "messageId"> {
23
+ messageId: string;
24
+ /** It mentions you, or replies to a message of yours. */
24
25
  mentionsYou: boolean;
25
26
  }
26
27
 
28
+ function sentAt(createdAt: string | null | undefined): number {
29
+ const time = createdAt ? Date.parse(createdAt) : Number.NaN;
30
+ return Number.isFinite(time) ? time : 0;
31
+ }
32
+
33
+ /** Whether the message answers one of `reader`'s, by account or, from a server that leaves the id out, by username. */
34
+ function repliesTo(message: ChatMessage, reader: Reader): boolean {
35
+ const author = message.replyTo?.user;
36
+ if (!author) return false;
37
+ if (author.id) return author.id === reader.id;
38
+ return !!reader.username && normalizeChatUsername(author.username) === reader.username;
39
+ }
40
+
27
41
  function unreadFact(message: ChatMessage, reader: Reader): UnreadFact {
28
42
  return {
43
+ messageId: message.id,
29
44
  senderId: message.user.id,
30
- mentionsYou: !!reader.username && chatMessageMentionsUsername(message.content, reader.username),
45
+ senderName: message.user.username?.trim() || message.user.displayName,
46
+ at: sentAt(message.createdAt),
47
+ mentionsYou: (!!reader.username && chatMessageMentionsUsername(message.content, reader.username))
48
+ || repliesTo(message, reader),
31
49
  };
32
50
  }
33
51
 
34
- function peopleOf(facts: Iterable<UnreadFact>): ChannelUnreadPeople {
52
+ /**
53
+ * The people behind unread messages, and the newest one for you. In a DM or
54
+ * group chat (`everyMessageForYou`) every message is for you; elsewhere only
55
+ * the ones that mention or reply to you are.
56
+ */
57
+ function peopleOf(facts: Iterable<UnreadFact>, everyMessageForYou: boolean): ChannelUnreadPeople {
35
58
  const senders = new Set<string>();
36
59
  const mentioners = new Set<string>();
60
+ let latestForYou: UnreadMessageForYou | null = null;
61
+ let forYouCount = 0;
37
62
  for (const fact of facts) {
38
63
  senders.add(fact.senderId);
39
64
  if (fact.mentionsYou) mentioners.add(fact.senderId);
65
+ if (!everyMessageForYou && !fact.mentionsYou) continue;
66
+ forYouCount += 1;
67
+ if (!latestForYou || fact.at >= latestForYou.at) {
68
+ latestForYou = { messageId: fact.messageId, senderId: fact.senderId, senderName: fact.senderName, at: fact.at };
69
+ }
40
70
  }
41
- return { senders: [...senders], mentioners: [...mentioners] };
71
+ return { senders: [...senders], mentioners: [...mentioners], latestForYou, forYouCount };
42
72
  }
43
73
 
44
74
  /**
45
- * The people behind unread messages: who sent them and who mentioned
46
- * `reader`. Your own messages are never unread.
75
+ * The people behind unread messages: who sent them, who mentioned or replied
76
+ * to `reader`, and the newest message for them. Your own messages are never
77
+ * unread.
47
78
  */
48
79
  export function summarizeUnreadMessages(
49
80
  messages: readonly ChatMessage[],
50
81
  reader: { id: string; username: string | null },
82
+ { everyMessageForYou = false }: { everyMessageForYou?: boolean } = {},
51
83
  ): ChannelUnreadPeople {
52
- return peopleOf(messages.filter((message) => message.user.id !== reader.id).map((message) => unreadFact(message, reader)));
84
+ return peopleOf(
85
+ messages.filter((message) => message.user.id !== reader.id).map((message) => unreadFact(message, reader)),
86
+ everyMessageForYou,
87
+ );
53
88
  }
54
89
 
55
90
  /** Messages read from the server for a channel whose unread messages are not all on this device. */
@@ -75,15 +110,16 @@ interface UnreadPeopleOptions {
75
110
  }
76
111
 
77
112
  /**
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.
113
+ * Who each channel's unread messages are from, for the app badge and the
114
+ * status bar. The server keeps a count per channel and a read marker, not
115
+ * the senders, so they come from the messages after the marker: the ones
116
+ * this device holds, and for a channel whose unread messages are not all
117
+ * here, the newest 300 of the rest read from the server once (then only what
118
+ * is new). Nothing is guessed: a channel that has not loaded yet reports the
119
+ * unread messages it does hold.
84
120
  *
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.
121
+ * It does nothing, and makes no request, until something asks to
122
+ * {@link track} it: the app badge, and the status bar while signed in.
87
123
  */
88
124
  export class ChatControllerUnreadPeople {
89
125
  private readonly loaded = new Map<string, LoadedUnread>();
@@ -113,11 +149,21 @@ export class ChatControllerUnreadPeople {
113
149
  const people: Record<string, ChannelUnreadPeople> = {};
114
150
  for (const [channelId, state] of this.options.getChannelStates()) {
115
151
  if (state.unreadCount <= 0) continue;
116
- const otherPerson = directPerson(channels.get(channelId));
152
+ const channel = channels.get(channelId);
153
+ const conversation = channel?.kind === "direct" || channel?.kind === "group";
154
+ const known = peopleOf(this.knownFacts(channelId, state, reader).values(), conversation);
155
+ // In a DM or group every unread message is for you, held here or not.
156
+ if (conversation) known.forYouCount = state.unreadCount;
157
+ const otherPerson = directPerson(channel);
117
158
  // Everything unread in a DM is from the one person on the other side.
118
159
  people[channelId] = otherPerson
119
- ? { senders: [otherPerson], mentioners: [] }
120
- : peopleOf(this.knownFacts(channelId, state, reader).values());
160
+ ? {
161
+ senders: [otherPerson.id],
162
+ mentioners: [],
163
+ latestForYou: known.latestForYou ?? newestFrom(otherPerson, state, channel),
164
+ forYouCount: known.forYouCount,
165
+ }
166
+ : known;
121
167
  }
122
168
  return people;
123
169
  }
@@ -216,9 +262,29 @@ export class ChatControllerUnreadPeople {
216
262
  }
217
263
  }
218
264
 
265
+ /**
266
+ * The newest unread message of a DM whose read marker is not on this device:
267
+ * unread messages are the newest ones, so it is the newest message held from
268
+ * the other person, or failing one, just them as of the conversation's last
269
+ * message.
270
+ */
271
+ function newestFrom(
272
+ person: NonNullable<ChatChannel["dmUser"]>,
273
+ state: ChannelRuntimeState,
274
+ channel: ChatChannel | undefined,
275
+ ): UnreadMessageForYou {
276
+ const held = state.messages.findLast((message) => message.user.id === person.id && !message.clientStatus);
277
+ return {
278
+ messageId: held?.id ?? null,
279
+ senderId: person.id,
280
+ senderName: person.username?.trim() || person.displayName,
281
+ at: Math.max(sentAt(held?.createdAt), sentAt(channel?.lastMessageAt)),
282
+ };
283
+ }
284
+
219
285
  /** 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;
286
+ function directPerson(channel: ChatChannel | undefined): NonNullable<ChatChannel["dmUser"]> | null {
287
+ return channel?.kind === "direct" ? channel.dmUser ?? null : null;
222
288
  }
223
289
 
224
290
  /**
@@ -20,9 +20,14 @@ export function chatMessageMentionsUsername(content: string, username: string):
20
20
  return false;
21
21
  }
22
22
 
23
- /** The message as one short line, its images as `[image]` in front of the text. */
23
+ /** The message on one line, its images as `[image]` in front of the text. */
24
+ export function chatMessageLine(message: ChatMessage): string {
25
+ return chatTextWithImages(message.content, message.attachments?.length ?? 0).replace(/\s+/g, " ").trim();
26
+ }
27
+
28
+ /** The message as one short line. */
24
29
  function formatMessageSnippet(message: ChatMessage): string {
25
- const normalized = chatTextWithImages(message.content, message.attachments?.length ?? 0).replace(/\s+/g, " ").trim();
30
+ const normalized = chatMessageLine(message);
26
31
  return normalized.length > 72 ? `${normalized.slice(0, 69)}...` : normalized;
27
32
  }
28
33
 
@@ -0,0 +1,79 @@
1
+ /** How long a message for you shows in the status bar before it folds into the chip. */
2
+ const STATUS_PREVIEW_MS = 9_000;
3
+
4
+ /** A message for you, as the status bar shows it on arrival. */
5
+ export interface ChatStatusPreviewItem {
6
+ channelId: string;
7
+ messageId: string;
8
+ senderName: string;
9
+ /** It came in a DM or group chat; otherwise it mentions or replies to you. */
10
+ direct: boolean;
11
+ /** The message on one line. */
12
+ text: string;
13
+ /** When it was sent, in ms. */
14
+ at: number;
15
+ }
16
+
17
+ /** Whether the status bar has room for the item now. */
18
+ type PreviewRoom = (item: ChatStatusPreviewItem) => boolean;
19
+
20
+ /**
21
+ * The message for you that the status bar shows on arrival, in place of the
22
+ * chat toast, for {@link STATUS_PREVIEW_MS}: then it folds into the chip that
23
+ * names who wants you. The newest message wins; nothing stacks. It shows only
24
+ * while a status bar with room for it is mounted ({@link attach}); otherwise
25
+ * the toast goes up as before.
26
+ */
27
+ export class ChatStatusPreview {
28
+ private current: ChatStatusPreviewItem | null = null;
29
+ private timer: ReturnType<typeof setTimeout> | null = null;
30
+ private readonly rooms = new Set<PreviewRoom>();
31
+ private readonly listeners = new Set<() => void>();
32
+ durationMs = STATUS_PREVIEW_MS;
33
+
34
+ /** A status bar that can show the preview when `room` says it fits, until the returned release. */
35
+ attach(room: PreviewRoom): () => void {
36
+ this.rooms.add(room);
37
+ return () => {
38
+ this.rooms.delete(room);
39
+ if (this.rooms.size === 0) this.dismiss();
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Takes the message for the status bar when one has room for it, and shows
45
+ * it unless a newer one is showing (that one stays; this one counts in the
46
+ * chip). False when no status bar can: the caller raises its toast instead.
47
+ */
48
+ offer(item: ChatStatusPreviewItem): boolean {
49
+ if (![...this.rooms].some((room) => room(item))) return false;
50
+ if (this.current && this.current.at > item.at) return true;
51
+ this.current = item;
52
+ if (this.timer) clearTimeout(this.timer);
53
+ this.timer = setTimeout(() => this.dismiss(), this.durationMs);
54
+ this.emit();
55
+ return true;
56
+ }
57
+
58
+ get = (): ChatStatusPreviewItem | null => this.current;
59
+
60
+ subscribe = (listener: () => void): (() => void) => {
61
+ this.listeners.add(listener);
62
+ return () => {
63
+ this.listeners.delete(listener);
64
+ };
65
+ };
66
+
67
+ /** Folds the message into the chip now: opened, read, or no status bar left to show it. */
68
+ dismiss(): void {
69
+ if (this.timer) clearTimeout(this.timer);
70
+ this.timer = null;
71
+ if (!this.current) return;
72
+ this.current = null;
73
+ this.emit();
74
+ }
75
+
76
+ private emit(): void {
77
+ for (const listener of this.listeners) listener();
78
+ }
79
+ }
@@ -1,9 +1,17 @@
1
1
  import { Button } from "../../../components/ui/button";
2
+ import { Badge, ClippedText } from "../../../components/ui/display";
2
3
  import { Icon } from "../../../components/ui/icon";
3
- import { useEffect, useState, useSyncExternalStore } from "react";
4
+ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
5
+ import {
6
+ STATUS_WIDGET_COLUMNS,
7
+ useOtherStatusWidgetClaims,
8
+ useStatusWidgetClaim,
9
+ useStatusWidgetRoom,
10
+ } from "../../../components/layout/status-widget-space";
4
11
  import { usePaneAppConfig } from "../../../state/app/context";
5
12
  import { colors } from "../../../theme/colors";
6
- import { Box, Span, Text, TextAttributes, useUiCapabilities } from "../../../ui";
13
+ import { Box, Span, Text, useUiCapabilities } from "../../../ui";
14
+ import { clipToDisplayWidth, displayWidth } from "../../../utils/format";
7
15
  import { usePluginAppActions } from "../../runtime";
8
16
  import { getNotificationLog, subscribeNotificationLog } from "../../../notifications/notification-log";
9
17
  import { InlineAuthActions } from "../cloud/auth-actions";
@@ -11,11 +19,75 @@ import {
11
19
  getPreferredChatOpenChannelId,
12
20
  } from "./channels";
13
21
  import { chatController, type ChatController } from "./controller";
22
+ import type { ChatStatusPreviewItem } from "./status-preview";
14
23
  import { UNREAD_INBOX_TEMPLATE_ID } from "./unread-inbox";
15
- import { unreadTotal } from "./unread-badge";
24
+ import { unreadForYou, unreadTotal } from "./unread-badge";
16
25
 
17
26
  interface ChatStatusWidgetProps {
18
- controller?: Pick<ChatController, "getSnapshot" | "refreshSession" | "subscribe">;
27
+ controller?: Pick<ChatController, "getSnapshot" | "refreshSession" | "subscribe" | "trackUnreadPeople" | "statusPreview">;
28
+ }
29
+
30
+ /** The widget's claim on the status bar's spare columns, for the chip and the message on arrival. */
31
+ const CLAIM_ID = "chat";
32
+ /** A name in the chip is cut to this many columns. */
33
+ const CHIP_NAME_COLUMNS = 12;
34
+ /** The message on arrival never takes more columns than this, and does not show in fewer than the least. */
35
+ const PREVIEW_MOST_COLUMNS = 48;
36
+ const PREVIEW_LEAST_COLUMNS = 12;
37
+
38
+ /** Who the chip names: the sender of the newest message for you, and whether it came in a DM or group chat. */
39
+ interface ChipWho {
40
+ senderName: string;
41
+ direct: boolean;
42
+ }
43
+
44
+ interface ChipFace {
45
+ icon?: "mail";
46
+ label: string;
47
+ /** Terminal cells, its padding included. */
48
+ columns: number;
49
+ }
50
+
51
+ /**
52
+ * The chip: an envelope for a DM or group message, `@` for a mention or a
53
+ * reply, then the sender's name and how many others want you, each left out
54
+ * when `name` or `more` says so.
55
+ */
56
+ function chipFace(who: ChipWho, others: number, { name, more }: { name: boolean; more: boolean }): ChipFace {
57
+ const label = [
58
+ who.direct ? "" : "@",
59
+ name ? clipToDisplayWidth(who.senderName, CHIP_NAME_COLUMNS) : "",
60
+ more && others > 0 ? `+${others}` : "",
61
+ ].filter(Boolean).join(" ");
62
+ // The glyph and its space (one cell wide in a terminal), then the label, inside one cell of padding each side.
63
+ const columns = (who.direct ? (label ? 2 : 1) : 0) + displayWidth(label) + 2;
64
+ return { ...(who.direct ? { icon: "mail" as const } : {}), label, columns };
65
+ }
66
+
67
+ /** The chip's faces from the fullest down: the name goes last, the count of others before it. */
68
+ const CHIP_FACES = [
69
+ { name: true, more: true },
70
+ { name: true, more: false },
71
+ { name: false, more: true },
72
+ { name: false, more: false },
73
+ ];
74
+
75
+ /**
76
+ * How the chip and the message fit in `spare` columns beside the account
77
+ * name: the full chip and the message, then the chip alone, shorter and
78
+ * shorter, or nothing. One space goes after the chip; the message takes one
79
+ * before it and one more after, so it does not run into the account name.
80
+ */
81
+ function fitChip(who: ChipWho | null, others: number, text: string | null, spare: number): { face: ChipFace; previewColumns: number } | null {
82
+ if (!who) return null;
83
+ for (const [index, parts] of CHIP_FACES.entries()) {
84
+ const face = chipFace(who, others, parts);
85
+ if (face.columns + 1 > spare) continue;
86
+ const room = Math.min(PREVIEW_MOST_COLUMNS, spare - face.columns - 3);
87
+ const previewColumns = text && index === 0 && room >= PREVIEW_LEAST_COLUMNS ? Math.min(room, displayWidth(text)) : 0;
88
+ return { face, previewColumns };
89
+ }
90
+ return null;
19
91
  }
20
92
 
21
93
  function CloudStatusIcon() {
@@ -44,6 +116,7 @@ function CloudStatusIcon() {
44
116
 
45
117
  export function ChatStatusWidget({ controller = chatController }: ChatStatusWidgetProps) {
46
118
  const { createPaneFromTemplate } = usePluginAppActions();
119
+ const { nativePaneChrome } = useUiCapabilities();
47
120
  const config = usePaneAppConfig();
48
121
  const initialSnapshot = controller.getSnapshot();
49
122
  const [username, setUsername] = useState<string | null>(initialSnapshot.user?.username ?? null);
@@ -51,7 +124,34 @@ export function ChatStatusWidget({ controller = chatController }: ChatStatusWidg
51
124
  const [snapshot, setSnapshot] = useState(initialSnapshot);
52
125
  // What the Unread list shows as unread: chat, then missed price, catalyst, economic release and team notices.
53
126
  const notices = useSyncExternalStore(subscribeNotificationLog, getNotificationLog, getNotificationLog);
54
- const unreadCount = unreadTotal(snapshot, notices);
127
+ const preview = useSyncExternalStore(controller.statusPreview.subscribe, controller.statusPreview.get, controller.statusPreview.get);
128
+ const room = useStatusWidgetRoom();
129
+ const othersClaimed = useOtherStatusWidgetClaims(CLAIM_ID);
130
+ const signedIn = !!username || hasSavedSession;
131
+
132
+ // Who wants you: the people the app badge counts, the newest of them named in the chip.
133
+ const forYou = unreadForYou(snapshot);
134
+ const total = unreadTotal(snapshot, notices);
135
+ // A message on arrival shows until its conversation is read.
136
+ const unreadIn = (channelId: string) => snapshot.channelStates.find((state) => state.channelId === channelId)?.unreadCount ?? 0;
137
+ const arriving = preview && unreadIn(preview.channelId) > 0 ? preview : null;
138
+ const who: ChipWho | null = arriving ?? forYou.latest;
139
+ const others = Math.max(0, forYou.people - 1);
140
+ // The status bar holds back its usual columns for every widget's own text; the chip and the message are extra.
141
+ const spare = room - STATUS_WIDGET_COLUMNS - othersClaimed;
142
+ const fit = signedIn ? fitChip(who, others, arriving?.text ?? null, spare) : null;
143
+ // Chatter and missed notices: the rest, once the chip names who wants you.
144
+ const rest = fit ? Math.max(0, total - forYou.messages) : total;
145
+ const showsMessage = !!arriving && !!fit && fit.previewColumns > 0;
146
+ useStatusWidgetClaim(CLAIM_ID, fit ? fit.face.columns + 1 + (showsMessage ? fit.previewColumns + 2 : 0) : 0);
147
+
148
+ // The alerts ask, as a message arrives, whether the bar has room to show it; if not, its toast goes up.
149
+ const fits = useRef<(item: ChatStatusPreviewItem) => boolean>(() => false);
150
+ fits.current = (item) => (fitChip(item, others, item.text, spare)?.previewColumns ?? 0) > 0;
151
+ useEffect(() => {
152
+ if (!signedIn) return;
153
+ return controller.statusPreview.attach((item) => fits.current(item));
154
+ }, [controller, signedIn]);
55
155
 
56
156
  const openChat = (event?: { preventDefault?: () => void; stopPropagation?: () => void }) => {
57
157
  event?.preventDefault?.();
@@ -66,6 +166,17 @@ export function ChatStatusWidget({ controller = chatController }: ChatStatusWidg
66
166
  createPaneFromTemplate(UNREAD_INBOX_TEMPLATE_ID);
67
167
  };
68
168
 
169
+ // The chip opens the conversation it names at the message.
170
+ const target = arriving ?? forYou.latest;
171
+ const openForYou = () => {
172
+ if (!target) return;
173
+ controller.statusPreview.dismiss();
174
+ createPaneFromTemplate("new-chat-pane", {
175
+ arg: target.channelId,
176
+ ...(target.messageId ? { values: { messageId: target.messageId } } : {}),
177
+ });
178
+ };
179
+
69
180
  useEffect(() => {
70
181
  const unsubscribe = controller.subscribe((nextSnapshot) => {
71
182
  setSnapshot(nextSnapshot);
@@ -76,17 +187,43 @@ export function ChatStatusWidget({ controller = chatController }: ChatStatusWidg
76
187
  return unsubscribe;
77
188
  }, [controller]);
78
189
 
190
+ // Who sent the unread messages is read only while someone is signed in.
191
+ useEffect(() => (signedIn ? controller.trackUnreadPeople() : undefined), [controller, signedIn]);
192
+
79
193
  return (
80
194
  <Box flexDirection="row" paddingRight={1}>
81
- {!username && !hasSavedSession ? (
195
+ {!signedIn ? (
82
196
  <>
83
197
  <CloudStatusIcon />
84
198
  <InlineAuthActions showSignup={false} variant="plain" />
85
199
  </>
86
200
  ) : (
87
201
  <>
202
+ {fit && who ? (
203
+ <>
204
+ <Button
205
+ label={`Open the message from ${who.senderName}`}
206
+ variant="plain"
207
+ compact
208
+ flush
209
+ stopPropagation
210
+ onPress={openForYou}
211
+ >
212
+ <Box flexDirection="row" alignItems="center">
213
+ <Badge label={fit.face.label} icon={fit.face.icon} tone="warning" translate={false} />
214
+ {arriving && showsMessage ? (
215
+ // The terminal sizes the message to its cells; the desktop and the web let it shrink to its text and cut it there.
216
+ <Box {...(nativePaneChrome ? { maxWidth: fit.previewColumns + 1, flexShrink: 1 } : { width: fit.previewColumns + 1, flexShrink: 0 })} minWidth={0}>
217
+ <ClippedText text={arriving.text} width={fit.previewColumns} lead=" " fg={colors.text} cut={clipToDisplayWidth} />
218
+ </Box>
219
+ ) : null}
220
+ </Box>
221
+ </Button>
222
+ <Text>{showsMessage ? " " : " "}</Text>
223
+ </>
224
+ ) : null}
88
225
  <Button label={username ? `Open chat as ${username}` : "Open chat"} variant="plain" compact stopPropagation onPress={openChat}>
89
- <Text fg={unreadCount > 0 ? colors.text : colors.textDim}>
226
+ <Text fg={total > 0 ? colors.text : colors.textDim}>
90
227
  <Span fg={colors.positive}>@</Span>
91
228
  {username ? (
92
229
  <>
@@ -96,15 +233,15 @@ export function ChatStatusWidget({ controller = chatController }: ChatStatusWidg
96
233
  ) : null}
97
234
  </Text>
98
235
  </Button>
99
- {unreadCount > 0 ? (
236
+ {rest > 0 ? (
100
237
  <Button
101
- label={`Open ${unreadCount} unread`}
238
+ label={`Open ${rest} unread`}
102
239
  variant="plain"
103
240
  compact
104
241
  stopPropagation
105
242
  onPress={openUnread}
106
243
  >
107
- <Text fg={colors.positive} attributes={TextAttributes.BOLD}>{` [${unreadCount}]`}</Text>
244
+ <Text fg={colors.textDim}>{` ${rest}`}</Text>
108
245
  </Button>
109
246
  ) : null}
110
247
  </>
@@ -23,22 +23,63 @@ function isPeopleConversation(channel: ChatChannel | undefined): boolean {
23
23
  return channel?.kind === "direct" || channel?.kind === "group";
24
24
  }
25
25
 
26
+ /** The newest unread message for you, and where it is. */
27
+ interface LatestUnreadForYou {
28
+ channelId: string;
29
+ /** Null for a DM whose unread messages are not on this device: it opens at the conversation. */
30
+ messageId: string | null;
31
+ senderName: string;
32
+ /** It came in a DM or group chat; otherwise it mentions or replies to you. */
33
+ direct: boolean;
34
+ }
35
+
36
+ /** Who wants you: the people the app badge counts, and the newest of their messages. */
37
+ export interface UnreadForYou {
38
+ /** The person who wrote to you last; null when nobody wants you. */
39
+ latest: LatestUnreadForYou | null;
40
+ /** Everyone who sent you an unread DM or group message, or mentioned or replied to you, each once. */
41
+ people: number;
42
+ /** Their unread messages for you. */
43
+ messages: number;
44
+ }
45
+
46
+ const NOBODY: UnreadForYou = { latest: null, people: 0, messages: 0 };
47
+
26
48
  /**
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}).
49
+ * Everyone who sent you an unread DM or group message, and everyone whose
50
+ * unread message in any other channel mentions or replies to you, each once
51
+ * however many messages they sent or where; with the newest of those
52
+ * messages and how many there are. Untagged channel messages are not for
53
+ * you. Nobody signed out.
33
54
  */
34
- export function unreadBadgeCount(snapshot: BadgeSnapshot, notices: readonly NotificationLogEntry[]): number {
35
- if (!snapshot.user && !snapshot.hasSavedSession) return 0;
55
+ export function unreadForYou(snapshot: BadgeSnapshot): UnreadForYou {
56
+ if (!snapshot.user && !snapshot.hasSavedSession) return NOBODY;
36
57
  const channels = new Map(snapshot.channels.map((channel) => [channel.id, channel]));
37
58
  const people = new Set<string>();
59
+ let latest: LatestUnreadForYou | null = null;
60
+ let latestAt = Number.NEGATIVE_INFINITY;
61
+ let messages = 0;
38
62
  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);
63
+ const direct = isPeopleConversation(channels.get(channelId));
64
+ for (const id of direct ? who.senders : who.mentioners) people.add(id);
65
+ messages += who.forYouCount;
66
+ const newest = who.latestForYou;
67
+ if (!newest || newest.at <= latestAt) continue;
68
+ latest = { channelId, messageId: newest.messageId, senderName: newest.senderName, direct };
69
+ latestAt = newest.at;
40
70
  }
41
- return people.size + unreadNotificationLogCount(notices);
71
+ return { latest, people: people.size, messages };
72
+ }
73
+
74
+ /**
75
+ * What the app badge counts: people, not messages ({@link unreadForYou}),
76
+ * then every missed notice not yet read. Untagged channel messages add
77
+ * nothing. Zero signed out. The status bar names the newest of those people
78
+ * and counts the rest of the unread messages ({@link unreadTotal}).
79
+ */
80
+ export function unreadBadgeCount(snapshot: BadgeSnapshot, notices: readonly NotificationLogEntry[]): number {
81
+ if (!snapshot.user && !snapshot.hasSavedSession) return 0;
82
+ return unreadForYou(snapshot).people + unreadNotificationLogCount(notices);
42
83
  }
43
84
 
44
85
  interface UnreadBadgeOptions {