style: 完成所有文件的lint

This commit is contained in:
claude-code-best
2026-05-01 21:39:30 +08:00
parent d136872cc9
commit 6182015005
1333 changed files with 68255 additions and 77882 deletions

View File

@@ -1,45 +1,36 @@
import * as React from 'react'
import { Box } from '@anthropic/ink'
import * as React from 'react';
import { Box } from '@anthropic/ink';
type QueuedMessageContextValue = {
isQueued: boolean
isFirst: boolean
isQueued: boolean;
isFirst: boolean;
/** Width reduction for container padding (e.g., 4 for paddingX={2}) */
paddingWidth: number
}
paddingWidth: number;
};
const QueuedMessageContext = React.createContext<
QueuedMessageContextValue | undefined
>(undefined)
const QueuedMessageContext = React.createContext<QueuedMessageContextValue | undefined>(undefined);
export function useQueuedMessage(): QueuedMessageContextValue | undefined {
return React.useContext(QueuedMessageContext)
return React.useContext(QueuedMessageContext);
}
const PADDING_X = 2
const PADDING_X = 2;
type Props = {
isFirst: boolean
useBriefLayout?: boolean
children: React.ReactNode
}
isFirst: boolean;
useBriefLayout?: boolean;
children: React.ReactNode;
};
export function QueuedMessageProvider({
isFirst,
useBriefLayout,
children,
}: Props): React.ReactNode {
export function QueuedMessageProvider({ isFirst, useBriefLayout, children }: Props): React.ReactNode {
// Brief mode already indents via paddingLeft in HighlightedThinkingText /
// BriefTool UI — adding paddingX here would double-indent the queue.
const padding = useBriefLayout ? 0 : PADDING_X
const value = React.useMemo(
() => ({ isQueued: true, isFirst, paddingWidth: padding * 2 }),
[isFirst, padding],
)
const padding = useBriefLayout ? 0 : PADDING_X;
const value = React.useMemo(() => ({ isQueued: true, isFirst, paddingWidth: padding * 2 }), [isFirst, padding]);
return (
<QueuedMessageContext.Provider value={value}>
<Box paddingX={padding}>{children}</Box>
</QueuedMessageContext.Provider>
)
);
}

View File

@@ -1,26 +1,19 @@
import React, { createContext, useContext } from 'react'
import type { FpsMetrics } from '../utils/fpsTracker.js'
import React, { createContext, useContext } from 'react';
import type { FpsMetrics } from '../utils/fpsTracker.js';
type FpsMetricsGetter = () => FpsMetrics | undefined
type FpsMetricsGetter = () => FpsMetrics | undefined;
const FpsMetricsContext = createContext<FpsMetricsGetter | undefined>(undefined)
const FpsMetricsContext = createContext<FpsMetricsGetter | undefined>(undefined);
type Props = {
getFpsMetrics: FpsMetricsGetter
children: React.ReactNode
}
getFpsMetrics: FpsMetricsGetter;
children: React.ReactNode;
};
export function FpsMetricsProvider({
getFpsMetrics,
children,
}: Props): React.ReactNode {
return (
<FpsMetricsContext.Provider value={getFpsMetrics}>
{children}
</FpsMetricsContext.Provider>
)
export function FpsMetricsProvider({ getFpsMetrics, children }: Props): React.ReactNode {
return <FpsMetricsContext.Provider value={getFpsMetrics}>{children}</FpsMetricsContext.Provider>;
}
export function useFpsMetrics(): FpsMetricsGetter | undefined {
return useContext(FpsMetricsContext)
return useContext(FpsMetricsContext);
}

View File

@@ -1,25 +1,21 @@
import React, { createContext, useContext, useMemo } from 'react'
import { Mailbox } from '../utils/mailbox.js'
import React, { createContext, useContext, useMemo } from 'react';
import { Mailbox } from '../utils/mailbox.js';
const MailboxContext = createContext<Mailbox | undefined>(undefined)
const MailboxContext = createContext<Mailbox | undefined>(undefined);
type Props = {
children: React.ReactNode
}
children: React.ReactNode;
};
export function MailboxProvider({ children }: Props): React.ReactNode {
const mailbox = useMemo(() => new Mailbox(), [])
return (
<MailboxContext.Provider value={mailbox}>
{children}
</MailboxContext.Provider>
)
const mailbox = useMemo(() => new Mailbox(), []);
return <MailboxContext.Provider value={mailbox}>{children}</MailboxContext.Provider>;
}
export function useMailbox(): Mailbox {
const mailbox = useContext(MailboxContext)
const mailbox = useContext(MailboxContext);
if (!mailbox) {
throw new Error('useMailbox must be used within a MailboxProvider')
throw new Error('useMailbox must be used within a MailboxProvider');
}
return mailbox
return mailbox;
}

