gloomberb 0.7.3 → 0.7.5

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 (80) hide show
  1. package/package.json +1 -1
  2. package/src/app.tsx +59 -30
  3. package/src/components/chart/chart-renderer.ts +109 -32
  4. package/src/components/chart/chart-types.ts +18 -0
  5. package/src/components/chart/chart.test.ts +152 -5
  6. package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
  7. package/src/components/chart/comparison-chart-renderer.ts +50 -11
  8. package/src/components/chart/comparison-stock-chart.tsx +45 -8
  9. package/src/components/chart/market-session.ts +187 -0
  10. package/src/components/chart/native/chart-rasterizer.ts +65 -2
  11. package/src/components/chart/stock-chart-renderer-switch.test.tsx +96 -0
  12. package/src/components/chart/stock-chart.tsx +36 -8
  13. package/src/components/chart/time-axis-label.tsx +56 -2
  14. package/src/components/command-bar/command-bar.test.tsx +1 -1
  15. package/src/components/command-bar/command-bar.tsx +26 -2
  16. package/src/components/command-bar/list-model.ts +8 -4
  17. package/src/components/command-bar/view-model.test.ts +20 -0
  18. package/src/components/command-bar/view-model.ts +15 -3
  19. package/src/components/layout/shell.test.tsx +26 -0
  20. package/src/components/layout/shell.tsx +69 -21
  21. package/src/components/ticker-list-table.tsx +4 -15
  22. package/src/core/state/session-persistence.ts +25 -0
  23. package/src/market-data/coordinator-subscriptions.test.ts +102 -18
  24. package/src/market-data/coordinator.test.ts +122 -2
  25. package/src/market-data/coordinator.ts +192 -55
  26. package/src/market-data/hooks.test.tsx +31 -0
  27. package/src/market-data/hooks.tsx +1 -0
  28. package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
  29. package/src/plugins/builtin/chat-controller.ts +8 -7
  30. package/src/plugins/builtin/chat.test.tsx +55 -0
  31. package/src/plugins/builtin/chat.tsx +20 -6
  32. package/src/plugins/builtin/holders/index.test.ts +30 -1
  33. package/src/plugins/builtin/holders/index.tsx +7 -3
  34. package/src/plugins/builtin/market-movers/index.tsx +72 -40
  35. package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
  36. package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
  37. package/src/plugins/builtin/news-wire/index.tsx +0 -2
  38. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  39. package/src/plugins/builtin/portfolio-list/pane.tsx +26 -9
  40. package/src/plugins/builtin/portfolio-list.test.tsx +29 -7
  41. package/src/plugins/builtin/sectors/index.tsx +30 -9
  42. package/src/plugins/builtin/ticker-detail/pane.tsx +18 -1
  43. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -1
  44. package/src/plugins/builtin/world-indices/index.tsx +58 -19
  45. package/src/plugins/pane-manager.test.ts +31 -0
  46. package/src/plugins/pane-manager.ts +32 -6
  47. package/src/renderers/electrobun/bun/index.ts +83 -3
  48. package/src/renderers/electrobun/shared/protocol.ts +8 -1
  49. package/src/renderers/electrobun/view/backend-rpc.ts +5 -0
  50. package/src/renderers/electrobun/view/fatal-screen.tsx +118 -0
  51. package/src/renderers/electrobun/view/main.tsx +38 -24
  52. package/src/renderers/electrobun/view/styles.css +48 -4
  53. package/src/sources/gloomberb-cloud.test.ts +55 -2
  54. package/src/sources/gloomberb-cloud.ts +180 -29
  55. package/src/sources/provider-router.test.ts +567 -10
  56. package/src/sources/provider-router.ts +323 -93
  57. package/src/sources/yahoo-finance.test.ts +37 -0
  58. package/src/sources/yahoo-finance.ts +48 -2
  59. package/src/state/app-bootstrap.test.ts +50 -23
  60. package/src/state/app-bootstrap.ts +26 -10
  61. package/src/state/session-persistence.test.ts +5 -3
  62. package/src/state/use-inline-tickers.ts +3 -0
  63. package/src/state/use-quote-streaming.ts +11 -0
  64. package/src/types/data-provider.ts +5 -1
  65. package/src/types/financials.ts +1 -0
  66. package/src/utils/api-client.test.ts +120 -0
  67. package/src/utils/api-client.ts +66 -19
  68. package/src/utils/exchanges.test.ts +16 -0
  69. package/src/utils/exchanges.ts +33 -0
  70. package/src/utils/options.ts +1 -1
  71. package/src/utils/price-history.test.ts +28 -1
  72. package/src/utils/price-history.ts +16 -0
  73. package/src/utils/quote-freshness.test.ts +150 -0
  74. package/src/utils/quote-freshness.ts +42 -1
  75. package/src/utils/quote-resolution.ts +2 -0
  76. package/src/utils/quote-time.test.ts +34 -0
  77. package/src/utils/quote-time.ts +18 -5
  78. package/src/utils/ticker-search.ts +72 -1
  79. package/src/version.ts +1 -1
  80. package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
