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,13 +1,14 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import { act } from "react";
2
+ import { act, useState } from "react";
3
3
  import { testRender } from "@opentui/react/test-utils";
4
4
  import { AppContext, createInitialState } from "../../state/app-context";
5
+ import { colors } from "../../theme/colors";
5
6
  import { createDefaultConfig } from "../../types/config";
6
7
  import type { PersistedResourceValue } from "../../types/persistence";
7
8
  import type { PluginPersistence } from "../../types/plugin";
8
9
  import { apiClient, type ChatMessage } from "../../utils/api-client";
9
10
  import { setSharedDataProviderForTests, setSharedRegistryForTests } from "../registry";
10
- import { ChatContent, ChatStatusWidget, gloomberbCloudPlugin } from "./chat";
11
+ import { ChatContent, ChatStatusWidget, getSelectedMessageScrollTop, gloomberbCloudPlugin } from "./chat";
11
12
  import { ChatController } from "./chat-controller";
12
13
 
13
14
  const TRANSCRIPT_KIND = "channel-transcript";
@@ -103,6 +104,7 @@ function createController(options: {
103
104
  messages?: ChatMessage[];
104
105
  sessionToken?: string | null;
105
106
  user?: { id: string; username: string; emailVerified: boolean } | null;
107
+ replyToId?: string | null;
106
108
  } = {}) {
107
109
  const messages = options.messages ?? [];
108
110
  const persistence = new MemoryPersistence();
@@ -116,7 +118,7 @@ function createController(options: {
116
118
  }, { schemaVersion: 1 });
117
119
  persistence.setState("channel:everyone", {
118
120
  draft: "",
119
- replyToId: null,
121
+ replyToId: options.replyToId ?? null,
120
122
  lastCursor: messages[messages.length - 1]?.id ?? null,
121
123
  }, { schemaVersion: 1 });
122
124
  if (messages.length > 0) {
@@ -138,6 +140,7 @@ function createHarness(
138
140
  width?: number;
139
141
  height?: number;
140
142
  configureState?: (state: ReturnType<typeof createInitialState>) => void;
143
+ close?: () => void;
141
144
  },
142
145
  ) {
143
146
  const width = options?.width ?? 60;
@@ -152,6 +155,7 @@ function createHarness(
152
155
  width={width}
153
156
  height={height}
154
157
  focused
158
+ close={options?.close}
155
159
  />
156
160
  </AppContext>
157
161
  );
@@ -164,6 +168,44 @@ async function flushFrame() {
164
168
  });
165
169
  }
166
170
 
