feat: add inline token count etc and fix venv

This commit is contained in:
Brooklyn Nicholson
2026-04-15 10:20:56 -05:00
parent 561cea0d4a
commit 33c615504d
21 changed files with 984 additions and 459 deletions

View File

@@ -1,84 +1,19 @@
import { AlternateScreen, Box, NoSelect, ScrollBox, type ScrollBoxHandle, Text } from '@hermes/ink'
import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import type { RefObject } from 'react'
import { PLACEHOLDER } from '../app/constants.js'
import type { CompletionItem, TranscriptRow, VirtualHistoryState } from '../app/interfaces.js'
import type { AppLayoutProps } from '../app/interfaces.js'
import { $isBlocked } from '../app/overlayStore.js'
import { $uiState } from '../app/uiStore.js'
import type { ActiveTool, ActivityItem, Msg } from '../types.js'
import { StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
import { AppOverlays } from './appOverlays.js'
import { Banner, Panel, SessionPanel } from './branding.js'
import { MessageLine } from './messageLine.js'
import { QueuedMessages } from './queuedMessages.js'
import type { PasteEvent } from './textInput.js'
import { TextInput } from './textInput.js'
import { ToolTrail } from './thinking.js'
export interface AppLayoutActions {
answerApproval: (choice: string) => void
answerClarify: (answer: string) => void
answerSecret: (value: string) => void
answerSudo: (pw: string) => void
onModelSelect: (value: string) => void
resumeById: (id: string) => void
setStickyPrompt: (value: string) => void
}
export interface AppLayoutComposerProps {
cols: number
compIdx: number
completions: CompletionItem[]
empty: boolean
handleTextPaste: (event: PasteEvent) => { cursor: number; value: string } | null
input: string
inputBuf: string[]
pagerPageSize: number
queueEditIdx: number | null
queuedDisplay: string[]
submit: (value: string) => void
updateInput: (next: string) => void
}
export interface AppLayoutProgressProps {
activity: ActivityItem[]
reasoning: string
reasoningActive: boolean
reasoningStreaming: boolean
showProgressArea: boolean
showStreamingArea: boolean
streaming: string
tools: ActiveTool[]
turnTrail: string[]
}
export interface AppLayoutStatusProps {
cwdLabel: string
durationLabel: string
showStickyPrompt: boolean
statusColor: string
stickyPrompt: string
voiceLabel: string
}
export interface AppLayoutTranscriptProps {
historyItems: Msg[]
scrollRef: RefObject<ScrollBoxHandle | null>
virtualHistory: VirtualHistoryState
virtualRows: TranscriptRow[]
}
export interface AppLayoutProps {
actions: AppLayoutActions
composer: AppLayoutComposerProps
mouseTracking: boolean
progress: AppLayoutProgressProps
status: AppLayoutStatusProps
transcript: AppLayoutTranscriptProps
}
export function AppLayout({ actions, composer, mouseTracking, progress, status, transcript }: AppLayoutProps) {
const ui = useStore($uiState)
const isBlocked = useStore($isBlocked)
@@ -125,8 +60,10 @@ export function AppLayout({ actions, composer, mouseTracking, progress, status,
reasoning={progress.reasoning}
reasoningActive={progress.reasoningActive}
reasoningStreaming={progress.reasoningStreaming}
reasoningTokens={progress.reasoningTokens}
t={ui.theme}
tools={progress.tools}
toolTokens={progress.toolTokens}
trail={progress.turnTrail}
/>
)}

View File

@@ -2,7 +2,7 @@ import { Box, Text } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { useGateway } from '../app/gatewayContext.js'
import type { CompletionItem } from '../app/interfaces.js'
import type { AppOverlaysProps } from '../app/interfaces.js'
import { $overlayState, patchOverlayState } from '../app/overlayStore.js'
import { $uiState } from '../app/uiStore.js'
@@ -12,19 +12,6 @@ import { ModelPicker } from './modelPicker.js'
import { ApprovalPrompt, ClarifyPrompt } from './prompts.js'
import { SessionPicker } from './sessionPicker.js'
export interface AppOverlaysProps {
cols: number
compIdx: number
completions: CompletionItem[]
onApprovalChoice: (choice: string) => void
onClarifyAnswer: (value: string) => void
onModelSelect: (value: string) => void
onPickerSelect: (sessionId: string) => void
onSecretSubmit: (value: string) => void
onSudoSubmit: (pw: string) => void
pagerPageSize: number
}
export function AppOverlays({
cols,
compIdx,

View File

@@ -85,7 +85,14 @@ export const MessageLine = memo(function MessageLine({
>
{showDetails && (
<Box flexDirection="column" marginBottom={1}>
<ToolTrail detailsMode={detailsMode} reasoning={thinking} t={t} trail={msg.tools} />
<ToolTrail
detailsMode={detailsMode}
reasoning={thinking}
reasoningTokens={msg.thinkingTokens}
t={t}
toolTokens={msg.toolTokens}
trail={msg.tools}
/>
</Box>
)}

View File

@@ -1,9 +1,10 @@
import { Box, Text } from '@hermes/ink'
import { memo, type ReactNode, useEffect, useState } from 'react'
import { memo, type ReactNode, useEffect, useMemo, useState } from 'react'
import spinners, { type BrailleSpinnerName } from 'unicode-animations'
import { FACES, VERBS } from '../constants.js'
import {
estimateTokensRough,
fmtK,
formatToolCall,
parseToolTrailResultLine,
pick,
@@ -89,6 +90,7 @@ function Chevron({
count,
onClick,
open,
suffix,
t,
title,
tone = 'dim'
@@ -96,6 +98,7 @@ function Chevron({
count?: number
onClick: () => void
open: boolean
suffix?: string
t: Theme
title: string
tone?: 'dim' | 'error' | 'warn'
@@ -108,6 +111,12 @@ function Chevron({
<Text color={t.color.amber}>{open ? '▾ ' : '▸ '}</Text>
{title}
{typeof count === 'number' ? ` (${count})` : ''}
{suffix ? (
<Text color={t.color.statusFg} dimColor>
{' '}
{suffix}
</Text>
) : null}
</Text>
</Box>
)
@@ -128,29 +137,35 @@ export const Thinking = memo(function Thinking({
streaming?: boolean
t: Theme
}) {
const [tick, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => setTick(v => v + 1), 1100)
return () => clearInterval(id)
}, [])
const preview = thinkingPreview(reasoning, mode, THINKING_COT_MAX)
const lines = useMemo(() => preview.split('\n').map(line => line.replace(/\t/g, ' ')), [preview])
return (
<Box flexDirection="column">
<Text color={t.color.dim}>
<Spinner color={t.color.dim} /> {FACES[tick % FACES.length] ?? '(•_•)'}{' '}
{VERBS[tick % VERBS.length] ?? 'thinking'}
</Text>
{preview ? (
<Text color={t.color.dim} dimColor wrap={mode === 'full' ? 'wrap-trim' : 'truncate-end'}>
<Text dimColor> </Text>
{preview}
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
</Text>
mode === 'full' ? (
<Box flexDirection="row">
<Text color={t.color.dim} dimColor>
{' '}
</Text>
<Box flexDirection="column" flexGrow={1}>
{lines.map((line, index) => (
<Text color={t.color.dim} dimColor key={index} wrap="wrap-trim">
{line || ' '}
{index === lines.length - 1 ? (
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
) : null}
</Text>
))}
</Box>
</Box>
) : (
<Text color={t.color.dim} dimColor wrap="truncate-end">
<Text dimColor> </Text>
{preview}
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
</Text>
)
) : active ? (
<Text color={t.color.dim} dimColor>
<Text dimColor> </Text>
@@ -175,9 +190,11 @@ export const ToolTrail = memo(function ToolTrail({
detailsMode = 'collapsed',
reasoningActive = false,
reasoning = '',
reasoningTokens,
reasoningStreaming = false,
t,
tools = [],
toolTokens,
trail = [],
activity = []
}: {
@@ -185,9 +202,11 @@ export const ToolTrail = memo(function ToolTrail({
detailsMode?: DetailsMode
reasoningActive?: boolean
reasoning?: string
reasoningTokens?: number
reasoningStreaming?: boolean
t: Theme
tools?: ActiveTool[]
toolTokens?: number
trail?: string[]
activity?: ActivityItem[]
}) {
@@ -311,6 +330,17 @@ export const ToolTrail = memo(function ToolTrail({
const hasTools = groups.length > 0
const hasMeta = meta.length > 0
const hasThinking = !!cot || reasoningActive || (busy && !hasTools)
const thinkingLive = reasoningActive || reasoningStreaming
const tokenCount = reasoningTokens !== undefined ? reasoningTokens : reasoning ? estimateTokensRough(reasoning) : 0
const toolTokenCount = toolTokens ?? 0
const totalTokenCount = tokenCount + toolTokenCount
const thinkingTokensLabel = tokenCount > 0 ? `~${fmtK(tokenCount)} tokens` : null
const toolTokensLabel = toolTokens !== undefined && toolTokens > 0 ? `~${fmtK(toolTokens)} tokens` : undefined
const totalTokensLabel = tokenCount > 0 && toolTokenCount > 0 ? `~${fmtK(totalTokenCount)} total` : null
// ── Hidden: errors/warnings only ──────────────────────────────
@@ -368,6 +398,13 @@ export const ToolTrail = memo(function ToolTrail({
))
: null
const totalBlock = totalTokensLabel ? (
<Text color={t.color.statusFg} dimColor>
<Text color={t.color.amber}>Σ </Text>
{totalTokensLabel}
</Text>
) : null
// ── Expanded: flat, no accordions ──────────────────────────────
if (detailsMode === 'expanded') {
@@ -376,6 +413,7 @@ export const ToolTrail = memo(function ToolTrail({
{thinkingBlock}
{toolBlock}
{metaBlock}
{totalBlock}
</Box>
)
}
@@ -392,7 +430,20 @@ export const ToolTrail = memo(function ToolTrail({
<Box flexDirection="column">
{hasThinking && (
<>
<Chevron onClick={() => setOpenThinking(v => !v)} open={openThinking} t={t} title="Thinking" />
<Box onClick={() => setOpenThinking(v => !v)}>
<Text color={t.color.dim} dimColor={!thinkingLive}>
<Text color={t.color.amber}>{openThinking ? '▾ ' : '▸ '}</Text>
<Text bold={thinkingLive} color={thinkingLive ? t.color.cornsilk : t.color.dim} dimColor={!thinkingLive}>
Thinking
</Text>
{thinkingTokensLabel ? (
<Text color={t.color.statusFg} dimColor>
{' '}
{thinkingTokensLabel}
</Text>
) : null}
</Text>
</Box>
{openThinking && thinkingBlock}
</>
)}
@@ -403,6 +454,7 @@ export const ToolTrail = memo(function ToolTrail({
count={groups.length}
onClick={() => setOpenTools(v => !v)}
open={openTools}
suffix={toolTokensLabel}
t={t}
title="Tool calls"
/>
@@ -423,6 +475,8 @@ export const ToolTrail = memo(function ToolTrail({
{openMeta && metaBlock}
</>
)}
{totalBlock}
</Box>
)
})