4
4
  "description": "Bloomberg-style stock portfolio tracker for the terminal",
5
5
  "module": "src/index.tsx",
6
6
  "type": "module",
package/src/app.tsx CHANGED
@@ -40,7 +40,7 @@ import {
40
40
  type PaneBinding,
41
41
  type PaneInstanceConfig,
42
42
  } from "./types/config";
43
- import type { CliLaunchRequest, PaneTemplateCreateOptions, PaneTemplateInstanceConfig, WizardStep } from "./types/plugin";
43
+ import type { CliLaunchRequest, PaneTemplateCreateOptions, PaneTemplateInstanceConfig, PinTickerOptions, WizardStep } from "./types/plugin";
44
44
  import type { PaneSettingField } from "./types/plugin";
45
45
  import type { TickerRecord } from "./types/ticker";
46
46
  import type { DataProvider } from "./types/data-provider";
@@ -48,7 +48,7 @@ import type { TickerFinancials } from "./types/financials";
48
48
  import type { BrokerAccount } from "./types/trading";
49
49
  import type { DesktopDockPreviewState, DesktopSharedStateSnapshot, DesktopThemePreviewState, DesktopWindowBridge } from "./types/desktop-window";
50
50
  import type { DesktopApplicationMenuBridge } from "./types/desktop-menu";
51
- import { resolveTickerSearch, upsertTickerFromSearchResult } from "./utils/ticker-search";
51
+ import { resolveTickerOpenTarget, type TickerOpenTarget } from "./utils/ticker-search";
52
52
 
