gloomberb 0.8.1 → 0.9.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 (196) hide show
  1. package/README.md +106 -54
  2. package/package.json +14 -13
  3. package/patches/{@opentui%2Fcore@0.1.90.patch → @opentui%2Fcore@0.3.2.patch} +175 -90
  4. package/src/api-client/chat.ts +8 -0
  5. package/src/api-client/index.test.ts +30 -0
  6. package/src/api-client/index.ts +4 -0
  7. package/src/api-client/normalizers.ts +1 -0
  8. package/src/api-client/types.ts +1 -0
  9. package/src/app/global-shortcuts.test.tsx +54 -4
  10. package/src/app/global-shortcuts.ts +11 -5
  11. package/src/capabilities/factories.ts +4 -0
  12. package/src/components/chart/comparison/data.test.ts +54 -0
  13. package/src/components/chart/comparison/data.ts +47 -24
  14. package/src/components/chart/core/projection.ts +51 -27
  15. package/src/components/command-bar/action-runtime.ts +1 -0
  16. package/src/components/command-bar/layout-items/current-layout.test.ts +115 -0
  17. package/src/components/command-bar/layout-items/current-layout.ts +33 -0
  18. package/src/components/command-bar/list/view.tsx +3 -1
  19. package/src/components/command-bar/pane-templates/items.ts +4 -1
  20. package/src/components/command-bar/panel/body.tsx +1 -0
  21. package/src/components/command-bar/routes/root/results.ts +0 -30
  22. package/src/components/command-bar/routes/root/runtime.ts +2 -20
  23. package/src/components/command-bar/routes/ticker-search/actions.ts +42 -4
  24. package/src/components/command-bar/routes/ticker-search/route.ts +4 -1
  25. package/src/components/command-bar/routing/launch-request.ts +14 -7
  26. package/src/components/command-bar/routing/list-state.ts +1 -3
  27. package/src/components/command-bar/surface/index.test.tsx +76 -4
  28. package/src/components/command-bar/surface/index.tsx +0 -2
  29. package/src/components/command-bar/surface/pane.test.tsx +1 -0
  30. package/src/components/command-bar/surface/test-harness.tsx +1 -0
  31. package/src/components/command-bar/view-model.ts +3 -3
  32. package/src/components/data-table/stack-view.test.tsx +14 -11
  33. package/src/components/data-table/stack-view.tsx +1 -2
  34. package/src/components/data-table/view.test.tsx +24 -18
  35. package/src/components/data-table/view.tsx +313 -83
  36. package/src/components/feed-data-table/stack-view.tsx +6 -8
  37. package/src/components/layout/pane/footer/index.test.tsx +6 -0
  38. package/src/components/layout/pane/footer/index.tsx +14 -2
  39. package/src/components/layout/pane/title.ts +5 -1
  40. package/src/components/layout/shell/index.test.tsx +120 -2
  41. package/src/components/layout/shell/index.tsx +2 -0
  42. package/src/components/layout/shell/menu.ts +1 -7
  43. package/src/components/layout/shell/pane/actions.ts +8 -0
  44. package/src/components/layout/shell/pane/management-shortcuts.ts +12 -2
  45. package/src/components/layout/shell/shortcuts.ts +14 -6
  46. package/src/components/layout/shell/window-mode/index.ts +8 -3
  47. package/src/components/layout/window-edit/presentation.ts +4 -4
  48. package/src/components/onboarding/onboarding-steps.tsx +3 -2
  49. package/src/components/pane-settings-dialog/field-dialogs.tsx +0 -2
  50. package/src/components/pane-settings-dialog/tui.tsx +1 -1
  51. package/src/components/ticker/list-table-view.tsx +17 -25
  52. package/src/components/ui/choice-dialog.tsx +12 -2
  53. package/src/components/ui/multi-select/dialog.tsx +17 -5
  54. package/src/components/ui/remote-image.tsx +1 -14
  55. package/src/components/ui/shortcut-hint.tsx +14 -2
  56. package/src/components/ui/table-layout.test.ts +2 -2
  57. package/src/components/ui/table-layout.ts +8 -2
  58. package/src/components/ui/tabs.tsx +115 -86
  59. package/src/components/ui/ui.test.tsx +83 -4
  60. package/src/core/state/app/types.ts +12 -4
  61. package/src/market-data/coordinator/auxiliary.ts +29 -1
  62. package/src/market-data/coordinator/index.ts +17 -1
  63. package/src/market-data/hooks.tsx +17 -1
  64. package/src/market-data/selectors.ts +4 -0
  65. package/src/plugins/builtin/account-management/form-components.tsx +0 -4
  66. package/src/plugins/builtin/account-management/model.test.ts +54 -0
  67. package/src/plugins/builtin/account-management/model.ts +13 -3
  68. package/src/plugins/builtin/account-management/pane.tsx +5 -11
  69. package/src/plugins/builtin/account-management/password-dialog.tsx +1 -1
  70. package/src/plugins/builtin/alerts/pane.tsx +5 -3
  71. package/src/plugins/builtin/analytics/index.tsx +0 -3
  72. package/src/plugins/builtin/analytics/view.tsx +6 -5
  73. package/src/plugins/builtin/broker-manager/index.tsx +6 -6
  74. package/src/plugins/builtin/buildout/pane/index.tsx +6 -6
  75. package/src/plugins/builtin/changelog/index.tsx +40 -27
  76. package/src/plugins/builtin/changelog/model.test.ts +77 -0
  77. package/src/plugins/builtin/changelog/model.ts +98 -0
  78. package/src/plugins/builtin/chat/channel-labels.ts +53 -0
  79. package/src/plugins/builtin/chat/channels.test.ts +28 -0
  80. package/src/plugins/builtin/chat/channels.ts +36 -19
  81. package/src/plugins/builtin/chat/chat.test.tsx +264 -5
  82. package/src/plugins/builtin/chat/content/channel-navigation.ts +50 -11
  83. package/src/plugins/builtin/chat/content/composer-runtime.test.tsx +144 -0
  84. package/src/plugins/builtin/chat/content/composer-runtime.ts +151 -20
  85. package/src/plugins/builtin/chat/content/composer.tsx +23 -2
  86. package/src/plugins/builtin/chat/content/index.tsx +66 -4
  87. package/src/plugins/builtin/chat/content/layout-metrics.ts +3 -1
  88. package/src/plugins/builtin/chat/content/shortcuts.ts +27 -1
  89. package/src/plugins/builtin/chat/content/transcript.tsx +8 -0
  90. package/src/plugins/builtin/chat/content/types.ts +1 -0
  91. package/src/plugins/builtin/chat/controller/index.ts +41 -0
  92. package/src/plugins/builtin/chat/controller/messages.ts +18 -0
  93. package/src/plugins/builtin/chat/controller/notifications.ts +10 -4
  94. package/src/plugins/builtin/chat/controller/utils.ts +6 -3
  95. package/src/plugins/builtin/chat/controller/view.ts +1 -1
  96. package/src/plugins/builtin/chat/controller.test.ts +189 -0
  97. package/src/plugins/builtin/chat/edit-window.ts +24 -0
  98. package/src/plugins/builtin/chat/layout.ts +94 -31
  99. package/src/plugins/builtin/chat/message/action-chip.tsx +1 -0
  100. package/src/plugins/builtin/chat/message/desktop.tsx +53 -20
  101. package/src/plugins/builtin/chat/message/inline-tokens.tsx +16 -10
  102. package/src/plugins/builtin/chat/message/render-state.ts +5 -1
  103. package/src/plugins/builtin/chat/message/terminal.tsx +49 -20
  104. package/src/plugins/builtin/chat/message/types.ts +2 -0
  105. package/src/plugins/builtin/chat/pane.tsx +35 -7
  106. package/src/plugins/builtin/chat/sidebar.test.tsx +72 -0
  107. package/src/plugins/builtin/chat/status-widget.tsx +29 -11
  108. package/src/plugins/builtin/chat/test-harness.tsx +2 -0
  109. package/src/plugins/builtin/cloud/plugin.tsx +3 -0
  110. package/src/plugins/builtin/cloud-tweets/table.tsx +7 -11
  111. package/src/plugins/builtin/congress-trades/pane.tsx +14 -22
  112. package/src/plugins/builtin/earnings/index.tsx +8 -7
  113. package/src/plugins/builtin/econ/index.tsx +6 -8
  114. package/src/plugins/builtin/help/components.tsx +1 -1
  115. package/src/plugins/builtin/help/index.test.tsx +105 -30
  116. package/src/plugins/builtin/help/index.tsx +326 -164
  117. package/src/plugins/builtin/help/shortcut-model.ts +5 -6
  118. package/src/plugins/builtin/holders/pane.tsx +7 -6
  119. package/src/plugins/builtin/market-movers/index.tsx +6 -3
  120. package/src/plugins/builtin/news/wire/news/table.tsx +7 -6
  121. package/src/plugins/builtin/options/view.tsx +16 -8
  122. package/src/plugins/builtin/portfolio-list/column-values.ts +304 -1
  123. package/src/plugins/builtin/portfolio-list/metrics.test.ts +93 -0
  124. package/src/plugins/builtin/portfolio-list/pane/data.ts +7 -0
  125. package/src/plugins/builtin/portfolio-list/pane/index.tsx +25 -2
  126. package/src/plugins/builtin/portfolio-list/pane/supplemental.ts +281 -0
  127. package/src/plugins/builtin/portfolio-list/settings.ts +27 -0
  128. package/src/plugins/builtin/portfolio-list/table.tsx +4 -2
  129. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +10 -68
  130. package/src/plugins/builtin/research/analyst-pane.tsx +1 -2
  131. package/src/plugins/builtin/research/corporate-actions-pane.tsx +358 -7
  132. package/src/plugins/builtin/research/index.test.ts +30 -1
  133. package/src/plugins/builtin/research/relative-valuation-pane.tsx +6 -8
  134. package/src/plugins/builtin/sec/filing-documents.ts +66 -0
  135. package/src/plugins/builtin/sec/index.tsx +132 -20
  136. package/src/plugins/builtin/sectors/index.tsx +13 -23
  137. package/src/plugins/builtin/shared/ticker-request.ts +1 -0
  138. package/src/plugins/builtin/substack/api/auth.ts +185 -0
  139. package/src/plugins/builtin/substack/api/cache.ts +78 -0
  140. package/src/plugins/builtin/substack/api/loaders.ts +146 -0
  141. package/src/plugins/builtin/substack/api/store.ts +186 -0
  142. package/src/plugins/builtin/substack/api/types.ts +40 -0
  143. package/src/plugins/builtin/substack/api.test.ts +298 -0
  144. package/src/plugins/builtin/substack/article-detail.tsx +359 -0
  145. package/src/plugins/builtin/substack/article-stack.tsx +129 -0
  146. package/src/plugins/builtin/substack/content.ts +478 -0
  147. package/src/plugins/builtin/substack/feed-tabs.tsx +41 -0
  148. package/src/plugins/builtin/substack/index.tsx +49 -0
  149. package/src/plugins/builtin/substack/login-view.tsx +126 -0
  150. package/src/plugins/builtin/substack/model.test.ts +155 -0
  151. package/src/plugins/builtin/substack/normalize.ts +343 -0
  152. package/src/plugins/builtin/substack/pane-footer.ts +71 -0
  153. package/src/plugins/builtin/substack/pane-state.ts +170 -0
  154. package/src/plugins/builtin/substack/pane.tsx +500 -0
  155. package/src/plugins/builtin/substack/table.ts +111 -0
  156. package/src/plugins/builtin/substack/types.ts +71 -0
  157. package/src/plugins/builtin/substack/utils.ts +131 -0
  158. package/src/plugins/builtin/thirteenf/pane.tsx +26 -25
  159. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +5 -4
  160. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -4
  161. package/src/plugins/builtin/ticker-detail/data-panes/provider-search.tsx +6 -5
  162. package/src/plugins/builtin/ticker-detail/financials/tab.tsx +10 -8
  163. package/src/plugins/builtin/ticker-detail/index.test.tsx +16 -2
  164. package/src/plugins/builtin/ticker-detail/index.tsx +1 -0
  165. package/src/plugins/builtin/ticker-detail/pane.tsx +13 -1
  166. package/src/plugins/builtin/world-indices/index.tsx +9 -6
  167. package/src/plugins/catalog-ui.ts +2 -0
  168. package/src/plugins/pane-manager/layout-state.ts +11 -0
  169. package/src/plugins/pane-manager.ts +1 -1
  170. package/src/plugins/prediction-markets/pane.tsx +10 -9
  171. package/src/plugins/registry/index.ts +3 -0
  172. package/src/plugins/runtime/context.tsx +1 -0
  173. package/src/plugins/runtime/index.tsx +4 -0
  174. package/src/react/use-throttled-commit-value.ts +116 -0
  175. package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +42 -0
  176. package/src/renderers/electrobun/bun/desktop/http-fetch.ts +5 -0
  177. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -0
  178. package/src/renderers/electrobun/view/data-table/index.tsx +3 -2
  179. package/src/renderers/electrobun/view/data-table/row.tsx +2 -2
  180. package/src/renderers/electrobun/view/host/input.tsx +23 -2
  181. package/src/renderers/electrobun/view/http-fetch.ts +10 -3
  182. package/src/renderers/electrobun/view/remote/asset-data-client.ts +1 -0
  183. package/src/sources/gloomberb-cloud/index.ts +4 -0
  184. package/src/sources/provider-router/cache.ts +1 -0
  185. package/src/sources/provider-router/document-routes.ts +45 -0
  186. package/src/sources/provider-router/index.ts +4 -0
  187. package/src/sources/sec-edgar/content.ts +9 -3
  188. package/src/sources/sec-edgar.test.ts +108 -1
  189. package/src/sources/sec-edgar.ts +105 -1
  190. package/src/sources/yahoo-finance.ts +4 -0
  191. package/src/state/app/context/index.test.tsx +16 -0
  192. package/src/test-support/plugin-runtime.ts +1 -0
  193. package/src/tickers/search/index.ts +1 -0
  194. package/src/types/data-provider.ts +11 -0
  195. package/src/ui/host.tsx +1 -0
  196. package/src/version.ts +1 -1
@@ -0,0 +1,144 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { act, useRef } from "react";
3
+ import type { ChatMessage } from "../../../../api-client";
4
+ import { testRender } from "../../../../renderers/opentui/test-utils";
5
+ import { useChatComposerRuntime } from "./composer-runtime";
6
+
7
+ let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
8
+
9
+ afterEach(async () => {
10
+ if (testSetup) {
11
+ await act(async () => {
12
+ testSetup?.renderer.destroy();
13
+ });
14
+ }
15
+ testSetup = undefined;
16
+ });
17
+
18
+ describe("useChatComposerRuntime", () => {
19
+ test("sends to the pending channel ref instead of the stale rendered channel", async () => {
20
+ const sent: Array<{ channelId: string; content: string; replyToId?: string }> = [];
21
+ let sendMessage = () => {};
22
+
23
+ function Harness() {
24
+ const applyingExternalDraftRef = useRef(false);
25
+ const channelIdRef = useRef("dm:test");
26
+ const inputRef = useRef(null);
27
+ const inputValueRef = useRef("PM reply test #2");
28
+ sendMessage = useChatComposerRuntime({
29
+ applyingExternalDraftRef,
30
+ blurInput: () => {},
31
+ canSend: true,
32
+ channelId: "everyone",
33
+ channelIdRef,
34
+ contentWidth: 80,
35
+ controller: {
36
+ send: () => false,
37
+ sendToChannel: (channelId, content, replyToId) => {
38
+ sent.push({ channelId, content, replyToId });
39
+ return true;
40
+ },
41
+ openDirectChannel: async () => { throw new Error("not used"); },
42
+ openGroupChannel: async () => { throw new Error("not used"); },
43
+ setDraft: () => {},
44
+ setChannelDraft: () => {},
45
+ setReplyToId: () => {},
46
+ setChannelReplyToId: () => {},
47
+ } as any,
48
+ focusInput: () => {},
49
+ focused: true,
50
+ inputFocused: true,
51
+ inputRef,
52
+ inputValueRef,
53
+ messages: [],
54
+ onChannelChange: () => {},
55
+ replyTo: null,
56
+ setDirectExpanded: () => {},
57
+ setFollowMessages: () => {},
58
+ setReplyTo: () => {},
59
+ setSelectedIdx: () => {},
60
+ updateComposerRows: () => {},
61
+ useDefaultControllerChannel: false,
62
+ }).sendMessage;
63
+ return null;
64
+ }
65
+
66
+ await act(async () => {
67
+ testSetup = await testRender(<Harness />, { width: 1, height: 1 });
68
+ });
69
+
70
+ await act(async () => {
71
+ sendMessage();
72
+ await testSetup?.renderOnce();
73
+ });
74
+
75
+ expect(sent).toEqual([{ channelId: "dm:test", content: "PM reply test #2", replyToId: undefined }]);
76
+ });
77
+
78
+ test("drops a reply target from a different channel before sending", async () => {
79
+ const sent: Array<{ channelId: string; content: string; replyToId?: string }> = [];
80
+ let sendMessage = () => {};
81
+ const staleReply: ChatMessage = {
82
+ id: "public-reply",
83
+ channelId: "everyone",
84
+ content: "public message",
85
+ replyToId: null,
86
+ createdAt: "2026-05-27T10:26:44.737Z",
87
+ user: { id: "u2", username: "vince", displayName: "Vince" },
88
+ };
89
+
90
+ function Harness() {
91
+ const applyingExternalDraftRef = useRef(false);
92
+ const channelIdRef = useRef("dm:test");
93
+ const inputRef = useRef(null);
94
+ const inputValueRef = useRef("replying in DM");
95
+ sendMessage = useChatComposerRuntime({
96
+ applyingExternalDraftRef,
97
+ blurInput: () => {},
98
+ canSend: true,
99
+ channelId: "dm:test",
100
+ channelIdRef,
101
+ contentWidth: 80,
102
+ controller: {
103
+ send: () => false,
104
+ sendToChannel: (channelId, content, replyToId) => {
105
+ sent.push({ channelId, content, replyToId });
106
+ return true;
107
+ },
108
+ openDirectChannel: async () => { throw new Error("not used"); },
109
+ openGroupChannel: async () => { throw new Error("not used"); },
110
+ setDraft: () => {},
111
+ setChannelDraft: () => {},
112
+ setReplyToId: () => {},
113
+ setChannelReplyToId: () => {},
114
+ } as any,
115
+ focusInput: () => {},
116
+ focused: true,
117
+ inputFocused: true,
118
+ inputRef,
119
+ inputValueRef,
120
+ messages: [],
121
+ onChannelChange: () => {},
122
+ replyTo: staleReply,
123
+ setDirectExpanded: () => {},
124
+ setFollowMessages: () => {},
125
+ setReplyTo: () => {},
126
+ setSelectedIdx: () => {},
127
+ updateComposerRows: () => {},
128
+ useDefaultControllerChannel: false,
129
+ }).sendMessage;
130
+ return null;
131
+ }
132
+
133
+ await act(async () => {
134
+ testSetup = await testRender(<Harness />, { width: 1, height: 1 });
135
+ });
136
+
137
+ await act(async () => {
138
+ sendMessage();
139
+ await testSetup?.renderOnce();
140
+ });
141
+
142
+ expect(sent).toEqual([{ channelId: "dm:test", content: "replying in DM", replyToId: undefined }]);
143
+ });
144
+ });
@@ -12,6 +12,26 @@ interface MutableRef<T> {
12
12
  current: T;
13
13
  }