View File

@@ -1,5 +1,5 @@
import { createContext, type RefObject, useContext } from 'react'
import type { ScrollBoxHandle } from '@anthropic/ink'
import { createContext, type RefObject, useContext } from 'react';
import type { ScrollBoxHandle } from '@anthropic/ink';
/**
* Set by FullscreenLayout when rendering content in its `modal` slot —
@@ -19,14 +19,14 @@ import type { ScrollBoxHandle } from '@anthropic/ink'
* null = not inside the modal slot.
*/
type ModalCtx = {
rows: number
columns: number
scrollRef: RefObject<ScrollBoxHandle | null> | null
}
export const ModalContext = createContext<ModalCtx | null>(null)
rows: number;
columns: number;
scrollRef: RefObject<ScrollBoxHandle | null> | null;
};
export const ModalContext = createContext<ModalCtx | null>(null);
export function useIsInsideModal(): boolean {
return useContext(ModalContext) !== null
return useContext(ModalContext) !== null;
}
/**
@@ -35,14 +35,11 @@ export function useIsInsideModal(): boolean {
* component caps its visible content height — the modal's inner area is
* smaller than the terminal.
*/
export function useModalOrTerminalSize(fallback: {
rows: number
columns: number
}): { rows: number; columns: number } {
const ctx = useContext(ModalContext)
return ctx ? { rows: ctx.rows, columns: ctx.columns } : fallback
export function useModalOrTerminalSize(fallback: { rows: number; columns: number }): { rows: number; columns: number } {
const ctx = useContext(ModalContext);
return ctx ? { rows: ctx.rows, columns: ctx.columns } : fallback;
}
export function useModalScrollRef(): RefObject<ScrollBoxHandle | null> | null {
return useContext(ModalContext)?.scrollRef ?? null
return useContext(ModalContext)?.scrollRef ?? null;
}

View File

@@ -1,70 +1,70 @@
import type * as React from 'react'
import { useCallback, useEffect } from 'react'
import { useAppStateStore, useSetAppState } from 'src/state/AppState.js'
import type { Theme } from '../utils/theme.js'
import type * as React from 'react';
import { useCallback, useEffect } from 'react';
import { useAppStateStore, useSetAppState } from 'src/state/AppState.js';
import type { Theme } from '../utils/theme.js';
type Priority = 'low' | 'medium' | 'high' | 'immediate'
type Priority = 'low' | 'medium' | 'high' | 'immediate';
type BaseNotification = {
key: string
key: string;
/**
* Keys of notifications that this notification invalidates.
* If a notification is invalidated, it will be removed from the queue
* and, if currently displayed, cleared immediately.
*/
invalidates?: string[]
priority: Priority
timeoutMs?: number
invalidates?: string[];
priority: Priority;
timeoutMs?: number;
/**
* Combine notifications with the same key, like Array.reduce().
* Called as fold(accumulator, incoming) when a notification with a matching
* key already exists in the queue or is currently displayed.
* Returns the merged notification (should carry fold forward for future merges).
*/
fold?: (accumulator: Notification, incoming: Notification) => Notification
}
fold?: (accumulator: Notification, incoming: Notification) => Notification;
};
type TextNotification = BaseNotification & {
text: string
color?: keyof Theme
}
text: string;
color?: keyof Theme;
};
type JSXNotification = BaseNotification & {
jsx: React.ReactNode
}
jsx: React.ReactNode;
};
type AddNotificationFn = (content: Notification) => void
type RemoveNotificationFn = (key: string) => void
type AddNotificationFn = (content: Notification) => void;
type RemoveNotificationFn = (key: string) => void;
export type Notification = TextNotification | JSXNotification
export type Notification = TextNotification | JSXNotification;
const DEFAULT_TIMEOUT_MS = 8000
const DEFAULT_TIMEOUT_MS = 8000;
// Track current timeout to clear it when immediate notifications arrive
let currentTimeoutId: NodeJS.Timeout | null = null
let currentTimeoutId: NodeJS.Timeout | null = null;
export function useNotifications(): {
addNotification: AddNotificationFn
removeNotification: RemoveNotificationFn
addNotification: AddNotificationFn;
removeNotification: RemoveNotificationFn;
} {
const store = useAppStateStore()
const setAppState = useSetAppState()
const store = useAppStateStore();
const setAppState = useSetAppState();
// Process queue when current notification finishes or queue changes
const processQueue = useCallback(() => {
setAppState(prev => {
const next = getNext(prev.notifications.queue)
const next = getNext(prev.notifications.queue);
if (prev.notifications.current !== null || !next) {
return prev
return prev;
}
currentTimeoutId = setTimeout(
(setAppState, nextKey, processQueue) => {
currentTimeoutId = null
currentTimeoutId = null;
setAppState(prev => {
// Compare by key instead of reference to handle re-created notifications
if (prev.notifications.current?.key !== nextKey) {
return prev
return prev;
}
return {
...prev,
@@ -72,15 +72,15 @@ export function useNotifications(): {
queue: prev.notifications.queue,
current: null,
},
}
})
processQueue()
};
});
processQueue();
},
next.timeoutMs ?? DEFAULT_TIMEOUT_MS,
setAppState,
next.key,
processQueue,
)
);
return {
...prev,
@@ -88,9 +88,9 @@ export function useNotifications(): {
queue: prev.notifications.queue.filter(_ => _ !== next),
current: next,
},
}
})
}, [setAppState])
};
});
}, [setAppState]);
const addNotification = useCallback<AddNotificationFn>(
(notif: Notification) => {
@@ -98,36 +98,34 @@ export function useNotifications(): {
if (notif.priority === 'immediate') {
// Clear any existing timeout since we're showing a new immediate notification
if (currentTimeoutId) {
clearTimeout(currentTimeoutId)
currentTimeoutId = null
clearTimeout(currentTimeoutId);
currentTimeoutId = null;
}
// Set up timeout for the immediate notification
currentTimeoutId = setTimeout(
(setAppState, notif, processQueue) => {
currentTimeoutId = null
currentTimeoutId = null;
setAppState(prev => {
// Compare by key instead of reference to handle re-created notifications
if (prev.notifications.current?.key !== notif.key) {
return prev
return prev;
}
return {
...prev,
notifications: {
queue: prev.notifications.queue.filter(
_ => !notif.invalidates?.includes(_.key),
),
queue: prev.notifications.queue.filter(_ => !notif.invalidates?.includes(_.key)),
current: null,
},
}
})
processQueue()
};
});
processQueue();
},
notif.timeoutMs ?? DEFAULT_TIMEOUT_MS,
setAppState,
notif,
processQueue,
)
);
// Show the immediate notification right away
setAppState(prev => ({
@@ -136,19 +134,12 @@ export function useNotifications(): {
current: notif,
queue:
// Only re-queue the current notification if it's not immediate
[
...(prev.notifications.current
? [prev.notifications.current]
: []),
...prev.notifications.queue,
].filter(
_ =>
_.priority !== 'immediate' &&
!notif.invalidates?.includes(_.key),
[...(prev.notifications.current ? [prev.notifications.current] : []), ...prev.notifications.queue].filter(
_ => _.priority !== 'immediate' && !notif.invalidates?.includes(_.key),
),
},
}))
return // IMPORTANT: Exit addNotification for immediate notifications
}));
return; // IMPORTANT: Exit addNotification for immediate notifications
}
// Handle non-immediate notifications
@@ -157,18 +148,18 @@ export function useNotifications(): {
if (notif.fold) {
// Fold into current notification if keys match
if (prev.notifications.current?.key === notif.key) {
const folded = notif.fold(prev.notifications.current, notif)
const folded = notif.fold(prev.notifications.current, notif);
// Reset timeout for the folded notification
if (currentTimeoutId) {
clearTimeout(currentTimeoutId)
currentTimeoutId = null
clearTimeout(currentTimeoutId);
currentTimeoutId = null;
}
currentTimeoutId = setTimeout(
(setAppState, foldedKey, processQueue) => {
currentTimeoutId = null
currentTimeoutId = null;
setAppState(p => {
if (p.notifications.current?.key !== foldedKey) {
return p
return p;
}
return {
...p,
@@ -176,15 +167,15 @@ export function useNotifications(): {
queue: p.notifications.queue,
current: null,
},
}
})
processQueue()
};
});
processQueue();
},
folded.timeoutMs ?? DEFAULT_TIMEOUT_MS,
setAppState,
folded.key,
processQueue,
)
);
return {
...prev,
@@ -192,45 +183,37 @@ export function useNotifications(): {
current: folded,
queue: prev.notifications.queue,
},
}
};
}
// Fold into queued notification if keys match
const queueIdx = prev.notifications.queue.findIndex(
_ => _.key === notif.key,
)
const queueIdx = prev.notifications.queue.findIndex(_ => _.key === notif.key);
if (queueIdx !== -1) {
const folded = notif.fold(
prev.notifications.queue[queueIdx]!,
notif,
)
const newQueue = [...prev.notifications.queue]
newQueue[queueIdx] = folded
const folded = notif.fold(prev.notifications.queue[queueIdx]!, notif);
const newQueue = [...prev.notifications.queue];
newQueue[queueIdx] = folded;
return {
...prev,
notifications: {
current: prev.notifications.current,
queue: newQueue,
},
}
};
}
}
// Only add to queue if not already present (prevent duplicates)
const queuedKeys = new Set(prev.notifications.queue.map(_ => _.key))
const shouldAdd =
!queuedKeys.has(notif.key) &&
prev.notifications.current?.key !== notif.key
const queuedKeys = new Set(prev.notifications.queue.map(_ => _.key));
const shouldAdd = !queuedKeys.has(notif.key) && prev.notifications.current?.key !== notif.key;
if (!shouldAdd) return prev
if (!shouldAdd) return prev;
const invalidatesCurrent =
prev.notifications.current !== null &&
notif.invalidates?.includes(prev.notifications.current.key)
prev.notifications.current !== null && notif.invalidates?.includes(prev.notifications.current.key);
if (invalidatesCurrent && currentTimeoutId) {
clearTimeout(currentTimeoutId)
currentTimeoutId = null
clearTimeout(currentTimeoutId);
currentTimeoutId = null;
}
return {
@@ -239,35 +222,33 @@ export function useNotifications(): {
current: invalidatesCurrent ? null : prev.notifications.current,
queue: [
...prev.notifications.queue.filter(
_ =>
_.priority !== 'immediate' &&
!notif.invalidates?.includes(_.key),
_ => _.priority !== 'immediate' && !notif.invalidates?.includes(_.key),
),
notif,
],
},
}
})
};
});
// Process queue after adding the notification
processQueue()
processQueue();
},
[setAppState, processQueue],
)
);
const removeNotification = useCallback<RemoveNotificationFn>(
(key: string) => {
setAppState(prev => {
const isCurrent = prev.notifications.current?.key === key
const inQueue = prev.notifications.queue.some(n => n.key === key)
const isCurrent = prev.notifications.current?.key === key;
const inQueue = prev.notifications.queue.some(n => n.key === key);
if (!isCurrent && !inQueue) {
return prev
return prev;
}
if (isCurrent && currentTimeoutId) {
clearTimeout(currentTimeoutId)
currentTimeoutId = null
clearTimeout(currentTimeoutId);
currentTimeoutId = null;
}
return {
@@ -276,13 +257,13 @@ export function useNotifications(): {
current: isCurrent ? null : prev.notifications.current,
queue: prev.notifications.queue.filter(n => n.key !== key),
},
}
})
};
});
processQueue()
processQueue();
},
[setAppState, processQueue],
)
);
// Process queue on mount if there are notifications in the initial state.
// Imperative read (not useAppState) — a subscription in a mount-only
@@ -290,11 +271,11 @@ export function useNotifications(): {
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
if (store.getState().notifications.queue.length > 0) {
processQueue()
processQueue();
}
}, [])
}, []);
return { addNotification, removeNotification }
return { addNotification, removeNotification };
}
const PRIORITIES: Record<Priority, number> = {
@@ -302,10 +283,8 @@ const PRIORITIES: Record<Priority, number> = {
high: 1,
medium: 2,
low: 3,
}
};
export function getNext(queue: Notification[]): Notification | undefined {
if (queue.length === 0) return undefined
return queue.reduce((min, n) =>
PRIORITIES[n.priority] < PRIORITIES[min.priority] ? n : min,
)
if (queue.length === 0) return undefined;
return queue.reduce((min, n) => (PRIORITIES[n.priority] < PRIORITIES[min.priority] ? n : min));
}

