mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
feat(journey): chronological slice/item tree in the TUI
Collapse the two-step slice list → detail page into one scrollable tree: each timeline slice is a parent header with its skills + memories nested under ├─/└─ branch chars, ordered oldest → newest (children now sorted chronologically in the renderer). One cursor walks the whole tree; Enter still opens a memory's body. Drops the separate detail mode.
This commit is contained in:
parent
dc61642419
commit
ae78326bf6
2 changed files with 93 additions and 147 deletions
|
|
@ -539,7 +539,8 @@ def _bucket_label_node(bucket: _ChartBucket) -> Optional[dict[str, Any]]:
|
|||
|
||||
def _bucket_nodes(bucket: _ChartBucket, memory_lookup: Optional[dict[str, dict[str, Any]]] = None) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
ordered = sorted(bucket.nodes, key=lambda n: _node_score(n, _to_ts(n.get("timestamp")) or bucket.ts), reverse=True)
|
||||
# Chronological within the slice so the TUI tree reads oldest → newest.
|
||||
ordered = sorted(bucket.nodes, key=lambda n: _to_ts(n.get("timestamp")) or bucket.ts)
|
||||
for node in ordered:
|
||||
style = STYLE_MEMORY if node.get("kind") == "memory" else STYLE_SKILL
|
||||
raw_label = str(node.get("label") or node.get("id") or "unknown").strip()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Box, NoSelect, ScrollBox, type ScrollBoxHandle, Text, useInput, useStdout } from '@hermes/ink'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { GatewayClient } from '../gatewayClient.js'
|
||||
import { rpcErrorMessage } from '../lib/rpc.js'
|
||||
|
|
@ -54,13 +54,30 @@ interface JourneyProps {
|
|||
t: Theme
|
||||
}
|
||||
|
||||
type Mode = 'detail' | 'item' | 'timeline'
|
||||
// Flattened timeline tree: a slice header followed by its chronological items.
|
||||
type TreeRow =
|
||||
| { bucket: BucketRow; kind: 'slice' }
|
||||
| { bucket: BucketRow; kind: 'node'; last: boolean; node: BucketNode }
|
||||
|
||||
type Cell = { color?: string; text: string }
|
||||
|
||||
const MAX_CHART_ROWS = 8
|
||||
|
||||
const rowText = (row: Run[]) => row.map(run => run[0]).join('')
|
||||
|
||||
const treeLength = (buckets: BucketRow[]) => buckets.reduce((n, b) => n + 1 + b.nodes.length, 0)
|
||||
|
||||
const buildTree = (buckets: BucketRow[]): TreeRow[] => {
|
||||
const out: TreeRow[] = []
|
||||
|
||||
for (const bucket of buckets) {
|
||||
out.push({ bucket, kind: 'slice' })
|
||||
bucket.nodes.forEach((node, j) => out.push({ bucket, kind: 'node', last: j === bucket.nodes.length - 1, node }))
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// Center a fixed-height window on the cursor, clamped to list bounds.
|
||||
const windowStart = (cursor: number, len: number, h: number) =>
|
||||
Math.max(0, Math.min(Math.max(0, len - h), cursor - Math.floor(h / 2)))
|
||||
|
|
@ -102,14 +119,14 @@ export function Journey({ gw, onClose, t }: JourneyProps) {
|
|||
const cols = Math.max(40, (stdout?.columns ?? 90) - 3)
|
||||
const rows = Math.max(16, (stdout?.rows ?? 30) - 2)
|
||||
const chartRows = Math.max(5, Math.min(MAX_CHART_ROWS, Math.floor(rows * 0.32)))
|
||||
const page = Math.max(4, rows - 6)
|
||||
|
||||
const palette = useMemo(() => deriveStarmapPalette(t.color.primary, t.color.text), [t.color.primary, t.color.text])
|
||||
const palette = deriveStarmapPalette(t.color.primary, t.color.text)
|
||||
|
||||
const [data, setData] = useState<FramesResponse | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [selectedRow, setSelectedRow] = useState(0)
|
||||
const [selectedNode, setSelectedNode] = useState(0)
|
||||
const [mode, setMode] = useState<Mode>('timeline')
|
||||
const [cursor, setCursor] = useState(0)
|
||||
const [mode, setMode] = useState<'item' | 'timeline'>('timeline')
|
||||
const [tick, setTick] = useState(0)
|
||||
const itemScroll = useRef<null | ScrollBoxHandle>(null)
|
||||
|
||||
|
|
@ -126,8 +143,7 @@ export function Journey({ gw, onClose, t }: JourneyProps) {
|
|||
}
|
||||
|
||||
setData(r)
|
||||
setSelectedRow(Math.max(0, (r?.buckets?.length ?? 1) - 1))
|
||||
setSelectedNode(0)
|
||||
setCursor(Math.max(0, treeLength(r?.buckets ?? []) - 1)) // open on the newest entry
|
||||
setMode('timeline')
|
||||
})
|
||||
.catch((e: unknown) => alive && setErr(rpcErrorMessage(e)))
|
||||
|
|
@ -137,20 +153,17 @@ export function Journey({ gw, onClose, t }: JourneyProps) {
|
|||
}
|
||||
}, [gw, cols, chartRows])
|
||||
|
||||
useEffect(() => setSelectedNode(0), [selectedRow])
|
||||
const tree = buildTree(data?.buckets ?? [])
|
||||
const activeRow = tree[Math.min(cursor, Math.max(0, tree.length - 1))]
|
||||
const activeNode = activeRow?.kind === 'node' ? activeRow.node : undefined
|
||||
const activeBucket = activeRow?.bucket
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'item') {
|
||||
itemScroll.current?.scrollTo(0)
|
||||
setTick(x => x + 1)
|
||||
}
|
||||
}, [mode, selectedNode])
|
||||
|
||||
const buckets = data?.buckets ?? []
|
||||
const selected = buckets.length ? buckets[Math.min(selectedRow, buckets.length - 1)] : null
|
||||
const nodes = selected?.nodes ?? []
|
||||
const activeNode = nodes[Math.min(selectedNode, Math.max(0, nodes.length - 1))]
|
||||
const page = Math.max(4, rows - 6)
|
||||
}, [mode, cursor])
|
||||
|
||||
const scrollItem = (dy: number) => {
|
||||
itemScroll.current?.scrollBy(dy)
|
||||
|
|
@ -166,7 +179,7 @@ export function Journey({ gw, onClose, t }: JourneyProps) {
|
|||
|
||||
if (mode === 'item') {
|
||||
if (back) {
|
||||
return setMode('detail')
|
||||
return setMode('timeline')
|
||||
}
|
||||
|
||||
if (key.upArrow || ch === 'k') {
|
||||
|
|
@ -200,66 +213,38 @@ export function Journey({ gw, onClose, t }: JourneyProps) {
|
|||
return
|
||||
}
|
||||
|
||||
if (mode === 'detail') {
|
||||
if (back) {
|
||||
return setMode('timeline')
|
||||
}
|
||||
|
||||
// Only memories carry body text; a skill row is already its own full
|
||||
// detail, so don't let it drill into an empty page.
|
||||
if ((key.return || key.rightArrow || ch === 'l') && activeNode?.body) {
|
||||
return setMode('item')
|
||||
}
|
||||
|
||||
if (key.upArrow || ch === 'k') {
|
||||
return setSelectedNode(v => Math.max(0, v - 1))
|
||||
}
|
||||
|
||||
if (key.downArrow || ch === 'j') {
|
||||
return setSelectedNode(v => Math.min(nodes.length - 1, v + 1))
|
||||
}
|
||||
|
||||
if (key.pageUp || (key.ctrl && ch === 'u')) {
|
||||
return setSelectedNode(v => Math.max(0, v - page))
|
||||
}
|
||||
|
||||
if (key.pageDown || (key.ctrl && ch === 'd')) {
|
||||
return setSelectedNode(v => Math.min(nodes.length - 1, v + page))
|
||||
}
|
||||
|
||||
if (ch === 'g') {
|
||||
return setSelectedNode(0)
|
||||
}
|
||||
|
||||
if (ch === 'G') {
|
||||
return setSelectedNode(Math.max(0, nodes.length - 1))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (back) {
|
||||
return onClose()
|
||||
}
|
||||
|
||||
if ((key.return || key.rightArrow || ch === 'l') && buckets.length) {
|
||||
return setMode('detail')
|
||||
// Only memories carry body text; everything else is already fully shown
|
||||
// inline in the tree, so don't drill into an empty page.
|
||||
if ((key.return || key.rightArrow || ch === 'l') && activeNode?.body) {
|
||||
return setMode('item')
|
||||
}
|
||||
|
||||
if (key.upArrow || ch === 'k') {
|
||||
return setSelectedRow(v => Math.max(0, v - 1))
|
||||
return setCursor(v => Math.max(0, v - 1))
|
||||
}
|
||||
|
||||
if (key.downArrow || ch === 'j') {
|
||||
return setSelectedRow(v => Math.min(buckets.length - 1, v + 1))
|
||||
return setCursor(v => Math.min(tree.length - 1, v + 1))
|
||||
}
|
||||
|
||||
if (key.pageUp || (key.ctrl && ch === 'u')) {
|
||||
return setCursor(v => Math.max(0, v - page))
|
||||
}
|
||||
|
||||
if (key.pageDown || (key.ctrl && ch === 'd')) {
|
||||
return setCursor(v => Math.min(tree.length - 1, v + page))
|
||||
}
|
||||
|
||||
if (ch === 'g') {
|
||||
return setSelectedRow(0)
|
||||
return setCursor(0)
|
||||
}
|
||||
|
||||
if (ch === 'G') {
|
||||
return setSelectedRow(Math.max(0, buckets.length - 1))
|
||||
return setCursor(Math.max(0, tree.length - 1))
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -289,8 +274,8 @@ export function Journey({ gw, onClose, t }: JourneyProps) {
|
|||
)
|
||||
}
|
||||
|
||||
// ── Item: a single skill/memory, body scrolled via the shared ScrollBox ──
|
||||
if (mode === 'item' && selected && activeNode) {
|
||||
// ── Item: a single memory, body scrolled via the shared ScrollBox ──
|
||||
if (mode === 'item' && activeBucket && activeNode) {
|
||||
const body = activeNode.body ? activeNode.body.split(/\r?\n/) : ['No additional detail recorded yet.']
|
||||
|
||||
return (
|
||||
|
|
@ -302,7 +287,7 @@ export function Journey({ gw, onClose, t }: JourneyProps) {
|
|||
</Text>
|
||||
</Text>
|
||||
<Text color={t.color.muted}>
|
||||
{selected.label} · {activeNode.meta} · item {selectedNode + 1}/{nodes.length}
|
||||
{activeBucket.label} · {activeNode.meta}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
|
|
@ -328,65 +313,12 @@ export function Journey({ gw, onClose, t }: JourneyProps) {
|
|||
)
|
||||
}
|
||||
|
||||
// ── Detail: the slice's skills + memories as a selectable list ──
|
||||
if (mode === 'detail' && selected) {
|
||||
const h = Math.max(4, rows - 6)
|
||||
const start = windowStart(selectedNode, nodes.length, h)
|
||||
|
||||
return (
|
||||
<Box alignItems="stretch" flexDirection="column" flexGrow={1} paddingX={1} paddingY={1}>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text wrap="truncate-end">
|
||||
<Text bold color={selected.color ? fadeHex(palette, selected.color, 1) : t.color.primary}>
|
||||
{selected.label}
|
||||
</Text>
|
||||
<Text color={t.color.muted}>
|
||||
{' '}
|
||||
{selected.skills} skills · {selected.memories} memories
|
||||
{selected.category ? ` · ${selected.category}` : ''} · slice {selected.index + 1}/{buckets.length}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" flexGrow={1} flexShrink={1} minHeight={0} overflow="hidden">
|
||||
{nodes.length ? (
|
||||
nodes.slice(start, start + h).map((node, i) => {
|
||||
const idx = start + i
|
||||
|
||||
return (
|
||||
<ListRow
|
||||
active={idx === selectedNode}
|
||||
cells={[
|
||||
{ color: t.color.muted, text: ` ${String(idx + 1).padStart(2, ' ')} ` },
|
||||
{ color: fadeInk(palette, node.style, 1), text: `${node.glyph} ${node.fullLabel || node.label}` },
|
||||
{ color: t.color.muted, text: ` ${node.meta}${node.body ? ' ›' : ''}` }
|
||||
]}
|
||||
key={`${node.label}:${idx}`}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<Text color={t.color.muted}>No objects in this slice.</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Footer>
|
||||
<Hint t={t}>
|
||||
{nodes.length ? `${selectedNode + 1}/${nodes.length} · ` : ''}↑↓/jk move
|
||||
{activeNode?.body ? ' · Enter/→ open' : ''} · g/G top/bottom · Esc/← back · q close
|
||||
</Hint>
|
||||
</Footer>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Timeline: static chart overview + selectable slice list ──
|
||||
// ── Timeline: static chart overview + a chronological slice/item tree ──
|
||||
const axisGap = Math.max(1, cols - 2 - data.axis.start.length - data.axis.end.length)
|
||||
const dataGrid = data.frames.at(-1)?.grid.filter(r => !rowText(r).trimStart().startsWith('trajectory')) ?? []
|
||||
const chartGrid = dataGrid.slice(-MAX_CHART_ROWS)
|
||||
const listH = Math.max(3, rows - chartGrid.length - (data.categories?.length ? 11 : 10))
|
||||
const start = windowStart(selectedRow, buckets.length, listH)
|
||||
const start = windowStart(cursor, tree.length, listH)
|
||||
|
||||
return (
|
||||
<Box alignItems="stretch" flexDirection="column" flexGrow={1} paddingX={1} paddingY={1}>
|
||||
|
|
@ -431,42 +363,55 @@ export function Journey({ gw, onClose, t }: JourneyProps) {
|
|||
</Box>
|
||||
|
||||
<Box flexDirection="column" flexGrow={1} flexShrink={1} minHeight={0} overflow="hidden">
|
||||
<Text color={t.color.muted}>Timeline slices</Text>
|
||||
{buckets.slice(start, start + listH).map((bucket, i) => {
|
||||
const idx = start + i
|
||||
const top = bucket.nodes[0]
|
||||
|
||||
return (
|
||||
<ListRow
|
||||
active={idx === selectedRow}
|
||||
cells={[
|
||||
{ color: t.color.muted, text: ` ${String(idx + 1).padStart(2, ' ')} ` },
|
||||
{
|
||||
color: bucket.color ? fadeHex(palette, bucket.color, 0.85) : t.color.label,
|
||||
text: bucket.label.padEnd(7, ' ')
|
||||
},
|
||||
{
|
||||
color: t.color.muted,
|
||||
text: ` ${bucket.skills} skills · ${bucket.memories} memories${
|
||||
bucket.category ? ` · ${bucket.category}` : ''
|
||||
} ${top ? top.fullLabel || top.label : 'empty'}`
|
||||
}
|
||||
]}
|
||||
key={`${bucket.label}:${idx}`}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{tree.slice(start, start + listH).map((row, i) => (
|
||||
<TreeLine active={start + i === cursor} key={start + i} palette={palette} row={row} t={t} />
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Footer>
|
||||
{data.summary.length ? <Hint t={t}>{data.summary.join(' · ')}</Hint> : null}
|
||||
<Hint t={t}>↑↓/jk move · Enter/→ open · g/G top/bottom · q close</Hint>
|
||||
<Hint t={t}>
|
||||
↑↓/jk move{activeNode?.body ? ' · Enter/→ open' : ''} · g/G top/bottom · q close
|
||||
</Hint>
|
||||
</Footer>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function TreeLine({ active, palette, row, t }: { active: boolean; palette: StarmapPalette; row: TreeRow; t: Theme }) {
|
||||
if (row.kind === 'slice') {
|
||||
const { bucket } = row
|
||||
|
||||
return (
|
||||
<ListRow
|
||||
active={active}
|
||||
cells={[
|
||||
{ color: bucket.color ? fadeHex(palette, bucket.color, 0.85) : t.color.label, text: bucket.label },
|
||||
{
|
||||
color: t.color.muted,
|
||||
text: ` · ${bucket.skills} skills · ${bucket.memories} memories${bucket.category ? ` · ${bucket.category}` : ''}`
|
||||
}
|
||||
]}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const { last, node } = row
|
||||
|
||||
return (
|
||||
<ListRow
|
||||
active={active}
|
||||
cells={[
|
||||
{ color: t.color.muted, text: ` ${last ? '└─' : '├─'} ` },
|
||||
{ color: fadeInk(palette, node.style, 1), text: `${node.glyph} ${node.fullLabel || node.label}` },
|
||||
{ color: t.color.muted, text: ` ${node.meta}${node.body ? ' ›' : ''}` }
|
||||
]}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Shell({ children, t }: { children: React.ReactNode; t: Theme }) {
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1} paddingY={1}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue