gloomberb 0.3.7 → 0.4.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 (133) hide show
  1. package/README.md +19 -8
  2. package/package.json +1 -1
  3. package/src/app.tsx +29 -65
  4. package/src/cli/base-converter.ts +31 -0
  5. package/src/cli/commands/search.ts +11 -3
  6. package/src/cli/commands/ticker.ts +22 -11
  7. package/src/cli/context.ts +27 -32
  8. package/src/cli/helpers.ts +1 -4
  9. package/src/cli/index.ts +140 -123
  10. package/src/cli/registry.test.ts +226 -0
  11. package/src/cli/registry.ts +201 -0
  12. package/src/cli.test.ts +219 -0
  13. package/src/components/chart/chart-controller.ts +309 -0
  14. package/src/components/chart/chart-data.ts +5 -6
  15. package/src/components/chart/chart-pane-settings.ts +27 -0
  16. package/src/components/chart/chart-resolution.test.ts +56 -0
  17. package/src/components/chart/chart-resolution.ts +362 -0
  18. package/src/components/chart/chart-types.ts +12 -4
  19. package/src/components/chart/chart-viewport.test.ts +25 -0
  20. package/src/components/chart/chart-viewport.ts +28 -0
  21. package/src/components/chart/comparison-chart-data.ts +20 -27
  22. package/src/components/chart/comparison-stock-chart.tsx +374 -99
  23. package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
  24. package/src/components/chart/stock-chart.tsx +455 -106
  25. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
  26. package/src/components/command-bar/command-bar.test.tsx +650 -5
  27. package/src/components/command-bar/command-bar.tsx +359 -63
  28. package/src/components/command-bar/command-registry.ts +8 -0
  29. package/src/components/command-bar/helpers.test.ts +23 -1
  30. package/src/components/command-bar/helpers.ts +14 -5
  31. package/src/components/command-bar/pane-template-display.ts +12 -0
  32. package/src/components/command-bar/root-shortcuts.ts +2 -1
  33. package/src/components/command-bar/workflow-ops.ts +4 -2
  34. package/src/components/detail-feed-view.test.tsx +124 -49
  35. package/src/components/detail-feed-view.tsx +396 -160
  36. package/src/components/layout/floating-pane.tsx +1 -1
  37. package/src/components/layout/pane-header.tsx +26 -2
  38. package/src/components/layout/shell.tsx +62 -50
  39. package/src/components/layout/status-bar.test.tsx +66 -0
  40. package/src/components/layout/status-bar.tsx +26 -5
  41. package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
  42. package/src/components/onboarding/onboarding-wizard.tsx +5 -6
  43. package/src/components/ticker-list-table.test.tsx +83 -0
  44. package/src/components/ticker-list-table.tsx +174 -97
  45. package/src/components/ui/data-table.tsx +27 -2
  46. package/src/components/ui/index.ts +1 -0
  47. package/src/components/ui/page-stack-view.tsx +60 -0
  48. package/src/components/use-double-click-activation.ts +31 -0
  49. package/src/index.tsx +10 -10
  50. package/src/market-data/coordinator.test.ts +92 -4
  51. package/src/market-data/coordinator.ts +32 -5
  52. package/src/market-data/hooks.test.tsx +120 -0
  53. package/src/market-data/hooks.tsx +53 -16
  54. package/src/market-data/request-types.ts +4 -2
  55. package/src/market-data/selectors.ts +24 -58
  56. package/src/plugins/builtin/chat-controller.test.ts +370 -3
  57. package/src/plugins/builtin/chat-controller.ts +310 -35
  58. package/src/plugins/builtin/chat.test.tsx +118 -6
  59. package/src/plugins/builtin/chat.tsx +175 -116
  60. package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
  61. package/src/plugins/builtin/comparison-chart.tsx +21 -1
  62. package/src/plugins/builtin/news.tsx +1 -11
  63. package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
  64. package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
  65. package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
  66. package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
  67. package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
  68. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  69. package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
  70. package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
  71. package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
  72. package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
  73. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
  74. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
  75. package/src/plugins/builtin/sec.tsx +1 -11
  76. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
  77. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
  78. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
  79. package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
  80. package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
  81. package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
  82. package/src/plugins/catalog.ts +65 -0
  83. package/src/plugins/ibkr/broker-adapter.ts +28 -0
  84. package/src/plugins/ibkr/gateway-service.test.ts +45 -1
  85. package/src/plugins/ibkr/gateway-service.ts +141 -7
  86. package/src/plugins/pane-manager.test.ts +1 -1
  87. package/src/plugins/plugin-runtime.tsx +27 -7
  88. package/src/plugins/prediction-markets/cli.ts +28 -0
  89. package/src/plugins/prediction-markets/controller-data.ts +11 -3
  90. package/src/plugins/prediction-markets/controller.ts +141 -121
  91. package/src/plugins/prediction-markets/index.tsx +2 -0
  92. package/src/plugins/prediction-markets/launch.ts +51 -16
  93. package/src/plugins/prediction-markets/metrics.ts +9 -5
  94. package/src/plugins/prediction-markets/pane.test.tsx +119 -57
  95. package/src/plugins/prediction-markets/pane.tsx +74 -153
  96. package/src/plugins/prediction-markets/table.tsx +6 -3
  97. package/src/plugins/prediction-markets/types.ts +0 -1
  98. package/src/sources/gloomberb-cloud.test.ts +29 -0
  99. package/src/sources/gloomberb-cloud.ts +89 -1
  100. package/src/sources/provider-router.test.ts +265 -3
  101. package/src/sources/provider-router.ts +239 -11
  102. package/src/sources/yahoo-finance.test.ts +26 -0
  103. package/src/sources/yahoo-finance.ts +54 -3
  104. package/src/state/app-context-selector.test.tsx +99 -0
  105. package/src/state/app-context.test.ts +18 -0
  106. package/src/state/app-context.tsx +170 -65
  107. package/src/state/selectors.ts +25 -0
  108. package/src/theme/colors.ts +8 -8
  109. package/src/theme/themes.test.ts +2 -0
  110. package/src/theme/themes.ts +22 -10
  111. package/src/types/broker.ts +21 -0
  112. package/src/types/config.ts +17 -1
  113. package/src/types/data-provider.ts +18 -0
  114. package/src/types/financials.ts +31 -1
  115. package/src/types/plugin.ts +70 -0
  116. package/src/utils/api-client.test.ts +57 -0
  117. package/src/utils/api-client.ts +35 -12
  118. package/src/utils/back-navigation.ts +27 -0
  119. package/src/utils/format.test.ts +14 -0
  120. package/src/utils/format.ts +3 -3
  121. package/src/utils/market-status.ts +46 -1
  122. package/src/utils/quote-freshness.ts +127 -0
  123. package/src/utils/quote-resolution.test.ts +277 -0
  124. package/src/utils/quote-resolution.ts +641 -0
  125. package/src/utils/slugify.ts +3 -0
  126. package/src/utils/timestamp.ts +25 -0
  127. package/src/version.ts +1 -1
  128. package/src/cli/commands/portfolio.ts +0 -210
  129. package/src/cli/commands/watchlist.ts +0 -210
  130. package/src/plugins/builtin/data-providers.ts +0 -16
  131. package/src/sources/exchange-rates.ts +0 -18
  132. package/src/utils/ascii-chart.ts +0 -172
  133. package/src/utils/ticker-tokenizer.test.ts +0 -48
@@ -1,7 +1,10 @@
1
- import { TextAttributes } from "@opentui/core";
1
+ import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core";
2
+ import { useKeyboard } from "@opentui/react";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
4
  import { colors, hoverBg } from "../theme/colors";
5
+ import { isBackNavigationKey } from "../utils/back-navigation";
3
6
  import { formatTimeAgo } from "../utils/format";
4
- import { ExternalLink } from "./ui";
7
+ import { ExternalLink, PageStackView } from "./ui";
5
8
 
6
9
  export interface DetailFeedItem {
7
10
  id: string;
@@ -18,6 +21,7 @@ export interface DetailFeedItem {
18
21
  interface DetailFeedViewProps {
19
22
  width: number;
20
23
  height: number;
24
+ focused: boolean;
21
25
  items: DetailFeedItem[];
22
26
  selectedIdx: number;
23
27
  hoveredIdx: number | null;
@@ -25,7 +29,6 @@ interface DetailFeedViewProps {
25
29
  onHover: (index: number) => void;
26
30
  helpText?: string;
27
31
  listVariant?: "comfortable" | "compact" | "single-line";
28
- splitListWidthRatio?: number;
29
32
  }
30
33
 
31
34
  function truncateWithEllipsis(text: string, width: number): string {
@@ -35,7 +38,11 @@ function truncateWithEllipsis(text: string, width: number): string {
35
38
  return `${text.slice(0, width - 3)}...`;
36
39
  }
37
40
 
38
- function wrapTextLines(text: string, width: number, maxLines = Number.MAX_SAFE_INTEGER): string[] {
41
+ function wrapTextLines(
42
+ text: string,
43
+ width: number,
44
+ maxLines = Number.MAX_SAFE_INTEGER,
45
+ ): string[] {
39
46
  if (width <= 0) return [];
40
47
 
41
48
  const paragraphs = text
@@ -48,7 +55,11 @@ function wrapTextLines(text: string, width: number, maxLines = Number.MAX_SAFE_I
48
55
  lines.push(line);
49
56
  };
50
57
 
51
- for (let paragraphIndex = 0; paragraphIndex < paragraphs.length; paragraphIndex += 1) {
58
+ for (
59
+ let paragraphIndex = 0;
60
+ paragraphIndex < paragraphs.length;
61
+ paragraphIndex += 1
62
+ ) {
52
63
  const paragraph = paragraphs[paragraphIndex]!;
53
64
  if (!paragraph) {
54
65
  pushLine("");
@@ -77,7 +88,7 @@ function wrapTextLines(text: string, width: number, maxLines = Number.MAX_SAFE_I
77
88
  continue;
78
89
  }
79
90
 
80
- if ((current.length + 1 + word.length) <= width) {
91
+ if (current.length + 1 + word.length <= width) {
81
92
  current = `${current} ${word}`;
82
93
  } else {
83
94
  pushLine(current);
@@ -86,15 +97,21 @@ function wrapTextLines(text: string, width: number, maxLines = Number.MAX_SAFE_I
86
97
  }
87
98
 
88
99
  if (current) pushLine(current);
89
- if (paragraphIndex < (paragraphs.length - 1)) pushLine("");
100
+ if (paragraphIndex < paragraphs.length - 1) pushLine("");
90
101
  if (lines.length >= maxLines) break;
91
102
  }
92
103
 
93
104
  if (lines.length > maxLines) {
94
105
  lines.length = maxLines;
95
106
  }
96
- if (lines.length === maxLines && paragraphs.join(" ").length > lines.join(" ").length) {
97
- lines[maxLines - 1] = truncateWithEllipsis(lines[maxLines - 1] ?? "", width);
107
+ if (
108
+ lines.length === maxLines
109
+ && paragraphs.join(" ").length > lines.join(" ").length
110
+ ) {
111
+ lines[maxLines - 1] = truncateWithEllipsis(
112
+ lines[maxLines - 1] ?? "",
113
+ width,
114
+ );
98
115
  }
99
116
 
100
117
  return lines;
@@ -111,185 +128,404 @@ function lineOrBlank(lines: string[], index: number): string {
111
128
  export function DetailFeedView({
112
129
  width,
113
130
  height,
131
+ focused,
114
132
  items,
115
133
  selectedIdx,
116
134
  hoveredIdx,
117
135
  onSelect,
118
136
  onHover,
119
- helpText = "j/k navigate",
137
+ helpText = "j/k move enter or click selected row",
120
138
  listVariant = "comfortable",
121
- splitListWidthRatio = 0.38,
122
139
  }: DetailFeedViewProps) {
123
- const selected = items[Math.max(0, Math.min(selectedIdx, items.length - 1))];
124
- const split = width >= 92 && height >= 14;
140
+ const [openItemId, setOpenItemId] = useState<string | null>(null);
141
+ const listScrollRef = useRef<ScrollBoxRenderable>(null);
142
+ const detailScrollRef = useRef<ScrollBoxRenderable>(null);
143
+ const selected =
144
+ items[Math.max(0, Math.min(selectedIdx, items.length - 1))] ?? null;
145
+ const openItem = useMemo(
146
+ () =>
147
+ openItemId
148
+ ? items.find((item) => item.id === openItemId) ?? null
149
+ : null,
150
+ [items, openItemId],
151
+ );
125
152
  const footerHeight = helpText ? 1 : 0;
126
- const availableHeight = Math.max(height - footerHeight, 6);
153
+ const listHeight = Math.max(height - footerHeight, 6);
154
+ const detailHeight = Math.max(height - 1, 4);
127
155
  const compactList = listVariant === "compact";
128
156
  const singleLineList = listVariant === "single-line";
129
- const listWidth = split ? Math.max(28, Math.min(Math.floor(width * splitListWidthRatio), 44)) : Math.max(width - 2, 20);
130
- const detailWidth = split ? Math.max(width - listWidth - 4, 28) : Math.max(width - 2, 20);
131
- const listHeight = split ? availableHeight : Math.max(5, Math.floor((availableHeight - 1) * 0.45));
132
- const detailHeight = split ? availableHeight : Math.max(3, availableHeight - listHeight - 1);
133
- const listTextWidth = contentWidth(listWidth);
134
- const detailTextWidth = contentWidth(detailWidth);
135
- const showPreviewLine = !singleLineList && listTextWidth >= (compactList ? 18 : 28);
157
+ const listTextWidth = contentWidth(width);
158
+ const detailTextWidth = contentWidth(width);
159
+ const showPreviewLine =
160
+ !singleLineList && listTextWidth >= (compactList ? 18 : 28);
136
161
  const cardHeight = compactList
137
- ? (showPreviewLine ? 2 : 1)
162
+ ? showPreviewLine
163
+ ? 2
164
+ : 1
138
165
  : singleLineList
139
166
  ? 1
140
- : (showPreviewLine ? 4 : 3);
167
+ : showPreviewLine
168
+ ? 4
169
+ : 3;
141
170
 
142
- return (
171
+ useEffect(() => {
172
+ if (openItemId && !items.some((item) => item.id === openItemId)) {
173
+ setOpenItemId(null);
174
+ }
175
+ }, [items, openItemId]);
176
+
177
+ useEffect(() => {
178
+ const scrollBox = listScrollRef.current;
179
+ if (!scrollBox?.viewport || items.length === 0 || selectedIdx < 0) return;
180
+
181
+ if (selectedIdx < scrollBox.scrollTop) {
182
+ scrollBox.scrollTop = selectedIdx;
183
+ return;
184
+ }
185
+
186
+ if (selectedIdx >= scrollBox.scrollTop + scrollBox.viewport.height) {
187
+ scrollBox.scrollTop = Math.max(
188
+ 0,
189
+ selectedIdx - scrollBox.viewport.height + 1,
190
+ );
191
+ }
192
+ }, [items.length, selectedIdx]);
193
+
194
+ useEffect(() => {
195
+ if (!openItemId) return;
196
+ const scrollBox = detailScrollRef.current;
197
+ if (scrollBox) {
198
+ scrollBox.scrollTop = 0;
199
+ }
200
+ }, [openItemId]);
201
+
202
+ const openSelectedItem = () => {
203
+ if (!selected) return;
204
+ setOpenItemId(selected.id);
205
+ };
206
+ const handleItemMouseDown = useCallback((itemId: string, index: number) => {
207
+ if (selectedIdx === index) {
208
+ setOpenItemId(itemId);
209
+ return;
210
+ }
211
+ onSelect(index);
212
+ }, [onSelect, selectedIdx]);
213
+
214
+ const scrollDetailBy = (delta: number) => {
215
+ const scrollBox = detailScrollRef.current;
216
+ if (!scrollBox?.viewport) return;
217
+ const maxScrollTop = Math.max(
218
+ 0,
219
+ scrollBox.scrollHeight - scrollBox.viewport.height,
220
+ );
221
+ scrollBox.scrollTop = Math.max(
222
+ 0,
223
+ Math.min(maxScrollTop, scrollBox.scrollTop + delta),
224
+ );
225
+ };
226
+
227
+ useKeyboard((event) => {
228
+ if (!focused || items.length === 0) return;
229
+
230
+ if (openItem) {
231
+ if (isBackNavigationKey(event)) {
232
+ event.stopPropagation?.();
233
+ event.preventDefault?.();
234
+ setOpenItemId(null);
235
+ } else if (event.name === "j" || event.name === "down") {
236
+ event.stopPropagation?.();
237
+ event.preventDefault?.();
238
+ scrollDetailBy(1);
239
+ } else if (event.name === "k" || event.name === "up") {
240
+ event.stopPropagation?.();
241
+ event.preventDefault?.();
242
+ scrollDetailBy(-1);
243
+ }
244
+ return;
245
+ }
246
+
247
+ if (event.name === "j" || event.name === "down") {
248
+ event.stopPropagation?.();
249
+ event.preventDefault?.();
250
+ onSelect(Math.min(selectedIdx + 1, items.length - 1));
251
+ return;
252
+ }
253
+
254
+ if (event.name === "k" || event.name === "up") {
255
+ event.stopPropagation?.();
256
+ event.preventDefault?.();
257
+ onSelect(Math.max(selectedIdx - 1, 0));
258
+ return;
259
+ }
260
+
261
+ if (event.name === "enter" || event.name === "return") {
262
+ event.stopPropagation?.();
263
+ event.preventDefault?.();
264
+ openSelectedItem();
265
+ }
266
+ });
267
+
268
+ const rootContent = (
143
269
  <box flexDirection="column" flexGrow={1} paddingX={1}>
144
- <box flexDirection={split ? "row" : "column"} gap={1}>
145
- <box width={split ? listWidth : undefined}>
146
- <scrollbox height={listHeight} scrollY>
147
- <box flexDirection="column">
148
- {items.map((item, index) => {
149
- const isSelected = index === selectedIdx;
150
- const isHovered = index === hoveredIdx && !isSelected;
151
- const titleLines = wrapTextLines(item.title, listTextWidth, compactList ? 1 : 2);
152
- const previewLine = item.preview ? truncateWithEllipsis(item.preview, listTextWidth) : "";
153
- const timestampText = item.timestamp ? formatTimeAgo(item.timestamp) : "";
154
- const inlineEyebrow = item.eyebrow ? `${item.eyebrow} | ` : "";
155
- const compactTitleWidth = Math.max(listTextWidth - (timestampText ? timestampText.length + 1 : 0), 8);
156
- const compactTitle = truncateWithEllipsis(`${inlineEyebrow}${item.title}`, compactTitleWidth);
157
- const singleLineTitle = truncateWithEllipsis(item.title, compactTitleWidth);
158
- return (
270
+ <scrollbox
271
+ ref={listScrollRef}
272
+ height={listHeight}
273
+ scrollY
274
+ selectable={false}
275
+ >
276
+ <box flexDirection="column">
277
+ {items.map((item, index) => {
278
+ const isSelected = index === selectedIdx;
279
+ const isHovered = index === hoveredIdx && !isSelected;
280
+ const handleRowMouseDown = (event: any) => {
281
+ event.preventDefault?.();
282
+ event.stopPropagation?.();
283
+ handleItemMouseDown(item.id, index);
284
+ };
285
+ const titleLines = wrapTextLines(
286
+ item.title,
287
+ listTextWidth,
288
+ compactList ? 1 : 2,
289
+ );
290
+ const previewLine = item.preview
291
+ ? truncateWithEllipsis(item.preview, listTextWidth)
292
+ : "";
293
+ const timestampText = item.timestamp
294
+ ? formatTimeAgo(item.timestamp)
295
+ : "";
296
+ const inlineEyebrow = item.eyebrow ? `${item.eyebrow} | ` : "";
297
+ const compactTitleWidth = Math.max(
298
+ listTextWidth - (timestampText ? timestampText.length + 1 : 0),
299
+ 8,
300
+ );
301
+ const compactTitle = truncateWithEllipsis(
302
+ `${inlineEyebrow}${item.title}`,
303
+ compactTitleWidth,
304
+ );
305
+ const singleLineTitle = truncateWithEllipsis(
306
+ item.title,
307
+ compactTitleWidth,
308
+ );
309
+
310
+ return (
311
+ <box
312
+ key={item.id}
313
+ flexDirection="column"
314
+ height={cardHeight}
315
+ paddingX={1}
316
+ selectable={false}
317
+ backgroundColor={
318
+ isSelected
319
+ ? colors.selected
320
+ : isHovered
321
+ ? hoverBg()
322
+ : colors.bg
323
+ }
324
+ onMouseMove={() => onHover(index)}
325
+ onMouseDown={handleRowMouseDown}
326
+ >
327
+ {singleLineList ? (
159
328
  <box
160
- key={item.id}
161
- flexDirection="column"
162
- height={cardHeight}
163
- paddingX={1}
164
- backgroundColor={isSelected ? colors.selected : isHovered ? hoverBg() : colors.bg}
165
- onMouseMove={() => onHover(index)}
166
- onMouseDown={() => onSelect(index)}
329
+ flexDirection="row"
330
+ height={1}
331
+ selectable={false}
332
+ onMouseDown={handleRowMouseDown}
167
333
  >
168
- {singleLineList ? (
169
- <box flexDirection="row" height={1}>
170
- <box flexGrow={1}>
171
- <text
172
- attributes={isSelected ? TextAttributes.BOLD : 0}
173
- fg={isSelected ? colors.selectedText : colors.textBright}
174
- >
175
- {singleLineTitle}
176
- </text>
177
- </box>
178
- {timestampText && (
179
- <text fg={colors.textDim}>{timestampText}</text>
180
- )}
334
+ <box flexGrow={1}>
335
+ <text
336
+ selectable={false}
337
+ attributes={isSelected ? TextAttributes.BOLD : 0}
338
+ fg={
339
+ isSelected
340
+ ? colors.selectedText
341
+ : colors.textBright
342
+ }
343
+ >
344
+ {singleLineTitle}
345
+ </text>
346
+ </box>
347
+ {timestampText ? (
348
+ <text selectable={false} fg={colors.textDim}>{timestampText}</text>
349
+ ) : null}
350
+ </box>
351
+ ) : compactList ? (
352
+ <>
353
+ <box
354
+ flexDirection="row"
355
+ height={1}
356
+ selectable={false}
357
+ onMouseDown={handleRowMouseDown}
358
+ >
359
+ <box flexGrow={1}>
360
+ <text
361
+ selectable={false}
362
+ attributes={isSelected ? TextAttributes.BOLD : 0}
363
+ fg={
364
+ isSelected
365
+ ? colors.selectedText
366
+ : colors.textBright
367
+ }
368
+ >
369
+ {compactTitle}
370
+ </text>
181
371
  </box>
182
- ) : compactList ? (
183
- <>
184
- <box flexDirection="row" height={1}>
185
- <box flexGrow={1}>
186
- <text
187
- attributes={isSelected ? TextAttributes.BOLD : 0}
188
- fg={isSelected ? colors.selectedText : colors.textBright}
189
- >
190
- {compactTitle}
191
- </text>
192
- </box>
193
- {timestampText && (
194
- <text fg={colors.textDim}>{timestampText}</text>
195
- )}
196
- </box>
197
- {showPreviewLine && (
198
- <box height={1}>
199
- <text fg={colors.textMuted}>
200
- {previewLine || truncateWithEllipsis(item.eyebrow ?? "", listTextWidth)}
201
- </text>
202
- </box>
203
- )}
204
- </>
205
- ) : (
206
- <>
207
- <box flexDirection="row" height={1}>
208
- <box flexGrow={1}>
209
- <text fg={colors.textMuted}>{truncateWithEllipsis(item.eyebrow ?? "", listTextWidth)}</text>
210
- </box>
211
- {timestampText && (
212
- <text fg={colors.textDim}>{timestampText}</text>
372
+ {timestampText ? (
373
+ <text selectable={false} fg={colors.textDim}>{timestampText}</text>
374
+ ) : null}
375
+ </box>
376
+ {showPreviewLine ? (
377
+ <box
378
+ height={1}
379
+ selectable={false}
380
+ onMouseDown={handleRowMouseDown}
381
+ >
382
+ <text selectable={false} fg={colors.textMuted}>
383
+ {previewLine
384
+ || truncateWithEllipsis(
385
+ item.eyebrow ?? "",
386
+ listTextWidth,
387
+ )}
388
+ </text>
389
+ </box>
390
+ ) : null}
391
+ </>
392
+ ) : (
393
+ <>
394
+ <box
395
+ flexDirection="row"
396
+ height={1}
397
+ selectable={false}
398
+ onMouseDown={handleRowMouseDown}
399
+ >
400
+ <box flexGrow={1}>
401
+ <text selectable={false} fg={colors.textMuted}>
402
+ {truncateWithEllipsis(
403
+ item.eyebrow ?? "",
404
+ listTextWidth,
213
405
  )}
214
- </box>
215
- <box height={1}>
216
- <text
217
- attributes={isSelected ? TextAttributes.BOLD : 0}
218
- fg={isSelected ? colors.selectedText : colors.textBright}
219
- >
220
- {lineOrBlank(titleLines, 0)}
221
- </text>
222
- </box>
223
- <box height={1}>
224
- <text fg={isSelected ? colors.selectedText : colors.text}>
225
- {lineOrBlank(titleLines, 1)}
226
- </text>
227
- </box>
228
- {showPreviewLine && (
229
- <box height={1}>
230
- <text fg={colors.textMuted}>{previewLine}</text>
231
- </box>
232
- )}
233
- </>
234
- )}
235
- </box>
236
- );
237
- })}
238
- </box>
239
- </scrollbox>
406
+ </text>
407
+ </box>
408
+ {timestampText ? (
409
+ <text selectable={false} fg={colors.textDim}>{timestampText}</text>
410
+ ) : null}
411
+ </box>
412
+ <box
413
+ height={1}
414
+ selectable={false}
415
+ onMouseDown={handleRowMouseDown}
416
+ >
417
+ <text
418
+ selectable={false}
419
+ attributes={isSelected ? TextAttributes.BOLD : 0}
420
+ fg={
421
+ isSelected
422
+ ? colors.selectedText
423
+ : colors.textBright
424
+ }
425
+ >
426
+ {lineOrBlank(titleLines, 0)}
427
+ </text>
428
+ </box>
429
+ <box
430
+ height={1}
431
+ selectable={false}
432
+ onMouseDown={handleRowMouseDown}
433
+ >
434
+ <text
435
+ selectable={false}
436
+ fg={isSelected ? colors.selectedText : colors.text}
437
+ >
438
+ {lineOrBlank(titleLines, 1)}
439
+ </text>
440
+ </box>
441
+ {showPreviewLine ? (
442
+ <box
443
+ height={1}
444
+ selectable={false}
445
+ onMouseDown={handleRowMouseDown}
446
+ >
447
+ <text selectable={false} fg={colors.textMuted}>{previewLine}</text>
448
+ </box>
449
+ ) : null}
450
+ </>
451
+ )}
452
+ </box>
453
+ );
454
+ })}
240
455
  </box>
456
+ </scrollbox>
241
457
 
242
- <box width={split ? detailWidth : undefined} flexGrow={1}>
243
- <scrollbox height={detailHeight} scrollY>
244
- {selected ? (
245
- <box flexDirection="column">
246
- {wrapTextLines(selected.detailTitle ?? selected.title, detailTextWidth, 4).map((line, index) => (
247
- <box key={`title-${index}`} height={1}>
248
- <text attributes={TextAttributes.BOLD} fg={colors.textBright}>{line}</text>
249
- </box>
250
- ))}
458
+ {helpText ? (
459
+ <box height={1}>
460
+ <text selectable={false} fg={colors.textMuted}>{helpText}</text>
461
+ </box>
462
+ ) : null}
463
+ </box>
464
+ );
251
465
 
252
- {(selected.detailMeta ?? []).flatMap((entry) => wrapTextLines(entry, detailTextWidth, 2)).map((line, index) => (
253
- <box key={`meta-${index}`} height={1}>
254
- <text fg={colors.textMuted}>{line}</text>
255
- </box>
256
- ))}
466
+ const detailContent = openItem ? (
467
+ <box flexDirection="column" flexGrow={1} paddingX={1}>
468
+ <scrollbox ref={detailScrollRef} height={detailHeight} scrollY>
469
+ <box flexDirection="column">
470
+ {wrapTextLines(
471
+ openItem.detailTitle ?? openItem.title,
472
+ detailTextWidth,
473
+ 4,
474
+ ).map((line, index) => (
475
+ <box key={`title-${index}`} height={1}>
476
+ <text attributes={TextAttributes.BOLD} fg={colors.textBright}>
477
+ {line}
478
+ </text>
479
+ </box>
480
+ ))}
257
481
 
258
- <box height={1} />
482
+ {(openItem.detailMeta ?? [])
483
+ .flatMap((entry) => wrapTextLines(entry, detailTextWidth, 2))
484
+ .map((line, index) => (
485
+ <box key={`meta-${index}`} height={1}>
486
+ <text fg={colors.textMuted}>{line}</text>
487
+ </box>
488
+ ))}
259
489
 
260
- {wrapTextLines(selected.detailBody ?? "", detailTextWidth).map((line, index) => (
261
- <box key={`body-${index}`} height={1}>
262
- <text fg={colors.text}>{line}</text>
263
- </box>
264
- ))}
490
+ <box height={1} />
265
491
 
266
- {selected.detailNote && (
267
- <>
268
- <box height={1} />
269
- {wrapTextLines(selected.detailNote, detailTextWidth).map((line, index) =>
270
- /^https?:\/\/\S+$/.test(line.trim()) ? (
271
- <ExternalLink key={`note-${index}`} url={line.trim()} />
272
- ) : (
273
- <box key={`note-${index}`} height={1}>
274
- <text fg={colors.textDim}>{line}</text>
275
- </box>
276
- )
277
- )}
278
- </>
279
- )}
492
+ {wrapTextLines(openItem.detailBody ?? "", detailTextWidth).map(
493
+ (line, index) => (
494
+ <box key={`body-${index}`} height={1}>
495
+ <text fg={colors.text}>{line}</text>
280
496
  </box>
281
- ) : (
282
- <text fg={colors.textDim}>Nothing selected.</text>
283
- )}
284
- </scrollbox>
285
- </box>
286
- </box>
497
+ ),
498
+ )}
287
499
 
288
- {helpText && (
289
- <box height={1}>
290
- <text fg={colors.textMuted}>{helpText}</text>
500
+ {openItem.detailNote ? (
501
+ <>
502
+ <box height={1} />
503
+ {wrapTextLines(openItem.detailNote, detailTextWidth).map(
504
+ (line, index) =>
505
+ /^https?:\/\/\S+$/.test(line.trim()) ? (
506
+ <ExternalLink key={`note-${index}`} url={line.trim()} />
507
+ ) : (
508
+ <box key={`note-${index}`} height={1}>
509
+ <text fg={colors.textDim}>{line}</text>
510
+ </box>
511
+ ),
512
+ )}
513
+ </>
514
+ ) : null}
291
515
  </box>
292
- )}
516
+ </scrollbox>
293
517
  </box>
518
+ ) : (
519
+ <box flexGrow={1} />
520
+ );
521
+
522
+ return (
523
+ <PageStackView
524
+ focused={focused}
525
+ detailOpen={!!openItem}
526
+ onBack={() => setOpenItemId(null)}
527
+ rootContent={rootContent}
528
+ detailContent={detailContent}
529
+ />
294
530
  );
295
531
  }
@@ -42,7 +42,7 @@ export function FloatingPaneWrapper({
42
42
 
43
43
  {/* Resize handle at bottom-right corner */}
44
44
  <box position="absolute" bottom={0} right={0} width={2} height={1}>
45
- <text fg={colors.textDim} selectable={false}> ◢</text>
45
+ <text fg={focused ? colors.borderFocused : colors.textDim} selectable={false}>{focused ? "─◢" : " ◢"}</text>
46
46
  </box>
47
47
  </box>
48
48
  );