View File

@@ -12,12 +12,12 @@
* The hook automatically registers on mount and unregisters on unmount,
* so no manual cleanup or state management is needed.
*/
import { useContext, useEffect, useLayoutEffect } from 'react'
import { instances } from '@anthropic/ink'
import { AppStoreContext, useAppState } from '../state/AppState.js'
import { useContext, useEffect, useLayoutEffect } from 'react';
import { instances } from '@anthropic/ink';
import { AppStoreContext, useAppState } from '../state/AppState.js';
// Non-modal overlays that shouldn't disable TextInput focus
const NON_MODAL_OVERLAYS = new Set(['autocomplete'])
const NON_MODAL_OVERLAYS = new Set(['autocomplete']);
/**
* Hook to register a component as an active overlay.
@@ -37,25 +37,25 @@ const NON_MODAL_OVERLAYS = new Set(['autocomplete'])
export function useRegisterOverlay(id: string, enabled = true): void {
// Use context directly so this is a no-op when rendered outside AppStateProvider
// (e.g., in isolated component tests that don't need the full app state tree).
const store = useContext(AppStoreContext)
const setAppState = store?.setState
const store = useContext(AppStoreContext);
const setAppState = store?.setState;
useEffect(() => {
if (!enabled || !setAppState) return
if (!enabled || !setAppState) return;
setAppState(prev => {
if (prev.activeOverlays.has(id)) return prev
const next = new Set(prev.activeOverlays)
next.add(id)
return { ...prev, activeOverlays: next }
})
if (prev.activeOverlays.has(id)) return prev;
const next = new Set(prev.activeOverlays);
next.add(id);
return { ...prev, activeOverlays: next };
});
return () => {
setAppState(prev => {
if (!prev.activeOverlays.has(id)) return prev
const next = new Set(prev.activeOverlays)
next.delete(id)
return { ...prev, activeOverlays: next }
})
}
}, [id, enabled, setAppState])
if (!prev.activeOverlays.has(id)) return prev;
const next = new Set(prev.activeOverlays);
next.delete(id);
return { ...prev, activeOverlays: next };
});
};
}, [id, enabled, setAppState]);
// On overlay close, force the next render to full-damage diff instead
// of blit. A tall overlay (e.g. FuzzyPicker with a 20-line preview)
@@ -66,9 +66,9 @@ export function useRegisterOverlay(id: string, enabled = true): void {
// deferred onRender (scheduleRender queues a microtask from
// resetAfterCommit; passive-effect cleanup would land after it).
useLayoutEffect(() => {
if (!enabled) return
return () => instances.get(process.stdout)?.invalidatePrevFrame()
}, [enabled])
if (!enabled) return;
return () => instances.get(process.stdout)?.invalidatePrevFrame();
}, [enabled]);
}
/**
@@ -85,7 +85,7 @@ export function useRegisterOverlay(id: string, enabled = true): void {
* }
*/
export function useIsOverlayActive(): boolean {
return useAppState(s => s.activeOverlays.size > 0)
return useAppState(s => s.activeOverlays.size > 0);
}
/**
@@ -102,8 +102,8 @@ export function useIsOverlayActive(): boolean {
export function useIsModalOverlayActive(): boolean {
return useAppState(s => {
for (const id of s.activeOverlays) {
if (!NON_MODAL_OVERLAYS.has(id)) return true
if (!NON_MODAL_OVERLAYS.has(id)) return true;
}
return false
})
return false;
});
}

View File

@@ -18,54 +18,42 @@
* Split into data/setter context pairs so writers never re-render on
* their own writes — the setter contexts are stable.
*/
import React, {
createContext,
type ReactNode,
useContext,
useEffect,
useState,
} from 'react'
import type { SuggestionItem } from '../components/PromptInput/PromptInputFooterSuggestions.js'
import React, { createContext, type ReactNode, useContext, useEffect, useState } from 'react';
import type { SuggestionItem } from '../components/PromptInput/PromptInputFooterSuggestions.js';
export type PromptOverlayData = {
suggestions: SuggestionItem[]
selectedSuggestion: number
maxColumnWidth?: number
}
suggestions: SuggestionItem[];
selectedSuggestion: number;
maxColumnWidth?: number;
};
type Setter<T> = (d: T | null) => void
type Setter<T> = (d: T | null) => void;
const DataContext = createContext<PromptOverlayData | null>(null)
const SetContext = createContext<Setter<PromptOverlayData> | null>(null)
const DialogContext = createContext<ReactNode>(null)
const SetDialogContext = createContext<Setter<ReactNode> | null>(null)
const DataContext = createContext<PromptOverlayData | null>(null);
const SetContext = createContext<Setter<PromptOverlayData> | null>(null);
const DialogContext = createContext<ReactNode>(null);
const SetDialogContext = createContext<Setter<ReactNode> | null>(null);
export function PromptOverlayProvider({
children,
}: {
children: ReactNode
}): ReactNode {
const [data, setData] = useState<PromptOverlayData | null>(null)
const [dialog, setDialog] = useState<ReactNode>(null)
export function PromptOverlayProvider({ children }: { children: ReactNode }): ReactNode {
const [data, setData] = useState<PromptOverlayData | null>(null);
const [dialog, setDialog] = useState<ReactNode>(null);
return (
<SetContext.Provider value={setData}>
<SetDialogContext.Provider value={setDialog}>
<DataContext.Provider value={data}>
<DialogContext.Provider value={dialog}>
{children}
</DialogContext.Provider>
<DialogContext.Provider value={dialog}>{children}</DialogContext.Provider>
</DataContext.Provider>
</SetDialogContext.Provider>
</SetContext.Provider>
)
);
}
export function usePromptOverlay(): PromptOverlayData | null {
return useContext(DataContext)
return useContext(DataContext);
}
export function usePromptOverlayDialog(): ReactNode {
return useContext(DialogContext)
return useContext(DialogContext);
}
/**
@@ -73,12 +61,12 @@ export function usePromptOverlayDialog(): ReactNode {
* No-op outside the provider (non-fullscreen renders inline instead).
*/
export function useSetPromptOverlay(data: PromptOverlayData | null): void {
const set = useContext(SetContext)
const set = useContext(SetContext);
useEffect(() => {
if (!set) return
set(data)
return () => set(null)
}, [set, data])
if (!set) return;
set(data);
return () => set(null);
}, [set, data]);
}
/**
@@ -86,10 +74,10 @@ export function useSetPromptOverlay(data: PromptOverlayData | null): void {
* No-op outside the provider (non-fullscreen renders inline instead).
*/
export function useSetPromptOverlayDialog(node: ReactNode): void {
const set = useContext(SetDialogContext)
const set = useContext(SetDialogContext);
useEffect(() => {
if (!set) return
set(node)
return () => set(null)
}, [set, node])
if (!set) return;
set(node);
return () => set(null);
}, [set, node]);
}

View File

@@ -1,173 +1,158 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
} from 'react'
import { saveCurrentProjectConfig } from '../utils/config.js'
import React, { createContext, useCallback, useContext, useEffect, useMemo } from 'react';
import { saveCurrentProjectConfig } from '../utils/config.js';
export type StatsStore = {
increment(name: string, value?: number): void
set(name: string, value: number): void
observe(name: string, value: number): void
add(name: string, value: string): void
getAll(): Record<string, number>
}
increment(name: string, value?: number): void;
set(name: string, value: number): void;
observe(name: string, value: number): void;
add(name: string, value: string): void;
getAll(): Record<string, number>;
};
function percentile(sorted: number[], p: number): number {
const index = (p / 100) * (sorted.length - 1)
const lower = Math.floor(index)
const upper = Math.ceil(index)
const index = (p / 100) * (sorted.length - 1);
const lower = Math.floor(index);
const upper = Math.ceil(index);
if (lower === upper) {
return sorted[lower]!
return sorted[lower]!;
}
return sorted[lower]! + (sorted[upper]! - sorted[lower]!) * (index - lower)
return sorted[lower]! + (sorted[upper]! - sorted[lower]!) * (index - lower);
}
const RESERVOIR_SIZE = 1024
const RESERVOIR_SIZE = 1024;
type Histogram = {
reservoir: number[]
count: number
sum: number
min: number
max: number
}
reservoir: number[];
count: number;
sum: number;
min: number;
max: number;
};
export function createStatsStore(): StatsStore {
const metrics = new Map<string, number>()
const histograms = new Map<string, Histogram>()
const sets = new Map<string, Set<string>>()
const metrics = new Map<string, number>();
const histograms = new Map<string, Histogram>();
const sets = new Map<string, Set<string>>();
return {
increment(name: string, value = 1) {
metrics.set(name, (metrics.get(name) ?? 0) + value)
metrics.set(name, (metrics.get(name) ?? 0) + value);
},
set(name: string, value: number) {
metrics.set(name, value)
metrics.set(name, value);
},
observe(name: string, value: number) {
let h = histograms.get(name)
let h = histograms.get(name);
if (!h) {
h = { reservoir: [], count: 0, sum: 0, min: value, max: value }
histograms.set(name, h)
h = { reservoir: [], count: 0, sum: 0, min: value, max: value };
histograms.set(name, h);
}
h.count++
h.sum += value
h.count++;
h.sum += value;
if (value < h.min) {
h.min = value
h.min = value;
}
if (value > h.max) {
h.max = value
h.max = value;
}
// Reservoir sampling (Algorithm R)
if (h.reservoir.length < RESERVOIR_SIZE) {
h.reservoir.push(value)
h.reservoir.push(value);
} else {
const j = Math.floor(Math.random() * h.count)
const j = Math.floor(Math.random() * h.count);
if (j < RESERVOIR_SIZE) {
h.reservoir[j] = value
h.reservoir[j] = value;
}
}
},
add(name: string, value: string) {
let s = sets.get(name)
let s = sets.get(name);
if (!s) {
s = new Set()
sets.set(name, s)
s = new Set();
sets.set(name, s);
}
s.add(value)
s.add(value);
},
getAll() {
const result: Record<string, number> = Object.fromEntries(metrics)
const result: Record<string, number> = Object.fromEntries(metrics);
for (const [name, h] of histograms) {
if (h.count === 0) {
continue
continue;
}
result[`${name}_count`] = h.count
result[`${name}_min`] = h.min
result[`${name}_max`] = h.max
result[`${name}_avg`] = h.sum / h.count
const sorted = [...h.reservoir].sort((a, b) => a - b)
result[`${name}_p50`] = percentile(sorted, 50)
result[`${name}_p95`] = percentile(sorted, 95)
result[`${name}_p99`] = percentile(sorted, 99)
result[`${name}_count`] = h.count;
result[`${name}_min`] = h.min;
result[`${name}_max`] = h.max;
result[`${name}_avg`] = h.sum / h.count;
const sorted = [...h.reservoir].sort((a, b) => a - b);
result[`${name}_p50`] = percentile(sorted, 50);
result[`${name}_p95`] = percentile(sorted, 95);
result[`${name}_p99`] = percentile(sorted, 99);
}
for (const [name, s] of sets) {
result[name] = s.size
result[name] = s.size;
}
return result
return result;
},
}
};
}
export const StatsContext = createContext<StatsStore | null>(null)
export const StatsContext = createContext<StatsStore | null>(null);
type Props = {
store?: StatsStore
children: React.ReactNode
}
store?: StatsStore;
children: React.ReactNode;
};
export function StatsProvider({
store: externalStore,
children,
}: Props): React.ReactNode {
const internalStore = useMemo(() => createStatsStore(), [])
const store = externalStore ?? internalStore
export function StatsProvider({ store: externalStore, children }: Props): React.ReactNode {
const internalStore = useMemo(() => createStatsStore(), []);
const store = externalStore ?? internalStore;
useEffect(() => {
const flush = () => {
const metrics = store.getAll()
const metrics = store.getAll();
if (Object.keys(metrics).length > 0) {
saveCurrentProjectConfig(current => ({
...current,
lastSessionMetrics: metrics,
}))
}));
}
}
process.on('exit', flush)
};
process.on('exit', flush);
return () => {
process.off('exit', flush)
}
}, [store])
process.off('exit', flush);
};
}, [store]);
return <StatsContext.Provider value={store}>{children}</StatsContext.Provider>
return <StatsContext.Provider value={store}>{children}</StatsContext.Provider>;
}
export function useStats(): StatsStore {
const store = useContext(StatsContext)
const store = useContext(StatsContext);
if (!store) {
throw new Error('useStats must be used within a StatsProvider')
throw new Error('useStats must be used within a StatsProvider');
}
return store
return store;
}
export function useCounter(name: string): (value?: number) => void {
const store = useStats()
return useCallback(
(value?: number) => store.increment(name, value),
[store, name],
)
const store = useStats();
return useCallback((value?: number) => store.increment(name, value), [store, name]);
}
export function useGauge(name: string): (value: number) => void {
const store = useStats()
return useCallback((value: number) => store.set(name, value), [store, name])
const store = useStats();
return useCallback((value: number) => store.set(name, value), [store, name]);
}
export function useTimer(name: string): (value: number) => void {
const store = useStats()
return useCallback(
(value: number) => store.observe(name, value),
[store, name],
)
const store = useStats();
return useCallback((value: number) => store.observe(name, value), [store, name]);
}
export function useSet(name: string): (value: string) => void {
const store = useStats()
return useCallback((value: string) => store.add(name, value), [store, name])
const store = useStats();
return useCallback((value: string) => store.add(name, value), [store, name]);
}

