From ae78326bf605169a5f1f79b682e451f5939a1604 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 11:21:25 -0500 Subject: [PATCH] feat(journey): chronological slice/item tree in the TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- agent/learning_graph_render.py | 3 +- ui-tui/src/components/journey.tsx | 237 ++++++++++++------------------ 2 files changed, 93 insertions(+), 147 deletions(-) diff --git a/agent/learning_graph_render.py b/agent/learning_graph_render.py index 0028312e6db..3e18e046cff 100644 --- a/agent/learning_graph_render.py +++ b/agent/learning_graph_render.py @@ -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() diff --git a/ui-tui/src/components/journey.tsx b/ui-tui/src/components/journey.tsx index 2f325993761..ba5037181e1 100644 --- a/ui-tui/src/components/journey.tsx +++ b/ui-tui/src/components/journey.tsx @@ -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(null) const [err, setErr] = useState('') - const [selectedRow, setSelectedRow] = useState(0) - const [selectedNode, setSelectedNode] = useState(0) - const [mode, setMode] = useState('timeline') + const [cursor, setCursor] = useState(0) + const [mode, setMode] = useState<'item' | 'timeline'>('timeline') const [tick, setTick] = useState(0) const itemScroll = useRef(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) { - {selected.label} · {activeNode.meta} · item {selectedNode + 1}/{nodes.length} + {activeBucket.label} · {activeNode.meta} @@ -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 ( - - - - - {selected.label} - - - {' '} - {selected.skills} skills · {selected.memories} memories - {selected.category ? ` · ${selected.category}` : ''} · slice {selected.index + 1}/{buckets.length} - - - - - - {nodes.length ? ( - nodes.slice(start, start + h).map((node, i) => { - const idx = start + i - - return ( - - ) - }) - ) : ( - No objects in this slice. - )} - - -
- - {nodes.length ? `${selectedNode + 1}/${nodes.length} · ` : ''}↑↓/jk move - {activeNode?.body ? ' · Enter/→ open' : ''} · g/G top/bottom · Esc/← back · q close - -
-
- ) - } - - // ── 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 ( @@ -431,42 +363,55 @@ export function Journey({ gw, onClose, t }: JourneyProps) { - Timeline slices - {buckets.slice(start, start + listH).map((bucket, i) => { - const idx = start + i - const top = bucket.nodes[0] - - return ( - - ) - })} + {tree.slice(start, start + listH).map((row, i) => ( + + ))}
{data.summary.length ? {data.summary.join(' · ')} : null} - ↑↓/jk move · Enter/→ open · g/G top/bottom · q close + + ↑↓/jk move{activeNode?.body ? ' · Enter/→ open' : ''} · g/G top/bottom · q close +
) } +function TreeLine({ active, palette, row, t }: { active: boolean; palette: StarmapPalette; row: TreeRow; t: Theme }) { + if (row.kind === 'slice') { + const { bucket } = row + + return ( + + ) + } + + const { last, node } = row + + return ( + + ) +} + function Shell({ children, t }: { children: React.ReactNode; t: Theme }) { return (