53
53
  import {
54
54
  clearPersistedBrokerAccounts,
@@ -70,6 +70,7 @@ import {
70
70
  gridlockAllPanes,
71
71
  isPaneInLayout,
72
72
  removePane,
73
+ type LayoutBounds,
73
74
  } from "./plugins/pane-manager";
74
75
  import { notifyGridlockComplete } from "./plugins/gridlock-notification";
75
76
  import {
@@ -206,6 +207,7 @@ function AppInner({
206
207
  const isDetachedWindow = desktopWindowBridge?.kind === "detached";
207
208
  const detachedPaneId = isDetachedWindow ? desktopWindowBridge.paneId ?? null : null;
208
209
  const [desktopDockPreview, setDesktopDockPreview] = useState<DesktopDockPreviewState | null>(null);
210
+ const [commandBarNativeOccluder, setCommandBarNativeOccluder] = useState<LayoutBounds | null>(null);
209
211
  appActiveRef.current = appActive;
210
212
  const appNotifier = useMemo(() => createAppNotifier({
211
213
  isAppActive: () => appActiveRef.current,
@@ -1359,14 +1361,45 @@ function AppInner({
1359
1361
 
1360
1362
  focusVisiblePane(instanceId);
1361
1363
  };
1362
- pluginRegistry.pinTickerFn = (symbol, options) => {
1363
- if (isDetachedWindow) return;
1364
+ const resolveOpenTickerTarget = async (rawSymbol: string): Promise<TickerOpenTarget | null> => {
1365
+ try {
1366
+ const target = await resolveTickerOpenTarget({
1367
+ query: rawSymbol,
1368
+ tickers: stateRef.current.tickers,
1369
+ dataProvider,
1370
+ tickerRepository,
1371
+ });
1372
+ if (!target) {
1373
+ pluginRegistry.notify({ body: `Could not open ${rawSymbol}.`, type: "error" });
1374
+ }
1375
+ return target;
1376
+ } catch (err) {
1377
+ const message = err instanceof Error ? err.message : String(err);
1378
+ pluginRegistry.notify({ body: `Failed to open ${rawSymbol}: ${message}`, type: "error" });
1379
+ return null;
1380
+ }
1381
+ };
1382
+ const publishTickerOpenTarget = (target: TickerOpenTarget) => {
1383
+ const currentTicker = stateRef.current.tickers.get(target.symbol);
1384
+ if (currentTicker !== target.ticker) {
1385
+ dispatch({ type: "UPDATE_TICKER", ticker: target.ticker });
1386
+ }
1387
+ if (target.created) {
1388
+ pluginRegistry.events.emit("ticker:added", { symbol: target.symbol, ticker: target.ticker });
1389
+ }
1390
+ };
1391
+ const placePinnedTickerTarget = (target: TickerOpenTarget, options?: PinTickerOptions) => {
1364
1392
  const paneType = options?.paneType ?? "ticker-detail";
1365
1393
  const paneDef = pluginRegistry.panes.get(paneType);
1366
1394
  if (!paneDef) return;
1395
+
1396
+ publishTickerOpenTarget(target);
1397
+ const symbol = target.symbol;
1398
+ const currentState = stateRef.current;
1399
+ const currentLayout = currentState.config.layout;
1367
1400
  const existing = options?.forceNewPane
1368
1401
  ? null
1369
- : findFixedTickerPaneForSymbol(state.config.layout, paneType, symbol);
1402
+ : findFixedTickerPaneForSymbol(currentLayout, paneType, symbol);
1370
1403
  if (existing) {
1371
1404
  focusVisiblePane(existing.instanceId);
1372
1405
  return;
@@ -1379,45 +1412,38 @@ function AppInner({
1379
1412
  const { width, height } = pluginRegistry.getTermSizeFn();
1380
1413
  const shouldFloat = options?.floating ?? true;
1381
1414
  const nextLayout = shouldFloat
1382
- ? addPaneFloating(state.config.layout, instance, width, height, paneDef)
1415
+ ? addPaneFloating(currentLayout, instance, width, height, paneDef)
1383
1416
  : addPaneToLayout(
1384
- state.config.layout,
1417
+ currentLayout,
1385
1418
  instance,
1386
1419
  {
1387
- relativeTo: state.focusedPaneId && isPaneInLayout(state.config.layout, state.focusedPaneId)
1388
- ? state.focusedPaneId
1389
- : (getDockedPaneIds(state.config.layout).at(-1) ?? instance.instanceId),
1420
+ relativeTo: currentState.focusedPaneId && isPaneInLayout(currentLayout, currentState.focusedPaneId)
1421
+ ? currentState.focusedPaneId
1422
+ : (getDockedPaneIds(currentLayout).at(-1) ?? instance.instanceId),
1390
1423
  position: "right",
1391
1424
  },
1392
1425
  );
1393
1426
  persistLayout(nextLayout);
1394
1427
  activatePane(instance.instanceId, nextLayout);
1395
1428
  };
1429
+ const openPinnedTicker = async (rawSymbol: string, options?: PinTickerOptions) => {
1430
+ const target = await resolveOpenTickerTarget(rawSymbol);
1431
+ if (!target) return;
1432
+ placePinnedTickerTarget(target, options);
1433
+ };
1434
+ pluginRegistry.pinTickerFn = (symbol, options) => {
1435
+ if (isDetachedWindow) return;
1436
+ void openPinnedTicker(symbol, options);
1437
+ };
1396
1438
 
1397
1439
  pluginRegistry.navigateTickerFn = (rawSymbol, options) => {
1398
1440
  if (isDetachedWindow) return;
1399
1441
  const sourcePaneId = options?.sourcePaneId ?? stateRef.current.focusedPaneId;
1400
1442
  (async () => {
1401
1443
  try {
1402
- // Resolve or create the ticker in the local database
1403
- const resolved = await resolveTickerSearch({
1404
- query: rawSymbol,
1405
- activeTicker: null,
1406
- tickers: stateRef.current.tickers,
1407
- dataProvider,
1408
- });
1409
-
1410
- let symbol = rawSymbol;
1411
- if (resolved?.kind === "local") {
1412
- symbol = resolved.symbol;
1413
- } else if (resolved?.kind === "provider" && resolved.result) {
1414
- const { ticker, created } = await upsertTickerFromSearchResult(tickerRepository, resolved.result);
1415
- symbol = ticker.metadata.ticker;
1416
- dispatch({ type: "UPDATE_TICKER", ticker });
1417
- if (created) {
1418
- pluginRegistry.events.emit("ticker:added", { symbol, ticker });
1419
- }
1420
- }
1444
+ const target = await resolveOpenTickerTarget(rawSymbol);
1445
+ if (!target) return;
1446
+ const symbol = target.symbol;
1421
1447
 
1422
1448
  // Only actions that originate from a ticker detail replace that exact pane.
1423
1449
  // Everything else opens or focuses a fixed ticker detail for the symbol.
@@ -1436,6 +1462,7 @@ function AppInner({
1436
1462
  };
1437
1463
 
1438
1464
  if (detailPane) {
1465
+ publishTickerOpenTarget(target);
1439
1466
  const nextLayout = {
1440
1467
  ...currentLayout,
1441
1468
  instances: currentLayout.instances.map((instance) => (
@@ -1451,7 +1478,7 @@ function AppInner({
1451
1478
  currentFocusedPaneId: stateRef.current.focusedPaneId,
1452
1479
  targetPaneId: null,
1453
1480
  })) {
1454
- pluginRegistry.pinTicker(symbol, { floating: false });
1481
+ placePinnedTickerTarget(target, { floating: false });
1455
1482
  }
1456
1483
  } catch (err) {
1457
1484
  const message = err instanceof Error ? err.message : String(err);
@@ -1618,6 +1645,7 @@ function AppInner({
1618
1645
  pluginRegistry={pluginRegistry}
1619
1646
  desktopWindowBridge={desktopWindowBridge}
1620
1647
  desktopDockPreview={desktopDockPreview}
1648
+ commandBarNativeOccluder={commandBarNativeOccluder}
1621
1649
  />
1622
1650
  <StatusBar />
1623
1651
  {state.commandBarOpen && (
@@ -1627,6 +1655,7 @@ function AppInner({
1627
1655
  pluginRegistry={pluginRegistry}
1628
1656
  quitApp={() => rendererHost.requestExit()}
1629
1657
  onCheckForUpdates={() => runUpdateCheck(true)}
1658
+ onNativeOccluderChange={setCommandBarNativeOccluder}
1630
1659
  />
1631
1660
  )}
1632
1661
  <ToastViewport position="bottom-right" />
@@ -1,7 +1,17 @@
1
1
  import { RGBA } from "../../ui";
2
- import type { ChartAxisMode, ChartColors, ChartIndicatorOverlays, Pixel, PixelBuffer, ChartRenderMode } from "./chart-types";
2
+ import type {
3
+ ChartAxisMode,
4
+ ChartColors,
5
+ ChartIndicatorOverlays,
6
+ ChartMarketSession,
7
+ ChartSessionBackgroundSpan,
8
+ Pixel,
9
+ PixelBuffer,
10
+ ChartRenderMode,
11
+ } from "./chart-types";
3
12
  import type { ProjectedChartPoint } from "./chart-data";
4
13
  import { formatCompactMarketPriceWithCurrency, formatMarketPrice, resolveAssetDisplayKind } from "../../utils/market-format";
14
+ import { resolveExtendedHoursBackgroundSpans } from "./market-session";
5
15
 
6
16
  // ---------------------------------------------------------------------------
7
17
  // Styled output types (unchanged public API)
@@ -26,7 +36,7 @@ export interface StyledContent {
26
36
  const LAYER_GRID = 0;
27
37
  const LAYER_FILL = 1;
28
38
  const LAYER_DATA = 2;
29
- const LAYER_OVERLAY = 2;
39
+ const LAYER_OVERLAY = 1;
30
40
  const LAYER_CROSSHAIR = 3;
31
41
 
32
42
  function normalizeCount(value: number, min: number): number {
@@ -135,6 +145,8 @@ export function resolveChartPalette(
135
145
  axisColor: baseColors.textDim,
136
146
  activeRangeColor: baseColors.text,
137
147
  inactiveRangeColor: blendHex(baseColors.bg, baseColors.textDim, 0.75),
148
+ preMarketBgColor: blendHex(baseColors.bg, "#8a641f", 0.28),
149
+ postMarketBgColor: blendHex(baseColors.bg, "#7a4624", 0.28),
138
150
  candleUp: baseColors.positive,
139
151
  candleDown: baseColors.negative,
140
152
  wickUp: blendHex(baseColors.positive, baseColors.text, 0.35),
@@ -150,10 +162,12 @@ export function createPixelBuffer(width: number, heightPixels: number): PixelBuf
150
162
  const bufferWidth = normalizeCount(width, 0);
151
163
  const bufferHeight = normalizeCount(heightPixels, 0);
152
164
  const pixels: (Pixel | null)[][] = [];
165
+ const backgrounds: (string | null)[][] = [];
153
166
  for (let y = 0; y < bufferHeight; y++) {
154
167
  pixels.push(new Array(bufferWidth).fill(null));
168
+ backgrounds.push(new Array(bufferWidth).fill(null));
155
169
  }
156
- return { width: bufferWidth, height: bufferHeight, pixels };
170
+ return { width: bufferWidth, height: bufferHeight, pixels, backgrounds };
157
171
  }
158
172
 
159
173
  function setPixel(buf: PixelBuffer, x: number, y: number, color: string, layer: number) {
@@ -224,6 +238,27 @@ function fillRect(buf: PixelBuffer, x0: number, y0: number, x1: number, y1: numb
224
238
  }
225
239
  }
226
240
 
241
+ function setBackgroundPixel(buf: PixelBuffer, x: number, y: number, color: string) {
242
+ const px = Math.round(x);
243
+ const py = Math.round(y);
244
+ if (px < 0 || px >= buf.width || py < 0 || py >= buf.height) return;
245
+ const row = buf.backgrounds[py];
246
+ if (!row) return;
247
+ row[px] = color;
248
+ }
249
+
250
+ function fillBackgroundRect(buf: PixelBuffer, x0: number, y0: number, x1: number, y1: number, color: string) {
251
+ const left = Math.max(Math.min(Math.round(x0), Math.round(x1)), 0);
252
+ const right = Math.min(Math.max(Math.round(x0), Math.round(x1)), Math.max(buf.width - 1, 0));
253
+ const top = Math.max(Math.min(Math.round(y0), Math.round(y1)), 0);
254
+ const bottom = Math.min(Math.max(Math.round(y0), Math.round(y1)), Math.max(buf.height - 1, 0));
255
+ for (let y = top; y <= bottom; y += 1) {
256
+ for (let x = left; x <= right; x += 1) {
257
+ setBackgroundPixel(buf, x, y, color);
258
+ }
259
+ }
260
+ }
261
+
227
262
  function drawWickOutsideBody(
228
263
  buf: PixelBuffer,
229
264
  x: number,
@@ -329,6 +364,38 @@ export function getPointTerminalColumn(index: number, pointCount: number, width:
329
364
  return Math.min(Math.max(Math.floor(getPointDotX(index, pointCount, width, mode) / 2), 0), width - 1);
330
365
  }
331
366
 
367
+ function getTimePointBand(index: number, pointCount: number, width: number): { left: number; right: number } {
368
+ const currentX = getSeriesPosition(index, pointCount, width);
369
+ const previousX = index > 0 ? getSeriesPosition(index - 1, pointCount, width) : currentX;
370
+ const nextX = index < pointCount - 1 ? getSeriesPosition(index + 1, pointCount, width) : currentX;
371
+ return {
372
+ left: index === 0 ? 0 : Math.floor((previousX + currentX) / 2) + 1,
373
+ right: index === pointCount - 1 ? Math.max(width - 1, 0) : Math.floor((currentX + nextX) / 2),
374
+ };
375
+ }
376
+
377
+ function getSessionBackgroundColor(span: ChartSessionBackgroundSpan, colors: ChartColors): string {
378
+ return span.kind === "pre" ? colors.preMarketBgColor : colors.postMarketBgColor;
379
+ }
380
+
381
+ function drawSessionBackgrounds(
382
+ buf: PixelBuffer,
383
+ points: ProjectedChartPoint[],
384
+ spans: readonly ChartSessionBackgroundSpan[],
385
+ yTop: number,
386
+ yBottom: number,
387
+ colors: ChartColors,
388
+ ) {
389
+ if (spans.length === 0 || points.length === 0) return;
390
+ for (const span of spans) {
391
+ const startIndex = Math.max(Math.min(span.startIndex, points.length - 1), 0);
392
+ const endIndex = Math.max(Math.min(span.endIndex, points.length - 1), startIndex);
393
+ const startBand = getTimePointBand(startIndex, points.length, buf.width);
394
+ const endBand = getTimePointBand(endIndex, points.length, buf.width);
395
+ fillBackgroundRect(buf, startBand.left, yTop, endBand.right, yBottom, getSessionBackgroundColor(span, colors));
396
+ }
397
+ }
398
+
332
399
  // ---------------------------------------------------------------------------
333
400
  // Chart mode renderers
334
401
  // ---------------------------------------------------------------------------
@@ -573,14 +640,19 @@ export function bufferToBrailleLines(buf: PixelBuffer): StyledContent[] {
573
640
  let topLayer = -1;
574
641
 
575
642
  // Track dots per layer so we can render only the top layer's dots
576
- const dotsByLayer: Map<number, number> = new Map(); // layer → braille bits
643
+ const dotsByLayer: Map<number, number> = new Map(); // layer -> braille bits
577
644
  const colorByLayer: Map<number, Map<string, number>> = new Map();
645
+ const backgroundCounts: Map<string, number> = new Map();
578
646
 
579
647
  for (let dy = 0; dy < 4; dy++) {
580
648
  for (let dx = 0; dx < 2; dx++) {
581
649
  const px = col * 2 + dx;
582
650
  const py = row * 4 + dy;
583
651
  if (px >= buf.width || py >= buf.height) continue;
652
+ const bg = buf.backgrounds[py]?.[px] ?? null;
653
+ if (bg) {
654
+ backgroundCounts.set(bg, (backgroundCounts.get(bg) || 0) + 1);
655
+ }
584
656
  const pixel = buf.pixels[py]?.[px] ?? null;
585
657
  if (pixel) {
586
658
  const bit = BRAILLE_DOT[dy]![dx]!;
@@ -598,14 +670,19 @@ export function bufferToBrailleLines(buf: PixelBuffer): StyledContent[] {
598
670
  let char: string;
599
671
  let cellFg: string | undefined;
600
672
  let cellBg: string | undefined;
673
+ let bestBackgroundCount = 0;
674
+ for (const [bg, count] of backgroundCounts) {
675
+ if (count > bestBackgroundCount) {
676
+ bestBackgroundCount = count;
677
+ cellBg = bg;
678
+ }
679
+ }
601
680
 
602
681
  if (topLayer < 0) {
603
682
  char = " ";
604
683
  cellFg = undefined;
605
- cellBg = undefined;
606
684
  } else {
607
- // Only show dots from the highest layer — keeps lines thin in mixed
608
- // cells (e.g. area chart where line and fill overlap).
685
+ // Only show dots from the highest layer so lines stay thin in mixed cells.
609
686
  const pattern = dotsByLayer.get(topLayer) || 0;
610
687
  char = String.fromCharCode(BRAILLE_BASE + pattern);
611
688
 
@@ -617,7 +694,6 @@ export function bufferToBrailleLines(buf: PixelBuffer): StyledContent[] {
617
694
  }
618
695
 
619
696
  cellFg = topColor;
620
- cellBg = undefined;
621
697
  }
622
698
 
623
699
  if (char === runChar && cellFg === runFg && cellBg === runBg) {
@@ -823,6 +899,10 @@ function formatClockTime(date: Date, unit: AxisLabelUnit): string {
823
899
  }
824
900
  }
825
901
 
902
+ function formatFullCalendarDate(date: Date): string {
903
+ return `${MONTHS[date.getMonth()]} ${date.getDate()} ${date.getFullYear()}`;
904
+ }
905
+
826
906
  function isSameCalendarDay(a: Date, b: Date): boolean {
827
907
  return a.getFullYear() === b.getFullYear()
828
908
  && a.getMonth() === b.getMonth()
@@ -985,6 +1065,10 @@ function formatCursorTimeAxisValue(
985
1065
  const minGapMs = getMinPositiveGapMs(validDates);
986
1066
  const effectiveStepMs = Math.max(spanMs / Math.max(roughLabelCount - 1, 1), minGapMs || 0);
987
1067
  const unit = resolveAxisLabelUnit(effectiveStepMs);
1068
+ if (unit === "year" || unit === "month") {
1069
+ return formatFullCalendarDate(date);
1070
+ }
1071
+
988
1072
  const counterpart = isSameCalendarDay(first, last)
989
1073
  ? first
990
1074
  : isSameCalendarDay(date, first)
@@ -1152,6 +1236,7 @@ export interface RenderChartOptions {
1152
1236
  colors: ResolvedChartPalette;
1153
1237
  timeAxisDates?: Array<Date | string | number>;
1154
1238
  indicators?: ChartIndicatorOverlays | null;
1239
+ marketSession?: ChartMarketSession | null;
1155
1240
  }
1156
1241
 
1157
1242
  export interface ChartScene {
@@ -1164,6 +1249,7 @@ export interface ChartScene {
1164
1249
  mode: ChartRenderMode;
1165
1250
  colors: ResolvedChartPalette;
1166
1251
  indicators: ChartIndicatorOverlays | null;
1252
+ sessionBackgroundSpans: ChartSessionBackgroundSpan[];
1167
1253
  min: number;
1168
1254
  max: number;
1169
1255
  activeIdx: number;
@@ -1225,27 +1311,6 @@ export function getActivePointIndex(
1225
1311
  return bestIndex;
1226
1312
  }
1227
1313
 
1228
- function getIndicatorOverlayValues(indicators: ChartIndicatorOverlays | null | undefined): number[] {
1229
- if (!indicators) return [];
1230
-
1231
- const values: number[] = [];
1232
- for (const line of indicators.smaLines) {
1233
- values.push(...line.points.map((point) => point.value));
1234
- }
1235
- for (const line of indicators.emaLines) {
1236
- values.push(...line.points.map((point) => point.value));
1237
- }
1238
- if (indicators.bollinger) {
1239
- values.push(
1240
- ...indicators.bollinger.upper.map((point) => point.value),
1241
- ...indicators.bollinger.middle.map((point) => point.value),
1242
- ...indicators.bollinger.lower.map((point) => point.value),
1243
- );
1244
- }
1245
-
1246
- return values.filter((value) => Number.isFinite(value));
1247
- }
1248
-
1249
1314
  export function buildChartScene(
1250
1315
  points: ProjectedChartPoint[],
1251
1316
  opts: RenderChartOptions,
@@ -1259,9 +1324,8 @@ export function buildChartScene(
1259
1324
  const dataMax = isHighLowMode(opts.mode)
1260
1325
  ? Math.max(...points.map((point) => point.high))
1261
1326
  : Math.max(...points.map((point) => point.close));
1262
- const indicatorValues = getIndicatorOverlayValues(opts.indicators);
1263
- const min = indicatorValues.length > 0 ? Math.min(dataMin, ...indicatorValues) : dataMin;
1264
- const max = indicatorValues.length > 0 ? Math.max(dataMax, ...indicatorValues) : dataMax;
1327
+ const min = dataMin;
1328
+ const max = dataMax;
1265
1329
  const activeIdx = getActivePointIndex(points.length, dimensions.width, opts.cursorX, opts.mode);
1266
1330
  const activePoint = points[activeIdx]!;
1267
1331
  const range = max - min || 1;
@@ -1290,6 +1354,10 @@ export function buildChartScene(
1290
1354
  : max - (cursorY / Math.max(dimensions.chartRows - 1, 1)) * range;
1291
1355
 
1292
1356
  const timeAxisDates = opts.timeAxisDates ?? points.map((point) => point.date);
1357
+ const sessionBackgroundSpans = resolveExtendedHoursBackgroundSpans(
1358
+ points.map((point) => point.date),
1359
+ opts.marketSession,
1360
+ );
1293
1361
 
1294
1362
  return {
1295
1363
  points,
@@ -1301,6 +1369,7 @@ export function buildChartScene(
1301
1369
  mode: opts.mode,
1302
1370
  colors: opts.colors,
1303
1371
  indicators: opts.indicators ?? null,
1372
+ sessionBackgroundSpans,
1304
1373
  min,
1305
1374
  max,
1306
1375
  activeIdx,
@@ -1412,6 +1481,14 @@ export function renderChart(
1412
1481
  getAxisFractionDigitFloor(assetCategory, priceRange),
1413
1482
  )
1414
1483
  : null;
1484
+ drawSessionBackgrounds(
1485
+ buf,
1486
+ points,
1487
+ scene.sessionBackgroundSpans,
1488
+ 0,
1489
+ showVolume ? volDotBottom : chartDotBottom,
1490
+ palette,
1491
+ );
1415
1492
  drawGridLines(buf, gridLines.map((line) => line.y), palette.gridColor);
1416
1493
 
1417
1494
  switch (mode) {
@@ -7,6 +7,7 @@ export type ChartAxisMode = "price" | "percent";
7
7
  export type ChartRendererPreference = "auto" | "kitty" | "braille";
8
8
  export type ResolvedChartRenderer = "kitty" | "braille";
9
9
  export type ComparisonChartRenderMode = "area" | "line";
10
+ export type ChartSessionBackgroundKind = "pre" | "post";
10
11
 
11
12
  export const TIME_RANGES: TimeRange[] = ["1D", "1W", "1M", "3M", "6M", "1Y", "5Y", "ALL"];
12
13
  export const CHART_RENDER_MODES: ChartRenderMode[] = ["area", "line", "candles", "ohlc", "hlc"];
@@ -46,6 +47,7 @@ export interface PixelBuffer {
46
47
  width: number;
47
48
  height: number; // virtual pixel rows (4x terminal rows for braille rendering)
48
49
  pixels: (Pixel | null)[][]; // [y][x] = pixel with color and layer, or null
50
+ backgrounds: (string | null)[][];
49
51
  }
50
52
 
51
53
  export interface ChartColors {
@@ -59,6 +61,22 @@ export interface ChartColors {
59
61
  axisColor: string;
60
62
  activeRangeColor: string;
61
63
  inactiveRangeColor: string;
64
+ preMarketBgColor: string;
65
+ postMarketBgColor: string;
66
+ }
67
+
68
+ export interface ChartMarketSession {
69
+ timeZone: string;
70
+ regularStartMinutes: number;
71
+ regularEndMinutes: number;
72
+ preMarketStartMinutes: number;
73
+ postMarketEndMinutes: number;
74
+ }
75
+
76
+ export interface ChartSessionBackgroundSpan {
77
+ kind: ChartSessionBackgroundKind;
78
+ startIndex: number;
79
+ endIndex: number;
62
80
  }
63
81
 
64
82
  export interface VisibleWindow {