View File

@@ -1,18 +1,13 @@
import React, {
createContext,
useContext,
useState,
useSyncExternalStore,
} from 'react'
import { createStore, type Store } from '../state/store.js'
import React, { createContext, useContext, useState, useSyncExternalStore } from 'react';
import { createStore, type Store } from '../state/store.js';
export type VoiceState = {
voiceState: 'idle' | 'recording' | 'processing'
voiceError: string | null
voiceInterimTranscript: string
voiceAudioLevels: number[]
voiceWarmingUp: boolean
}
voiceState: 'idle' | 'recording' | 'processing';
voiceError: string | null;
voiceInterimTranscript: string;
voiceAudioLevels: number[];
voiceWarmingUp: boolean;
};
const DEFAULT_STATE: VoiceState = {
voiceState: 'idle',
@@ -20,29 +15,29 @@ const DEFAULT_STATE: VoiceState = {
voiceInterimTranscript: '',
voiceAudioLevels: [],
voiceWarmingUp: false,
}
};
type VoiceStore = Store<VoiceState>
type VoiceStore = Store<VoiceState>;
const VoiceContext = createContext<VoiceStore | null>(null)
const VoiceContext = createContext<VoiceStore | null>(null);
type Props = {
children: React.ReactNode
}
children: React.ReactNode;
};
export function VoiceProvider({ children }: Props): React.ReactNode {
// Store is created once — stable context value means the provider never
// triggers re-renders. Consumers subscribe to slices via useVoiceState.
const [store] = useState(() => createStore<VoiceState>(DEFAULT_STATE))
return <VoiceContext.Provider value={store}>{children}</VoiceContext.Provider>
const [store] = useState(() => createStore<VoiceState>(DEFAULT_STATE));
return <VoiceContext.Provider value={store}>{children}</VoiceContext.Provider>;
}
function useVoiceStore(): VoiceStore {
const store = useContext(VoiceContext)
const store = useContext(VoiceContext);
if (!store) {
throw new Error('useVoiceState must be used within a VoiceProvider')
throw new Error('useVoiceState must be used within a VoiceProvider');
}
return store
return store;
}
/**
@@ -50,9 +45,9 @@ function useVoiceStore(): VoiceStore {
* value changes (compared via Object.is).
*/
export function useVoiceState<T>(selector: (state: VoiceState) => T): T {
const store = useVoiceStore()
const get = () => selector(store.getState())
return useSyncExternalStore(store.subscribe, get, get)
const store = useVoiceStore();
const get = () => selector(store.getState());
return useSyncExternalStore(store.subscribe, get, get);
}
/**
@@ -60,10 +55,8 @@ export function useVoiceState<T>(selector: (state: VoiceState) => T): T {
* store.setState is synchronous: callers can read getVoiceState() immediately
* after to observe the new value (VoiceKeybindingHandler relies on this).
*/
export function useSetVoiceState(): (
updater: (prev: VoiceState) => VoiceState,
) => void {
return useVoiceStore().setState
export function useSetVoiceState(): (updater: (prev: VoiceState) => VoiceState) => void {
return useVoiceStore().setState;
}
/**
@@ -72,5 +65,5 @@ export function useSetVoiceState(): (
* inside event handlers that need to read state set earlier in the same tick.
*/
export function useGetVoiceState(): () => VoiceState {
return useVoiceStore().getState
return useVoiceStore().getState;
}