mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-03 02:11:48 +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
29
ui-tui/src/domain/details.ts
Normal file
29
ui-tui/src/domain/details.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import type { DetailsMode } from '../types.js'
|
||||
|
||||
const DETAILS_MODES: DetailsMode[] = ['hidden', 'collapsed', 'expanded']
|
||||
|
||||
const THINKING_FALLBACK: Record<string, DetailsMode> = {
|
||||
collapsed: 'collapsed',
|
||||
full: 'expanded',
|
||||
truncated: 'collapsed'
|
||||
}
|
||||
|
||||
export const parseDetailsMode = (v: unknown): DetailsMode | null => {
|
||||
const s = typeof v === 'string' ? v.trim().toLowerCase() : ''
|
||||
|
||||
return DETAILS_MODES.includes(s as DetailsMode) ? (s as DetailsMode) : null
|
||||
}
|
||||
|
||||
export const resolveDetailsMode = (
|
||||
d: { details_mode?: unknown; thinking_mode?: unknown } | null | undefined
|
||||
): DetailsMode =>
|
||||
parseDetailsMode(d?.details_mode) ??
|
||||
THINKING_FALLBACK[
|
||||
String(d?.thinking_mode ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
] ??
|
||||
'collapsed'
|
||||
|
||||
export const nextDetailsMode = (m: DetailsMode): DetailsMode =>
|
||||
DETAILS_MODES[(DETAILS_MODES.indexOf(m) + 1) % DETAILS_MODES.length]!
|
||||
102
ui-tui/src/domain/messages.ts
Normal file
102
ui-tui/src/domain/messages.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { LONG_MSG } from '../config/limits.js'
|
||||
import { buildToolTrailLine, fmtK } from '../lib/text.js'
|
||||
import type { Msg, SessionInfo } from '../types.js'
|
||||
|
||||
interface ImageMeta {
|
||||
height?: number
|
||||
token_estimate?: number
|
||||
width?: number
|
||||
}
|
||||
|
||||
interface TranscriptRow {
|
||||
context?: string
|
||||
name?: string
|
||||
role?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
export const introMsg = (info: SessionInfo): Msg => ({ info, kind: 'intro', role: 'system', text: '' })
|
||||
|
||||
export const imageTokenMeta = (info: ImageMeta | null | undefined) =>
|
||||
[
|
||||
info?.width && info.height ? `${info.width}x${info.height}` : '',
|
||||
typeof info?.token_estimate === 'number' && info.token_estimate > 0 ? `~${fmtK(info.token_estimate)} tok` : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
export const userDisplay = (text: string): string => {
|
||||
if (text.length <= LONG_MSG) {
|
||||
return text
|
||||
}
|
||||
|
||||
const first = text.split('\n')[0]?.trim() ?? ''
|
||||
const words = first.split(/\s+/).filter(Boolean)
|
||||
const prefix = (words.length > 1 ? words.slice(0, 4).join(' ') : first).slice(0, 80)
|
||||
|
||||
return `${prefix || '(message)'} [long message]`
|
||||
}
|
||||
|
||||
export const toTranscriptMessages = (rows: unknown): Msg[] => {
|
||||
if (!Array.isArray(rows)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const result: Msg[] = []
|
||||
let pendingTools: string[] = []
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row || typeof row !== 'object') {
|
||||
continue
|
||||
}
|
||||
|
||||
const { context, name, role, text } = row as TranscriptRow
|
||||
|
||||
if (role === 'tool') {
|
||||
pendingTools.push(buildToolTrailLine(name ?? 'tool', context ?? ''))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof text !== 'string' || !text.trim()) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (role === 'assistant') {
|
||||
const msg: Msg = { role, text }
|
||||
|
||||
if (pendingTools.length) {
|
||||
msg.tools = pendingTools
|
||||
pendingTools = []
|
||||
}
|
||||
|
||||
result.push(msg)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (role === 'user' || role === 'system') {
|
||||
pendingTools = []
|
||||
result.push({ role, text })
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function fmtDuration(ms: number) {
|
||||
const total = Math.max(0, Math.floor(ms / 1000))
|
||||
const hours = Math.floor(total / 3600)
|
||||
const mins = Math.floor((total % 3600) / 60)
|
||||
const secs = total % 60
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${mins}m`
|
||||
}
|
||||
|
||||
if (mins > 0) {
|
||||
return `${mins}m ${secs}s`
|
||||
}
|
||||
|
||||
return `${secs}s`
|
||||
}
|
||||
5
ui-tui/src/domain/paths.ts
Normal file
5
ui-tui/src/domain/paths.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export const shortCwd = (cwd: string, max = 28) => {
|
||||
const p = process.env.HOME && cwd.startsWith(process.env.HOME) ? `~${cwd.slice(process.env.HOME.length)}` : cwd
|
||||
|
||||
return p.length <= max ? p : `…${p.slice(-(max - 1))}`
|
||||
}
|
||||
9
ui-tui/src/domain/roles.ts
Normal file
9
ui-tui/src/domain/roles.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import type { Theme } from '../theme.js'
|
||||
import type { Role } from '../types.js'
|
||||
|
||||
export const ROLE: Record<Role, (t: Theme) => { body: string; glyph: string; prefix: string }> = {
|
||||
assistant: t => ({ body: t.color.cornsilk, glyph: t.brand.tool, prefix: t.color.bronze }),
|
||||
system: t => ({ body: '', glyph: '·', prefix: t.color.dim }),
|
||||
tool: t => ({ body: t.color.dim, glyph: '⚡', prefix: t.color.dim }),
|
||||
user: t => ({ body: t.color.label, glyph: t.brand.prompt, prefix: t.color.label })
|
||||
}
|
||||
25
ui-tui/src/domain/slash.ts
Normal file
25
ui-tui/src/domain/slash.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
export interface ParsedSlashCommand {
|
||||
arg: string
|
||||
cmd: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export const looksLikeSlashCommand = (text: string) => {
|
||||
if (!text.startsWith('/')) {
|
||||
return false
|
||||
}
|
||||
|
||||
const first = text.split(/\s+/, 1)[0] || ''
|
||||
|
||||
return !first.slice(1).includes('/')
|
||||
}
|
||||
|
||||
export const parseSlashCommand = (cmd: string): ParsedSlashCommand => {
|
||||
const [rawName = '', ...rest] = cmd.slice(1).split(/\s+/)
|
||||
|
||||
return {
|
||||
arg: rest.join(' '),
|
||||
cmd,
|
||||
name: rawName.toLowerCase()
|
||||
}
|
||||
}
|
||||
3
ui-tui/src/domain/usage.ts
Normal file
3
ui-tui/src/domain/usage.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import type { Usage } from '../types.js'
|
||||
|
||||
export const ZERO: Usage = { calls: 0, input: 0, output: 0, total: 0 }
|
||||
44
ui-tui/src/domain/viewport.ts
Normal file
44
ui-tui/src/domain/viewport.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import type { Msg } from '../types.js'
|
||||
|
||||
import { userDisplay } from './messages.js'
|
||||
|
||||
const upperBound = (offsets: ArrayLike<number>, target: number) => {
|
||||
let lo = 0
|
||||
let hi = offsets.length
|
||||
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
|
||||
offsets[mid]! <= target ? (lo = mid + 1) : (hi = mid)
|
||||
}
|
||||
|
||||
return lo
|
||||
}
|
||||
|
||||
export const stickyPromptFromViewport = (
|
||||
messages: readonly Msg[],
|
||||
offsets: ArrayLike<number>,
|
||||
top: number,
|
||||
sticky: boolean
|
||||
) => {
|
||||
if (sticky || !messages.length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const first = Math.max(0, Math.min(messages.length - 1, upperBound(offsets, top) - 1))
|
||||
const aboveViewport = (i: number) => (offsets[i] ?? 0) + 1 < top
|
||||
|
||||
if (messages[first]?.role === 'user' && !aboveViewport(first)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
for (let i = first - 1; i >= 0; i--) {
|
||||
if (messages[i]?.role !== 'user' || !aboveViewport(i)) {
|
||||
continue
|
||||
}
|
||||
|
||||
return userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue