diff --git a/package.json b/package.json index be99880..eb7fd85 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "test": "vitest run", "lint": "eslint .", "format": "prettier --write .", - "prepublishOnly": "pnpm run build", + "clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"", + "prepublishOnly": "pnpm run clean && pnpm run build", "gen:x402-metrics": "node scripts/generate-x402-metrics.mjs" }, "keywords": [ @@ -68,7 +69,7 @@ "glassnode-api": "^0.29.3", "ink": "^7.1.1", "ink-spinner": "^5.0.0", - "ink-uplot": "^0.2.13", + "ink-uplot": "^0.2.15", "path2d": "^0.3.4", "react": "^19.3.0", "uplot": "^1.6.32", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9bdc9be..29af624 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,8 +34,8 @@ importers: specifier: ^5.0.0 version: 5.0.0(ink@7.1.1(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) ink-uplot: - specifier: ^0.2.13 - version: 0.2.13(ink@7.1.1(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(uplot@1.6.32) + specifier: ^0.2.15 + version: 0.2.15(ink@7.1.1(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(uplot@1.6.32) path2d: specifier: ^0.3.4 version: 0.3.4 @@ -766,8 +766,8 @@ packages: '@types/react': optional: true - ink-uplot@0.2.13: - resolution: {integrity: sha512-zXO16Ipy3+aTHKPadkaJBk4g6TVl2lD8uP71Rizr9WN3fcHHnses45NGnvvp2dt5Y/QLSKjt9wxF+5RXnAcEAQ==} + ink-uplot@0.2.15: + resolution: {integrity: sha512-k9rOhV/ryoAdyDZOeXHOCp62ONhdgD1gBMRan9ck/fctHQ9AYr6N8cHecVHdPxEyTQsiFUQj3l2HVxSWUFYE2g==} engines: {node: '>=18.0.0'} peerDependencies: ink: '>=4.0.0' @@ -1723,7 +1723,7 @@ snapshots: optionalDependencies: '@types/react': 19.3.0 - ink-uplot@0.2.13(ink@7.1.1(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(uplot@1.6.32): + ink-uplot@0.2.15(ink@7.1.1(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(uplot@1.6.32): dependencies: canvas: 3.2.3 chafa-wasm: 0.3.3 diff --git a/src/App.tsx b/src/App.tsx index b0e72d1..def00fc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,13 @@ import { LogView } from './components/LogView.js'; import { PriceTicker } from './components/PriceTicker.js'; import { Spinner } from './components/Spinner.js'; import { usePulses, getTickerAssets } from './hooks/usePulses.js'; +import { useRedrawAfterInput } from './hooks/useRedrawAfterInput.js'; +import { detectFormat } from 'ink-uplot'; + +// Inline-image charts (iTerm2 protocol — VS Code, iTerm2 — and sixels) live in the text +// cells, so Ink repaints after a keypress can erase them. Kitty's graphics layer and text +// charts survive repaints. +const CHART_ERASED_BY_REPAINTS = ['iterm2', 'sixels'].includes(detectFormat()); import { loadStartupData, buildMetricList } from './lib/startup-data.js'; import { Pane, @@ -33,7 +40,8 @@ function cycleNext(arr: readonly T[], current: T): T { export function App(): React.ReactElement { const { stdout } = useStdout(); const [termHeight, setTermHeight] = useState(stdout?.rows ?? 24); - const [termWidth, setTermWidth] = useState(stdout?.columns ?? 80); + // Not read directly: updating it re-renders on width-only resizes so DataView re-measures the chart. + const [, setTermWidth] = useState(stdout?.columns ?? 80); useEffect(() => { if (!stdout) return; @@ -80,6 +88,7 @@ export function App(): React.ReactElement { const [dataViewMode, setDataViewMode] = useState('chart'); const [showPrice, setShowPrice] = useState(true); const [showLogs, setShowLogs] = useState(false); + const chartRedrawKey = useRedrawAfterInput(150, CHART_ERASED_BY_REPAINTS && !showLogs && dataViewMode === 'chart'); const [leftSearchQuery, setLeftSearchQuery] = useState(''); const [middleSearchQuery, setMiddleSearchQuery] = useState(''); const activeSearchQuery = activePane === Pane.Left ? leftSearchQuery : middleSearchQuery; @@ -323,7 +332,11 @@ export function App(): React.ReactElement { params, showPrice, ); - const dataNavReal = useListNavigation({ itemCount: data.length, viewportSize }); + // Table page size = rows DataView measured for its body, minus the table header row. + // Until the first measurement, assume the pane's param bar, metric line and header. + const [dataBodyHeight, setDataBodyHeight] = useState(null); + const dataPageSize = Math.max(1, dataBodyHeight === null ? viewportSize - 3 : dataBodyHeight - 1); + const dataNavReal = useListNavigation({ itemCount: data.length, viewportSize: dataPageSize }); // Pane switching const nextPane = useCallback(() => { @@ -569,8 +582,8 @@ export function App(): React.ReactElement { viewMode={dataViewMode} showPrice={showPrice} priceData={priceData} - chartWidth={Math.max(20, termWidth - 30 - 50 - 6)} - chartHeight={viewportSize} + chartRedrawKey={chartRedrawKey} + onBodyHeightChange={setDataBodyHeight} /> )} diff --git a/src/cli.tsx b/src/cli.tsx index 92e4f8a..bc8f491 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -27,7 +27,9 @@ if (!hasApiKey && !hasX402) { try { // Build the client up front (x402 setup is async) so hooks can use it synchronously. await initClient(); - render(, { exitOnCtrlC: true }); + // Incremental rendering rewrites only changed lines, so live updates (price ticker) + // don't repaint the rows under an inline-image chart and erase it. + render(, { exitOnCtrlC: true, incrementalRendering: true }); } catch (err) { fail(`Error initializing Glassnode client: ${err instanceof Error ? err.message : String(err)}`); } diff --git a/src/components/DataView.tsx b/src/components/DataView.tsx index 5282f2f..ea3e746 100644 --- a/src/components/DataView.tsx +++ b/src/components/DataView.tsx @@ -1,5 +1,5 @@ -import React, { useMemo } from 'react'; -import { Box, Text } from 'ink'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Box, Text, measureElement, type DOMElement } from 'ink'; import { InkUPlot } from 'ink-uplot'; import type { DataPoint, MetricParams } from '../lib/types.js'; import { formatDate, formatValue, padRight } from '../lib/format.js'; @@ -21,8 +21,10 @@ interface DataViewProps { viewMode: DataViewMode; showPrice: boolean; priceData: DataPoint[]; - chartWidth: number; - chartHeight: number; + /** Changing this re-draws the chart (see useRedrawAfterInput). */ + chartRedrawKey: number; + /** Called with the rows available for the table/chart body (sizes the table's page). */ + onBodyHeightChange?: (height: number) => void; } export function DataView({ @@ -38,10 +40,28 @@ export function DataView({ viewMode, showPrice, priceData, - chartWidth, - chartHeight, + chartRedrawKey, + onBodyHeightChange, }: DataViewProps): React.ReactElement { const [start, end] = visibleRange; + + // Measure the space actually left in the pane for the table/chart body (below the param + // bar and metric line, which wrap when the pane is narrow). Inline-image charts are drawn + // at exactly this size, and the table's page size comes from it (via onBodyHeightChange), + // so a guess would spill over the pane border. + const bodyRef = useRef(null); + // `measured` distinguishes "not laid out yet" from a real 0 (a pane too short for a body). + const [bodyArea, setBodyArea] = useState({ width: 0, height: 0, measured: false }); + useEffect(() => { + if (!bodyRef.current) return; + const { width, height } = measureElement(bodyRef.current); + if (!bodyArea.measured || width !== bodyArea.width || height !== bodyArea.height) { + setBodyArea({ width, height, measured: true }); + } + }); + useEffect(() => { + if (bodyArea.measured) onBodyHeightChange?.(bodyArea.height); + }, [bodyArea.measured, bodyArea.height, onBodyHeightChange]); const visible = data.slice(start, end); // Price lookup for table view @@ -96,7 +116,9 @@ export function DataView({ } return { width: 800, height: 400, series, axes }; - }, [priceMap]); + // chartRedrawKey: a new opts object makes InkUPlot re-render and re-stamp an inline + // image that an Ink repaint erased. + }, [priceMap, chartRedrawKey]); return ( @@ -116,57 +138,54 @@ export function DataView({ )} - {!loading && !error && data.length > 0 && viewMode === 'table' && ( - - - {padRight('Date', 16)} - {padRight('Value', 16)} - {priceMap && Price} - - {visible.map((point, i) => { - const globalIndex = start + i; - const isSelected = globalIndex === selectedIndex; - const value = point.v !== undefined ? point.v : point.o; - const price = priceMap?.get(point.t); - return ( - - - {padRight(formatDate(point.t, params.interval), 16)} - - - {padRight(formatValue(value), 16)} - - {priceMap && ( - - {price != null ? formatValue(price) : '—'} - - )} - - ); - })} - - )} - - {!loading && !error && data.length > 0 && viewMode === 'chart' && chartData && ( - - )} - {!loading && !error && data.length === 0 && selectedMetric && selectedAsset && ( No data )} + + + {!loading && !error && data.length > 0 && viewMode === 'table' && ( + + + {padRight('Date', 16)} + {padRight('Value', 16)} + {priceMap && Price} + + {visible.map((point, i) => { + const globalIndex = start + i; + const isSelected = globalIndex === selectedIndex; + const value = point.v !== undefined ? point.v : point.o; + const price = priceMap?.get(point.t); + return ( + + + {padRight(formatDate(point.t, params.interval), 16)} + + + {padRight(formatValue(value), 16)} + + {priceMap && ( + + {price != null ? formatValue(price) : '—'} + + )} + + ); + })} + + )} + + {!loading && !error && data.length > 0 && viewMode === 'chart' && chartData && bodyArea.width > 0 && bodyArea.height > 0 && ( + + )} + ); } diff --git a/src/hooks/useListNavigation.ts b/src/hooks/useListNavigation.ts index 85b94ee..2562c6e 100644 --- a/src/hooks/useListNavigation.ts +++ b/src/hooks/useListNavigation.ts @@ -56,6 +56,17 @@ export function useListNavigation({ itemCount, viewportSize, isSelectable }: Lis } }, [itemCount]); // eslint-disable-line react-hooks/exhaustive-deps + // When the viewport changes size (terminal resize, a pane header wrapping), keep the + // selection visible and don't leave empty rows past the end of the list. + useEffect(() => { + setScrollOffset((prev) => { + let next = prev; + if (selectedIndex >= next + viewportSize) next = selectedIndex - viewportSize + 1; + if (selectedIndex < next) next = selectedIndex; + return Math.max(0, Math.min(next, Math.max(0, itemCount - viewportSize))); + }); + }, [viewportSize]); // eslint-disable-line react-hooks/exhaustive-deps + const findSelectable = useCallback( (from: number, direction: 1 | -1): number => { if (!isSelectable) return from; diff --git a/src/hooks/useRedrawAfterInput.ts b/src/hooks/useRedrawAfterInput.ts new file mode 100644 index 0000000..7a8d916 --- /dev/null +++ b/src/hooks/useRedrawAfterInput.ts @@ -0,0 +1,29 @@ +import { useEffect, useRef, useState } from 'react'; +import { useInput } from 'ink'; + +/** + * Returns a counter that bumps once, `delayMs` after the last keypress. + * + * Inline-image charts (iTerm2 protocol, e.g. in VS Code) are written straight to the + * terminal, outside Ink's frame. Any Ink repaint of the rows under the image erases it, + * and a keypress (pane focus, list navigation, search) can repaint every row. Feeding + * this counter into the chart's inputs re-stamps the image once after the user stops + * typing, instead of redrawing it on a timer (which flickers). + * + * Pass `enabled = false` when no chart is shown or the chart's format survives repaints + * (kitty's graphics layer, text charts): each bump re-renders and re-sends the image. + */ +export function useRedrawAfterInput(delayMs = 150, enabled = true): number { + const [epoch, setEpoch] = useState(0); + const timer = useRef | undefined>(undefined); + + useInput(() => { + if (!enabled) return; + clearTimeout(timer.current); + timer.current = setTimeout(() => setEpoch((e) => e + 1), delayMs); + }); + + useEffect(() => () => clearTimeout(timer.current), []); + + return epoch; +} diff --git a/test/components/App.spec.tsx b/test/components/App.spec.tsx index 584eb97..ff1c4d7 100644 --- a/test/components/App.spec.tsx +++ b/test/components/App.spec.tsx @@ -148,6 +148,7 @@ vi.mock('../../src/lib/api-client.js', () => ({ vi.mock('ink-uplot', () => ({ InkUPlot: () => null, + detectFormat: () => 'symbols', })); // Helper: wait for async renders to settle diff --git a/test/components/DataView.spec.tsx b/test/components/DataView.spec.tsx new file mode 100644 index 0000000..237ee27 --- /dev/null +++ b/test/components/DataView.spec.tsx @@ -0,0 +1,107 @@ +import { vi, describe, it, expect } from 'vitest'; +import React from 'react'; +import { Box } from 'ink'; +import { render } from 'ink-testing-library'; + +// Capture the size DataView gives the chart instead of rendering a real one. +const chartSizes = vi.hoisted(() => [] as Array<{ width?: number; height?: number }>); +vi.mock('ink-uplot', () => ({ + InkUPlot: (props: { width?: number; height?: number }) => { + chartSizes.push({ width: props.width, height: props.height }); + return null; + }, +})); + +const { DataView } = await import('../../src/components/DataView.js'); + +// Measuring takes a couple of render passes (layout, measure, setState, report), so poll +// for the expected state instead of sleeping a fixed time (flaky on slower CI runners). +async function until(cond: () => boolean, timeout = 3000): Promise { + const start = Date.now(); + while (!cond()) { + if (Date.now() - start > timeout) return; // let the assertion report the actual value + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +function renderDataView( + paneWidth: number, + paneHeight: number, + { viewMode = 'chart', points = 2, onBodyHeightChange }: { + viewMode?: 'chart' | 'table'; + points?: number; + onBodyHeightChange?: (height: number) => void; + } = {}, +) { + chartSizes.length = 0; + const data = Array.from({ length: points }, (_, i) => ({ t: 86400 * (i + 1), v: i + 1 })); + return render( + + + , + ); +} + +describe('DataView chart sizing', () => { + it('gives the chart exactly the space left inside the pane', async () => { + // Wide pane: border (2 rows) + param bar (1) + metric line (1) leave 20 - 4 = 16 rows; + // border leaves 80 - 2 = 78 columns. + renderDataView(80, 20); + await until(() => chartSizes.length > 0); + expect(chartSizes.at(-1)).toEqual({ width: 78, height: 16 }); + }); + + it('shrinks the chart when the param bar wraps in a narrow pane', async () => { + // Narrow pane: the param bar and metric line wrap, so fewer rows remain. + renderDataView(34, 20); + await until(() => chartSizes.length > 0); + const size = chartSizes.at(-1)!; + expect(size.width).toBe(32); + expect(size.height).toBeLessThan(16); + expect(size.height).toBeGreaterThan(0); + }); +}); + +describe('DataView table sizing', () => { + it('reports the rows available below the param bar and metric line', async () => { + const heights: number[] = []; + renderDataView(80, 20, { viewMode: 'table', points: 50, onBodyHeightChange: (h) => heights.push(h) }); + await until(() => heights.length > 0); + // 20 - border (2) - param bar (1) - metric line (1) = 16 rows (table header + 15 data rows). + expect(heights.at(-1)).toBe(16); + }); + + it('never draws past the pane border, even when given more rows than fit', async () => { + const { lastFrame } = renderDataView(80, 20, { viewMode: 'table', points: 50 }); + await until(() => (lastFrame() ?? '').split('\n').length === 20); + const lines = (lastFrame() ?? '').split('\n'); + expect(lines).toHaveLength(20); + expect(lines.at(-1)).toMatch(/^└─+┘$/); + // The param bar and metric line aren't squeezed out by the long table. + expect(lines[1]).toContain('[i]'); + expect(lines[2]).toContain('/addresses/count'); + }); + + it('reports 0 when the pane has no room left for a body', async () => { + const heights: number[] = []; + // 4 rows = border (2) + param bar (1) + metric line (1): nothing left. + renderDataView(80, 4, { viewMode: 'table', points: 50, onBodyHeightChange: (h) => heights.push(h) }); + await until(() => heights.length > 0); + expect(heights.at(-1)).toBe(0); + }); +}); diff --git a/test/hooks/useListNavigation.spec.tsx b/test/hooks/useListNavigation.spec.tsx new file mode 100644 index 0000000..b1a896a --- /dev/null +++ b/test/hooks/useListNavigation.spec.tsx @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import React from 'react'; +import { Text } from 'ink'; +import { render } from 'ink-testing-library'; +import { useListNavigation, type ListNavigation } from '../../src/hooks/useListNavigation.js'; + +let nav!: ListNavigation; +function Probe({ itemCount, viewportSize }: { itemCount: number; viewportSize: number }): React.ReactElement { + nav = useListNavigation({ itemCount, viewportSize }); + return {`${nav.selectedIndex} ${nav.visibleRange[0]}-${nav.visibleRange[1]}`}; +} + +async function settle(): Promise { + await new Promise((resolve) => setTimeout(resolve, 20)); +} + +describe('useListNavigation', () => { + it('keeps the selection visible when the viewport shrinks', async () => { + const { rerender, lastFrame } = render(); + await settle(); + nav.goToEnd(); + await settle(); + expect(lastFrame()).toBe('29 10-30'); + + // The pane gets shorter (terminal resize, a wrapping header line, an error line…). + rerender(); + await settle(); + const [start, end] = nav.visibleRange; + expect(nav.selectedIndex).toBe(29); + expect(29 >= start && 29 < end).toBe(true); + }); + + it('does not scroll past the end when the viewport grows', async () => { + const { rerender } = render(); + await settle(); + nav.goToEnd(); + await settle(); + rerender(); + await settle(); + expect(nav.visibleRange).toEqual([5, 30]); + }); +}); diff --git a/test/hooks/useRedrawAfterInput.spec.tsx b/test/hooks/useRedrawAfterInput.spec.tsx new file mode 100644 index 0000000..0c1f3a3 --- /dev/null +++ b/test/hooks/useRedrawAfterInput.spec.tsx @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import React from 'react'; +import { Text } from 'ink'; +import { render } from 'ink-testing-library'; +import { useRedrawAfterInput } from '../../src/hooks/useRedrawAfterInput.js'; + +function Probe({ delayMs, enabled }: { delayMs: number; enabled?: boolean }): React.ReactElement { + const epoch = useRedrawAfterInput(delayMs, enabled); + return epoch:{epoch}; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe('useRedrawAfterInput', () => { + it('starts at 0 and does not change without input', async () => { + const { lastFrame } = render(); + await delay(200); + expect(lastFrame()).toBe('epoch:0'); + }); + + it('bumps once, delayMs after the last of a burst of keypresses', async () => { + const { lastFrame, stdin } = render(); + await delay(20); + + stdin.write('\t'); + await delay(50); + stdin.write('x'); + await delay(50); + // 100ms after the first key but only 50ms after the last: no bump yet. + expect(lastFrame()).toBe('epoch:0'); + + await delay(100); + expect(lastFrame()).toBe('epoch:1'); + + // No further bumps while idle. + await delay(200); + expect(lastFrame()).toBe('epoch:1'); + }); + + it('never bumps while disabled (no chart shown, or an image format that survives repaints)', async () => { + const { lastFrame, stdin } = render(); + await delay(20); + stdin.write('\t'); + await delay(200); + expect(lastFrame()).toBe('epoch:0'); + }); +});