14
14
 
15
+ function moveComposerCursorToEnd(textarea: TextareaRenderable, draft: string) {
16
+ const offset = draft.length;
17
+ const editBuffer = textarea.editBuffer as typeof textarea.editBuffer & {
18
+ setCursorByOffset?: (offset: number) => void;
19
+ };
20
+ if (typeof editBuffer.setCursorByOffset === "function") {
21
+ editBuffer.setCursorByOffset(offset);
22
+ return;
23
+ }
24
+ if (typeof textarea.setCursorOffset === "function") {
25
+ textarea.setCursorOffset(offset);
26
+ return;
27
+ }
28
+ try {
29
+ textarea.cursorOffset = offset;
30
+ } catch {
31
+ // Some host renderers expose cursorOffset as read-only.
32
+ }
33
+ }
34
+
15
35
  export function useChatComposerRuntime({
16
36
  applyingExternalDraftRef,
17
37
  blurInput,
@@ -27,7 +47,10 @@ export function useChatComposerRuntime({
27
47
  inputValueRef,
28
48
  messages,
29
49
  onChannelChange,
50
+ editingMessage,
51
+ latestEditableMessageId,
30
52
  replyTo,
53
+ setEditingMessage,
31
54
  setDirectExpanded,
32
55
  setFollowMessages,
33
56
  setReplyTo,
@@ -49,7 +72,10 @@ export function useChatComposerRuntime({
49
72
  inputValueRef: MutableRef<string>;
50
73
  messages: ChatMessage[];
51
74
  onChannelChange?: (channelId: string) => void;
75
+ editingMessage: ChatMessage | null;
76
+ latestEditableMessageId: string | null;
52
77
  replyTo: ChatMessage | null;
78
+ setEditingMessage: Dispatch<SetStateAction<ChatMessage | null>>;
53
79
  setDirectExpanded: Dispatch<SetStateAction<boolean>>;
54
80
  setFollowMessages: Dispatch<SetStateAction<boolean>>;
55
81
  setReplyTo: Dispatch<SetStateAction<ChatMessage | null>>;
@@ -59,6 +85,9 @@ export function useChatComposerRuntime({
59
85
  }) {
60
86
  const replyToRef = useRef(replyTo);
61
87
  replyToRef.current = replyTo;
88
+ const editingMessageRef = useRef(editingMessage);
89
+ editingMessageRef.current = editingMessage;
90
+ const editSubmittingRef = useRef(false);
62
91
 
63
92
  const focusComposer = useCallback(() => {
64
93
  setSelectedIdx(-1);
@@ -75,10 +104,40 @@ export function useChatComposerRuntime({
75
104
  }
76
105
  }, [channelId, controller, setReplyTo, useDefaultControllerChannel]);
77
106
 
107
+ const persistDraft = useCallback((draft: string) => {
108
+ if (useDefaultControllerChannel) {
109
+ controller.setDraft(draft);
110
+ } else {
111
+ controller.setChannelDraft(channelId, draft);
112
+ }
113
+ }, [channelId, controller, useDefaultControllerChannel]);
114
+
115
+ const replaceLocalComposer = useCallback((draft: string) => {
116
+ inputValueRef.current = draft;
117
+ updateComposerRows(draft);
118
+ const textarea = inputRef.current;
119
+ if (textarea && textarea.editBuffer.getText() !== draft) {
120
+ applyingExternalDraftRef.current = true;
121
+ try {
122
+ textarea.setText(draft);
123
+ moveComposerCursorToEnd(textarea, draft);
124
+ } finally {
125
+ applyingExternalDraftRef.current = false;
126
+ }
127
+ } else if (textarea) {
128
+ moveComposerCursorToEnd(textarea, draft);
129
+ }
130
+ }, [applyingExternalDraftRef, inputRef, inputValueRef, updateComposerRows]);
131
+
78
132
  const beginReplyTo = useCallback((index: number, options?: { deferFocus?: boolean }) => {
79
133
  if (!canSend || index < 0 || index >= messages.length) return;
80
134
  const nextReplyTo = messages[index] ?? null;
81
135
  if (!nextReplyTo) return;
136
+ if (editingMessageRef.current) {
137
+ replaceLocalComposer("");
138
+ persistDraft("");
139
+ }
140
+ setEditingMessage(null);
82
141
  setSelectedIdx(index);
83
142
  setFollowMessages(index === messages.length - 1);
84
143
  setReplyTo(nextReplyTo);
@@ -98,12 +157,58 @@ export function useChatComposerRuntime({
98
157
  controller,
99
158
  focusInput,
100
159
  messages,
160
+ persistDraft,
161
+ replaceLocalComposer,
162
+ setEditingMessage,
101
163
  setFollowMessages,
102
164
  setReplyTo,
103
165
  setSelectedIdx,
104
166
  useDefaultControllerChannel,
105
167
  ]);
106
168
 
169
+ const beginEditMessage = useCallback((index: number, options?: { deferFocus?: boolean }) => {
170
+ if (!canSend || index < 0 || index >= messages.length) return false;
171
+ const message = messages[index] ?? null;
172
+ if (!message || message.id !== latestEditableMessageId) return false;
173
+ clearReplyTarget();
174
+ setEditingMessage(message);
175
+ setSelectedIdx(index);
176
+ setFollowMessages(index === messages.length - 1);
177
+ replaceLocalComposer(message.content);
178
+ persistDraft(message.content);
179
+ if (options?.deferFocus) {
180
+ queueMicrotask(() => focusInput());
181
+ } else {
182
+ focusInput();
183
+ }
184
+ return true;
185
+ }, [
186
+ canSend,
187
+ clearReplyTarget,
188
+ focusInput,
189
+ latestEditableMessageId,
190
+ messages,
191
+ persistDraft,
192
+ replaceLocalComposer,
193
+ setEditingMessage,
194
+ setFollowMessages,
195
+ setSelectedIdx,
196
+ ]);
197
+
198
+ const beginEditLatestMessage = useCallback((options?: { deferFocus?: boolean }) => {
199
+ if (!latestEditableMessageId) return false;
200
+ const index = messages.findIndex((message) => message.id === latestEditableMessageId);
201
+ return beginEditMessage(index, options);
202
+ }, [beginEditMessage, latestEditableMessageId, messages]);
203
+
204
+ const cancelEditMessage = useCallback(() => {
205
+ if (!editingMessageRef.current) return;
206
+ setEditingMessage(null);
207
+ replaceLocalComposer("");
208
+ persistDraft("");
209
+ focusInput();
210
+ }, [focusInput, persistDraft, replaceLocalComposer, setEditingMessage]);
211
+
107
212
  const returnToComposer = useCallback(() => {
108
213
  setSelectedIdx(-1);
109
214
  setFollowMessages(true);
@@ -113,22 +218,30 @@ export function useChatComposerRuntime({
113
218
  }, [canSend, focusInput, setFollowMessages, setSelectedIdx]);
114
219
 
115
220
  const clearLocalComposer = useCallback(() => {
116
- inputValueRef.current = "";
117
- updateComposerRows("");
118
- const textarea = inputRef.current;
119
- if (textarea && textarea.editBuffer.getText() !== "") {
120
- applyingExternalDraftRef.current = true;
121
- try {
122
- textarea.setText("");
123
- } finally {
124
- applyingExternalDraftRef.current = false;
125
- }
126
- }
127
- }, [applyingExternalDraftRef, inputRef, inputValueRef, updateComposerRows]);
221
+ replaceLocalComposer("");
222
+ }, [replaceLocalComposer]);
128
223
 
129
224
  const sendMessage = useCallback(() => {
130
225
  const content = inputValueRef.current.trim();
131
226
  if (!content) return;
227
+ const editing = editingMessageRef.current;
228
+ if (editing) {
229
+ if (editSubmittingRef.current) return;
230
+ const sendChannelId = useDefaultControllerChannel ? channelId : channelIdRef.current;
231
+ if (editing.channelId !== sendChannelId) return;
232
+ editSubmittingRef.current = true;
233
+ void controller.editChannelMessage(sendChannelId, editing.id, content).then((accepted) => {
234
+ if (!accepted) return;
235
+ setEditingMessage(null);
236
+ clearLocalComposer();
237
+ persistDraft("");
238
+ setSelectedIdx(-1);
239
+ setFollowMessages(true);
240
+ }).finally(() => {
241
+ editSubmittingRef.current = false;
242
+ });
243
+ return;
244
+ }
132
245
  const composerCommand = parseChatComposerCommand(content);
133
246
  if (composerCommand?.kind === "direct") {
134
247
  void controller.openDirectChannel({ username: composerCommand.username }).then((channel) => {
@@ -154,9 +267,13 @@ export function useChatComposerRuntime({
154
267
  }).catch(() => {});
155
268
  return;
156
269
  }
270
+ const sendChannelId = useDefaultControllerChannel ? channelId : channelIdRef.current;
271
+ const replyToId = replyToRef.current?.channelId === sendChannelId
272
+ ? replyToRef.current.id
273
+ : undefined;
157
274
  const accepted = useDefaultControllerChannel
158
- ? controller.send(content, replyToRef.current?.id)
159
- : controller.sendToChannel(channelId, content, replyToRef.current?.id);
275
+ ? controller.send(content, replyToId)
276
+ : controller.sendToChannel(sendChannelId, content, replyToId);
160
277
  if (!accepted) return;
161
278
  clearLocalComposer();
162
279
  setSelectedIdx(-1);
@@ -168,7 +285,9 @@ export function useChatComposerRuntime({
168
285
  controller,
169
286
  inputValueRef,
170
287
  onChannelChange,
288
+ persistDraft,
171
289
  setDirectExpanded,
290
+ setEditingMessage,
172
291
  setFollowMessages,
173
292
  setSelectedIdx,
174
293
  useDefaultControllerChannel,
@@ -178,16 +297,13 @@ export function useChatComposerRuntime({
178
297
  if (applyingExternalDraftRef.current) return;
179
298
  inputValueRef.current = draft;
180
299
  updateComposerRows(draft);
181
- if (useDefaultControllerChannel) {
182
- controller.setDraft(draft);
183
- } else {
184
- controller.setChannelDraft(channelId, draft);
185
- }
300
+ persistDraft(draft);
186
301
  }, [
187
302
  applyingExternalDraftRef,
188
303
  channelId,
189
304
  controller,
190
305
  inputValueRef,
306
+ persistDraft,
191
307
  updateComposerRows,
192
308
  useDefaultControllerChannel,
193
309
  ]);
@@ -230,18 +346,33 @@ export function useChatComposerRuntime({
230
346
  clearReplyTarget();
231
347
  }, [canSend, clearReplyTarget, replyTo]);
232
348
 
349
+ useEffect(() => {
350
+ if (canSend || !editingMessage) return;
351
+ cancelEditMessage();
352
+ }, [canSend, cancelEditMessage, editingMessage]);
353
+
233
354
  const replyPreview = replyTo
234
355
  ? formatInlinePreview(
235
356
  replyTo.content,
236
357
  Math.max(contentWidth - ` replying to @${replyTo.user.username}: `.length - COMPOSER_ACTION_WIDTH - 1, 0),
237
358
  )
238
359
  : "";
239
- const inputPlaceholder = replyTo ? `Reply to @${replyTo.user.username}...` : "Type a message...";
360
+ const editingPreview = editingMessage
361
+ ? formatInlinePreview(
362
+ editingMessage.content,
363
+ Math.max(contentWidth - " editing: ".length - COMPOSER_ACTION_WIDTH - 1, 0),
364
+ )
365
+ : "";
366
+ const inputPlaceholder = editingMessage ? "Edit message..." : replyTo ? `Reply to @${replyTo.user.username}...` : "Type a message...";
240
367
 
241
368
  return {
369
+ beginEditLatestMessage,
370
+ beginEditMessage,
242
371
  beginReplyTo,
372
+ cancelEditMessage,
243
373
  clearReplyTarget,
244
374
  commitLocalDraft,
375
+ editingPreview,
245
376
  focusComposer,
246
377
  inputPlaceholder,
247
378
  replyPreview,
@@ -14,6 +14,7 @@ interface MutableRef<T> {
14
14
 
15
15
  interface ChatComposerAreaProps {
16
16
  canSend: boolean;
17
+ cancelEditMessage: () => void;
17
18
  clearReplyTarget: () => void;
18
19
  commitLocalDraft: (draft: string) => void;
19
20
  composerHeight: number;
@@ -27,6 +28,8 @@ interface ChatComposerAreaProps {
27
28
  inputRef: MutableRef<TextareaRenderable | null>;
28
29
  inputValueRef: MutableRef<string>;
29
30
  nativePaneChrome: boolean | undefined;
31
+ editingMessage: ChatMessage | null;
32
+ editingPreview: string;
30
33
  replyPreview: string;
31
34
  replyTo: ChatMessage | null;
32
35
  sendMessage: () => void;
@@ -35,6 +38,7 @@ interface ChatComposerAreaProps {
35
38
 
36
39
  export function ChatComposerArea({
37
40
  canSend,
41
+ cancelEditMessage,
38
42
  clearReplyTarget,
39
43
  commitLocalDraft,
40
44
  composerHeight,
@@ -48,6 +52,8 @@ export function ChatComposerArea({
48
52
  inputRef,
49
53
  inputValueRef,
50
54
  nativePaneChrome,
55
+ editingMessage,
56
+ editingPreview,
51
57
  replyPreview,
52
58
  replyTo,
53
59
  sendMessage,
@@ -89,7 +95,20 @@ export function ChatComposerArea({
89
95
  />
90
96
  )}
91
97
 
92
- {replyTo && (
98
+ {editingMessage && (
99
+ <Box height={1} width={contentWidth} flexDirection="row">
100
+ <Text fg={colors.textMuted}> editing </Text>
101
+ <Text fg={colors.textDim}>{editingPreview ? `: ${editingPreview}` : ""}</Text>
102
+ <Box flexGrow={1} />
103
+ <ChatActionChip
104
+ label="Cancel"
105
+ width={COMPOSER_ACTION_WIDTH}
106
+ onPress={cancelEditMessage}
107
+ />
108
+ </Box>
109
+ )}
110
+
111
+ {!editingMessage && replyTo && (
93
112
  <Box height={1} width={contentWidth} flexDirection="row">
94
113
  <Text fg={colors.textMuted}> replying to </Text>
95
114
  <Text fg={colors.positive} attributes={TextAttributes.BOLD}>{`@${replyTo.user.username}`}</Text>
@@ -135,14 +154,16 @@ export function ChatComposerArea({
135
154
  export function getChatComposerAreaHeight({
136
155
  canSend,
137
156
  composerHeight,
157
+ editingMessage,
138
158
  nativePaneChrome,
139
159
  replyTo,
140
160
  }: {
141
161
  canSend: boolean;
142
162
  composerHeight: number;
163
+ editingMessage: ChatMessage | null;
143
164
  nativePaneChrome: boolean | undefined;
144
165
  replyTo: ChatMessage | null;
145
166
  }) {
146
167
  if (!canSend) return 2;
147
- return getMessageComposerBlockHeight({ height: composerHeight, nativePaneChrome }) + (replyTo ? 1 : 0);
168
+ return getMessageComposerBlockHeight({ height: composerHeight, nativePaneChrome }) + (editingMessage || replyTo ? 1 : 0);
148
169
  }