gloomberb 0.4.0 → 0.5.0

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 (218) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/app.tsx +184 -69
  4. package/src/cli/commands/ticker.ts +45 -15
  5. package/src/cli/helpers.ts +4 -2
  6. package/src/cli/registry.test.ts +42 -35
  7. package/src/cli.test.ts +4 -2
  8. package/src/components/chart/chart-control-hint.tsx +39 -0
  9. package/src/components/chart/chart-controller.test.ts +110 -0
  10. package/src/components/chart/chart-controller.ts +364 -2
  11. package/src/components/chart/chart-data.ts +177 -29
  12. package/src/components/chart/chart-indicator-selector.tsx +53 -0
  13. package/src/components/chart/chart-pane-settings.ts +20 -0
  14. package/src/components/chart/chart-renderer.ts +220 -30
  15. package/src/components/chart/chart-resolution.test.ts +67 -0
  16. package/src/components/chart/chart-resolution.ts +74 -4
  17. package/src/components/chart/chart-scroll.test.ts +69 -0
  18. package/src/components/chart/chart-scroll.ts +104 -0
  19. package/src/components/chart/chart-types.ts +12 -0
  20. package/src/components/chart/chart-viewport.test.ts +5 -1
  21. package/src/components/chart/chart-viewport.ts +1 -1
  22. package/src/components/chart/chart.test.ts +249 -10
  23. package/src/components/chart/comparison-chart-renderer.test.ts +71 -1
  24. package/src/components/chart/comparison-chart-renderer.ts +40 -3
  25. package/src/components/chart/comparison-stock-chart.tsx +218 -123
  26. package/src/components/chart/indicators/bands.test.ts +50 -0
  27. package/src/components/chart/indicators/bands.ts +29 -0
  28. package/src/components/chart/indicators/moving-averages.test.ts +54 -0
  29. package/src/components/chart/indicators/moving-averages.ts +42 -0
  30. package/src/components/chart/indicators/options.test.ts +32 -0
  31. package/src/components/chart/indicators/options.ts +107 -0
  32. package/src/components/chart/indicators/oscillators.test.ts +79 -0
  33. package/src/components/chart/indicators/oscillators.ts +92 -0
  34. package/src/components/chart/indicators/types.ts +36 -0
  35. package/src/components/chart/native/chart-rasterizer.ts +51 -0
  36. package/src/components/chart/native/kitty.test.ts +68 -0
  37. package/src/components/chart/stock-chart-renderer-switch.test.tsx +740 -9
  38. package/src/components/chart/stock-chart.test.ts +239 -0
  39. package/src/components/chart/stock-chart.tsx +2257 -372
  40. package/src/components/command-bar/command-bar.test.tsx +337 -5
  41. package/src/components/command-bar/command-bar.tsx +296 -120
  42. package/src/components/command-bar/helpers.ts +1 -1
  43. package/src/components/command-bar/root-shortcuts.ts +33 -4
  44. package/src/components/command-bar/view-model.test.ts +3 -3
  45. package/src/components/command-bar/view-model.ts +4 -4
  46. package/src/components/command-bar/workflow-ops.ts +10 -0
  47. package/src/components/layout/floating-pane.tsx +3 -1
  48. package/src/components/layout/header.tsx +5 -4
  49. package/src/components/layout/pane-sizing.ts +6 -0
  50. package/src/components/layout/pane.tsx +5 -1
  51. package/src/components/layout/shell.test.tsx +263 -32
  52. package/src/components/layout/shell.tsx +10 -6
  53. package/src/components/layout/status-bar.test.tsx +16 -7
  54. package/src/components/layout/status-bar.tsx +4 -1
  55. package/src/components/markdown-editor.tsx +1 -0
  56. package/src/components/pane-settings-dialog.tsx +13 -152
  57. package/src/components/pane-template-wizard.tsx +1 -0
  58. package/src/components/price-selector-dialog.tsx +12 -7
  59. package/src/components/ticker-badge-text.test.tsx +50 -0
  60. package/src/components/ticker-badge-text.tsx +17 -2
  61. package/src/components/ticker-badge.tsx +2 -2
  62. package/src/components/ticker-list-table.tsx +62 -2
  63. package/src/components/toggle-list.tsx +15 -2
  64. package/src/components/ui/data-table.tsx +82 -5
  65. package/src/components/ui/external-link.tsx +48 -11
  66. package/src/components/ui/fields.tsx +2 -1
  67. package/src/components/ui/index.ts +8 -1
  68. package/src/components/ui/multi-select-chips.tsx +69 -0
  69. package/src/components/ui/multi-select-dialog.tsx +267 -0
  70. package/src/components/ui/multi-select.ts +78 -0
  71. package/src/components/ui/ui.test.tsx +343 -1
  72. package/src/data/plugin-state-store.ts +30 -19
  73. package/src/data/resource-store.ts +133 -103
  74. package/src/data/session-store.ts +19 -12
  75. package/src/data/sqlite-cache.test.ts +37 -2
  76. package/src/data/sqlite-database.ts +8 -2
  77. package/src/data/sqlite-retry.test.ts +63 -0
  78. package/src/data/sqlite-retry.ts +66 -0
  79. package/src/data/ticker-store.ts +29 -18
  80. package/src/index.tsx +6 -1
  81. package/src/market-data/coordinator.test.ts +114 -5
  82. package/src/market-data/coordinator.ts +70 -1
  83. package/src/market-data/hooks.test.tsx +73 -2
  84. package/src/market-data/hooks.tsx +16 -4
  85. package/src/notifications/app-notifier.test.ts +177 -0
  86. package/src/notifications/app-notifier.ts +241 -0
  87. package/src/pane-settings.test.ts +2 -0
  88. package/src/plugins/builtin/ai/index.test.tsx +1 -1
  89. package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
  90. package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
  91. package/src/plugins/builtin/ai/ticker-context.ts +3 -2
  92. package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
  93. package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
  94. package/src/plugins/builtin/alerts/index.test.tsx +357 -0
  95. package/src/plugins/builtin/alerts/index.tsx +519 -0
  96. package/src/plugins/builtin/alerts/types.ts +14 -0
  97. package/src/plugins/builtin/analytics/index.test.tsx +300 -0
  98. package/src/plugins/builtin/analytics/index.tsx +775 -0
  99. package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
  100. package/src/plugins/builtin/analytics/metrics.ts +155 -0
  101. package/src/plugins/builtin/ask-ai.test.tsx +2 -0
  102. package/src/plugins/builtin/chat-controller.test.ts +92 -18
  103. package/src/plugins/builtin/chat-controller.ts +40 -11
  104. package/src/plugins/builtin/chat.test.tsx +715 -4
  105. package/src/plugins/builtin/chat.tsx +420 -119
  106. package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
  107. package/src/plugins/builtin/comparison-chart.tsx +1 -2
  108. package/src/plugins/builtin/correlation/compute.test.ts +147 -0
  109. package/src/plugins/builtin/correlation/compute.ts +115 -0
  110. package/src/plugins/builtin/correlation/index.tsx +385 -0
  111. package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
  112. package/src/plugins/builtin/correlation/settings.test.ts +34 -0
  113. package/src/plugins/builtin/correlation/settings.ts +95 -0
  114. package/src/plugins/builtin/debug.tsx +36 -50
  115. package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
  116. package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
  117. package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
  118. package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
  119. package/src/plugins/builtin/earnings/index.tsx +337 -0
  120. package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
  121. package/src/plugins/builtin/econ/calendar-source.ts +121 -0
  122. package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
  123. package/src/plugins/builtin/econ/fred-client.ts +79 -0
  124. package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
  125. package/src/plugins/builtin/econ/index.tsx +999 -0
  126. package/src/plugins/builtin/econ/types.ts +13 -0
  127. package/src/plugins/builtin/fred-settings.ts +10 -0
  128. package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
  129. package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
  130. package/src/plugins/builtin/help.test.tsx +1 -5
  131. package/src/plugins/builtin/help.tsx +1 -16
  132. package/src/plugins/builtin/insider/index.tsx +417 -0
  133. package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
  134. package/src/plugins/builtin/insider/insider-data.ts +49 -0
  135. package/src/plugins/builtin/layout-manager.tsx +33 -16
  136. package/src/plugins/builtin/market-movers/index.tsx +573 -0
  137. package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
  138. package/src/plugins/builtin/market-movers/screener.ts +105 -0
  139. package/src/plugins/builtin/options.tsx +4 -3
  140. package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
  141. package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
  142. package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
  143. package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
  144. package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
  145. package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
  146. package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
  147. package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
  148. package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
  149. package/src/plugins/builtin/sec.tsx +114 -7
  150. package/src/plugins/builtin/sectors/index.tsx +359 -0
  151. package/src/plugins/builtin/sectors/sector-data.ts +18 -0
  152. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
  153. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
  154. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
  155. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
  156. package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
  157. package/src/plugins/builtin/world-indices/index.tsx +410 -0
  158. package/src/plugins/builtin/world-indices/indices.ts +52 -0
  159. package/src/plugins/builtin/yield-curve/index.tsx +242 -0
  160. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
  161. package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
  162. package/src/plugins/catalog.ts +22 -0
  163. package/src/plugins/gridlock-notification.ts +15 -0
  164. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  165. package/src/plugins/ibkr/gateway-service.test.ts +22 -0
  166. package/src/plugins/ibkr/gateway-service.ts +6 -0
  167. package/src/plugins/ibkr/index.test.tsx +1 -1
  168. package/src/plugins/ibkr/index.tsx +8 -2
  169. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  170. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  171. package/src/plugins/ibkr/trade-utils.ts +7 -6
  172. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  173. package/src/plugins/plugin-runtime.test.tsx +2 -0
  174. package/src/plugins/plugin-runtime.tsx +10 -0
  175. package/src/plugins/prediction-markets/controller-data.ts +129 -14
  176. package/src/plugins/prediction-markets/controller.ts +1 -1
  177. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  178. package/src/plugins/prediction-markets/index.tsx +5 -1
  179. package/src/plugins/prediction-markets/pane.test.tsx +78 -0
  180. package/src/plugins/prediction-markets/pane.tsx +7 -3
  181. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  182. package/src/plugins/prediction-markets/services/fetch.ts +17 -7
  183. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  184. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  185. package/src/plugins/prediction-markets/test-helpers.tsx +22 -5
  186. package/src/plugins/registry.ts +45 -13
  187. package/src/sources/provider-router.test.ts +3 -2
  188. package/src/sources/sec-edgar.ts +13 -1
  189. package/src/sources/yahoo-finance.ts +75 -1
  190. package/src/state/app-context-selector.test.tsx +17 -1
  191. package/src/state/app-context.test.tsx +24 -0
  192. package/src/state/app-context.tsx +29 -6
  193. package/src/theme/colors.ts +5 -0
  194. package/src/theme/themes.ts +25 -0
  195. package/src/types/config.test.ts +107 -0
  196. package/src/types/config.ts +30 -1
  197. package/src/types/data-provider.ts +13 -0
  198. package/src/types/plugin.ts +35 -1
  199. package/src/updater.test.ts +2 -0
  200. package/src/utils/api-client.ts +12 -0
  201. package/src/utils/format.test.ts +11 -1
  202. package/src/utils/format.ts +86 -8
  203. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  204. package/src/utils/inline-content-tokenizer.ts +76 -0
  205. package/src/utils/link-tokenizer.test.ts +27 -0
  206. package/src/utils/link-tokenizer.ts +93 -0
  207. package/src/utils/market-format.test.ts +109 -0
  208. package/src/utils/market-format.ts +299 -0
  209. package/src/utils/market-status.ts +3 -2
  210. package/src/utils/selection-clipboard.test.ts +43 -0
  211. package/src/utils/selection-clipboard.ts +88 -0
  212. package/src/utils/terminal-input-reset.test.ts +50 -0
  213. package/src/utils/terminal-input-reset.ts +24 -0
  214. package/src/utils/throttled-fetch.test.ts +145 -0
  215. package/src/utils/throttled-fetch.ts +212 -0
  216. package/src/utils/ticker-tokenizer.test.ts +31 -0
  217. package/src/utils/ticker-tokenizer.ts +7 -42
  218. package/src/version.ts +1 -1
@@ -1,6 +1,6 @@
1
- import { useState, useEffect, useRef, useCallback } from "react";
1
+ import { Fragment, useState, useEffect, useRef, useCallback } from "react";
2
2
  import { useKeyboard } from "@opentui/react";
3
- import { TextAttributes, type InputRenderable, type ScrollBoxRenderable } from "@opentui/core";
3
+ import { TextAttributes, type ScrollBoxRenderable, type TextareaRenderable } from "@opentui/core";
4
4
  import type { GloomPlugin, PaneProps } from "../../types/plugin";
5
5
  import { useAppState } from "../../state/app-context";
6
6
  import { useInlineTickers } from "../../state/use-inline-tickers";
@@ -27,14 +27,11 @@ interface ChatStatusWidgetProps {
27
27
  controller?: Pick<ChatController, "getSnapshot" | "refreshSession" | "subscribe">;
28
28
  }
29
29
 
30
- function estimateWrappedLineCount(text: string, width: number) {
31
- const safeWidth = Math.max(width, 1);
32
- return text.split("\n").reduce((total, line) => (
33
- total + Math.max(1, Math.ceil(Math.max(line.length, 1) / safeWidth))
34
- ), 0);
35
- }
36
-
37
30
  const MESSAGE_GROUP_THRESHOLD_MS = 5 * 60 * 1000;
