mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-30 01:41:43 +00:00
refactor(tui): store-driven turn state + slash registry + module split
Hoist turn state from a 286-line hook into $turnState atom + turnController
singleton. createGatewayEventHandler becomes a typed dispatch over the
controller; its ctx shrinks from 30 fields to 5. Event-handler refs and 16
threaded actions are gone.
Fold three createSlash*Handler factories into a data-driven SlashCommand[]
registry under slash/commands/{core,session,ops}.ts. Aliases are data;
findSlashCommand does name+alias lookup. Shared guarded/guardedErr combinator
in slash/guarded.ts.
Split constants.ts + app/helpers.ts into config/ (timing/limits/env),
content/ (faces/placeholders/hotkeys/verbs/charms/fortunes), domain/ (roles/
details/messages/paths/slash/viewport/usage), protocol/ (interpolation/paste).
Type every RPC response in gatewayTypes.ts (26 new interfaces); drop all
`(r: any)` across slash + main app.
Shrink useMainApp from 1216 -> 646 lines by extracting useSessionLifecycle,
useSubmission, useConfigSync. Add <Fg> themed primitive and strip ~50
`as any` color casts.
Tests: 50 passing. Build + type-check clean.
This commit is contained in:
parent
9c71f3a6ea
commit
68ecdb6e26
56 changed files with 3666 additions and 4117 deletions
|
|
@ -1,7 +1,8 @@
|
|||
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
|
||||
import { type ReactNode, type RefObject, useCallback, useEffect, useState, useSyncExternalStore } from 'react'
|
||||
|
||||
import { fmtDuration, stickyPromptFromViewport } from '../app/helpers.js'
|
||||
import { fmtDuration } from '../domain/messages.js'
|
||||
import { stickyPromptFromViewport } from '../domain/viewport.js'
|
||||
import { fmtK } from '../lib/text.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
import type { Msg, Usage } from '../types.js'
|
||||
|
|
@ -66,7 +67,7 @@ export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) {
|
|||
return () => clearTimeout(id)
|
||||
}, [t.color.amber, tick])
|
||||
|
||||
return <Text color={color as any}>{active ? '♥' : ' '}</Text>
|
||||
return <Text color={color}>{active ? '♥' : ' '}</Text>
|
||||
}
|
||||
|
||||
export function StatusRule({
|
||||
|
|
@ -108,29 +109,29 @@ export function StatusRule({
|
|||
return (
|
||||
<Box>
|
||||
<Box flexShrink={1} width={leftWidth}>
|
||||
<Text color={t.color.bronze as any} wrap="truncate-end">
|
||||
<Text color={t.color.bronze} wrap="truncate-end">
|
||||
{'─ '}
|
||||
<Text color={statusColor as any}>{status}</Text>
|
||||
<Text color={t.color.dim as any}> │ {model}</Text>
|
||||
{ctxLabel ? <Text color={t.color.dim as any}> │ {ctxLabel}</Text> : null}
|
||||
<Text color={statusColor}>{status}</Text>
|
||||
<Text color={t.color.dim}> │ {model}</Text>
|
||||
{ctxLabel ? <Text color={t.color.dim}> │ {ctxLabel}</Text> : null}
|
||||
{bar ? (
|
||||
<Text color={t.color.dim as any}>
|
||||
<Text color={t.color.dim}>
|
||||
{' │ '}
|
||||
<Text color={barColor as any}>[{bar}]</Text> <Text color={barColor as any}>{pctLabel}</Text>
|
||||
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pctLabel}</Text>
|
||||
</Text>
|
||||
) : null}
|
||||
{sessionStartedAt ? (
|
||||
<Text color={t.color.dim as any}>
|
||||
<Text color={t.color.dim}>
|
||||
{' │ '}
|
||||
<SessionDuration startedAt={sessionStartedAt} />
|
||||
</Text>
|
||||
) : null}
|
||||
{voiceLabel ? <Text color={t.color.dim as any}> │ {voiceLabel}</Text> : null}
|
||||
{bgCount > 0 ? <Text color={t.color.dim as any}> │ {bgCount} bg</Text> : null}
|
||||
{voiceLabel ? <Text color={t.color.dim}> │ {voiceLabel}</Text> : null}
|
||||
{bgCount > 0 ? <Text color={t.color.dim}> │ {bgCount} bg</Text> : null}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={t.color.bronze as any}> ─ </Text>
|
||||
<Text color={t.color.label as any}>{cwdLabel}</Text>
|
||||
<Text color={t.color.bronze}> ─ </Text>
|
||||
<Text color={t.color.label}>{cwdLabel}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -139,7 +140,7 @@ export function FloatBox({ children, color }: { children: ReactNode; color: stri
|
|||
return (
|
||||
<Box
|
||||
alignSelf="flex-start"
|
||||
borderColor={color as any}
|
||||
borderColor={color}
|
||||
borderStyle="double"
|
||||
flexDirection="column"
|
||||
marginTop={1}
|
||||
|
|
@ -247,21 +248,21 @@ export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject<Scr
|
|||
width={1}
|
||||
>
|
||||
{!scrollable ? (
|
||||
<Text color={trackColor as any} dim>
|
||||
<Text color={trackColor} dim>
|
||||
{' \n'.repeat(Math.max(0, vp - 1))}{' '}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
{thumbTop > 0 ? (
|
||||
<Text color={trackColor as any} dim={!hover}>
|
||||
<Text color={trackColor} dim={!hover}>
|
||||
{`${'│\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? '│' : ''}`}
|
||||
</Text>
|
||||
) : null}
|
||||
{thumb > 0 ? (
|
||||
<Text color={thumbColor as any}>{`${'┃\n'.repeat(Math.max(0, thumb - 1))}${thumb > 0 ? '┃' : ''}`}</Text>
|
||||
<Text color={thumbColor}>{`${'┃\n'.repeat(Math.max(0, thumb - 1))}${thumb > 0 ? '┃' : ''}`}</Text>
|
||||
) : null}
|
||||
{vp - thumbTop - thumb > 0 ? (
|
||||
<Text color={trackColor as any} dim={!hover}>
|
||||
<Text color={trackColor} dim={!hover}>
|
||||
{`${'│\n'.repeat(Math.max(0, vp - thumbTop - thumb - 1))}${vp - thumbTop - thumb > 0 ? '│' : ''}`}
|
||||
</Text>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink'
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { memo } from 'react'
|
||||
|
||||
import { PLACEHOLDER } from '../app/constants.js'
|
||||
import type { AppLayoutProps } from '../app/interfaces.js'
|
||||
import { $isBlocked } from '../app/overlayStore.js'
|
||||
import { $uiState } from '../app/uiStore.js'
|
||||
import { PLACEHOLDER } from '../content/placeholders.js'
|
||||
|
||||
import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
|
||||
import { AppOverlays } from './appOverlays.js'
|
||||
|
|
@ -119,14 +119,14 @@ const ComposerPane = memo(function ComposerPane({
|
|||
/>
|
||||
|
||||
{ui.bgTasks.size > 0 && (
|
||||
<Text color={ui.theme.color.dim as any}>
|
||||
<Text color={ui.theme.color.dim}>
|
||||
{ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{status.showStickyPrompt ? (
|
||||
<Text color={ui.theme.color.dim as any} wrap="truncate-end">
|
||||
<Text color={ui.theme.color.label as any}>↳ </Text>
|
||||
<Text color={ui.theme.color.dim} wrap="truncate-end">
|
||||
<Text color={ui.theme.color.label}>↳ </Text>
|
||||
|
||||
{status.stickyPrompt}
|
||||
</Text>
|
||||
|
|
@ -169,19 +169,19 @@ const ComposerPane = memo(function ComposerPane({
|
|||
{composer.inputBuf.map((line, i) => (
|
||||
<Box key={i}>
|
||||
<Box width={3}>
|
||||
<Text color={ui.theme.color.dim as any}>{i === 0 ? `${ui.theme.brand.prompt} ` : ' '}</Text>
|
||||
<Text color={ui.theme.color.dim}>{i === 0 ? `${ui.theme.brand.prompt} ` : ' '}</Text>
|
||||
</Box>
|
||||
|
||||
<Text color={ui.theme.color.cornsilk as any}>{line || ' '}</Text>
|
||||
<Text color={ui.theme.color.cornsilk}>{line || ' '}</Text>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Box position="relative">
|
||||
<Box width={pw}>
|
||||
{sh ? (
|
||||
<Text color={ui.theme.color.shellDollar as any}>$ </Text>
|
||||
<Text color={ui.theme.color.shellDollar}>$ </Text>
|
||||
) : (
|
||||
<Text bold color={ui.theme.color.gold as any}>
|
||||
<Text bold color={ui.theme.color.gold}>
|
||||
{composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `}
|
||||
</Text>
|
||||
)}
|
||||
|
|
@ -204,7 +204,7 @@ const ComposerPane = memo(function ComposerPane({
|
|||
</Box>
|
||||
)}
|
||||
|
||||
{!composer.empty && !ui.sid && <Text color={ui.theme.color.dim as any}>⚕ {ui.status}</Text>}
|
||||
{!composer.empty && !ui.sid && <Text color={ui.theme.color.dim}>⚕ {ui.status}</Text>}
|
||||
</NoSelect>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ export function AppOverlays({
|
|||
<Box flexDirection="column" paddingX={1} paddingY={1}>
|
||||
{overlay.pager.title && (
|
||||
<Box justifyContent="center" marginBottom={1}>
|
||||
<Text bold color={ui.theme.color.gold as any}>
|
||||
<Text bold color={ui.theme.color.gold}>
|
||||
{overlay.pager.title}
|
||||
</Text>
|
||||
</Box>
|
||||
|
|
@ -123,7 +123,7 @@ export function AppOverlays({
|
|||
))}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={ui.theme.color.dim as any}>
|
||||
<Text color={ui.theme.color.dim}>
|
||||
{overlay.pager.offset + pagerPageSize < overlay.pager.lines.length
|
||||
? `Enter/Space for more · q to close (${Math.min(overlay.pager.offset + pagerPageSize, overlay.pager.lines.length)}/${overlay.pager.lines.length})`
|
||||
: `end · q to close (${overlay.pager.lines.length} lines)`}
|
||||
|
|
@ -141,16 +141,16 @@ export function AppOverlays({
|
|||
|
||||
return (
|
||||
<Box
|
||||
backgroundColor={active ? (ui.theme.color.completionCurrentBg as any) : undefined}
|
||||
backgroundColor={active ? ui.theme.color.completionCurrentBg : undefined}
|
||||
flexDirection="row"
|
||||
key={`${start + i}:${item.text}:${item.display}:${item.meta ?? ''}`}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold={active} color={ui.theme.color.bronze as any}>
|
||||
<Text bold={active} color={ui.theme.color.bronze}>
|
||||
{' '}
|
||||
{item.display}
|
||||
</Text>
|
||||
{item.meta ? <Text color={ui.theme.color.dim as any}> {item.meta}</Text> : null}
|
||||
{item.meta ? <Text color={ui.theme.color.dim}> {item.meta}</Text> : null}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { Ansi, Box, NoSelect, Text } from '@hermes/ink'
|
||||
import { memo } from 'react'
|
||||
|
||||
import { LONG_MSG, ROLE } from '../constants.js'
|
||||
import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi, userDisplay } from '../lib/text.js'
|
||||
import { LONG_MSG } from '../config/limits.js'
|
||||
import { userDisplay } from '../domain/messages.js'
|
||||
import { ROLE } from '../domain/roles.js'
|
||||
import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi } from '../lib/text.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
import type { DetailsMode, Msg } from '../types.js'
|
||||
|
||||
|
|
|
|||
45
ui-tui/src/components/themed.tsx
Normal file
45
ui-tui/src/components/themed.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { Text } from '@hermes/ink'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { $uiState } from '../app/uiStore.js'
|
||||
import type { ThemeColors } from '../theme.js'
|
||||
|
||||
export type ThemeColor = keyof ThemeColors
|
||||
|
||||
export interface FgProps {
|
||||
bold?: boolean
|
||||
c?: ThemeColor
|
||||
children?: ReactNode
|
||||
dim?: boolean
|
||||
italic?: boolean
|
||||
literal?: string
|
||||
strikethrough?: boolean
|
||||
underline?: boolean
|
||||
wrap?: 'end' | 'middle' | 'truncate' | 'truncate-end' | 'truncate-middle' | 'truncate-start' | 'wrap' | 'wrap-trim'
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme-aware text. `literal` wins; otherwise `c` is a palette key.
|
||||
*
|
||||
* <Fg c="amber">hi</Fg> // amber
|
||||
* <Fg c="dim" dim>…</Fg> // dim cornsilk
|
||||
* <Fg literal="#ff00ff">x</Fg> // raw hex
|
||||
*/
|
||||
export function Fg({ bold, c, children, dim, italic, literal, strikethrough, underline, wrap }: FgProps) {
|
||||
const { theme } = useStore($uiState)
|
||||
|
||||
return (
|
||||
<Text
|
||||
bold={bold}
|
||||
color={literal ?? (c && theme.color[c])}
|
||||
dimColor={dim}
|
||||
italic={italic}
|
||||
strikethrough={strikethrough}
|
||||
underline={underline}
|
||||
wrap={wrap}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { Box, NoSelect, Text } from '@hermes/ink'
|
|||
import { memo, type ReactNode, useEffect, useMemo, useState } from 'react'
|
||||
import spinners, { type BrailleSpinnerName } from 'unicode-animations'
|
||||
|
||||
import { THINKING_COT_MAX } from '../config/limits.js'
|
||||
import {
|
||||
compactPreview,
|
||||
estimateTokensRough,
|
||||
|
|
@ -9,7 +10,6 @@ import {
|
|||
formatToolCall,
|
||||
parseToolTrailResultLine,
|
||||
pick,
|
||||
THINKING_COT_MAX,
|
||||
thinkingPreview,
|
||||
toolTrailLabel
|
||||
} from '../lib/text.js'
|
||||
|
|
@ -55,7 +55,7 @@ function TreeRow({
|
|||
return (
|
||||
<Box>
|
||||
<NoSelect flexShrink={0} fromLeftEdge width={lead.length}>
|
||||
<Text color={(stemColor ?? t.color.dim) as any} dim={stemDim}>
|
||||
<Text color={stemColor ?? t.color.dim} dim={stemDim}>
|
||||
{lead}
|
||||
</Text>
|
||||
</NoSelect>
|
||||
|
|
@ -84,11 +84,11 @@ function TreeTextRow({
|
|||
wrap?: 'truncate-end' | 'wrap' | 'wrap-trim'
|
||||
}) {
|
||||
const text = dimColor ? (
|
||||
<Text color={color as any} dim wrap={wrap}>
|
||||
<Text color={color} dim wrap={wrap}>
|
||||
{content}
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={color as any} wrap={wrap}>
|
||||
<Text color={color} wrap={wrap}>
|
||||
{content}
|
||||
</Text>
|
||||
)
|
||||
|
|
@ -144,7 +144,7 @@ export function Spinner({ color, variant = 'think' }: { color: string; variant?:
|
|||
return () => clearInterval(id)
|
||||
}, [spin])
|
||||
|
||||
return <Text color={color as any}>{spin.frames[frame]}</Text>
|
||||
return <Text color={color}>{spin.frames[frame]}</Text>
|
||||
}
|
||||
|
||||
interface DetailRow {
|
||||
|
|
@ -195,11 +195,11 @@ function StreamCursor({
|
|||
}
|
||||
|
||||
return dimColor ? (
|
||||
<Text color={color as any} dim>
|
||||
<Text color={color} dim>
|
||||
{streaming && on ? '▍' : ' '}
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={color as any}>{streaming && on ? '▍' : ' '}</Text>
|
||||
<Text color={color}>{streaming && on ? '▍' : ' '}</Text>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -224,12 +224,12 @@ function Chevron({
|
|||
|
||||
return (
|
||||
<Box onClick={(e: any) => onClick(!!e?.shiftKey || !!e?.ctrlKey)}>
|
||||
<Text color={color as any} dim={tone === 'dim'}>
|
||||
<Text color={t.color.amber as any}>{open ? '▾ ' : '▸ '}</Text>
|
||||
<Text color={color} dim={tone === 'dim'}>
|
||||
<Text color={t.color.amber}>{open ? '▾ ' : '▸ '}</Text>
|
||||
{title}
|
||||
{typeof count === 'number' ? ` (${count})` : ''}
|
||||
{suffix ? (
|
||||
<Text color={t.color.statusFg as any} dim>
|
||||
<Text color={t.color.statusFg} dim>
|
||||
{' '}
|
||||
{suffix}
|
||||
</Text>
|
||||
|
|
@ -366,7 +366,7 @@ function SubagentAccordion({
|
|||
color={t.color.cornsilk}
|
||||
content={
|
||||
<>
|
||||
<Text color={t.color.amber as any}>● </Text>
|
||||
<Text color={t.color.amber}>● </Text>
|
||||
{line}
|
||||
</>
|
||||
}
|
||||
|
|
@ -501,7 +501,7 @@ export const Thinking = memo(function Thinking({
|
|||
{preview ? (
|
||||
mode === 'full' ? (
|
||||
lines.map((line, index) => (
|
||||
<Text color={t.color.dim as any} dim key={index} wrap="wrap-trim">
|
||||
<Text color={t.color.dim} dim key={index} wrap="wrap-trim">
|
||||
{line || ' '}
|
||||
{index === lines.length - 1 ? (
|
||||
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
|
||||
|
|
@ -509,13 +509,13 @@ export const Thinking = memo(function Thinking({
|
|||
</Text>
|
||||
))
|
||||
) : (
|
||||
<Text color={t.color.dim as any} dim wrap="truncate-end">
|
||||
<Text color={t.color.dim} dim wrap="truncate-end">
|
||||
{preview}
|
||||
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
|
||||
</Text>
|
||||
)
|
||||
) : (
|
||||
<Text color={t.color.dim as any} dim>
|
||||
<Text color={t.color.dim} dim>
|
||||
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
|
||||
</Text>
|
||||
)}
|
||||
|
|
@ -715,7 +715,7 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
return alerts.length ? (
|
||||
<Box flexDirection="column">
|
||||
{alerts.map(i => (
|
||||
<Text color={(i.tone === 'error' ? t.color.error : t.color.warn) as any} key={`ha-${i.id}`}>
|
||||
<Text color={i.tone === 'error' ? t.color.error : t.color.warn} key={`ha-${i.id}`}>
|
||||
{i.tone === 'error' ? '✗' : '!'} {i.text}
|
||||
</Text>
|
||||
))}
|
||||
|
|
@ -773,19 +773,19 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<Text color={t.color.dim as any} dim={!thinkingLive}>
|
||||
<Text color={t.color.amber as any}>{detailsMode === 'expanded' || openThinking ? '▾ ' : '▸ '}</Text>
|
||||
<Text color={t.color.dim} dim={!thinkingLive}>
|
||||
<Text color={t.color.amber}>{detailsMode === 'expanded' || openThinking ? '▾ ' : '▸ '}</Text>
|
||||
{thinkingLive ? (
|
||||
<Text bold color={t.color.cornsilk as any}>
|
||||
<Text bold color={t.color.cornsilk}>
|
||||
Thinking
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={t.color.dim as any} dim>
|
||||
<Text color={t.color.dim} dim>
|
||||
Thinking
|
||||
</Text>
|
||||
)}
|
||||
{thinkingTokensLabel ? (
|
||||
<Text color={t.color.statusFg as any} dim>
|
||||
<Text color={t.color.statusFg} dim>
|
||||
{' '}
|
||||
{thinkingTokensLabel}
|
||||
</Text>
|
||||
|
|
@ -843,7 +843,7 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
color={group.color}
|
||||
content={
|
||||
<>
|
||||
<Text color={t.color.amber as any}>● </Text>
|
||||
<Text color={t.color.amber}>● </Text>
|
||||
{group.content}
|
||||
</>
|
||||
}
|
||||
|
|
@ -952,7 +952,7 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
color={t.color.statusFg}
|
||||
content={
|
||||
<>
|
||||
<Text color={t.color.amber as any}>Σ </Text>
|
||||
<Text color={t.color.amber}>Σ </Text>
|
||||
{totalTokensLabel}
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue