mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 04:08:28 +08:00
feat(tui): split learning ledger into category panels
Stress the shared overlay grid with separate memories, skills, recalls, and connected panels plus a details panel navigated by arrow keys.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Box, Text, useInput, useStdout } from '@hermes/ink'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { GatewayClient } from '../gatewayClient.js'
|
||||
import { rpcErrorMessage } from '../lib/rpc.js'
|
||||
@@ -9,11 +9,17 @@ import { OverlayGrid } from './overlayGrid.js'
|
||||
import { OverlayHint, windowItems, windowOffset } from './overlayControls.js'
|
||||
|
||||
const EDGE_GUTTER = 10
|
||||
const GRID_GAP = 2
|
||||
const MAX_WIDTH = 132
|
||||
const MIN_WIDTH = 64
|
||||
const VISIBLE_ROWS = 12
|
||||
|
||||
const LISTS = [
|
||||
{ id: 'memories', title: 'Memories', types: ['user', 'memory'] },
|
||||
{ id: 'skills', title: 'Skills', types: ['skill-use'] },
|
||||
{ id: 'recalls', title: 'Recalls', types: ['recall'] },
|
||||
{ id: 'connected', title: 'Connected', types: ['integration'] }
|
||||
] as const
|
||||
|
||||
const typeIcon: Record<string, string> = {
|
||||
integration: '◇',
|
||||
memory: '◆',
|
||||
@@ -42,7 +48,8 @@ const fmtTime = (ts?: null | number) => {
|
||||
|
||||
export function LearningLedger({ borderColor, gw, maxHeight, onClose, t, width: fixedWidth }: LearningLedgerProps) {
|
||||
const [ledger, setLedger] = useState<LearningLedgerResponse | null>(null)
|
||||
const [idx, setIdx] = useState(0)
|
||||
const [activeList, setActiveList] = useState(0)
|
||||
const [indices, setIndices] = useState<Record<string, number>>({})
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -60,17 +67,14 @@ export function LearningLedger({ borderColor, gw, maxHeight, onClose, t, width:
|
||||
}, [gw])
|
||||
|
||||
const items = ledger?.items ?? []
|
||||
const selected = items[idx]
|
||||
const lists = LISTS.map(list => ({
|
||||
...list,
|
||||
items: items.filter(item => list.types.includes(item.type as never))
|
||||
}))
|
||||
const active = lists[activeList] ?? lists[0]!
|
||||
const activeIdx = Math.min(indices[active.id] ?? 0, Math.max(0, active.items.length - 1))
|
||||
const selected = active.items[activeIdx]
|
||||
const detailOpen = expanded && !!selected
|
||||
const counts = useMemo(
|
||||
() =>
|
||||
Object.entries(ledger?.counts ?? {})
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([k, v]) => `${k}:${v}`)
|
||||
.join(' · '),
|
||||
[ledger?.counts]
|
||||
)
|
||||
|
||||
useInput((ch, key) => {
|
||||
if (key.escape || ch.toLowerCase() === 'q') {
|
||||
onClose()
|
||||
@@ -78,14 +82,26 @@ export function LearningLedger({ borderColor, gw, maxHeight, onClose, t, width:
|
||||
return
|
||||
}
|
||||
|
||||
if (key.upArrow && idx > 0) {
|
||||
setIdx(v => v - 1)
|
||||
if (key.leftArrow && activeList > 0) {
|
||||
setActiveList(v => v - 1)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (key.downArrow && idx < items.length - 1) {
|
||||
setIdx(v => v + 1)
|
||||
if (key.rightArrow && activeList < lists.length - 1) {
|
||||
setActiveList(v => v + 1)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (key.upArrow && activeIdx > 0) {
|
||||
setIndices(v => ({ ...v, [active.id]: activeIdx - 1 }))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (key.downArrow && activeIdx < active.items.length - 1) {
|
||||
setIndices(v => ({ ...v, [active.id]: activeIdx + 1 }))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -97,11 +113,11 @@ export function LearningLedger({ borderColor, gw, maxHeight, onClose, t, width:
|
||||
}
|
||||
|
||||
const n = ch === '0' ? 10 : parseInt(ch, 10)
|
||||
if (!Number.isNaN(n) && n >= 1 && n <= Math.min(10, items.length)) {
|
||||
const next = windowOffset(items.length, idx, VISIBLE_ROWS) + n - 1
|
||||
if (!Number.isNaN(n) && n >= 1 && n <= Math.min(10, active.items.length)) {
|
||||
const next = windowOffset(active.items.length, activeIdx, VISIBLE_ROWS) + n - 1
|
||||
|
||||
if (items[next]) {
|
||||
setIdx(next)
|
||||
if (active.items[next]) {
|
||||
setIndices(v => ({ ...v, [active.id]: next }))
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -134,29 +150,33 @@ export function LearningLedger({ borderColor, gw, maxHeight, onClose, t, width:
|
||||
)
|
||||
}
|
||||
|
||||
const { items: visible, offset } = windowItems(items, idx, VISIBLE_ROWS)
|
||||
const listPanel = (
|
||||
<LearningList
|
||||
counts={counts}
|
||||
items={visible}
|
||||
ledger={ledger}
|
||||
offset={offset}
|
||||
selectedIndex={idx}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
const listPanels = lists.map((list, listIdx) => {
|
||||
const selectedIndex = Math.min(indices[list.id] ?? 0, Math.max(0, list.items.length - 1))
|
||||
const { items: visible, offset } = windowItems(list.items, selectedIndex, Math.max(3, Math.floor(VISIBLE_ROWS / 2)))
|
||||
|
||||
return {
|
||||
content: (
|
||||
<LearningList
|
||||
active={activeList === listIdx}
|
||||
items={visible}
|
||||
offset={offset}
|
||||
selectedIndex={selectedIndex}
|
||||
t={t}
|
||||
total={list.items.length}
|
||||
/>
|
||||
),
|
||||
grow: 1,
|
||||
id: `learning-${list.id}`,
|
||||
title: list.title
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<OverlayGrid
|
||||
borderColor={borderColor}
|
||||
footer={<OverlayHint t={t}>←/→ panel · ↑/↓ select · Enter/Space details · 1-9,0 quick · Esc/q close</OverlayHint>}
|
||||
panels={[
|
||||
{
|
||||
content: listPanel,
|
||||
footer: <OverlayHint t={t}>↑/↓ select · Enter/Space details · 1-9,0 quick · Esc/q close</OverlayHint>,
|
||||
grow: 7,
|
||||
id: 'learning-list',
|
||||
title: 'Recent Learning'
|
||||
},
|
||||
...listPanels,
|
||||
...(detailOpen && selected
|
||||
? [
|
||||
{
|
||||
@@ -175,16 +195,11 @@ export function LearningLedger({ borderColor, gw, maxHeight, onClose, t, width:
|
||||
)
|
||||
}
|
||||
|
||||
function LearningList({ counts, items, ledger, offset, selectedIndex, t }: LearningListProps) {
|
||||
function LearningList({ active, items, offset, selectedIndex, t, total }: LearningListProps) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={t.color.muted}>
|
||||
{ledger?.total ?? items.length} traces{counts ? ` · ${counts}` : ''}
|
||||
</Text>
|
||||
{ledger?.inventory?.skills ? (
|
||||
<Text color={t.color.muted}>available knowledge: {ledger.inventory.skills} installed skills</Text>
|
||||
) : null}
|
||||
{offset > 0 && <Text color={t.color.muted}> ↑ {offset} more</Text>}
|
||||
<Text color={active ? t.color.accent : t.color.muted}>{total} item{total === 1 ? '' : 's'}</Text>
|
||||
{offset > 0 && <Text color={t.color.muted}>↑ {offset} more</Text>}
|
||||
|
||||
<Box flexDirection="column">
|
||||
{items.map((item, i) => {
|
||||
@@ -192,7 +207,7 @@ function LearningList({ counts, items, ledger, offset, selectedIndex, t }: Learn
|
||||
|
||||
return (
|
||||
<LedgerRow
|
||||
active={absolute === selectedIndex}
|
||||
active={active && absolute === selectedIndex}
|
||||
index={i + 1}
|
||||
item={item}
|
||||
key={`${item.type}:${item.name}:${i}`}
|
||||
@@ -202,8 +217,8 @@ function LearningList({ counts, items, ledger, offset, selectedIndex, t }: Learn
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{offset + VISIBLE_ROWS < (ledger?.items?.length ?? items.length) && (
|
||||
<Text color={t.color.muted}> ↓ {(ledger?.items?.length ?? items.length) - offset - VISIBLE_ROWS} more</Text>
|
||||
{offset + items.length < total && (
|
||||
<Text color={t.color.muted}>↓ {total - offset - items.length} more</Text>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
@@ -272,12 +287,12 @@ interface LearningLedgerResponse {
|
||||
}
|
||||
|
||||
interface LearningListProps {
|
||||
counts: string
|
||||
active: boolean
|
||||
items: LearningLedgerItem[]
|
||||
ledger: LearningLedgerResponse | null
|
||||
offset: number
|
||||
selectedIndex: number
|
||||
t: Theme
|
||||
total: number
|
||||
}
|
||||
|
||||
interface LedgerRowProps {
|
||||
|
||||
@@ -5,10 +5,11 @@ import type { Theme } from '../theme.js'
|
||||
|
||||
const GAP = 2
|
||||
|
||||
export function OverlayGrid({ borderColor, maxHeight, panels, t, width }: OverlayGridProps) {
|
||||
export function OverlayGrid({ borderColor, footer, maxHeight, panels, t, width }: OverlayGridProps) {
|
||||
const visible = panels.filter(p => p.content)
|
||||
const innerWidth = Math.max(20, width - 4)
|
||||
const innerHeight = maxHeight ? Math.max(1, maxHeight - 2) : undefined
|
||||
const panelHeight = innerHeight ? Math.max(1, innerHeight - (footer ? 1 : 0)) : undefined
|
||||
const gapTotal = Math.max(0, visible.length - 1) * GAP
|
||||
const usable = Math.max(1, innerWidth - gapTotal)
|
||||
const growTotal = visible.reduce((sum, p) => sum + (p.grow ?? 1), 0) || 1
|
||||
@@ -19,40 +20,43 @@ export function OverlayGrid({ borderColor, maxHeight, panels, t, width }: Overla
|
||||
alignSelf="flex-start"
|
||||
borderColor={borderColor}
|
||||
borderStyle="double"
|
||||
flexDirection="row"
|
||||
flexDirection="column"
|
||||
marginTop={1}
|
||||
opaque
|
||||
paddingX={1}
|
||||
width={width}
|
||||
>
|
||||
{visible.map((panel, i) => {
|
||||
const last = i === visible.length - 1
|
||||
const panelWidth = last
|
||||
? Math.max(1, usable - used)
|
||||
: Math.max(1, Math.floor((usable * (panel.grow ?? 1)) / growTotal))
|
||||
used += panelWidth
|
||||
<Box flexDirection="row">
|
||||
{visible.map((panel, i) => {
|
||||
const last = i === visible.length - 1
|
||||
const panelWidth = last
|
||||
? Math.max(1, usable - used)
|
||||
: Math.max(1, Math.floor((usable * (panel.grow ?? 1)) / growTotal))
|
||||
used += panelWidth
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" key={panel.id}>
|
||||
<Box flexDirection="column" flexShrink={0} width={panelWidth}>
|
||||
{panel.title ? (
|
||||
<Text bold color={t.color.accent}>
|
||||
{panel.title}
|
||||
</Text>
|
||||
) : null}
|
||||
<Box
|
||||
flexDirection="column"
|
||||
height={innerHeight ? Math.max(1, innerHeight - (panel.title ? 1 : 0) - (panel.footer ? 1 : 0)) : undefined}
|
||||
overflow="hidden"
|
||||
>
|
||||
{panel.content}
|
||||
return (
|
||||
<Box flexDirection="row" key={panel.id}>
|
||||
<Box flexDirection="column" flexShrink={0} width={panelWidth}>
|
||||
{panel.title ? (
|
||||
<Text bold color={t.color.accent}>
|
||||
{panel.title}
|
||||
</Text>
|
||||
) : null}
|
||||
<Box
|
||||
flexDirection="column"
|
||||
height={panelHeight ? Math.max(1, panelHeight - (panel.title ? 1 : 0) - (panel.footer ? 1 : 0)) : undefined}
|
||||
overflow="hidden"
|
||||
>
|
||||
{panel.content}
|
||||
</Box>
|
||||
{panel.footer ? <Box flexDirection="column">{panel.footer}</Box> : null}
|
||||
</Box>
|
||||
{panel.footer ? <Box flexDirection="column">{panel.footer}</Box> : null}
|
||||
{!last ? <Box flexShrink={0} width={GAP} /> : null}
|
||||
</Box>
|
||||
{!last ? <Box flexShrink={0} width={GAP} /> : null}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
{footer ? <Box flexDirection="column">{footer}</Box> : null}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -67,6 +71,7 @@ export interface OverlayGridPanel {
|
||||
|
||||
interface OverlayGridProps {
|
||||
borderColor: string
|
||||
footer?: ReactNode
|
||||
maxHeight?: number
|
||||
panels: OverlayGridPanel[]
|
||||
t: Theme
|
||||
|
||||
Reference in New Issue
Block a user