31
+ const CHAT_COMPOSER_MAX_ROWS = 5;
32
+ const MESSAGE_ACTION_WIDTH = 9;
33
+ const COMPOSER_ACTION_WIDTH = 10;
34
+ const MESSAGE_SELECTION_BOTTOM_INSET = 1;
38
35
 
39
36
  function isGroupedWithPrevious(messages: ChatMessage[], index: number) {
40
37
  if (index === 0) return false;
@@ -44,11 +41,58 @@ function isGroupedWithPrevious(messages: ChatMessage[], index: number) {
44
41
  return new Date(curr.createdAt).getTime() - new Date(prev.createdAt).getTime() < MESSAGE_GROUP_THRESHOLD_MS;
45
42
  }
46
43
 
44
+ function normalizeInlinePreview(text: string) {
45
+ return text.replace(/\s+/g, " ").trim();
46
+ }
47
+
48
+ function truncateInlinePreview(text: string, width: number) {
49
+ if (width <= 0) return "";
50
+ if (text.length <= width) return text;
51
+ if (width <= 3) return text.slice(0, width);
52
+ return `${text.slice(0, width - 3)}...`;
53
+ }
54
+
55
+ function formatInlinePreview(text: string, width: number) {
56
+ return truncateInlinePreview(normalizeInlinePreview(text), width);
57
+ }
58
+
59
+ function wrapTextLines(text: string, width: number) {
60
+ const safeWidth = Math.max(width, 1);
61
+ const lines: string[] = [];
62
+
63
+ for (const paragraph of text.split("\n")) {
64
+ let remaining = paragraph;
65
+ if (remaining.length === 0) {
66
+ lines.push("");
67
+ continue;
68
+ }
69
+
70
+ while (remaining.length > safeWidth) {
71
+ const candidate = remaining.slice(0, safeWidth + 1);
72
+ const breakAt = candidate.lastIndexOf(" ");
73
+ const lineEnd = breakAt > 0 ? breakAt : safeWidth;
74
+ lines.push(remaining.slice(0, lineEnd).trimEnd());
75
+ remaining = remaining.slice(lineEnd).trimStart();
76
+ }
77
+
78
+ lines.push(remaining);
79
+ }
80
+
81
+ return lines.length > 0 ? lines : [""];
82
+ }
83
+
84
+ function getMessageBodyLines(message: ChatMessage, width: number) {
85
+ const contentLineWidth = Math.max(width - 4 - MESSAGE_ACTION_WIDTH, 1);
86
+ return wrapTextLines(message.content, contentLineWidth);
87
+ }
88
+
47
89
  function estimateMessageHeight(message: ChatMessage, width: number, grouped = false) {
48
- const contentLineWidth = Math.max(width - 4, 1);
49
- const normalizedContent = message.content.replace(/\$[A-Z][A-Z0-9.-]{0,9}/g, (match) => ` ${match.slice(1)} +0% `);
50
90
  const headerHeight = grouped ? 0 : 1;
51
- return headerHeight + (message.replyTo ? 1 : 0) + estimateWrappedLineCount(normalizedContent, contentLineWidth);
91
+ return headerHeight + (message.replyTo ? 1 : 0) + getMessageBodyLines(message, width).length;
92
+ }
93
+
94
+ function estimateComposerHeight(text: string, width: number) {
95
+ return Math.max(1, Math.min(CHAT_COMPOSER_MAX_ROWS, wrapTextLines(text, width).length));
52
96
  }
53
97
 
54
98
  function getMessageTopOffset(messages: ChatMessage[], index: number, width: number) {
@@ -64,6 +108,28 @@ function scrollToBottom(scrollBox: ScrollBoxRenderable | null) {
64
108
  scrollBox.scrollTo(Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height));
65
109
  }
66
110
 
111
+ export function getSelectedMessageScrollTop({
112
+ scrollTop,
113
+ viewportHeight,
114
+ top,
115
+ rowHeight,
116
+ bottomInset = MESSAGE_SELECTION_BOTTOM_INSET,
117
+ }: {
118
+ scrollTop: number;
119
+ viewportHeight: number;
120
+ top: number;
121
+ rowHeight: number;
122
+ bottomInset?: number;
123
+ }) {
124
+ const safeViewportHeight = Math.max(viewportHeight, 1);
125
+ const visibleMessageRows = Math.max(safeViewportHeight - bottomInset, 1);
126
+ if (top < scrollTop) return top;
127
+ if (top + rowHeight > scrollTop + visibleMessageRows) {
128
+ return Math.max(top + rowHeight - visibleMessageRows, 0);
129
+ }
130
+ return scrollTop;
131
+ }
132
+
67
133
  function openAuthCommand(query: string, event?: { preventDefault?: () => void; stopPropagation?: () => void }) {
68
134
  event?.preventDefault?.();
69
135
  event?.stopPropagation?.();
@@ -100,6 +166,43 @@ function InlineAuthActions({ showSignup = true }: { showSignup?: boolean }) {
100
166
  );
101
167
  }
102
168
 
169
+ function ChatActionChip({
170
+ label,
171
+ width,
172
+ emphasized = false,
173
+ onPress,
174
+ }: {
175
+ label: string;
176
+ width: number;
177
+ emphasized?: boolean;
178
+ onPress: () => void;
179
+ }) {
180
+ return (
181
+ <box
182
+ width={width}
183
+ height={1}
184
+ backgroundColor={emphasized ? colors.borderFocused : colors.panel}
185
+ onMouseDown={(event: any) => {
186
+ event?.preventDefault?.();
187
+ event?.stopPropagation?.();
188
+ onPress();
189
+ }}
190
+ >
191
+ <text
192
+ fg={emphasized ? colors.bg : colors.text}
193
+ attributes={emphasized ? TextAttributes.BOLD : 0}
194
+ onMouseDown={(event: any) => {
195
+ event?.preventDefault?.();
196
+ event?.stopPropagation?.();
197
+ onPress();
198
+ }}
199
+ >
200
+ {` ${label} `}
201
+ </text>
202
+ </box>
203
+ );
204
+ }
205
+
103
206
  export function ChatContent({
104
207
  width,
105
208
  height,
@@ -122,9 +225,16 @@ export function ChatContent({
122
225
  const [replyTo, setReplyTo] = useState<ChatMessage | null>(() => (
123
226
  initialSnapshot.replyToId ? initialSnapshot.messages.find((message) => message.id === initialSnapshot.replyToId) ?? null : null
124
227
  ));
125
- const inputRef = useRef<InputRenderable>(null);
228
+ const inputRef = useRef<TextareaRenderable>(null);
126
229
  const scrollRef = useRef<ScrollBoxRenderable>(null);
230
+ const applyingExternalDraftRef = useRef(false);
127
231
  const canSend = !!user?.emailVerified;
232
+ const messageBodyWidth = Math.max(contentWidth - 4 - MESSAGE_ACTION_WIDTH, 1);
233
+ const composerPrefixWidth = 3;
234
+ const composerTextWidth = Math.max(contentWidth - composerPrefixWidth, 1);
235
+ const composerHeight = canSend ? estimateComposerHeight(inputValue, composerTextWidth) : 0;
236
+ const selectionActive = selectedIdx >= 0 && selectedIdx < messages.length;
237
+ const stickyTranscript = followMessages && !selectionActive;
128
238
 
129
239
  useEffect(() => {
130
240
  return controller.attachView();
@@ -136,7 +246,13 @@ export function ChatContent({
136
246
  setHasSavedSession(snapshot.hasSavedSession);
137
247
  setUser(snapshot.user);
138
248
  setLoading(snapshot.loading);
139
- setInputValue(snapshot.draft);
249
+ setInputValue((current) => (current === snapshot.draft ? current : snapshot.draft));
250
+ const textarea = inputRef.current;
251
+ if (textarea && textarea.editBuffer.getText() !== snapshot.draft) {
252
+ applyingExternalDraftRef.current = true;
253
+ textarea.setText(snapshot.draft);
254
+ applyingExternalDraftRef.current = false;
255
+ }
140
256
  setReplyTo(snapshot.replyToId
141
257
  ? snapshot.messages.find((message) => message.id === snapshot.replyToId) ?? null
142
258
  : null);
@@ -165,11 +281,64 @@ export function ChatContent({
165
281
  dispatch({ type: "SET_INPUT_CAPTURED", captured: false });
166
282
  }, [dispatch]);
167
283
 
168
- const updateDraft = useCallback((draft: string) => {
169
- setInputValue(draft);
170
- controller.setDraft(draft);
284
+ const clearReplyTarget = useCallback(() => {
285
+ setReplyTo(null);
286
+ controller.setReplyToId(null);
171
287
  }, [controller]);
172
288
 
289
+ const beginReplyTo = useCallback((index: number, options?: { deferFocus?: boolean }) => {
290
+ if (!canSend || index < 0 || index >= messages.length) return;
291
+ const nextReplyTo = messages[index] ?? null;
292
+ if (!nextReplyTo) return;
293
+ setSelectedIdx(index);
294
+ setFollowMessages(index === messages.length - 1);
295
+ setReplyTo(nextReplyTo);
296
+ controller.setReplyToId(nextReplyTo.id);
297
+ if (options?.deferFocus) {
298
+ queueMicrotask(() => focusInput());
299
+ } else {
300
+ focusInput();
301
+ }
302
+ }, [canSend, controller, focusInput, messages]);
303
+
304
+ const returnToComposer = useCallback(() => {
305
+ setSelectedIdx(-1);
306
+ setFollowMessages(true);
307
+ if (canSend) {
308
+ queueMicrotask(() => focusInput());
309
+ }
310
+ }, [canSend, focusInput]);
311
+
312
+ const moveMessageSelection = useCallback((direction: "up" | "down") => {
313
+ if (messages.length === 0) return false;
314
+
315
+ let next: number;
316
+ if (selectedIdx < 0) {
317
+ if (direction === "down") return false;
318
+ next = messages.length - 1;
319
+ } else if (direction === "down") {
320
+ next = Math.min(selectedIdx + 1, messages.length - 1);
321
+ } else {
322
+ next = Math.max(selectedIdx - 1, 0);
323
+ }
324
+
325
+ setSelectedIdx(next);
326
+ setFollowMessages(next === messages.length - 1);
327
+ return true;
328
+ }, [messages.length, selectedIdx]);
329
+
330
+ const shouldLeaveComposerForSelection = useCallback((direction: "up" | "down") => {
331
+ const textarea = inputRef.current;
332
+ if (!textarea || textarea.hasSelection()) return false;
333
+
334
+ const visualLineCount = Math.max(textarea.virtualLineCount, 1);
335
+ if (direction === "up") {
336
+ return textarea.visualCursor.visualRow <= 0;
337
+ }
338
+
339
+ return textarea.visualCursor.visualRow >= visualLineCount - 1;
340
+ }, []);
341
+
173
342
  const sendMessage = useCallback(() => {
174
343
  const content = inputValueRef.current.trim();
175
344
  if (!content) return;
@@ -196,108 +365,178 @@ export function ChatContent({
196
365
  }
197
366
  }, [focused, inputFocused]);
198
367
 
368
+ useEffect(() => {
369
+ const textarea = inputRef.current;
370
+ if (!textarea) return;
371
+
372
+ textarea.onContentChange = () => {
373
+ if (applyingExternalDraftRef.current) return;
374
+ const draft = textarea.editBuffer.getText();
375
+ setInputValue((current) => (current === draft ? current : draft));
376
+ controller.setDraft(draft);
377
+ };
378
+
379
+ return () => {
380
+ if (textarea) {
381
+ textarea.onContentChange = undefined;
382
+ }
383
+ };
384
+ }, [controller]);
385
+
199
386
  useEffect(() => {
200
387
  if (canSend || !replyTo) return;
201
- setReplyTo(null);
202
- controller.setReplyToId(null);
203
- }, [canSend, controller, replyTo]);
388
+ clearReplyTarget();
389
+ }, [canSend, clearReplyTarget, replyTo]);
204
390
 
205
391
  useKeyboard((event) => {
206
392
  if (!focused) return;
207
-
208
- if (event.name === "c" && event.shift) {
209
- if (inputFocused) {
210
- blurInput();
211
- }
212
- close?.();
213
- return;
214
- }
393
+ const isEnterKey = event.name === "return" || event.name === "enter";
215
394
 
216
395
  if (inputFocused) {
217
396
  if (event.name === "escape") {
397
+ event.preventDefault?.();
398
+ event.stopPropagation?.();
218
399
  if (replyTo) {
219
- setReplyTo(null);
220
- controller.setReplyToId(null);
400
+ clearReplyTarget();
221
401
  } else {
222
402
  blurInput();
223
403
  }
404
+ return;
224
405
  }
406
+
407
+ if ((event.name === "up" || event.name === "down") && shouldLeaveComposerForSelection(event.name)) {
408
+ const moved = moveMessageSelection(event.name);
409
+ if (moved) {
410
+ event.preventDefault?.();
411
+ event.stopPropagation?.();
412
+ blurInput();
413
+ return;
414
+ }
415
+ }
416
+
225
417
  return;
226
418
  }
227
419
 
228
- if ((event.name === "return" || event.name === "i") && canSend) {
229
- focusInput();
420
+ if (canSend && isEnterKey && selectedIdx >= 0 && selectedIdx < messages.length) {
421
+ event.preventDefault?.();
422
+ event.stopPropagation?.();
423
+ beginReplyTo(selectedIdx, { deferFocus: true });
424
+ return;
425
+ }
426
+
427
+ if ((isEnterKey || event.name === "i") && canSend) {
428
+ event.preventDefault?.();
429
+ event.stopPropagation?.();
430
+ queueMicrotask(() => focusInput());
230
431
  return;
231
432
  }
232
433
 
233
434
  if (event.name === "escape") {
234
- close?.();
435
+ event.preventDefault?.();
436
+ event.stopPropagation?.();
437
+ if (selectedIdx >= 0) {
438
+ setSelectedIdx(-1);
439
+ setFollowMessages(true);
440
+ }
235
441
  return;
236
442
  }
237
443
 
238
444
  if (event.name === "j" || event.name === "down") {
239
- if (messages.length === 0) return;
240
- const next = Math.min(selectedIdx + 1, messages.length - 1);
241
- setSelectedIdx(next);
242
- setFollowMessages(next === messages.length - 1);
445
+ event.preventDefault?.();
446
+ event.stopPropagation?.();
447
+ if (selectedIdx === messages.length - 1) {
448
+ returnToComposer();
449
+ return;
450
+ }
451
+ moveMessageSelection("down");
243
452
  return;
244
453
  }
245
454
  if (event.name === "k" || event.name === "up") {
246
- if (messages.length === 0) return;
247
- const start = selectedIdx < 0 ? messages.length - 1 : selectedIdx;
248
- const next = Math.max(start - 1, 0);
249
- setSelectedIdx(next);
250
- setFollowMessages(false);
455
+ event.preventDefault?.();
456
+ event.stopPropagation?.();
457
+ moveMessageSelection("up");
251
458
  return;
252
459
  }
253
460
 
254
461
  if (canSend && event.name === "r" && selectedIdx >= 0 && selectedIdx < messages.length) {
255
- const nextReplyTo = messages[selectedIdx] ?? null;
256
- setReplyTo(nextReplyTo);
257
- controller.setReplyToId(nextReplyTo?.id ?? null);
258
- focusInput();
462
+ event.preventDefault?.();
463
+ event.stopPropagation?.();
464
+ beginReplyTo(selectedIdx, { deferFocus: true });
259
465
  return;
260
466
  }
261
467
 
262
468
  if (event.name === "g" && !event.shift) {
469
+ event.preventDefault?.();
470
+ event.stopPropagation?.();
263
471
  setSelectedIdx(0);
264
472
  setFollowMessages(false);
265
473
  scrollRef.current?.scrollTo(0);
266
474
  return;
267
475
  }
268
476
  if (event.name === "g" && event.shift) {
477
+ event.preventDefault?.();
478
+ event.stopPropagation?.();
269
479
  setSelectedIdx(messages.length - 1);
270
480
  setFollowMessages(true);
271
481
  queueMicrotask(() => scrollToBottom(scrollRef.current));
272
482
  return;
273
483
  }
274
- }, [blurInput, canSend, close, controller, focusInput, focused, inputFocused, messages, replyTo, selectedIdx]);
484
+ }, [
485
+ beginReplyTo,
486
+ blurInput,
487
+ canSend,
488
+ clearReplyTarget,
489
+ focused,
490
+ inputFocused,
491
+ messages.length,
492
+ moveMessageSelection,
493
+ replyTo,
494
+ returnToComposer,
495
+ selectedIdx,
496
+ shouldLeaveComposerForSelection,
497
+ ]);
498
+
499
+ const inputMetaHeight = canSend && replyTo ? 1 : 0;
500
+ const inputAreaHeight = canSend ? composerHeight + inputMetaHeight : 2;
501
+ const headerHeight = 1;
502
+ const separatorHeight = 1;
503
+ const footerSeparatorHeight = 1;
504
+ const messageAreaHeight = Math.max(1, height - headerHeight - separatorHeight - footerSeparatorHeight - inputAreaHeight);
275
505
 
276
506
  useEffect(() => {
277
507
  if (selectedIdx < messages.length) return;
278
508
  setSelectedIdx(messages.length - 1);
279
509
  }, [messages.length, selectedIdx]);
280
510
 
511
+ useEffect(() => {
512
+ if (!stickyTranscript) return;
513
+ queueMicrotask(() => scrollToBottom(scrollRef.current));
514
+ }, [contentWidth, height, messages, messageAreaHeight, stickyTranscript]);
515
+
281
516
  useEffect(() => {
282
517
  const sb = scrollRef.current;
283
518
  if (!sb) return;
284
- if (followMessages) return;
285
- if (selectedIdx < 0 || selectedIdx >= messages.length) return;
519
+ if (!selectionActive) return;
286
520
  const top = getMessageTopOffset(messages, selectedIdx, contentWidth);
287
521
  const rowHeight = estimateMessageHeight(messages[selectedIdx]!, contentWidth, isGroupedWithPrevious(messages, selectedIdx));
288
- const viewportHeight = Math.max(sb.viewport.height, 1);
289
- if (top < sb.scrollTop) {
290
- sb.scrollTo(top);
291
- } else if (top + rowHeight > sb.scrollTop + viewportHeight) {
292
- sb.scrollTo(Math.max(top + rowHeight - viewportHeight, 0));
522
+ const nextScrollTop = getSelectedMessageScrollTop({
523
+ scrollTop: sb.scrollTop,
524
+ viewportHeight: sb.viewport.height,
525
+ top,
526
+ rowHeight,
527
+ });
528
+ if (nextScrollTop !== sb.scrollTop) {
529
+ sb.scrollTo(nextScrollTop);
293
530
  }
294
- }, [contentWidth, followMessages, messages, selectedIdx]);
531
+ }, [contentWidth, messages, selectedIdx, selectionActive]);
295
532
 
296
- const replyBarHeight = canSend && replyTo ? 1 : 0;
297
- const inputAreaHeight = canSend ? 1 + replyBarHeight : 2;
298
- const headerHeight = 1;
299
- const separatorHeight = 1;
300
- const messageAreaHeight = Math.max(1, height - headerHeight - separatorHeight - inputAreaHeight - 1);
533
+ const replyPreview = replyTo
534
+ ? formatInlinePreview(
535
+ replyTo.content,
536
+ Math.max(contentWidth - ` replying to @${replyTo.user.username}: `.length - COMPOSER_ACTION_WIDTH - 1, 0),
537
+ )
538
+ : "";
539
+ const inputPlaceholder = replyTo ? `Reply to @${replyTo.user.username}...` : "Type a message...";
301
540
 
302
541
  if (loading && messages.length === 0) {
303
542
  return (
@@ -324,7 +563,7 @@ export function ChatContent({
324
563
  height={messageAreaHeight}
325
564
  scrollY
326
565
  focusable={false}
327
- stickyScroll={followMessages}
566
+ stickyScroll={stickyTranscript}
328
567
  stickyStart="bottom"
329
568
  >
330
569
  {messages.length === 0 && (
@@ -335,56 +574,96 @@ export function ChatContent({
335
574
  {messages.map((msg, index) => {
336
575
  const isSelected = index === selectedIdx;
337
576
  const isHovered = index === hoveredIdx && !isSelected;
577
+ const showReplyAction = canSend && (isSelected || hoveredIdx === index);
338
578
  const bgColor = isSelected ? colors.selected : isHovered ? hoverBg() : undefined;
339
579
  const grouped = isGroupedWithPrevious(messages, index);
340
580
  const isSending = msg.clientStatus === "sending";
341
581
  const hasFailed = msg.clientStatus === "failed";
582
+ const selectedTextColor = hasFailed ? colors.negative : colors.selectedText;
583
+ const replyMetaColor = isSelected ? selectedTextColor : colors.textMuted;
584
+ const replyAuthorColor = isSelected ? selectedTextColor : colors.textDim;
342
585
  const headerStatus = isSending ? "sending..." : hasFailed ? "failed" : formatTimeAgo(msg.createdAt);
343
- const headerStatusColor = isSending ? colors.textDim : hasFailed ? colors.negative : colors.textMuted;
586
+ const headerStatusColor = isSelected
587
+ ? selectedTextColor
588
+ : isSending
589
+ ? colors.textDim
590
+ : hasFailed
591
+ ? colors.negative
592
+ : colors.textMuted;
593
+ const authorColor = isSelected ? selectedTextColor : hasFailed ? colors.negative : colors.positive;
344
594
  const authorAttributes = (isSending ? TextAttributes.DIM : 0) | TextAttributes.BOLD;
345
- const bodyColor = hasFailed ? colors.negative : isSending ? colors.textDim : colors.text;
346
-
595
+ const bodyColor = isSelected ? selectedTextColor : hasFailed ? colors.negative : isSending ? colors.textDim : colors.text;
596
+ const bodyLines = getMessageBodyLines(msg, contentWidth);
597
+ const setHovered = () => setHoveredIdx(index);
598
+ const clearHovered = () => setHoveredIdx((current) => (current === index ? null : current));
599
+ const messageRowProps = {
600
+ width: contentWidth,
601
+ backgroundColor: bgColor,
602
+ onMouseMove: setHovered,
603
+ onMouseOut: clearHovered,
604
+ };
347
605
  return (
348
- <box
349
- key={msg.id}
350
- flexDirection="column"
351
- width={contentWidth}
352
- backgroundColor={bgColor}
353
- onMouseMove={() => setHoveredIdx(index)}
354
- onMouseDown={() => {
355
- setSelectedIdx(index);
356
- setFollowMessages(index === messages.length - 1);
357
- }}
358
- >
606
+ <Fragment key={msg.id}>
359
607
  {msg.replyTo && (
360
- <box flexDirection="row" height={1} paddingLeft={2}>
361
- <text fg={colors.textMuted}>reply </text>
362
- <text fg={colors.textDim}>{msg.replyTo.user.username}: </text>
363
- <text fg={colors.textMuted}>
364
- {msg.replyTo.content.length > contentWidth - 20
365
- ? msg.replyTo.content.slice(0, contentWidth - 23) + "..."
366
- : msg.replyTo.content}
608
+ <box
609
+ {...messageRowProps}
610
+ flexDirection="row"
611
+ height={1}
612
+ paddingLeft={2}
613
+ >
614
+ <text fg={replyMetaColor}>reply </text>
615
+ <text fg={replyAuthorColor}>{msg.replyTo.user.username}: </text>
616
+ <text fg={replyMetaColor}>
617
+ {formatInlinePreview(
618
+ msg.replyTo.content,
619
+ Math.max(messageBodyWidth - `reply ${msg.replyTo.user.username}: `.length, 0),
620
+ )}
367
621
  </text>
368
622
  </box>
369
623
  )}
370
624
  {!grouped && (
371
- <box flexDirection="row" height={1} paddingLeft={1}>
372
- <text fg={colors.positive} attributes={authorAttributes}>
625
+ <box
626
+ {...messageRowProps}
627
+ flexDirection="row"
628
+ height={1}
629
+ paddingLeft={1}
630
+ >
631
+ <text fg={authorColor} attributes={authorAttributes}>
373
632
  {msg.user.username ?? "anon"}
374
633
  </text>
375
634
  <text fg={headerStatusColor}> ({headerStatus})</text>
376
635
  </box>
377
636
  )}
378
- <box paddingLeft={3}>
379
- <TickerBadgeText
380
- text={msg.content}
381
- lineWidth={contentWidth - 4}
382
- catalog={catalog}
383
- textColor={bodyColor}
384
- openTicker={openTicker}
385
- />
386
- </box>
387
- </box>
637
+ {bodyLines.map((line, lineIndex) => (
638
+ <box
639
+ key={`${msg.id}:body:${lineIndex}`}
640
+ {...messageRowProps}
641
+ paddingLeft={3}
642
+ height={1}
643
+ flexDirection="row"
644
+ >
645
+ <box width={messageBodyWidth} height={1}>
646
+ <TickerBadgeText
647
+ text={line}
648
+ lineWidth={messageBodyWidth}
649
+ catalog={catalog}
650
+ textColor={bodyColor}
651
+ openTicker={openTicker}
652
+ />
653
+ </box>
654
+ <box width={MESSAGE_ACTION_WIDTH} height={1}>
655
+ {lineIndex === 0 && showReplyAction && (
656
+ <ChatActionChip
657
+ label="Reply"
658
+ width={MESSAGE_ACTION_WIDTH}
659
+ emphasized={isSelected}
660
+ onPress={() => beginReplyTo(index)}
661
+ />
662
+ )}
663
+ </box>
664
+ </box>
665
+ ))}
666
+ </Fragment>
388
667
  );
389
668
  })}
390
669
  </scrollbox>
@@ -398,31 +677,48 @@ export function ChatContent({
398
677
  {replyTo && (
399
678
  <box height={1} width={contentWidth} flexDirection="row">
400
679
  <text fg={colors.textMuted}> replying to </text>
401
- <text fg={colors.positive}>{replyTo.user.username}</text>
680
+ <text fg={colors.positive} attributes={TextAttributes.BOLD}>{`@${replyTo.user.username}`}</text>
681
+ <text fg={colors.textDim}>{replyPreview ? `: ${replyPreview}` : ""}</text>
402
682
  <box flexGrow={1} />
403
- <text fg={colors.textDim}>[Esc cancel]</text>
683
+ <ChatActionChip
684
+ label="Cancel"
685
+ width={COMPOSER_ACTION_WIDTH}
686
+ onPress={clearReplyTarget}
687
+ />
404
688
  </box>
405
689
  )}
406
690
 
407
- <box height={1} width={contentWidth} flexDirection="row" onMouseDown={focusInput}>
408
- <text fg={colors.textDim}> {">"} </text>
409
- <input
410
- ref={inputRef}
411
- value={inputValue}
412
- focused={inputFocused && focused}
413
- placeholder="Type a message..."
414
- placeholderColor={colors.textMuted}
415
- textColor={colors.text}
416
- backgroundColor={colors.bg}
417
- flexGrow={1}
418
- onInput={updateDraft}
419
- onChange={updateDraft}
420
- onSubmit={() => {
421
- if (inputValueRef.current.trim()) {
422
- sendMessage();
423
- }
424
- }}
425
- />
691
+ <box height={composerHeight} width={contentWidth} flexDirection="row" onMouseDown={focusInput}>
692
+ <box width={composerPrefixWidth} height={composerHeight}>
693
+ <text fg={colors.textDim}> {">"} </text>
694
+ </box>
695
+ <box width={composerTextWidth} height={composerHeight}>
696
+ <textarea
697
+ ref={inputRef}
698
+ initialValue={inputValue}
699
+ width={composerTextWidth}
700
+ height={composerHeight}
701
+ focused={inputFocused && focused}
702
+ placeholder={inputPlaceholder}
703
+ placeholderColor={colors.textMuted}
704
+ textColor={colors.text}
705
+ backgroundColor={colors.bg}
706
+ keyBindings={[
707
+ { name: "return", action: "submit" },
708
+ { name: "linefeed", action: "submit" },
709
+ { name: "return", shift: true, action: "newline" },
710
+ { name: "linefeed", shift: true, action: "newline" },
711
+ { name: "return", meta: true, action: "submit" },
712
+ { name: "linefeed", meta: true, action: "submit" },
713
+ ]}
714
+ onSubmit={() => {
715
+ if (inputValueRef.current.trim()) {
716
+ sendMessage();
717
+ }
718
+ }}
719
+ wrapText
720
+ />
721
+ </box>
426
722
  </box>
427
723
  </>
428
724
  ) : (
@@ -545,7 +841,7 @@ export const gloomberbCloudPlugin: GloomPlugin = {
545
841
 
546
842
  setup(ctx) {
547
843
  chatController.attachPersistence(ctx.persistence, ctx.resume);
548
- chatController.setToastNotifier(ctx.showToast);
844
+ chatController.setNotifier(ctx.notify);
549
845
 
550
846
  ctx.registerPane({
551
847
  id: "chat",
@@ -647,7 +943,7 @@ export const gloomberbCloudPlugin: GloomPlugin = {
647
943
  },
648
944
  execute: async () => {
649
945
  await apiClient.sendVerification();
650
- ctx.showToast("Verification email sent.", { type: "success" });
946
+ ctx.notify({ body: "Verification email sent.", type: "success" });
651
947
  },
652
948
  });
653
949
 
@@ -663,17 +959,22 @@ export const gloomberbCloudPlugin: GloomPlugin = {
663
959
  category: "config",
664
960
  execute: async () => {
665
961
  if (!apiClient.getSessionToken()) {
666
- ctx.showToast("Not logged in.", { type: "error" });
962
+ ctx.notify({ body: "Not logged in.", type: "error" });
667
963
  return;
668
964
  }
669
965
  await apiClient.signOut();
670
966
  await chatController.refreshSession();
671
967
  await chatController.refreshMessages();
672
- ctx.showToast("Logged out.", { type: "info" });
968
+ ctx.notify({ body: "Logged out.", type: "info" });
673
969
  },
674
970
  hidden: () => !apiClient.getSessionToken(),
675
971
  });
676
972
  },
973
+
974
+ dispose() {
975
+ chatController.dispose();
976
+ apiClient.dispose();
977
+ },
677
978
  };
678
979
 
679
980
  export const chatPlugin = gloomberbCloudPlugin;