diff --git a/apps/desktop/src/app/agents/index.tsx b/apps/desktop/src/app/agents/index.tsx index e84458877a..480efe444b 100644 --- a/apps/desktop/src/app/agents/index.tsx +++ b/apps/desktop/src/app/agents/index.tsx @@ -1,3 +1,4 @@ +import { compactNumber } from '@hermes/shared' import { useStore } from '@nanostores/react' import { type ReactNode, useEffect, useMemo, useState } from 'react' @@ -8,7 +9,6 @@ import { Codicon } from '@/components/ui/codicon' import { FadeText } from '@/components/ui/fade-text' import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { type Translations, useI18n } from '@/i18n' -import { compactNumber } from '@/lib/format' import { AlertCircle, CheckCircle2 } from '@/lib/icons' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' diff --git a/apps/desktop/src/app/chat/composer/reasoning-pill.tsx b/apps/desktop/src/app/chat/composer/reasoning-pill.tsx index 6df4a81535..d13700ced1 100644 --- a/apps/desktop/src/app/chat/composer/reasoning-pill.tsx +++ b/apps/desktop/src/app/chat/composer/reasoning-pill.tsx @@ -1,3 +1,4 @@ +import { DEFAULT_REASONING_EFFORT } from '@hermes/shared' import { useStore } from '@nanostores/react' import { useState } from 'react' @@ -9,7 +10,7 @@ import { releaseTypingFocus } from '@/components/ui/keyboard-first' import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { ChevronDown } from '@/lib/icons' -import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort' +import { reasoningEffortLabel } from '@/lib/reasoning-effort' import { cn } from '@/lib/utils' import { $defaultReasoningEffort } from '@/store/session' diff --git a/apps/desktop/src/app/chat/sidebar/chrome.tsx b/apps/desktop/src/app/chat/sidebar/chrome.tsx index 1bcb2117c8..aad36a0878 100644 --- a/apps/desktop/src/app/chat/sidebar/chrome.tsx +++ b/apps/desktop/src/app/chat/sidebar/chrome.tsx @@ -1,3 +1,4 @@ +import { compactNumber } from '@hermes/shared' import { useStore } from '@nanostores/react' import type * as React from 'react' @@ -7,7 +8,6 @@ import { Codicon } from '@/components/ui/codicon' import { DisclosureCaret } from '@/components/ui/disclosure-caret' import { RowButton } from '@/components/ui/row-button' import { Tip } from '@/components/ui/tooltip' -import { compactNumber } from '@/lib/format' import { cn } from '@/lib/utils' import { $sidebarRowMeta } from '@/store/layout' import type { TileDock } from '@/store/session-states' diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index 41aa0abc2c..cfe2076fb2 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -1,3 +1,4 @@ +import { compactNumber } from '@hermes/shared' import { useStore } from '@nanostores/react' import { memo } from 'react' import type * as React from 'react' @@ -15,7 +16,6 @@ import type { SessionInfo } from '@/hermes' import { type Translations, useI18n } from '@/i18n' import { sessionTitle } from '@/lib/chat-runtime' import { pathLeaf } from '@/lib/display-path' -import { compactNumber } from '@/lib/format' import { triggerHaptic } from '@/lib/haptics' import { middleClickHandlers } from '@/lib/middle-click' import { displayModelName } from '@/lib/model-status-label' diff --git a/apps/desktop/src/app/command-center/index.tsx b/apps/desktop/src/app/command-center/index.tsx index f2d9830172..2192ec1b84 100644 --- a/apps/desktop/src/app/command-center/index.tsx +++ b/apps/desktop/src/app/command-center/index.tsx @@ -1,3 +1,4 @@ +import { compactNumber } from '@hermes/shared' import { type MouseEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { LogTail } from '@/components/chat/log-tail' @@ -12,7 +13,6 @@ import { getActionStatus, getLogs, getStatus, getUsageAnalytics, restartGateway, import type { ActionStatusResponse, AnalyticsResponse, SessionInfo, StatusResponse } from '@/hermes' import { useI18n } from '@/i18n' import { sessionTitle } from '@/lib/chat-runtime' -import { compactNumber } from '@/lib/format' import { Activity, AlertCircle, diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 46142dda84..70ac9ab455 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -1,3 +1,5 @@ +import { REASONING_EFFORTS } from '@hermes/shared' + import { Box, Brain, @@ -12,7 +14,6 @@ import { Sun, Wrench } from '@/lib/icons' -import { REASONING_EFFORTS } from '@/lib/reasoning-effort' import type { ThemeMode } from '@/themes/context' // Single source of truth for built-in personality names lives in diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx index 8c169070db..1b2dd71e40 100644 --- a/apps/desktop/src/app/settings/model-settings.tsx +++ b/apps/desktop/src/app/settings/model-settings.tsx @@ -1,4 +1,5 @@ import type { ModelOptionProvider } from '@hermes/shared' +import { DEFAULT_REASONING_EFFORT, REASONING_EFFORT_VALUES } from '@hermes/shared' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button } from '@/components/ui/button' @@ -27,7 +28,6 @@ import type { import { useI18n } from '@/i18n' import { isCodeSkewRestartRequired } from '@/lib/code-skew-error' import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons' -import { DEFAULT_REASONING_EFFORT, REASONING_EFFORT_VALUES } from '@/lib/reasoning-effort' import { cn } from '@/lib/utils' import { setMainModelAssignment } from '@/store/cron-model-impact' import { notifyError, readableError } from '@/store/notifications' diff --git a/apps/desktop/src/app/shell/context-usage-panel.tsx b/apps/desktop/src/app/shell/context-usage-panel.tsx index d98517fb20..f604193536 100644 --- a/apps/desktop/src/app/shell/context-usage-panel.tsx +++ b/apps/desktop/src/app/shell/context-usage-panel.tsx @@ -1,7 +1,7 @@ +import { compactNumber } from '@hermes/shared' import { useMemo } from 'react' import { useI18n } from '@/i18n' -import { compactNumber } from '@/lib/format' import { cn } from '@/lib/utils' import type { ContextBreakdown, ContextUsageCategory, UsageStats } from '@/types/hermes' diff --git a/apps/desktop/src/app/shell/model-catalog-menu.tsx b/apps/desktop/src/app/shell/model-catalog-menu.tsx index 58d836e0f5..d99ff8a421 100644 --- a/apps/desktop/src/app/shell/model-catalog-menu.tsx +++ b/apps/desktop/src/app/shell/model-catalog-menu.tsx @@ -1,4 +1,5 @@ import type { ModelOptionProvider, ModelOptionsResponse } from '@hermes/shared' +import { DEFAULT_REASONING_EFFORT } from '@hermes/shared' import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' import { createContext, type ReactNode, useContext, useEffect, useMemo, useRef, useState } from 'react' @@ -24,7 +25,7 @@ import { getLocalModelsStatus } from '@/hermes' import { useI18n } from '@/i18n' import { catalogProviderMatches, modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options' import { displayModelName, modelDisplayParts } from '@/lib/model-status-label' -import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort' +import { reasoningEffortLabel } from '@/lib/reasoning-effort' import { foldIncludes, normalize } from '@/lib/text' import { useStoreSelector } from '@/lib/use-session-slice' import { cn } from '@/lib/utils' diff --git a/apps/desktop/src/app/shell/model-edit-submenu.tsx b/apps/desktop/src/app/shell/model-edit-submenu.tsx index cafc938876..9b77c081fb 100644 --- a/apps/desktop/src/app/shell/model-edit-submenu.tsx +++ b/apps/desktop/src/app/shell/model-edit-submenu.tsx @@ -1,3 +1,5 @@ +import { REASONING_EFFORTS } from '@hermes/shared' + import { DropdownMenuItem, DropdownMenuLabel, @@ -10,7 +12,7 @@ import { } from '@/components/ui/dropdown-menu' import { Switch } from '@/components/ui/switch' import { useI18n } from '@/i18n' -import { isThinkingEnabled, REASONING_EFFORTS, resolveReasoningEffort } from '@/lib/reasoning-effort' +import { isThinkingEnabled, resolveReasoningEffort } from '@/lib/reasoning-effort' // Hermes' real reasoning levels live in lib/reasoning-effort; `none` is owned // by the Thinking toggle, not the radio. diff --git a/apps/desktop/src/app/shell/titlebar-controls.tsx b/apps/desktop/src/app/shell/titlebar-controls.tsx index 023e4e32d4..203cef0923 100644 --- a/apps/desktop/src/app/shell/titlebar-controls.tsx +++ b/apps/desktop/src/app/shell/titlebar-controls.tsx @@ -1,3 +1,4 @@ +import { compactNumber } from '@hermes/shared' import { useStore } from '@nanostores/react' import { type ComponentProps, type MouseEvent, type ReactNode, useEffect, useState } from 'react' import { useLocation, useNavigate } from 'react-router' @@ -11,7 +12,6 @@ import { Tip, TipKeybindLabel } from '@/components/ui/tooltip' import { Slot } from '@/contrib/react/slot' import { useContributions } from '@/contrib/react/use-contributions' import { useI18n } from '@/i18n' -import { compactNumber } from '@/lib/format' import { triggerHaptic } from '@/lib/haptics' import { formatModifierToken } from '@/lib/keybinds/combo' import { cn } from '@/lib/utils' diff --git a/apps/desktop/src/app/shell/use-model-menu-controller.ts b/apps/desktop/src/app/shell/use-model-menu-controller.ts index c8bb3e951c..e49e44fec1 100644 --- a/apps/desktop/src/app/shell/use-model-menu-controller.ts +++ b/apps/desktop/src/app/shell/use-model-menu-controller.ts @@ -1,4 +1,4 @@ -import type { ModelOptionsResponse } from '@hermes/shared' +import { DEFAULT_REASONING_EFFORT, type ModelOptionsResponse } from '@hermes/shared' import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' @@ -7,7 +7,6 @@ import type { HermesGateway } from '@/hermes' import { useI18n } from '@/i18n' import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options' import { currentPickerSelection } from '@/lib/model-status-label' -import { DEFAULT_REASONING_EFFORT } from '@/lib/reasoning-effort' import { $modelPresets, applyModelPreset, modelPresetKey, setModelPreset } from '@/store/model-presets' import { notifyError } from '@/store/notifications' import { diff --git a/apps/desktop/src/app/skills/index.tsx b/apps/desktop/src/app/skills/index.tsx index 3a17f97b62..f98e548f8a 100644 --- a/apps/desktop/src/app/skills/index.tsx +++ b/apps/desktop/src/app/skills/index.tsx @@ -1,3 +1,4 @@ +import { compactNumber } from '@hermes/shared' import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' import type * as React from 'react' @@ -29,7 +30,6 @@ import { } from '@/hermes' import { useI18n } from '@/i18n' import { isDesktopToolsetVisible } from '@/lib/desktop-toolsets' -import { compactNumber } from '@/lib/format' import { Loader2 } from '@/lib/icons' import { queryClient } from '@/lib/query-client' import { invalidateSlashCompletions } from '@/lib/slash-completion-cache' diff --git a/apps/desktop/src/app/skills/mcp-tab.tsx b/apps/desktop/src/app/skills/mcp-tab.tsx index 020d3e8e2a..830165b7ee 100644 --- a/apps/desktop/src/app/skills/mcp-tab.tsx +++ b/apps/desktop/src/app/skills/mcp-tab.tsx @@ -1,3 +1,4 @@ +import { compactNumber } from '@hermes/shared' import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' import { useEffect, useMemo, useRef, useState } from 'react' @@ -32,7 +33,6 @@ import { } from '@/hermes' import { type Translations, useI18n } from '@/i18n' import { startCompletionPoll } from '@/lib/completion-poll' -import { compactNumber } from '@/lib/format' import { brandFor } from '@/lib/mcp-brands' import { estimateServerTokens, serverUsageCount } from '@/lib/mcp-cost' import { completeMcpDesktopOAuth } from '@/lib/mcp-dashboard-oauth' diff --git a/apps/desktop/src/components/ui/tab-dropdown.tsx b/apps/desktop/src/components/ui/tab-dropdown.tsx index 83a5d68ebf..9c670f34a4 100644 --- a/apps/desktop/src/components/ui/tab-dropdown.tsx +++ b/apps/desktop/src/components/ui/tab-dropdown.tsx @@ -1,3 +1,4 @@ +import { compactNumber } from '@hermes/shared' import { Fragment } from 'react' import { Codicon } from '@/components/ui/codicon' @@ -10,7 +11,6 @@ import { } from '@/components/ui/dropdown-menu' import { CountSkeleton } from '@/components/ui/skeleton' import { TextTab, TextTabMeta } from '@/components/ui/text-tab' -import { compactNumber } from '@/lib/format' import type { IconComponent } from '@/lib/icons' import { cn } from '@/lib/utils' diff --git a/apps/desktop/src/lib/reasoning-effort.test.ts b/apps/desktop/src/lib/reasoning-effort.test.ts index bc61b91fb9..1bbecd8a5e 100644 --- a/apps/desktop/src/lib/reasoning-effort.test.ts +++ b/apps/desktop/src/lib/reasoning-effort.test.ts @@ -1,22 +1,9 @@ +import { DEFAULT_REASONING_EFFORT, REASONING_EFFORT_VALUES } from '@hermes/shared' import { describe, expect, it } from 'vitest' -import { - DEFAULT_REASONING_EFFORT, - isReasoningEffort, - isThinkingEnabled, - REASONING_EFFORT_VALUES, - REASONING_EFFORTS, - reasoningEffortLabel, - resolveReasoningEffort -} from './reasoning-effort' +import { isThinkingEnabled, reasoningEffortLabel, resolveReasoningEffort } from './reasoning-effort' describe('reasoning-effort', () => { - it('keeps the scale ascending and `none` off it', () => { - expect(REASONING_EFFORTS).not.toContain('none') - expect(REASONING_EFFORT_VALUES[0]).toBe('none') - expect(REASONING_EFFORT_VALUES).toHaveLength(REASONING_EFFORTS.length + 1) - }) - it('labels every level it claims to support', () => { for (const effort of REASONING_EFFORT_VALUES) { expect(reasoningEffortLabel(effort)).not.toBe('') @@ -27,13 +14,6 @@ describe('reasoning-effort', () => { expect(reasoningEffortLabel('bogus')).toBe('bogus') }) - it('recognizes only real scale levels', () => { - expect(isReasoningEffort(DEFAULT_REASONING_EFFORT)).toBe(true) - expect(isReasoningEffort('HIGH')).toBe(true) - expect(isReasoningEffort('none')).toBe(false) - expect(isReasoningEffort('bogus')).toBe(false) - }) - it('treats empty as inherit and only `none` as off', () => { expect(isThinkingEnabled('none')).toBe(false) expect(isThinkingEnabled('high')).toBe(true) diff --git a/apps/desktop/src/lib/reasoning-effort.ts b/apps/desktop/src/lib/reasoning-effort.ts index 5d3e17afd0..87e968ff4c 100644 --- a/apps/desktop/src/lib/reasoning-effort.ts +++ b/apps/desktop/src/lib/reasoning-effort.ts @@ -1,19 +1,7 @@ +import { DEFAULT_REASONING_EFFORT, isReasoningEffort } from '@hermes/shared' + import { normalize } from '@/lib/text' -/** Hermes' reasoning levels, in ascending order — mirrors the backend's - * VALID_REASONING_EFFORTS (hermes_constants.py). `none` is not a level: it's - * thinking disabled, owned by the Thinking toggle rather than the scale. */ -export const REASONING_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const - -export type ReasoningEffort = (typeof REASONING_EFFORTS)[number] - -/** The scale plus the off state — the full set a config value may hold. */ -export const REASONING_EFFORT_VALUES = ['none', ...REASONING_EFFORTS] as const - -/** Hermes' built-in level when neither the surface nor the profile config - * specifies one (mirrors the backend's own fallback). */ -export const DEFAULT_REASONING_EFFORT: ReasoningEffort = 'medium' - /** Compact labels for chrome where space is tight (pill, picker rows). Menus * and settings use the translated `shell.modelOptions` strings instead. */ const SHORT_LABELS: Record = { @@ -33,9 +21,6 @@ export function reasoningEffortLabel(effort: string): string { return key ? (SHORT_LABELS[key] ?? effort) : '' } -export const isReasoningEffort = (value: string): value is ReasoningEffort => - REASONING_EFFORTS.includes(normalize(value) as ReasoningEffort) - /** Thinking is on unless a level explicitly says otherwise; an empty value * means "inherit", so it resolves through `fallback` first. */ export const isThinkingEnabled = (effort: string, fallback: string = DEFAULT_REASONING_EFFORT): boolean => diff --git a/apps/desktop/src/lib/statusbar.tsx b/apps/desktop/src/lib/statusbar.tsx index 4f875fa90e..b12e6056c2 100644 --- a/apps/desktop/src/lib/statusbar.tsx +++ b/apps/desktop/src/lib/statusbar.tsx @@ -1,8 +1,8 @@ +import { compactNumber } from '@hermes/shared' import { useState } from 'react' import { StableText } from '@/components/chat/stable-text' import { useViewedInterval } from '@/hooks/use-viewed-interval' -import { compactNumber } from '@/lib/format' import type { UsageStats } from '@/types/hermes' export function formatDuration(elapsedMs: number): string { diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 5720b09008..e1880bc8ef 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -1669,9 +1669,6 @@ export { type BudgetedLoop, type BudgetedLoopOptions, createBudgetedLoop } from /** The blank transcript as a contribution area: claim the sessions you own and * render what stands in the gap. Core's own splash keeps a fresh draft. */ export { CHAT_EMPTY_AREA, type ChatEmptyContribution, type ChatEmptyProps } from '@/lib/chat-empty' -/** THE compact-number formatter — every user-facing count/token figure goes - * through here (1230 → "1.2k", 1_500_000 → "1.5M"). Don't hand-roll `/1000`. */ -export { compactNumber } from '@/lib/format' /** THE confirm flow for guarded model switches — when a gateway model-switch * RPC answers `confirm_required` (data-policy / expensive-model guard), * route it through this shared applier instead of forking a per-surface @@ -1703,17 +1700,11 @@ export { PROFILE_SWATCHES, profileColor, profileColorSoft } from '@/lib/profile- * `ctx.socket` frame invalidating a query). Inside components keep using * `useQueryClient`. */ export { queryClient } from '@/lib/query-client' +/** Compact labels for the reasoning levels exported from @hermes/shared, so a + * plugin surfacing a thinking depth uses the same spelling as the app. */ +export { reasoningEffortLabel } from '@/lib/reasoning-effort' export const PANES_AREA = 'panes' -/** Hermes' reasoning levels + their compact labels, so a plugin surfacing a - * thinking depth uses the same scale and spelling as the rest of the app. */ -export { - DEFAULT_REASONING_EFFORT, - REASONING_EFFORT_VALUES, - REASONING_EFFORTS, - type ReasoningEffort, - reasoningEffortLabel -} from '@/lib/reasoning-effort' export const STATUSBAR_AREAS = { left: 'statusBar.left', right: 'statusBar.right' } as const export const TITLEBAR_AREAS = { center: 'titleBar.center', left: 'titleBar.left', right: 'titleBar.right' } as const @@ -1783,6 +1774,17 @@ export { THEMES_AREA } from '@/themes/user-themes' export type { StatusResponse } from '@/types/hermes' /** Public SDK name for the shared gateway wire event; kept stable for plugins. */ export type { GatewayEvent as RpcEvent } from '@hermes/shared' +/** THE compact-number formatter — every user-facing count/token figure goes + * through here (1230 → "1.2k", 1_500_000 → "1.5M"). Don't hand-roll `/1000`. */ +export { compactNumber } from '@hermes/shared' +/** Hermes' reasoning levels, so a plugin surfacing a thinking depth uses the + * same scale as the rest of the app (labels: `reasoningEffortLabel`). */ +export { + DEFAULT_REASONING_EFFORT, + REASONING_EFFORT_VALUES, + REASONING_EFFORTS, + type ReasoningEffort +} from '@hermes/shared' /** Subscribe a component to a `host.state` atom. */ export { useStore as useValue } from '@nanostores/react' /** The app's data-fetching layer. Plugins share the ONE QueryClient mounted at diff --git a/apps/shared/package.json b/apps/shared/package.json index b610261962..a12c8c1dfe 100644 --- a/apps/shared/package.json +++ b/apps/shared/package.json @@ -8,11 +8,16 @@ "./billing": "./src/billing-types.ts", "./billing-policy": "./src/billing-policy.ts", "./charge-settlement": "./src/charge-settlement.ts", + "./format": "./src/format.ts", "./fuzzy": "./src/fuzzy.ts", "./gateway-events": "./src/gateway-events.ts", "./json-rpc-channel": "./src/json-rpc-channel.ts", "./model-search-text": "./src/model-search-text.ts", +<<<<<<< HEAD "./reconnect-backoff": "./src/reconnect-backoff.ts", +======= + "./reasoning-effort": "./src/reasoning-effort.ts", +>>>>>>> 914084147a68 (refactor(ts): one compactNumber and one reasoning-effort value set in @hermes/shared) "./skin": "./src/skin.ts" }, "types": "./src/index.ts", diff --git a/apps/shared/src/format.test.ts b/apps/shared/src/format.test.ts new file mode 100644 index 0000000000..b2820fc032 --- /dev/null +++ b/apps/shared/src/format.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' + +import { compactNumber } from './format' + +describe('compactNumber', () => { + it.each([ + [0, '0'], + [999, '999'], + [1000, '1k'], + [1500, '1.5k'], + [128_000, '128k'], + // Promotion guard: a value that would round to "1000k" promotes to M. + [999_949, '999.9k'], + [999_999, '1M'], + [1_000_000, '1M'], + [1_500_000, '1.5M'], + [1_000_000_000, '1000M'] + ])('%d → %s', (value, expected) => { + expect(compactNumber(value)).toBe(expected) + }) + + it('never emits a unit-boundary artifact like "1000k" or "1000"', () => { + for (const value of [999.5, 999.9, 999_950, 999_999.4]) { + expect(compactNumber(value)).not.toMatch(/^1000(\.\d)?k?$/) + } + }) +}) diff --git a/apps/desktop/src/lib/format.ts b/apps/shared/src/format.ts similarity index 100% rename from apps/desktop/src/lib/format.ts rename to apps/shared/src/format.ts diff --git a/apps/shared/src/index.ts b/apps/shared/src/index.ts index 44ff583105..ea0da12a0f 100644 --- a/apps/shared/src/index.ts +++ b/apps/shared/src/index.ts @@ -46,6 +46,7 @@ export { DATA_URL_READ_MAX_MAX_MB, DATA_URL_READ_MIN_MAX_MB } from './data-url-read-max' +export { compactNumber } from './format' export { type FuzzyMatch, fuzzyRank, fuzzyScore, fuzzyScoreMulti, type RankedItem } from './fuzzy' export { type ApprovalRequestPayload, @@ -113,6 +114,14 @@ export { } from './json-rpc-gateway' export { reconnectBackoffDelayMs, type ReconnectBackoffOptions } from './reconnect-backoff' export { modelSearchText } from './model-search-text' +export { + DEFAULT_REASONING_EFFORT, + isReasoningEffort, + REASONING_EFFORT_VALUES, + REASONING_EFFORTS, + type ReasoningEffort, + type ReasoningEffortValue +} from './reasoning-effort' export { skillInvocationText } from './skill-scaffold' export { type HermesSkin, diff --git a/apps/shared/src/reasoning-effort.test.ts b/apps/shared/src/reasoning-effort.test.ts new file mode 100644 index 0000000000..b4afc83ad3 --- /dev/null +++ b/apps/shared/src/reasoning-effort.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' + +import { + DEFAULT_REASONING_EFFORT, + isReasoningEffort, + REASONING_EFFORT_VALUES, + REASONING_EFFORTS +} from './reasoning-effort' + +describe('reasoning-effort', () => { + it('is one duplicate-free value set with `none` as the only non-level', () => { + expect(new Set(REASONING_EFFORT_VALUES).size).toBe(REASONING_EFFORT_VALUES.length) + expect(REASONING_EFFORT_VALUES.filter(v => !isReasoningEffort(v))).toEqual(['none']) + }) + + it('defaults to a real level and recognizes it case-insensitively', () => { + expect(REASONING_EFFORTS).toContain(DEFAULT_REASONING_EFFORT) + expect(isReasoningEffort(DEFAULT_REASONING_EFFORT.toUpperCase())).toBe(true) + }) +}) diff --git a/apps/shared/src/reasoning-effort.ts b/apps/shared/src/reasoning-effort.ts new file mode 100644 index 0000000000..e92adf3a19 --- /dev/null +++ b/apps/shared/src/reasoning-effort.ts @@ -0,0 +1,19 @@ +/** Hermes' reasoning levels, in ascending order — mirrors the backend's + * VALID_REASONING_EFFORTS (hermes_constants.py). `none` is not a level: it's + * thinking disabled. */ +export const REASONING_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const + +export type ReasoningEffort = (typeof REASONING_EFFORTS)[number] + +/** The scale plus the off state — the full set a config value may hold. */ +export const REASONING_EFFORT_VALUES = ['none', ...REASONING_EFFORTS] as const + +export type ReasoningEffortValue = (typeof REASONING_EFFORT_VALUES)[number] + +/** Hermes' built-in level when neither the surface nor the profile config + * specifies one (mirrors the backend's own fallback). */ +export const DEFAULT_REASONING_EFFORT: ReasoningEffort = 'medium' + +/** True for a real level (case-insensitive, trimmed); `none` is not a level. */ +export const isReasoningEffort = (value: string): value is ReasoningEffort => + REASONING_EFFORTS.includes(value.trim().toLowerCase() as ReasoningEffort) diff --git a/ui-tui/src/__tests__/text.test.ts b/ui-tui/src/__tests__/text.test.ts index 707ad227ec..fba3109299 100644 --- a/ui-tui/src/__tests__/text.test.ts +++ b/ui-tui/src/__tests__/text.test.ts @@ -7,7 +7,6 @@ import { edgePreview, estimateRows, estimateTokensRough, - fmtK, hasAnsi, isToolTrailResultLine, lastCotTrailIndex, @@ -118,22 +117,6 @@ describe('sameToolTrailGroup', () => { }) }) -describe('fmtK', () => { - it('keeps small numbers plain', () => { - expect(fmtK(999)).toBe('999') - }) - - it('formats thousands as lowercase k', () => { - expect(fmtK(1000)).toBe('1k') - expect(fmtK(1500)).toBe('1.5k') - }) - - it('formats millions and billions with lowercase suffixes', () => { - expect(fmtK(1_000_000)).toBe('1m') - expect(fmtK(1_000_000_000)).toBe('1b') - }) -}) - describe('estimateTokensRough', () => { it('uses 4 chars per token rounding up', () => { expect(estimateTokensRough('')).toBe(0) diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index 5367b5a1b9..502fd21524 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -1,3 +1,5 @@ +import { compactNumber } from '@hermes/shared/format' + import { usageBarsText } from '../../../components/overlayPrimitives.js' import { introMsg, toTranscriptMessages } from '../../../domain/messages.js' import { sessionScopedModelArg, TUI_SESSION_MODEL_FLAG } from '../../../domain/slash.js' @@ -12,7 +14,6 @@ import type { VoiceToggleResponse } from '../../../gatewayTypes.js' import { formatVoiceRecordKey, parseVoiceRecordKey } from '../../../lib/platform.js' -import { fmtK } from '../../../lib/text.js' import type { PanelSection } from '../../../types.js' import { applyConfiguredTuiTheme } from '../../createGatewayEventHandler.js' import { DEFAULT_INDICATOR_STYLE, INDICATOR_STYLES, type IndicatorStyle } from '../../interfaces.js' @@ -281,7 +282,7 @@ export const sessionCommands: SlashCommand[] = [ } ctx.transcript.sys( - `compressed ${r.removed} messages${r.usage?.total ? ` · ${fmtK(r.usage.total)} tok` : ''}` + `compressed ${r.removed} messages${r.usage?.total ? ` · ${compactNumber(r.usage.total)} tok` : ''}` ) }) ) diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 4deef2c9bb..2a5de7ce85 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -1,5 +1,6 @@ import { Box, type ScrollBoxHandle, stringWidth, Text } from '@hermes/ink' import type { Usage } from '@hermes/shared/gateway-events' +import { compactNumber } from '@hermes/shared/format' import { useStore } from '@nanostores/react' import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from 'react' import unicodeSpinners from 'unicode-animations' @@ -14,7 +15,6 @@ import { VERBS } from '../content/verbs.js' import { fmtDuration } from '../domain/messages.js' import { stickyPromptFromViewport } from '../domain/viewport.js' import { buildSubagentTree, treeTotals, widthByDepth } from '../lib/subagentTree.js' -import { fmtK } from '../lib/text.js' import { useScrollbarSnapshot, useViewportSnapshot } from '../lib/viewportStore.js' import type { Theme } from '../theme.js' import type { Msg } from '../types.js' @@ -525,10 +525,10 @@ export function StatusRule({ ok('context_detail') || ok('context_pct') ? usage.context_max ? segs.compactCtx - ? `${contextMark}${fmtK(usage.context_used ?? 0)} tok` - : `${contextMark}${fmtK(usage.context_used ?? 0)}/${fmtK(usage.context_max)}` + ? `${contextMark}${compactNumber(usage.context_used ?? 0)} tok` + : `${contextMark}${compactNumber(usage.context_used ?? 0)}/${compactNumber(usage.context_max)}` : usage.total > 0 - ? `${fmtK(usage.total)} tok` + ? `${compactNumber(usage.total)} tok` : '' : '' diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index da484a0fa6..222b8a0a15 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -1,4 +1,5 @@ import { Box, NoSelect, Text } from '@hermes/ink' +import { compactNumber } from '@hermes/shared/format' import { memo, type ReactNode, useEffect, useMemo, useRef, useState } from 'react' import spinners, { type BrailleSpinnerName } from 'unicode-animations' @@ -18,7 +19,6 @@ import { boundedLiveRenderText, compactPreview, estimateTokensRough, - fmtK, formatToolCall, parseToolTrailResultLine, pick, @@ -934,11 +934,12 @@ export const ToolTrail = memo(function ToolTrail({ const toolTokenCount = toolTokens ?? 0 const totalTokenCount = tokenCount + toolTokenCount - const thinkingTokensLabel = tokenCount > 0 ? `~${fmtK(tokenCount)} tokens` : null + const thinkingTokensLabel = tokenCount > 0 ? `~${compactNumber(tokenCount)} tokens` : null - const toolTokensLabel = toolTokens !== undefined && toolTokens > 0 ? `~${fmtK(toolTokens)} tokens` : undefined + const toolTokensLabel = + toolTokens !== undefined && toolTokens > 0 ? `~${compactNumber(toolTokens)} tokens` : undefined - const totalTokensLabel = tokenCount > 0 && toolTokenCount > 0 ? `~${fmtK(totalTokenCount)} total` : null + const totalTokensLabel = tokenCount > 0 && toolTokenCount > 0 ? `~${compactNumber(totalTokenCount)} total` : null const delegateGroups = groups.filter(g => g.label.startsWith('Delegate Task')) const inlineDelegateKey = hasSubagents && delegateGroups.length === 1 ? delegateGroups[0]!.key : null diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 003930dbe2..18c11d8263 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -1,3 +1,5 @@ +import { compactNumber } from '@hermes/shared/format' + import { LIVE_RENDER_MAX_CHARS, LIVE_RENDER_MAX_LINES, @@ -96,14 +98,14 @@ export const pasteTokenLabel = (text: string, lineCount: number) => { const preview = edgePreview(text) if (!preview) { - return `[[ [${fmtK(lineCount)} lines] ]]` + return `[[ [${compactNumber(lineCount)} lines] ]]` } const [head = preview, tail = ''] = preview.split('.. ', 2) return tail - ? `[[ ${head.trimEnd()}.. [${fmtK(lineCount)} lines] .. ${tail.trimStart()} ]]` - : `[[ ${preview} [${fmtK(lineCount)} lines] ]]` + ? `[[ ${head.trimEnd()}.. [${compactNumber(lineCount)} lines] .. ${tail.trimStart()} ]]` + : `[[ ${preview} [${compactNumber(lineCount)} lines] ]]` } const THINKING_STATUS_RE = new RegExp(`^(?:${VERBS.join('|')})\\.{0,3}$`, 'i') @@ -177,8 +179,8 @@ const boundedRenderText = ( const label = omittedLines > 0 - ? `[${labelPrefix}; omitted ${fmtK(omittedLines)} lines / ${fmtK(omittedChars)} chars]\n` - : `[${labelPrefix}; omitted ${fmtK(omittedChars)} chars]\n` + ? `[${labelPrefix}; omitted ${compactNumber(omittedLines)} lines / ${compactNumber(omittedChars)} chars]\n` + : `[${labelPrefix}; omitted ${compactNumber(omittedChars)} chars]\n` return `${label}${tail}` } @@ -410,10 +412,6 @@ export const clarifyBatchRevisitState = ( export const flat = (r: Record) => Object.values(r).flat() -const COMPACT_NUMBER = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1, notation: 'compact' }) - -export const fmtK = (n: number) => COMPACT_NUMBER.format(n).replace(/[KMBT]$/, s => s.toLowerCase()) - export const pick = (a: T[]) => a[Math.floor(Math.random() * a.length)]! export const isPasteBackedText = (text: string) => diff --git a/web/src/components/ModelInfoCard.tsx b/web/src/components/ModelInfoCard.tsx index 6feb6113df..389f2a5c3c 100644 --- a/web/src/components/ModelInfoCard.tsx +++ b/web/src/components/ModelInfoCard.tsx @@ -3,7 +3,7 @@ import { Brain, Eye, Gauge, Lightbulb, Wrench } from "lucide-react"; import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { api } from "@/lib/api"; import type { ModelInfoResponse } from "@/lib/api"; -import { formatTokenCount } from "@/lib/format"; +import { compactNumber } from "@hermes/shared"; interface ModelInfoCardProps { /** Current model string from config state — used to detect changes */ @@ -57,11 +57,11 @@ export function ModelInfoCard({
- {formatTokenCount(info.effective_context_length)} + {compactNumber(info.effective_context_length)} {info.config_context_length > 0 ? ( - (override — auto: {formatTokenCount(info.auto_context_length)}) + (override — auto: {compactNumber(info.auto_context_length)}) ) : ( @@ -78,7 +78,7 @@ export function ModelInfoCard({ Max Output
- {formatTokenCount(caps.max_output_tokens)} + {compactNumber(caps.max_output_tokens)} )} diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts deleted file mode 100644 index f5e373ca6b..0000000000 --- a/web/src/lib/format.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Format a token count as a human-readable string (e.g. 1M, 128K, 4096). - * Strips trailing ".0" for clean round numbers. - */ -export function formatTokenCount(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}K`; - return String(n); -} diff --git a/web/src/lib/reasoning-effort.ts b/web/src/lib/reasoning-effort.ts index 2d5fecd312..55bc303baa 100644 --- a/web/src/lib/reasoning-effort.ts +++ b/web/src/lib/reasoning-effort.ts @@ -1,12 +1,18 @@ +import { + DEFAULT_REASONING_EFFORT, + REASONING_EFFORT_VALUES, + type ReasoningEffortValue, +} from "@hermes/shared"; + /** * Pure reasoning-effort helpers shared by the dashboard ReasoningPicker. * * Kept DOM-free so the node-environment vitest harness can cover the * resolution logic without loading React or the UI kit. * - * Values mirror hermes_constants.VALID_REASONING_EFFORTS plus `none` - * (thinking-off). An empty/unset config value means the Hermes default, - * which is `medium`. + * Values come from @hermes/shared (hermes_constants.VALID_REASONING_EFFORTS + * plus `none`, thinking-off). An empty/unset config value means the Hermes + * default. */ export interface EffortOption { @@ -14,25 +20,28 @@ export interface EffortOption { label: string; } -export const EFFORT_OPTIONS: ReadonlyArray = [ - { value: "none", label: "Off (no thinking)" }, - { value: "minimal", label: "Minimal" }, - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High" }, - { value: "xhigh", label: "Extra High" }, - { value: "max", label: "Max" }, - { value: "ultra", label: "Ultra" }, -]; +const EFFORT_LABELS: Record = { + none: "Off (no thinking)", + minimal: "Minimal", + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + max: "Max", + ultra: "Ultra", +}; -export const VALID_EFFORTS: ReadonlySet = new Set( - EFFORT_OPTIONS.map((o) => o.value), +/** `none` first, then the seven levels ascending — the shared value order. */ +export const EFFORT_OPTIONS: ReadonlyArray = REASONING_EFFORT_VALUES.map( + (value) => ({ value, label: EFFORT_LABELS[value] }), ); +export const VALID_EFFORTS: ReadonlySet = new Set(REASONING_EFFORT_VALUES); + /** Normalize a raw `agent.reasoning_effort` config value to a selectable * option. Empty/unknown → `medium` (Hermes' default when unset). */ export function normalizeEffort(raw: unknown): string { const value = String(raw ?? "").trim().toLowerCase(); - if (!value) return "medium"; - return VALID_EFFORTS.has(value) ? value : "medium"; + if (!value) return DEFAULT_REASONING_EFFORT; + return VALID_EFFORTS.has(value) ? value : DEFAULT_REASONING_EFFORT; } diff --git a/web/src/pages/ModelsPage.tsx b/web/src/pages/ModelsPage.tsx index a1fbe0d122..a4a6161e9e 100644 --- a/web/src/pages/ModelsPage.tsx +++ b/web/src/pages/ModelsPage.tsx @@ -28,7 +28,7 @@ import { DASHBOARD_MODAL_PANEL, shouldCloseOuterModalOnEscape, } from "@/lib/dashboard-modal-shell"; -import { formatTokenCount } from "@/lib/format"; +import { compactNumber } from "@hermes/shared"; import { Button } from "@nous-research/ui/ui/components/button"; import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { Stats } from "@nous-research/ui/ui/components/stats"; @@ -430,12 +430,12 @@ function ModelCard({ )} {caps.context_window && caps.context_window > 0 && ( - {formatTokenCount(caps.context_window)} ctx + {compactNumber(caps.context_window)} ctx )} {caps.max_output_tokens && caps.max_output_tokens > 0 && ( - {formatTokenCount(caps.max_output_tokens)} out + {compactNumber(caps.max_output_tokens)} out )}