Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
294 changes: 261 additions & 33 deletions src/dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { homedir } from 'os'
import { EventEmitter } from 'node:events'

import React, { useState, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import { render, Box, Text, measureElement, useInput, useApp, useWindowSize, type DOMElement } from 'ink'
import { render, Box, Text, measureElement, useInput, useApp, useWindowSize, type DOMElement, type Instance, type RenderOptions } from 'ink'
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
import { formatCost, formatTokens, markEstimated, carriedCostNote } from './format.js'
import { aggregateModelEfficiency } from './model-efficiency.js'
Expand All @@ -18,7 +19,7 @@ import { CompareView } from './compare.js'
import { getPlanUsages, type PlanUsage } from './plan-usage.js'
import { planDisplayName } from './plans.js'
import { formatDayRangeLabel, getDateRange, parseDayFlag, PERIODS, PERIOD_LABELS, shiftDay, type Period } from './cli-date.js'
import { patchStdoutForWindows } from './ink-win.js'
import { BSU, ESU, patchStdoutForWindows } from './ink-win.js'

type View = 'dashboard' | 'optimize' | 'compare'

Expand All @@ -30,6 +31,244 @@ export type DailyActivityRow = {

export const DAILY_ACTIVITY_PAGE_SIZE = 10
export const INTERACTIVE_RENDER_OPTIONS = { alternateScreen: true } as const
export const RESIZE_DEBOUNCE_MS = 150

export type TerminalSize = { columns: number; rows: number }
export type DebouncedResizeStream = NodeJS.WriteStream & {
dispose(): void
onSettledResize(listener: (size: TerminalSize) => void): () => void
}

function normalizeTerminalDimension(value: number | undefined, fallback: number): number {
return Number.isFinite(value) && value! > 0 ? Math.floor(value!) : fallback
}

function terminalSizeOf(source: NodeJS.WriteStream): TerminalSize {
return {
columns: normalizeTerminalDimension(source.columns, 80),
rows: normalizeTerminalDimension(source.rows, 24),
}
}

export function createDebouncedResizeStream(source: NodeJS.WriteStream, delayMs: number): DebouncedResizeStream {
let resizeTimer: ReturnType<typeof setTimeout> | undefined
let releaseTimer: ReturnType<typeof setTimeout> | undefined
let disposed = false
let pendingResize = false
let suppressingFrame = false
let capturingResizeWrites = false
let finalFramePreamble = ''
let { columns, rows } = terminalSizeOf(source)
const resizeEvents = new EventEmitter()
const settledResizeListeners = new Set<(size: TerminalSize) => void>()

const resize = () => {
pendingResize = true
if (resizeTimer) clearTimeout(resizeTimer)
if (releaseTimer) clearTimeout(releaseTimer)
releaseTimer = undefined
resizeTimer = setTimeout(() => {
resizeTimer = undefined
if (disposed) return
;({ columns, rows } = terminalSizeOf(source))
const size = { columns, rows }
// Ink and useWindowSize both observe the same updated facade dimensions.
// Their immediate writes remain suppressed until React has applied the
// hook update; the wrapper then requests the sole coherent final frame.
capturingResizeWrites = true
try {
resizeEvents.emit('resize')
} finally {
capturingResizeWrites = false
}
releaseTimer = setTimeout(() => {
releaseTimer = undefined
if (disposed) return
pendingResize = false
suppressingFrame = false
for (const listener of [...settledResizeListeners]) listener(size)
}, 0)
}, delayMs)
}
source.on('resize', resize)

const dispose = () => {
if (disposed) return
disposed = true
source.off('resize', resize)
if (resizeTimer) clearTimeout(resizeTimer)
if (releaseTimer) clearTimeout(releaseTimer)
resizeTimer = undefined
releaseTimer = undefined
pendingResize = false
suppressingFrame = false
capturingResizeWrites = false
finalFramePreamble = ''
resizeEvents.removeAllListeners()
settledResizeListeners.clear()
}

const listenerMethods = new Set<PropertyKey>([
'addListener', 'on', 'once', 'prependListener', 'prependOnceListener', 'off', 'removeListener',
])

let stream: DebouncedResizeStream
stream = new Proxy(source as DebouncedResizeStream, {
get(target, property) {
if (property === 'dispose') return dispose
if (property === 'onSettledResize') {
return (listener: (size: TerminalSize) => void) => {
if (disposed) return () => {}
settledResizeListeners.add(listener)
return () => settledResizeListeners.delete(listener)
}
}
if (property === 'columns') return columns
if (property === 'rows') return rows
if (property === 'write') {
return (chunk: unknown, ...args: unknown[]) => {
if (typeof chunk !== 'string') {
return Reflect.apply(target.write, target, [chunk, ...args])
}

if (!pendingResize) {
if (finalFramePreamble.length === 0) {
return Reflect.apply(target.write, target, [chunk, ...args])
}
const start = chunk.indexOf(BSU)
const output = start === -1
? finalFramePreamble + chunk
: chunk.slice(0, start + BSU.length) + finalFramePreamble + chunk.slice(start + BSU.length)
finalFramePreamble = ''
return Reflect.apply(target.write, target, [output, ...args])
}

let visible = ''
let rest = chunk
while (rest.length > 0) {
if (suppressingFrame) {
const end = rest.indexOf(ESU)
if (end === -1) {
rest = ''
break
}
suppressingFrame = false
rest = rest.slice(end + ESU.length)
continue
}
const start = rest.indexOf(BSU)
if (start === -1) {
visible += rest
break
}
visible += rest.slice(0, start)
suppressingFrame = true
rest = rest.slice(start + BSU.length)
}

if (capturingResizeWrites) finalFramePreamble += visible
// An empty native write is a terminal no-op while retaining the
// source stream's callback timing and backpressure return value.
return Reflect.apply(target.write, target, [capturingResizeWrites ? '' : visible, ...args])
}
}
if (property === 'emit') {
return (event: string | symbol, ...args: unknown[]) => event === 'resize'
? !disposed && resizeEvents.emit(event, ...args)
: target.emit(event, ...args)
}
if (property === 'setMaxListeners') {
return (count: number) => {
target.setMaxListeners(count)
resizeEvents.setMaxListeners(count)
return stream
}
}
if (property === 'getMaxListeners') return () => resizeEvents.getMaxListeners()
if (property === 'eventNames') {
return () => {
const names = target.eventNames().filter(event => event !== 'resize')
if (resizeEvents.listenerCount('resize') > 0) names.push('resize')
return [...new Set(names)]
}
}
if (property === 'removeAllListeners') {
return (event?: string | symbol) => {
if (event === 'resize') {
resizeEvents.removeAllListeners(event)
} else if (event === undefined) {
target.removeAllListeners()
resizeEvents.removeAllListeners()
// The source relay is implementation-owned, not part of the
// facade's public listener set, so preserve it across a global clear.
if (!disposed) source.on('resize', resize)
} else {
target.removeAllListeners(event)
}
return stream
}
}
if (listenerMethods.has(property)) {
return (event: string | symbol, ...args: unknown[]) => {
if (event === 'resize' && disposed) return stream
const emitter = event === 'resize' ? resizeEvents : target
Reflect.apply(Reflect.get(emitter, property, emitter) as Function, emitter, [event, ...args])
return stream
}
}
if (property === 'listenerCount' || property === 'listeners' || property === 'rawListeners') {
return (event: string | symbol, ...args: unknown[]) => {
const emitter = event === 'resize' ? resizeEvents : target
return Reflect.apply(Reflect.get(emitter, property, emitter) as Function, emitter, [event, ...args])
}
}
const value = Reflect.get(target, property, target)
return typeof value === 'function' ? value.bind(target) : value
},
})
return stream
}

function DisposeOnUnmount({ dispose, children }: { dispose: () => void; children: React.ReactNode }) {
useLayoutEffect(() => dispose, [dispose])
return children
}

export type DebouncedInteractiveInstance = Instance & {
dispose(): void
stdout: DebouncedResizeStream
}

export function renderDebouncedInteractive(
source: NodeJS.WriteStream,
view: (size: TerminalSize) => React.ReactElement,
options: Omit<RenderOptions, 'stdout'> = INTERACTIVE_RENDER_OPTIONS,
): DebouncedInteractiveInstance {
const stdout = createDebouncedResizeStream(source, RESIZE_DEBOUNCE_MS)
let size = { columns: stdout.columns, rows: stdout.rows }
let unsubscribe = () => {}
let disposed = false
const dispose = () => {
if (disposed) return
disposed = true
unsubscribe()
stdout.dispose()
}
const dashboard = () => <DisposeOnUnmount dispose={dispose}>{view(size)}</DisposeOnUnmount>
let app: Instance
try {
app = render(dashboard(), { ...options, stdout })
} catch (error) {
dispose()
throw error
}
unsubscribe = stdout.onSettledResize(nextSize => {
if (disposed) return
size = nextSize
app.rerender(dashboard())
})
return Object.assign(app, { dispose, stdout })
}

export function getDailyActivityPageSize(columnCount: 1 | 2 | 3, projectRows: number, activityRows: number, dayMode = false): number {
if (dayMode) return 1
Expand Down Expand Up @@ -1224,23 +1463,21 @@ const WHEEL_LINES_PER_TICK = 3
const MOUSE_TRACKING_ON = '\x1b[?1000h\x1b[?1006h'
const MOUSE_TRACKING_OFF = '\x1b[?1006l\x1b[?1000l'

function ScrollableViewport({ children, width, lineScroll = true }: { children: React.ReactNode; width: number; lineScroll?: boolean }) {
const { rows } = useWindowSize()
function ScrollableViewport({ children, width, rows, lineScroll = true }: { children: React.ReactNode; width: number; rows: number; lineScroll?: boolean }) {
const height = Math.max(1, rows - 1)
const contentRef = useRef<DOMElement>(null)
const [maxOffset, setMaxOffset] = useState(0)
const [offset, setOffset] = useState(0)
// The stdin listener below outlives renders; it reads the current bound
// through a ref so scrolling never re-subscribes (and never re-emits the
// tracking enable sequence).
const maxOffsetRef = useRef(0)
maxOffsetRef.current = maxOffset

// Re-measure after every commit, but keep the bound outside React state.
// A terminal-only resize must preserve the reader's exact anchor and must
// not schedule a second correction frame; the next scroll input clamps
// against this freshly measured bound instead.
useLayoutEffect(() => {
if (!contentRef.current) return
const nextMaxOffset = Math.max(0, measureElement(contentRef.current).height - height)
setMaxOffset(current => current === nextMaxOffset ? current : nextMaxOffset)
setOffset(current => Math.min(current, nextMaxOffset))
maxOffsetRef.current = Math.max(0, measureElement(contentRef.current).height - height)
})

// Mouse-wheel scrolling via SGR mouse reporting. Known tradeoff: while
Expand All @@ -1261,24 +1498,24 @@ function ScrollableViewport({ children, width, lineScroll = true }: { children:
}, [])

useInput((_input, key) => {
if (lineScroll && key.downArrow) setOffset(current => Math.min(current + 1, maxOffset))
else if (lineScroll && key.upArrow) setOffset(current => Math.max(current - 1, 0))
else if (key.pageDown) setOffset(current => Math.min(current + height, maxOffset))
else if (key.pageUp) setOffset(current => Math.max(current - height, 0))
if (lineScroll && key.downArrow) setOffset(current => Math.min(current + 1, maxOffsetRef.current))
else if (lineScroll && key.upArrow) setOffset(current => Math.max(Math.min(current, maxOffsetRef.current) - 1, 0))
else if (key.pageDown) setOffset(current => Math.min(current + height, maxOffsetRef.current))
else if (key.pageUp) setOffset(current => Math.max(Math.min(current, maxOffsetRef.current) - height, 0))
else if (key.home) setOffset(0)
else if (key.end) setOffset(maxOffset)
else if (key.end) setOffset(maxOffsetRef.current)
})

return (
<Box width={width} height={height} overflowY="hidden">
<Box ref={contentRef} flexDirection="column" position="absolute" top={-Math.min(offset, maxOffset)} left={0}>
<Box ref={contentRef} flexDirection="column" position="absolute" top={-offset} left={0}>
{children}
</Box>
</Box>
)
}

export function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, initialPeriod, initialProvider, initialPlanUsages, initialDurable, refreshSeconds, projectFilter, excludeFilter, customRange, customRangeLabel, initialDay, windowColumns }: {
export function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, initialPeriod, initialProvider, initialPlanUsages, initialDurable, refreshSeconds, projectFilter, excludeFilter, customRange, customRangeLabel, initialDay, terminalSize }: {
initialProjects: ProjectSummary[]
initialDailyHistoryProjects?: ProjectSummary[]
initialPeriod: Period
Expand All @@ -1291,9 +1528,10 @@ export function InteractiveDashboard({ initialProjects, initialDailyHistoryProje
customRange?: DateRange | null
customRangeLabel?: string
initialDay?: string
windowColumns: number
terminalSize: TerminalSize
}) {
const { exit } = useApp()
const { columns, rows } = terminalSize
const [period, setPeriod] = useState<Period>(initialPeriod)
const [projects, setProjects] = useState<ProjectSummary[]>(initialProjects)
const [durable, setDurable] = useState<DurableOverview | undefined>(initialDurable)
Expand All @@ -1318,7 +1556,6 @@ export function InteractiveDashboard({ initialProjects, initialDailyHistoryProje
const isDayMode = dayDate != null
const isCustomRange = customRange != null && !isDayMode
const scrollableDailyHistory = !isCustomRange && !isDayMode
const columns = windowColumns
const maxContentWidth = useMemo(
() => getDashboardMaxWidth(projects, projectBudgets, activeProvider),
[projects, projectBudgets, activeProvider],
Expand Down Expand Up @@ -1641,6 +1878,7 @@ export function InteractiveDashboard({ initialProjects, initialDailyHistoryProje
<ScrollableViewport
key={`${view}:${period}:${activeProvider}:${dayDate ?? ''}:${customRangeLabel ?? ''}`}
width={dashWidth}
rows={rows}
lineScroll={view !== 'compare'}
>
{content}
Expand Down Expand Up @@ -1698,23 +1936,13 @@ export async function renderDashboard(period: Period = 'week', provider: string
const label = initialDay ? formatDayRangeLabel(initialDay) : customRangeLabel
patchStdoutForWindows()
if (isTTY) {
let windowColumns = process.stdout.columns
const dashboard = () => (
<InteractiveDashboard initialProjects={filteredProjects} initialDailyHistoryProjects={scrollableDailyHistory ? scannedProjects : undefined} initialPeriod={period} initialProvider={provider} initialPlanUsages={planUsages} initialDurable={initialDurable} refreshSeconds={refreshSeconds} projectFilter={projectFilter} excludeFilter={excludeFilter} customRange={customRange} customRangeLabel={customRangeLabel} initialDay={initialDay} windowColumns={windowColumns} />
)
const app = render(
dashboard(),
INTERACTIVE_RENDER_OPTIONS,
)
const resize = () => {
windowColumns = process.stdout.columns
app.rerender(dashboard())
}
process.stdout.prependListener('resize', resize)
const app = renderDebouncedInteractive(process.stdout, terminalSize => (
<InteractiveDashboard initialProjects={filteredProjects} initialDailyHistoryProjects={scrollableDailyHistory ? scannedProjects : undefined} initialPeriod={period} initialProvider={provider} initialPlanUsages={planUsages} initialDurable={initialDurable} refreshSeconds={refreshSeconds} projectFilter={projectFilter} excludeFilter={excludeFilter} customRange={customRange} customRangeLabel={customRangeLabel} initialDay={initialDay} terminalSize={terminalSize} />
))
try {
await app.waitUntilExit()
} finally {
process.stdout.off('resize', resize)
app.dispose()
}
} else {
const { unmount } = render(<StaticDashboard projects={filteredProjects} period={period} activeProvider={provider} planUsages={planUsages} label={label} dayMode={initialDay != null} durable={initialDurable} />, { patchConsole: false })
Expand Down
Loading
Loading