171
+ async function emitKeypress(event: {
172
+ name?: string;
173
+ sequence?: string;
174
+ ctrl?: boolean;
175
+ meta?: boolean;
176
+ shift?: boolean;
177
+ option?: boolean;
178
+ }) {
179
+ await act(async () => {
180
+ testSetup!.renderer.keyInput.emit("keypress", {
181
+ ctrl: false,
182
+ meta: false,
183
+ option: false,
184
+ shift: false,
185
+ eventType: "press",
186
+ repeated: false,
187
+ stopPropagation: () => {},
188
+ preventDefault: () => {},
189
+ ...event,
190
+ } as any);
191
+ await testSetup!.renderOnce();
192
+ });
193
+ }
194
+
195
+ function lineText(line: { spans: Array<{ text: string }> }) {
196
+ return line.spans.map((span) => span.text).join("");
197
+ }
198
+
199
+ function hexToRgbaInts(hex: string) {
200
+ const normalized = hex.replace("#", "");
201
+ return [
202
+ parseInt(normalized.slice(0, 2), 16),
203
+ parseInt(normalized.slice(2, 4), 16),
204
+ parseInt(normalized.slice(4, 6), 16),
205
+ 255,
206
+ ].join(",");
207
+ }
208
+
167
209
  afterEach(async () => {
168
210
  setSharedRegistryForTests(undefined);
169
211
  setSharedDataProviderForTests(undefined);
@@ -215,6 +257,588 @@ describe("ChatContent", () => {
215
257
  expect(frameAfterType).not.toContain("> FCD");
216
258
  });
217
259
 
260
+ test("keeps appending typed text while transcript updates arrive", async () => {
261
+ const controller = createController({
262
+ messages: [makeMessage(1)],
263
+ });
264
+
265
+ await act(async () => {
266
+ testSetup = await testRender(createHarness(controller), {
267
+ width: 60,
268
+ height: 12,
269
+ });
270
+ });
271
+
272
+ await flushFrame();
273
+
274
+ const frameBeforeClick = testSetup.captureCharFrame().split("\n");
275
+ const inputRow = frameBeforeClick.findIndex((line) => line.includes("Type a message..."));
276
+ const inputCol = frameBeforeClick[inputRow]?.indexOf("Type a message...") ?? -1;
277
+
278
+ expect(inputRow).toBeGreaterThanOrEqual(0);
279
+ expect(inputCol).toBeGreaterThanOrEqual(0);
280
+
281
+ await act(async () => {
282
+ await testSetup!.mockMouse.click(inputCol + 1, inputRow);
283
+ await testSetup!.renderOnce();
284
+ await testSetup!.renderOnce();
285
+ });
286
+
287
+ await act(async () => {
288
+ await testSetup!.mockInput.typeText("alpha");
289
+ await testSetup!.renderOnce();
290
+ await testSetup!.renderOnce();
291
+ });
292
+
293
+ await act(async () => {
294
+ (controller as any).mergeMessages([makeMessage(2)]);
295
+ });
296
+
297
+ await flushFrame();
298
+
299
+ await act(async () => {
300
+ await testSetup!.mockInput.typeText("beta");
301
+ await testSetup!.renderOnce();
302
+ await testSetup!.renderOnce();
303
+ });
304
+
305
+ const frameAfterType = testSetup.captureCharFrame();
306
+ expect(frameAfterType).toContain("> alphabeta");
307
+ expect(frameAfterType).not.toContain("> betaalpha");
308
+ });
309
+
310
+ test("does not render an idle helper row above the composer", async () => {
311
+ const controller = createController({
312
+ messages: [makeMessage(1)],
313
+ });
314
+
315
+ await act(async () => {
316
+ testSetup = await testRender(createHarness(controller, {
317
+ width: 72,
318
+ height: 12,
319
+ }), {
320
+ width: 72,
321
+ height: 12,
322
+ });
323
+ });
324
+
325
+ await flushFrame();
326
+
327
+ const frame = testSetup.captureCharFrame();
328
+ expect(frame).toContain("Type a message...");
329
+ expect(frame).not.toContain("click a message");
330
+ });
331
+
332
+ test("does not select a message when the row is clicked", async () => {
333
+ const controller = createController({
334
+ messages: [makeMessage(1)],
335
+ });
336
+
337
+ await act(async () => {
338
+ testSetup = await testRender(createHarness(controller, {
339
+ width: 72,
340
+ height: 12,
341
+ }), {
342
+ width: 72,
343
+ height: 12,
344
+ });
345
+ });
346
+
347
+ await flushFrame();
348
+
349
+ const lines = testSetup.captureCharFrame().split("\n");
350
+ const messageRow = lines.findIndex((line) => line.includes("message 1"));
351
+ const messageCol = lines[messageRow]?.indexOf("message 1") ?? -1;
352
+
353
+ expect(messageRow).toBeGreaterThanOrEqual(0);
354
+ expect(messageCol).toBeGreaterThanOrEqual(0);
355
+
356
+ await act(async () => {
357
+ await testSetup!.mockMouse.click(messageCol + 1, messageRow);
358
+ await testSetup!.renderOnce();
359
+ await testSetup!.renderOnce();
360
+ });
361
+
362
+ const frame = testSetup.captureCharFrame();
363
+ expect(frame).toContain("Type a message...");
364
+ expect(frame).not.toContain("Reply to @user1...");
365
+ expect(frame).not.toContain("Cancel");
366
+ });
367
+
368
+ test("down arrow does not jump to the oldest message when nothing is selected", async () => {
369
+ const controller = createController({
370
+ messages: [makeMessage(1)],
371
+ });
372
+
373
+ await act(async () => {
374
+ testSetup = await testRender(createHarness(controller, {
375
+ width: 72,
376
+ height: 12,
377
+ }), {
378
+ width: 72,
379
+ height: 12,
380
+ });
381
+ });
382
+
383
+ await flushFrame();
384
+
385
+ await emitKeypress({ name: "down", sequence: "\u001b[B" });
386
+ await flushFrame();
387
+
388
+ const frame = testSetup.captureCharFrame();
389
+ expect(frame).toContain("Type a message...");
390
+ expect(frame).not.toContain("replying to @user1");
391
+ expect(frame).not.toContain("Reply to @user1...");
392
+ });
393
+
394
+ test("up arrow selects the newest message first when nothing is selected", async () => {
395
+ const controller = createController({
396
+ messages: [makeMessage(1), makeMessage(2)],
397
+ });
398
+
399
+ await act(async () => {
400
+ testSetup = await testRender(createHarness(controller, {
401
+ width: 72,
402
+ height: 12,
403
+ }), {
404
+ width: 72,
405
+ height: 12,
406
+ });
407
+ });
408
+
409
+ await flushFrame();
410
+
411
+ await emitKeypress({ name: "up", sequence: "\u001b[A" });
412
+ await emitKeypress({ name: "return", sequence: "\r" });
413
+ await flushFrame();
414
+
415
+ const frame = testSetup.captureCharFrame();
416
+ expect(frame).toContain("replying to @user2");
417
+ expect(frame).toContain("Reply to @user2...");
418
+ });
419
+
420
+ test("down arrow from the newest selected message returns focus to the composer", async () => {
421
+ const controller = createController({
422
+ messages: [makeMessage(1), makeMessage(2)],
423
+ });
424
+
425
+ await act(async () => {
426
+ testSetup = await testRender(createHarness(controller, {
427
+ width: 72,
428
+ height: 12,
429
+ }), {
430
+ width: 72,
431
+ height: 12,
432
+ });
433
+ });
434
+
435
+ await flushFrame();
436
+
437
+ await emitKeypress({ name: "up", sequence: "\u001b[A" });
438
+ await flushFrame();
439
+ await emitKeypress({ name: "down", sequence: "\u001b[B" });
440
+ await flushFrame();
441
+
442
+ const frame = testSetup.captureCharFrame();
443
+ expect(frame).toContain("Type a message...");
444
+ expect(frame).not.toContain("Reply to @user2...");
445
+ expect(frame).not.toContain("replying to @user2");
446
+ });
447
+
448
+ test("up arrow leaves the composer and selects the latest message when the caret is already at the top", async () => {
449
+ const controller = createController({
450
+ messages: [makeMessage(1)],
451
+ });
452
+
453
+ await act(async () => {
454
+ testSetup = await testRender(createHarness(controller, {
455
+ width: 72,
456
+ height: 12,
457
+ }), {
458
+ width: 72,
459
+ height: 12,
460
+ });
461
+ });
462
+
463
+ await flushFrame();
464
+
465
+ const lines = testSetup.captureCharFrame().split("\n");
466
+ const inputRow = lines.findIndex((line) => line.includes("Type a message..."));
467
+ const inputCol = lines[inputRow]?.indexOf("Type a message...") ?? -1;
468
+
469
+ expect(inputRow).toBeGreaterThanOrEqual(0);
470
+ expect(inputCol).toBeGreaterThanOrEqual(0);
471
+
472
+ await act(async () => {
473
+ await testSetup!.mockMouse.click(inputCol + 1, inputRow);
474
+ await testSetup!.renderOnce();
475
+ await testSetup!.renderOnce();
476
+ });
477
+
478
+ await act(async () => {
479
+ testSetup!.mockInput.pressArrow("up");
480
+ await testSetup!.renderOnce();
481
+ await testSetup!.renderOnce();
482
+ });
483
+ await emitKeypress({ name: "return", sequence: "\r" });
484
+ await flushFrame();
485
+
486
+ const frame = testSetup.captureCharFrame();
487
+ expect(frame).toContain("replying to @user1");
488
+ expect(frame).toContain("Reply to @user1...");
489
+ });
490
+
491
+ test("up arrow targets the newest bottom message when multiple messages share a timestamp", async () => {
492
+ const controller = createController({
493
+ messages: [
494
+ {
495
+ id: "z-older",
496
+ channelId: "everyone",
497
+ content: "older same timestamp",
498
+ replyToId: null,
499
+ createdAt: "2026-03-30T00:00:01.000Z",
500
+ user: { id: "u1", username: "older", displayName: "Older" },
501
+ },
502
+ {
503
+ id: "a-newer",
504
+ channelId: "everyone",
505
+ content: "newer same timestamp",
506
+ replyToId: null,
507
+ createdAt: "2026-03-30T00:00:01.000Z",
508
+ user: { id: "u2", username: "newer", displayName: "Newer" },
509
+ },
510
+ ],
511
+ });
512
+
513
+ await act(async () => {
514
+ testSetup = await testRender(createHarness(controller, {
515
+ width: 72,
516
+ height: 12,
517
+ }), {
518
+ width: 72,
519
+ height: 12,
520
+ });
521
+ });
522
+
523
+ await flushFrame();
524
+
525
+ const lines = testSetup.captureCharFrame().split("\n");
526
+ const inputRow = lines.findIndex((line) => line.includes("Type a message..."));
527
+ const inputCol = lines[inputRow]?.indexOf("Type a message...") ?? -1;
528
+
529
+ expect(inputRow).toBeGreaterThanOrEqual(0);
530
+ expect(inputCol).toBeGreaterThanOrEqual(0);
531
+
532
+ await act(async () => {
533
+ await testSetup!.mockMouse.click(inputCol + 1, inputRow);
534
+ await testSetup!.renderOnce();
535
+ await testSetup!.renderOnce();
536
+ });
537
+
538
+ await act(async () => {
539
+ testSetup!.mockInput.pressArrow("up");
540
+ await testSetup!.renderOnce();
541
+ await testSetup!.renderOnce();
542
+ });
543
+ await emitKeypress({ name: "return", sequence: "\r" });
544
+ await flushFrame();
545
+
546
+ const frame = testSetup.captureCharFrame();
547
+ expect(frame).toContain("replying to @newer");
548
+ expect(frame).toContain("Reply to @newer...");
549
+ });
550
+
551
+ test("leaves one row of clearance when scrolling a selected message into the bottom edge", () => {
552
+ const nextScrollTop = getSelectedMessageScrollTop({
553
+ scrollTop: 12,
554
+ viewportHeight: 4,
555
+ top: 14,
556
+ rowHeight: 2,
557
+ });
558
+
559
+ expect(nextScrollTop).toBe(13);
560
+ });
561
+
562
+ test("up arrow still moves within a multi-line draft before handing off to message selection", async () => {
563
+ const controller = createController({ sessionToken: "token-123", messages: [makeMessage(1)] });
564
+ const sentMessages: string[] = [];
565
+ apiClient.connectChannel = (() => ({
566
+ send: async (content: string) => {
567
+ sentMessages.push(content);
568
+ return {
569
+ id: `server:${sentMessages.length}`,
570
+ channelId: "everyone",
571
+ content,
572
+ replyToId: null,
573
+ createdAt: "2026-03-30T00:00:30.000Z",
574
+ user: { id: "u0", username: "vince", displayName: "Vince" },
575
+ };
576
+ },
577
+ close: () => {},
578
+ })) as typeof apiClient.connectChannel;
579
+
580
+ await act(async () => {
581
+ testSetup = await testRender(createHarness(controller, {
582
+ width: 60,
583
+ height: 12,
584
+ }), {
585
+ width: 60,
586
+ height: 12,
587
+ });
588
+ });
589
+
590
+ await flushFrame();
591
+
592
+ const lines = testSetup.captureCharFrame().split("\n");
593
+ const inputRow = lines.findIndex((line) => line.includes("Type a message..."));
594
+ const inputCol = lines[inputRow]?.indexOf("Type a message...") ?? -1;
595
+
596
+ expect(inputRow).toBeGreaterThanOrEqual(0);
597
+ expect(inputCol).toBeGreaterThanOrEqual(0);
598
+
599
+ await act(async () => {
600
+ await testSetup!.mockMouse.click(inputCol + 1, inputRow);
601
+ await testSetup!.renderOnce();
602
+ await testSetup!.renderOnce();
603
+ });
604
+
605
+ await act(async () => {
606
+ await testSetup!.mockInput.typeText("first line");
607
+ await testSetup!.renderOnce();
608
+ });
609
+
610
+ await emitKeypress({ name: "return", sequence: "\r", shift: true });
611
+
612
+ await act(async () => {
613
+ await testSetup!.mockInput.typeText("second line");
614
+ await testSetup!.renderOnce();
615
+ await testSetup!.renderOnce();
616
+ });
617
+
618
+ await emitKeypress({ name: "up", sequence: "\u001b[A" });
619
+
620
+ await act(async () => {
621
+ testSetup!.mockInput.pressEnter();
622
+ await testSetup!.renderOnce();
623
+ await testSetup!.renderOnce();
624
+ });
625
+
626
+ expect(sentMessages).toEqual(["first line\nsecond line"]);
627
+ expect(testSetup.captureCharFrame()).not.toContain("replying to @user1");
628
+ });
629
+
630
+ test("shows a clear reply composer state when a reply target is active", async () => {
631
+ const controller = createController({
632
+ messages: [makeMessage(1)],
633
+ replyToId: "m1",
634
+ });
635
+
636
+ await act(async () => {
637
+ testSetup = await testRender(createHarness(controller, {
638
+ width: 72,
639
+ height: 12,
640
+ }), {
641
+ width: 72,
642
+ height: 12,
643
+ });
644
+ });
645
+
646
+ await flushFrame();
647
+
648
+ const frame = testSetup.captureCharFrame();
649
+ expect(frame).toContain("replying to @user1");
650
+ expect(frame).toContain("Cancel");
651
+ expect(frame).toContain("Reply to @user1...");
652
+ });
653
+
654
+ test("uses selected text colors for selected message rows", async () => {
655
+ const controller = createController({
656
+ messages: [makeMessage(1), makeMessage(2)],
657
+ });
658
+
659
+ await act(async () => {
660
+ testSetup = await testRender(createHarness(controller, {
661
+ width: 72,
662
+ height: 12,
663
+ }), {
664
+ width: 72,
665
+ height: 12,
666
+ });
667
+ });
668
+
669
+ await flushFrame();
670
+
671
+ await emitKeypress({ name: "up", sequence: "\u001b[A" });
672
+ await flushFrame();
673
+
674
+ const expectedSelectedFg = hexToRgbaInts(colors.selectedText);
675
+ const frame = testSetup.captureSpans();
676
+ const headerLine = frame.lines.find((line) => lineText(line).includes("user2"));
677
+ const bodyLine = frame.lines.find((line) => lineText(line).includes("message 2"));
678
+ const headerSpan = headerLine?.spans.find((span) => span.text.includes("user2"));
679
+ const bodySpan = bodyLine?.spans.find((span) => span.text.includes("message 2"));
680
+
681
+ expect(headerSpan).toBeDefined();
682
+ expect(bodySpan).toBeDefined();
683
+ expect(headerSpan!.fg.toInts().join(",")).toBe(expectedSelectedFg);
684
+ expect(bodySpan!.fg.toInts().join(",")).toBe(expectedSelectedFg);
685
+ });
686
+
687
+ test("does not use escape as a pane-close shortcut", async () => {
688
+ const controller = createController({
689
+ messages: [makeMessage(1)],
690
+ });
691
+ let closeCalls = 0;
692
+
693
+ await act(async () => {
694
+ testSetup = await testRender(createHarness(controller, {
695
+ width: 72,
696
+ height: 12,
697
+ close: () => {
698
+ closeCalls += 1;
699
+ },
700
+ }), {
701
+ width: 72,
702
+ height: 12,
703
+ });
704
+ });
705
+
706
+ await flushFrame();
707
+
708
+ await act(async () => {
709
+ testSetup!.mockInput.pressKey("escape");
710
+ await testSetup!.renderOnce();
711
+ await testSetup!.renderOnce();
712
+ });
713
+
714
+ expect(closeCalls).toBe(0);
715
+ });
716
+
717
+ test("grows the composer for multi-line drafts", async () => {
718
+ const controller = createController();
719
+
720
+ await act(async () => {
721
+ testSetup = await testRender(createHarness(controller, {
722
+ width: 32,
723
+ height: 12,
724
+ }), {
725
+ width: 32,
726
+ height: 12,
727
+ });
728
+ });
729
+
730
+ await flushFrame();
731
+
732
+ const frameBeforeClick = testSetup.captureCharFrame().split("\n");
733
+ const inputRow = frameBeforeClick.findIndex((line) => line.includes("Type a message..."));
734
+ const inputCol = frameBeforeClick[inputRow]?.indexOf("Type a message...") ?? -1;
735
+
736
+ expect(inputRow).toBeGreaterThanOrEqual(0);
737
+ expect(inputCol).toBeGreaterThanOrEqual(0);
738
+
739
+ await act(async () => {
740
+ await testSetup!.mockMouse.click(inputCol + 1, inputRow);
741
+ await testSetup!.renderOnce();
742
+ await testSetup!.renderOnce();
743
+ });
744
+
745
+ await act(async () => {
746
+ await testSetup!.mockInput.typeText("alpha bravo");
747
+ await testSetup!.renderOnce();
748
+ });
749
+
750
+ await emitKeypress({ name: "return", sequence: "\r", shift: true });
751
+
752
+ await act(async () => {
753
+ await testSetup!.mockInput.typeText("charlie delta");
754
+ await testSetup!.renderOnce();
755
+ });
756
+
757
+ await emitKeypress({ name: "return", sequence: "\r", shift: true });
758
+
759
+ await act(async () => {
760
+ await testSetup!.mockInput.typeText("echo foxtrot");
761
+ await testSetup!.renderOnce();
762
+ await testSetup!.renderOnce();
763
+ });
764
+
765
+ const rows = testSetup.captureCharFrame().split("\n");
766
+ const firstRow = rows.findIndex((line) => line.includes("alpha bravo"));
767
+ const secondRow = rows.findIndex((line) => line.includes("charlie delta"));
768
+ const thirdRow = rows.findIndex((line) => line.includes("echo foxtrot"));
769
+
770
+ expect(firstRow).toBeGreaterThanOrEqual(0);
771
+ expect(secondRow).toBeGreaterThan(firstRow);
772
+ expect(thirdRow).toBeGreaterThan(secondRow);
773
+ });
774
+
775
+ test("keeps Enter as send and uses Shift+Enter for composer newlines", async () => {
776
+ const controller = createController({ sessionToken: "token-123" });
777
+ const sentMessages: string[] = [];
778
+ apiClient.connectChannel = (() => ({
779
+ send: async (content: string) => {
780
+ sentMessages.push(content);
781
+ return {
782
+ id: `server:${sentMessages.length}`,
783
+ channelId: "everyone",
784
+ content,
785
+ replyToId: null,
786
+ createdAt: "2026-03-30T00:00:30.000Z",
787
+ user: { id: "u0", username: "vince", displayName: "Vince" },
788
+ };
789
+ },
790
+ close: () => {},
791
+ })) as typeof apiClient.connectChannel;
792
+
793
+ await act(async () => {
794
+ testSetup = await testRender(createHarness(controller), {
795
+ width: 60,
796
+ height: 12,
797
+ });
798
+ });
799
+
800
+ await flushFrame();
801
+
802
+ const frameBeforeClick = testSetup.captureCharFrame().split("\n");
803
+ const inputRow = frameBeforeClick.findIndex((line) => line.includes("Type a message..."));
804
+ const inputCol = frameBeforeClick[inputRow]?.indexOf("Type a message...") ?? -1;
805
+
806
+ expect(inputRow).toBeGreaterThanOrEqual(0);
807
+ expect(inputCol).toBeGreaterThanOrEqual(0);
808
+
809
+ await act(async () => {
810
+ await testSetup!.mockMouse.click(inputCol + 1, inputRow);
811
+ await testSetup!.renderOnce();
812
+ await testSetup!.renderOnce();
813
+ });
814
+
815
+ await act(async () => {
816
+ await testSetup!.mockInput.typeText("first line");
817
+ await testSetup!.renderOnce();
818
+ });
819
+
820
+ await emitKeypress({ name: "return", sequence: "\r", shift: true });
821
+
822
+ await act(async () => {
823
+ await testSetup!.mockInput.typeText("second line");
824
+ await testSetup!.renderOnce();
825
+ await testSetup!.renderOnce();
826
+ });
827
+
828
+ const frameAfterNewline = testSetup.captureCharFrame();
829
+ expect(frameAfterNewline).toContain("first line");
830
+ expect(frameAfterNewline).toContain("second line");
831
+ expect(sentMessages).toEqual([]);
832
+
833
+ await act(async () => {
834
+ testSetup!.mockInput.pressEnter();
835
+ await testSetup!.renderOnce();
836
+ await testSetup!.renderOnce();
837
+ });
838
+
839
+ expect(sentMessages).toEqual(["first line\nsecond line"]);
840
+ });
841
+
218
842
  test("renders optimistic sends with a sending status", async () => {
219
843
  const controller = createController({
220
844
  messages: [{
@@ -280,6 +904,65 @@ describe("ChatContent", () => {
280
904
  expect(frameAfterUpdate).not.toContain("message 1");
281
905
  });
282
906
 
907
+ test("keeps the bottom reachable after narrowing the chat pane", async () => {
908
+ const controller = createController({
909
+ messages: [
910
+ {
911
+ ...makeMessage(1),
912
+ content: "A wide pane can fit this update in fewer rows, but a narrow pane needs several wrapped rows before the final message.",
913
+ },
914
+ {
915
+ ...makeMessage(2),
916
+ content: "Another wrapped message takes up more transcript height after the pane gets smaller.",
917
+ },
918
+ {
919
+ ...makeMessage(3),
920
+ user: { id: "u3", username: "vince", displayName: "Vince" },
921
+ content: "newest message stays reachable after shrink",
922
+ },
923
+ ],
924
+ });
925
+
926
+ const state = createInitialState(createDefaultConfig("/tmp/gloomberb-chat-resizable"));
927
+ let resize: ((size: { width: number; height: number }) => void) | undefined;
928
+
929
+ function ResizableChatHarness() {
930
+ const [size, setSize] = useState({ width: 78, height: 12 });
931
+ resize = setSize;
932
+
933
+ return (
934
+ <AppContext value={{ state, dispatch: () => {} }}>
935
+ <ChatContent
936
+ controller={controller}
937
+ width={size.width}
938
+ height={size.height}
939
+ focused
940
+ />
941
+ </AppContext>
942
+ );
943
+ }
944
+
945
+ await act(async () => {
946
+ testSetup = await testRender(<ResizableChatHarness />, {
947
+ width: 78,
948
+ height: 12,
949
+ });
950
+ });
951
+
952
+ await flushFrame();
953
+
954
+ await act(async () => {
955
+ resize?.({ width: 42, height: 9 });
956
+ testSetup!.resize(42, 9);
957
+ });
958
+ await flushFrame();
959
+
960
+ const frame = testSetup!.captureCharFrame();
961
+ expect(frame).toContain("vince");
962
+ expect(frame).toContain("newest message stays");
963
+ expect(frame).toContain("reachable after shrink");
964
+ });
965
+
283
966
  test("renders ticker badges and opens a floating detail pane on click", async () => {
284
967
  const controller = createController({
285
968
  messages: [{
@@ -355,6 +1038,34 @@ describe("ChatContent", () => {
355
1038
  expect(opened).toEqual(["TSLA"]);
356
1039
  });
357
1040
 
1041
+ test("renders detected links in chat messages", async () => {
1042
+ const controller = createController({
1043
+ messages: [{
1044
+ id: "m1",
1045
+ channelId: "everyone",
1046
+ content: "Read https://example.com/story.",
1047
+ replyToId: null,
1048
+ createdAt: "2026-03-28T00:00:00.000Z",
1049
+ user: { id: "u1", username: "vince", displayName: "Vince" },
1050
+ }],
1051
+ });
1052
+
1053
+ await act(async () => {
1054
+ testSetup = await testRender(createHarness(controller, {
1055
+ width: 60,
1056
+ height: 12,
1057
+ }), {
1058
+ width: 60,
1059
+ height: 12,
1060
+ });
1061
+ });
1062
+
1063
+ await flushFrame();
1064
+
1065
+ const frame = testSetup.captureCharFrame();
1066
+ expect(frame).toContain("https://example.com/story.");
1067
+ });
1068
+
358
1069
  test("shows a saved-login read-only footer when a session token is cached", async () => {
359
1070
  const controller = createController({
360
1071
  sessionToken: "token-123",
@@ -544,7 +1255,7 @@ describe("ChatContent", () => {
544
1255
  },
545
1256
  showWidget: () => {},
546
1257
  hideWidget: () => {},
547
- showToast: () => {},
1258
+ notify: () => {},
548
1259
  } as any);
549
1260
 
550
1261
  const loginCommand = registeredCommands.find((command) => command.id === "auth-login");