From a22aefb5d0d90de0b38f30aca92b45e3bca0b9cf Mon Sep 17 00:00:00 2001 From: planadecu Date: Tue, 29 Sep 2026 09:57:41 +0200 Subject: [PATCH 1/6] Keep inline-image charts on screen in VS Code In VS Code, ink-uplot draws the chart as an iTerm2 inline image written straight to stdout. Ink repainted the whole frame (erase + rewrite every line) on each price-ticker update, erasing the image; ink-uplot doesn't re-stamp in VS Code. - Render with Ink's incrementalRendering: only changed lines are rewritten, so ticker updates touch the header row only and the chart survives. - useRedrawAfterInput: a keypress (pane focus, navigation, search) can still repaint the chart rows, so re-draw the chart once, 150ms after the last key, instead of on a timer (which flickers). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PzJHgXssByMwBXH1PGbXCL --- src/App.tsx | 3 ++ src/cli.tsx | 4 ++- src/components/DataView.tsx | 7 ++++- src/hooks/useRedrawAfterInput.ts | 25 +++++++++++++++ test/hooks/useRedrawAfterInput.spec.tsx | 41 +++++++++++++++++++++++++ 5 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 src/hooks/useRedrawAfterInput.ts create mode 100644 test/hooks/useRedrawAfterInput.spec.tsx diff --git a/src/App.tsx b/src/App.tsx index b0e72d1..bf6a771 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ 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 { loadStartupData, buildMetricList } from './lib/startup-data.js'; import { Pane, @@ -80,6 +81,7 @@ export function App(): React.ReactElement { const [dataViewMode, setDataViewMode] = useState('chart'); const [showPrice, setShowPrice] = useState(true); const [showLogs, setShowLogs] = useState(false); + const chartRedrawKey = useRedrawAfterInput(); const [leftSearchQuery, setLeftSearchQuery] = useState(''); const [middleSearchQuery, setMiddleSearchQuery] = useState(''); const activeSearchQuery = activePane === Pane.Left ? leftSearchQuery : middleSearchQuery; @@ -571,6 +573,7 @@ export function App(): React.ReactElement { priceData={priceData} chartWidth={Math.max(20, termWidth - 30 - 50 - 6)} chartHeight={viewportSize} + chartRedrawKey={chartRedrawKey} /> )} 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..e97afba 100644 --- a/src/components/DataView.tsx +++ b/src/components/DataView.tsx @@ -23,6 +23,8 @@ interface DataViewProps { priceData: DataPoint[]; chartWidth: number; chartHeight: number; + /** Changing this re-draws the chart (see useRedrawAfterInput). */ + chartRedrawKey: number; } export function DataView({ @@ -40,6 +42,7 @@ export function DataView({ priceData, chartWidth, chartHeight, + chartRedrawKey, }: DataViewProps): React.ReactElement { const [start, end] = visibleRange; const visible = data.slice(start, end); @@ -96,7 +99,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 ( diff --git a/src/hooks/useRedrawAfterInput.ts b/src/hooks/useRedrawAfterInput.ts new file mode 100644 index 0000000..605c68e --- /dev/null +++ b/src/hooks/useRedrawAfterInput.ts @@ -0,0 +1,25 @@ +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). + */ +export function useRedrawAfterInput(delayMs = 150): number { + const [epoch, setEpoch] = useState(0); + const timer = useRef | undefined>(undefined); + + useInput(() => { + clearTimeout(timer.current); + timer.current = setTimeout(() => setEpoch((e) => e + 1), delayMs); + }); + + useEffect(() => () => clearTimeout(timer.current), []); + + return epoch; +} diff --git a/test/hooks/useRedrawAfterInput.spec.tsx b/test/hooks/useRedrawAfterInput.spec.tsx new file mode 100644 index 0000000..8482c33 --- /dev/null +++ b/test/hooks/useRedrawAfterInput.spec.tsx @@ -0,0 +1,41 @@ +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 }: { delayMs: number }): React.ReactElement { + const epoch = useRedrawAfterInput(delayMs); + 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'); + }); +}); From db4db38c0928067e64cf02d9e2b24ca8f6600909 Mon Sep 17 00:00:00 2001 From: planadecu Date: Tue, 29 Sep 2026 16:17:12 +0200 Subject: [PATCH 2/6] Size the chart to the space actually left in the data pane The chart was given height = viewportSize (terminal rows - 8, sized for the list panes) and width = termWidth - 86 (a guess at the pane layout). The data pane also holds the param bar (wraps to 2 lines when narrow) and the metric line, so an inline-image chart, drawn at exactly the requested size, spilled ~3 rows over the pane border and status bar. DataView now measures a flex-grow box filling the rest of the pane (measureElement) and gives the chart exactly that size. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PzJHgXssByMwBXH1PGbXCL --- src/App.tsx | 5 +-- src/components/DataView.tsx | 36 ++++++++++------- test/components/DataView.spec.tsx | 65 +++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 18 deletions(-) create mode 100644 test/components/DataView.spec.tsx diff --git a/src/App.tsx b/src/App.tsx index bf6a771..b4aabaf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,7 +34,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; @@ -571,8 +572,6 @@ export function App(): React.ReactElement { viewMode={dataViewMode} showPrice={showPrice} priceData={priceData} - chartWidth={Math.max(20, termWidth - 30 - 50 - 6)} - chartHeight={viewportSize} chartRedrawKey={chartRedrawKey} /> )} diff --git a/src/components/DataView.tsx b/src/components/DataView.tsx index e97afba..635cb25 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,6 @@ interface DataViewProps { viewMode: DataViewMode; showPrice: boolean; priceData: DataPoint[]; - chartWidth: number; - chartHeight: number; /** Changing this re-draws the chart (see useRedrawAfterInput). */ chartRedrawKey: number; } @@ -40,11 +38,20 @@ export function DataView({ viewMode, showPrice, priceData, - chartWidth, - chartHeight, chartRedrawKey, }: DataViewProps): React.ReactElement { const [start, end] = visibleRange; + + // Size the chart to the space actually left in the pane (below the param bar and + // metric line, which wrap when the pane is narrow). Inline-image charts are drawn + // at exactly this size, so a guess would spill over the pane border. + const chartAreaRef = useRef(null); + const [chartArea, setChartArea] = useState({ width: 0, height: 0 }); + useEffect(() => { + if (!chartAreaRef.current) return; + const { width, height } = measureElement(chartAreaRef.current); + if (width !== chartArea.width || height !== chartArea.height) setChartArea({ width, height }); + }); const visible = data.slice(start, end); // Price lookup for table view @@ -158,20 +165,19 @@ export function DataView({ )} - {!loading && !error && data.length > 0 && viewMode === 'chart' && chartData && ( - - )} - {!loading && !error && data.length === 0 && selectedMetric && selectedAsset && ( No data )} + + {viewMode === 'chart' && ( + + {!loading && !error && data.length > 0 && chartData && chartArea.width > 0 && chartArea.height > 0 && ( + + )} + + )} ); } diff --git a/test/components/DataView.spec.tsx b/test/components/DataView.spec.tsx new file mode 100644 index 0000000..2e53771 --- /dev/null +++ b/test/components/DataView.spec.tsx @@ -0,0 +1,65 @@ +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'); + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function renderDataView(paneWidth: number, paneHeight: number) { + chartSizes.length = 0; + 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 delay(50); + 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 delay(50); + const size = chartSizes.at(-1)!; + expect(size.width).toBe(32); + expect(size.height).toBeLessThan(16); + expect(size.height).toBeGreaterThan(0); + }); +}); From 3d4c92bfbd8d564947b48a521056e584553336e0 Mon Sep 17 00:00:00 2001 From: planadecu Date: Tue, 29 Sep 2026 22:45:51 +0200 Subject: [PATCH 3/6] Bump ink-uplot to 0.2.14; fit the table to its pane; clean dist before publish - ink-uplot 0.2.14: quiet kitty commands (no replies leaking into stdin as keypresses), inline images blanked before Ink's next frame, cursor saved and restored around graphics writes, per-chart kitty image ids. - Table view (#14): the table showed viewportSize rows (terminal rows - 8, sized for the list panes) and overflowed the data pane. DataView now measures one body box for both table and chart (flex-basis 0, clipped) and reports its height; App uses it as the table's page size. - prepublishOnly (#15) removes dist/ before building, so stale compiled files can't be published. Closes #14, closes #15. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PzJHgXssByMwBXH1PGbXCL --- package.json | 5 +- pnpm-lock.yaml | 10 +-- src/App.tsx | 7 +- src/components/DataView.tsx | 109 ++++++++++++++++-------------- test/components/DataView.spec.tsx | 42 ++++++++++-- 5 files changed, 106 insertions(+), 67 deletions(-) diff --git a/package.json b/package.json index be99880..2fa19f0 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.14", "path2d": "^0.3.4", "react": "^19.3.0", "uplot": "^1.6.32", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9bdc9be..e818313 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.14 + version: 0.2.14(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.14: + resolution: {integrity: sha512-r47jr20kJc0H41+BBN6ftBT6tH5mVK8LLxNKSY2w/9ZNUE7KLrPOyMCLG4j0rpF/hPtzAcwTvlzvezWmiK4ePw==} 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.14(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 b4aabaf..b1ea143 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -326,7 +326,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(0); + const dataPageSize = dataBodyHeight > 1 ? dataBodyHeight - 1 : Math.max(1, viewportSize - 3); + const dataNavReal = useListNavigation({ itemCount: data.length, viewportSize: dataPageSize }); // Pane switching const nextPane = useCallback(() => { @@ -573,6 +577,7 @@ export function App(): React.ReactElement { showPrice={showPrice} priceData={priceData} chartRedrawKey={chartRedrawKey} + onBodyHeightChange={setDataBodyHeight} /> )} diff --git a/src/components/DataView.tsx b/src/components/DataView.tsx index 635cb25..b0a3d17 100644 --- a/src/components/DataView.tsx +++ b/src/components/DataView.tsx @@ -23,6 +23,8 @@ interface DataViewProps { priceData: DataPoint[]; /** 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({ @@ -39,19 +41,24 @@ export function DataView({ showPrice, priceData, chartRedrawKey, + onBodyHeightChange, }: DataViewProps): React.ReactElement { const [start, end] = visibleRange; - // Size the chart to the space actually left in the pane (below the param bar and - // metric line, which wrap when the pane is narrow). Inline-image charts are drawn - // at exactly this size, so a guess would spill over the pane border. - const chartAreaRef = useRef(null); - const [chartArea, setChartArea] = useState({ width: 0, height: 0 }); + // 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); + const [bodyArea, setBodyArea] = useState({ width: 0, height: 0 }); useEffect(() => { - if (!chartAreaRef.current) return; - const { width, height } = measureElement(chartAreaRef.current); - if (width !== chartArea.width || height !== chartArea.height) setChartArea({ width, height }); + if (!bodyRef.current) return; + const { width, height } = measureElement(bodyRef.current); + if (width !== bodyArea.width || height !== bodyArea.height) setBodyArea({ width, height }); }); + useEffect(() => { + if (bodyArea.height > 0) onBodyHeightChange?.(bodyArea.height); + }, [bodyArea.height, onBodyHeightChange]); const visible = data.slice(start, end); // Price lookup for table view @@ -128,56 +135,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 && selectedMetric && selectedAsset && ( No data )} - {viewMode === 'chart' && ( - - {!loading && !error && data.length > 0 && chartData && chartArea.width > 0 && chartArea.height > 0 && ( - - )} - - )} + + {!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/test/components/DataView.spec.tsx b/test/components/DataView.spec.tsx index 2e53771..952d085 100644 --- a/test/components/DataView.spec.tsx +++ b/test/components/DataView.spec.tsx @@ -18,27 +18,34 @@ function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -function renderDataView(paneWidth: number, paneHeight: number) { +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( , ); @@ -63,3 +70,24 @@ describe('DataView chart sizing', () => { 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 delay(50); + // 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 delay(50); + 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'); + }); +}); From 0e45ba39b8efaedf65136648aaf89d8929e57d27 Mon Sep 17 00:00:00 2001 From: planadecu Date: Tue, 29 Sep 2026 22:47:34 +0200 Subject: [PATCH 4/6] Poll instead of sleeping in DataView sizing tests Measuring takes a couple of render passes; a fixed 50ms wait was too short on the Node 22 CI runner. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PzJHgXssByMwBXH1PGbXCL --- test/components/DataView.spec.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/test/components/DataView.spec.tsx b/test/components/DataView.spec.tsx index 952d085..9493761 100644 --- a/test/components/DataView.spec.tsx +++ b/test/components/DataView.spec.tsx @@ -14,8 +14,14 @@ vi.mock('ink-uplot', () => ({ const { DataView } = await import('../../src/components/DataView.js'); -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); +// 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( @@ -56,14 +62,14 @@ describe('DataView chart sizing', () => { // 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 delay(50); + 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 delay(50); + await until(() => chartSizes.length > 0); const size = chartSizes.at(-1)!; expect(size.width).toBe(32); expect(size.height).toBeLessThan(16); @@ -75,14 +81,14 @@ 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 delay(50); + 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 delay(50); + await until(() => (lastFrame() ?? '').split('\n').length === 20); const lines = (lastFrame() ?? '').split('\n'); expect(lines).toHaveLength(20); expect(lines.at(-1)).toMatch(/^└─+┘$/); From 3f9ae923bcf15fda8aa3c328575ef0aaa7885771 Mon Sep 17 00:00:00 2001 From: planadecu Date: Tue, 29 Sep 2026 23:18:06 +0200 Subject: [PATCH 5/6] Address PR review: keep table selection visible, zero-height body, gate redraw - useListNavigation: when the viewport size changes, clamp the scroll offset so the selection stays visible and no empty rows show past the end (e.g. the selected table row scrolled out of view after the pane got shorter). - DataView reports its measured body height even when it is 0; App uses the measurement once one exists (page size >= 1) instead of the estimate. - useRedrawAfterInput only runs for inline-image charts (iTerm2 protocol, sixels) in chart view: kitty's graphics layer and text charts survive Ink repaints, so bumping there re-sent the whole image for nothing. Closes #18. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PzJHgXssByMwBXH1PGbXCL --- src/App.tsx | 12 +++++-- src/components/DataView.tsx | 11 ++++--- src/hooks/useListNavigation.ts | 11 +++++++ src/hooks/useRedrawAfterInput.ts | 6 +++- test/components/App.spec.tsx | 1 + test/components/DataView.spec.tsx | 8 +++++ test/hooks/useListNavigation.spec.tsx | 42 +++++++++++++++++++++++++ test/hooks/useRedrawAfterInput.spec.tsx | 12 +++++-- 8 files changed, 93 insertions(+), 10 deletions(-) create mode 100644 test/hooks/useListNavigation.spec.tsx diff --git a/src/App.tsx b/src/App.tsx index b1ea143..def00fc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,12 @@ 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, @@ -82,7 +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(); + 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; @@ -328,8 +334,8 @@ export function App(): React.ReactElement { ); // 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(0); - const dataPageSize = dataBodyHeight > 1 ? dataBodyHeight - 1 : Math.max(1, viewportSize - 3); + 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 diff --git a/src/components/DataView.tsx b/src/components/DataView.tsx index b0a3d17..ea3e746 100644 --- a/src/components/DataView.tsx +++ b/src/components/DataView.tsx @@ -50,15 +50,18 @@ export function DataView({ // 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); - const [bodyArea, setBodyArea] = useState({ width: 0, height: 0 }); + // `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 (width !== bodyArea.width || height !== bodyArea.height) setBodyArea({ width, height }); + if (!bodyArea.measured || width !== bodyArea.width || height !== bodyArea.height) { + setBodyArea({ width, height, measured: true }); + } }); useEffect(() => { - if (bodyArea.height > 0) onBodyHeightChange?.(bodyArea.height); - }, [bodyArea.height, onBodyHeightChange]); + if (bodyArea.measured) onBodyHeightChange?.(bodyArea.height); + }, [bodyArea.measured, bodyArea.height, onBodyHeightChange]); const visible = data.slice(start, end); // Price lookup for table view 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 index 605c68e..7a8d916 100644 --- a/src/hooks/useRedrawAfterInput.ts +++ b/src/hooks/useRedrawAfterInput.ts @@ -9,12 +9,16 @@ import { useInput } from 'ink'; * 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): number { +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); }); 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 index 9493761..237ee27 100644 --- a/test/components/DataView.spec.tsx +++ b/test/components/DataView.spec.tsx @@ -96,4 +96,12 @@ describe('DataView table sizing', () => { 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 index 8482c33..0c1f3a3 100644 --- a/test/hooks/useRedrawAfterInput.spec.tsx +++ b/test/hooks/useRedrawAfterInput.spec.tsx @@ -4,8 +4,8 @@ import { Text } from 'ink'; import { render } from 'ink-testing-library'; import { useRedrawAfterInput } from '../../src/hooks/useRedrawAfterInput.js'; -function Probe({ delayMs }: { delayMs: number }): React.ReactElement { - const epoch = useRedrawAfterInput(delayMs); +function Probe({ delayMs, enabled }: { delayMs: number; enabled?: boolean }): React.ReactElement { + const epoch = useRedrawAfterInput(delayMs, enabled); return epoch:{epoch}; } @@ -38,4 +38,12 @@ describe('useRedrawAfterInput', () => { 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'); + }); }); From 45cef0c8e0061fd33ffea4bc4e31db63ab0e4a83 Mon Sep 17 00:00:00 2001 From: planadecu Date: Wed, 30 Sep 2026 00:03:36 +0200 Subject: [PATCH 6/6] Bump ink-uplot to 0.2.15 (resize fixes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the review finding on this PR: resizing the terminal with an inline-image chart on screen (VS Code / iTerm2) left rows wiped — ink-uplot drew a stale old-size image after the resize, blanked it at stale coordinates, and blanked rows below Ink's cursor line onto the status bar. 0.2.15 also places images only after Ink has flushed the frame they were positioned for. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PzJHgXssByMwBXH1PGbXCL --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 2fa19f0..eb7fd85 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "glassnode-api": "^0.29.3", "ink": "^7.1.1", "ink-spinner": "^5.0.0", - "ink-uplot": "^0.2.14", + "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 e818313..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.14 - version: 0.2.14(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.14: - resolution: {integrity: sha512-r47jr20kJc0H41+BBN6ftBT6tH5mVK8LLxNKSY2w/9ZNUE7KLrPOyMCLG4j0rpF/hPtzAcwTvlzvezWmiK4ePw==} + 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.14(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