feat(journey): CLI + TUI learning timeline (/journey)

Terminal rendition of the desktop Star Map / Memory Graph: learned skills
and memories on a timeline, shared by `hermes journey` and the TUI
`/journey` overlay via one size-aware Python renderer
(agent/learning_graph_render.py).

- TUI overlay mirrors /agents: static chart overview + selectable slice
  list → slice detail → single skill/memory body, with the shared
  inverse-row selection treatment and a pinned footer.
- Reuse primitives: extract OverlayScrollbar into its own module (now
  shared with agentsOverlay), scroll the item body via ScrollBox, and
  unify both lists through one table-driven ListRow.
- No animation/playback in the TUI — pure data; the renderer's reveal
  scrubber stays available in the CLI (`--play`, `--reveal`).
This commit is contained in:
Brooklyn Nicholson 2026-06-30 04:43:47 -05:00
parent 1d495cfbbf
commit e971dc1e9d
17 changed files with 2192 additions and 87 deletions

View file

@ -0,0 +1,881 @@
"""Terminal renderer for the Star Map (learned skills + memories over time).
The desktop app (``apps/desktop/src/app/starmap``) paints a GPU radial
constellation. A terminal can't do that — so this is a *rendition* that ports
the design language from the desktop source and from prior-art terminal star
maps, rather than guessing:
- **Orbital terminal chart.** A framed "instrument panel" with sparse time
shells, a small core, category sectors, and a gentle recency spiral. It keeps
the starmap feel without dumping a canvas worth of dots into the terminal.
- **A few constellation strokes, not a yarn ball.** Related skills can be joined
only when the segment is short enough to read as a local asterism.
- **Time is radial.** Stars sit on oldernewer shells (corerim), and playback
reveals outward like the desktop map's radial build-up.
- **Magnitude + age gradient** (``geometry.ts`` ``nodeRadius`` / ``recencyInk``):
used/pinned/recent stars are bigger and brighter; old ones are small + quiet.
- **Complementary memory ink** (``color.ts`` ``memoryInkFor``): memories render
in a muted complement of the theme primary.
Grids are emitted as style runs ``[text, style, alpha]`` so each consumer
maps the semantic style + brightness onto its own palette. Pure, stdlib-only.
"""
from __future__ import annotations
import math
from datetime import datetime, timezone
from typing import Any, Iterable, Optional
# time-axis.ts LEAD_IN: the oldest node sits just off recency 0.
LEAD_IN = 0.06
# constants.ts AGE_GRADIENT — old quiet, recent bright.
AGE_OLD_INK = 0.42
AGE_MID_INK = 0.74
AGE_NEW_INK = 0.95
AGE_MID = 0.52
# Style keys consumers map to base colors (brightness = the run alpha).
STYLE_BG = "bg"
STYLE_SKILL = "skill"
STYLE_MEMORY = "memory"
STYLE_LABEL = "label"
STYLE_DIM = "dim"
# Legend glyphs mirror NODE_SHAPE (skill = circle, memory = diamond).
SKILL_GLYPH = ""
MEMORY_GLYPH = ""
_LABEL_KEYS = tuple("123456789abc")
# Braille subpixel bit layout (Unicode spec): cell is 2 wide × 4 tall.
_BRAILLE = ((0x01, 0x08), (0x02, 0x10), (0x04, 0x20), (0x40, 0x80))
# Star "magnitude" → subpixel offsets painted around the center.
_STAR_PATTERNS = {
1: ((0, 0),),
2: ((0, 0), (1, 0), (0, 1)),
3: ((0, 0), (1, 0), (-1, 0), (0, 1), (0, -1)),
4: ((0, 0), (1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (-1, -1), (1, -1), (-1, 1)),
}
Run = list # [text, style, alpha]
Row = list # list[Run]
Grid = list # list[Row]
def _to_ts(value: Any) -> Optional[float]:
try:
return None if value is None else float(value)
except (TypeError, ValueError):
return None
def _clamp(v: float, lo: float, hi: float) -> float:
return lo if v < lo else hi if v > hi else v
def _smoothstep(p: float) -> float:
p = _clamp(p, 0.0, 1.0)
return p * p * (3 - 2 * p)
def recency_ink(rec: float) -> float:
"""Port of geometry.ts ``recencyInk`` — smoothstep age → ink alpha."""
t = _clamp(rec, 0.0, 1.0)
if t <= AGE_MID:
return AGE_OLD_INK + (AGE_MID_INK - AGE_OLD_INK) * _smoothstep(t / AGE_MID)
return AGE_MID_INK + (AGE_NEW_INK - AGE_MID_INK) * _smoothstep((t - AGE_MID) / (1 - AGE_MID))
def _hash(s: str) -> int:
"""FNV-1a (geometry.ts ``hash``) — stable per-id scatter seed."""
h = 2166136261
for ch in s:
h = ((h ^ ord(ch)) * 16777619) & 0xFFFFFFFF
return h
def format_date(ts: Optional[float]) -> str:
if not ts:
return "unknown"
try:
return datetime.fromtimestamp(float(ts), tz=timezone.utc).strftime("%-d %b %Y")
except (ValueError, OSError, OverflowError):
return "unknown"
def compute_recency(nodes: list[dict[str, Any]]) -> dict[str, Any]:
"""Port of time-axis.ts ``computeRecency`` (id → recency ratio, timed flag)."""
known = [t for t in (_to_ts(n.get("timestamp")) for n in nodes) if t is not None]
min_ts = min(known) if known else None
max_ts = max(known) if known else None
timed = min_ts is not None and max_ts is not None and max_ts > min_ts
ordered = sorted(
nodes,
key=lambda n: (
_to_ts(n.get("timestamp")) if _to_ts(n.get("timestamp")) is not None else math.inf,
str(n.get("id", "")),
),
)
last = max(len(ordered) - 1, 1)
ord_ratio = {str(n.get("id", "")): (i / last if len(ordered) > 1 else 0.0) for i, n in enumerate(ordered)}
rec: dict[str, float] = {}
for n in nodes:
nid = str(n.get("id", ""))
ts = _to_ts(n.get("timestamp"))
if timed and ts is not None and min_ts is not None and max_ts is not None:
ratio = (ts - min_ts) / (max_ts - min_ts)
else:
ratio = ord_ratio.get(nid, 0.0)
rec[nid] = LEAD_IN + (1 - LEAD_IN) * _clamp(ratio, 0.0, 1.0)
return {"rec": rec, "timed": timed, "minTs": min_ts, "maxTs": max_ts}
def _date_at(rec: dict[str, Any], reveal: float) -> Optional[float]:
if not rec.get("timed"):
return None
lo, hi = rec.get("minTs"), rec.get("maxTs")
if lo is None or hi is None:
return None
return round(lo + _clamp(reveal, 0, 1) * (hi - lo))
# ── Color: ported from color.ts so memory ink + age fade match the desktop ──
def hex_to_rgb(s: str) -> tuple[int, int, int]:
s = s.strip().lstrip("#")
if len(s) == 3:
s = "".join(c * 2 for c in s)
try:
return int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16)
except (ValueError, IndexError):
return 255, 215, 0
def rgb_to_hex(c: tuple) -> str:
return "#{:02X}{:02X}{:02X}".format(*(int(_clamp(v, 0, 255)) for v in c))
def mix_rgb(a: tuple, b: tuple, t: float) -> tuple[int, int, int]:
p = _clamp(t, 0.0, 1.0)
return tuple(round(a[i] + (b[i] - a[i]) * p) for i in range(3)) # type: ignore[return-value]
def _rgb_to_hsl(c: tuple) -> tuple[float, float, float]:
r, g, b = (x / 255 for x in c)
mx, mn = max(r, g, b), min(r, g, b)
light = (mx + mn) / 2
d = mx - mn
if not d:
return 0.0, 0.0, light
s = d / (2 - mx - mn) if light > 0.5 else d / (mx + mn)
if mx == r:
h = (g - b) / d + (6 if g < b else 0)
elif mx == g:
h = (b - r) / d + 2
else:
h = (r - g) / d + 4
return h * 60, s, light
def _hsl_to_rgb(h: float, s: float, light: float) -> tuple[int, int, int]:
hue = ((h % 360) + 360) % 360
c = (1 - abs(2 * light - 1)) * s
x = c * (1 - abs(((hue / 60) % 2) - 1))
m = light - c / 2
if hue < 60:
r, g, b = c, x, 0.0
elif hue < 120:
r, g, b = x, c, 0.0
elif hue < 180:
r, g, b = 0.0, c, x
elif hue < 240:
r, g, b = 0.0, x, c
elif hue < 300:
r, g, b = x, 0.0, c
else:
r, g, b = c, 0.0, x
return round((r + m) * 255), round((g + m) * 255), round((b + m) * 255)
def _complementary_ink(c: tuple) -> tuple[int, int, int]:
h, s, light = _rgb_to_hsl(c)
return _hsl_to_rgb(h + 165, max(s, 0.5), _clamp(light, 0.5, 0.7))
def derive_palette(primary_hex: str, *, dark: bool = True) -> dict[str, str]:
"""Port of color.ts ``computePalette`` (the bits a terminal needs)."""
primary = hex_to_rgb(primary_hex)
base = (255, 255, 255) if dark else (0, 0, 0)
bg = (8, 8, 12) if dark else (250, 250, 250)
return {
"primary": primary_hex,
"skill": rgb_to_hex(mix_rgb(primary, base, 0.12 if dark else 0.18)),
"memory": rgb_to_hex(mix_rgb(_complementary_ink(primary), bg, 0.45)),
"label": rgb_to_hex(mix_rgb(base, bg, 0.35)),
"dim": rgb_to_hex(mix_rgb(base, bg, 0.7)),
"bg": rgb_to_hex(bg),
}
# ── Braille canvas ─────────────────────────────────────────────────────────
#
# Kept around as a small primitive in case we want a future --braille mode, but
# the default UX is now the composed orbital character scene below. The braille
# web looked technically clever and visually awful for this dataset.
class _Braille:
"""A 2×4-subpixel-per-cell canvas → braille style runs (one color per cell)."""
def __init__(self, cols: int, rows: int):
self.cols = max(1, cols)
self.rows = max(1, rows)
# (col,row) -> [mask, style, alpha, prio]
self.cells: dict[tuple[int, int], list] = {}
@property
def width(self) -> int:
return self.cols * 2
@property
def height(self) -> int:
return self.rows * 4
def plot(self, x: int, y: int, style: str, alpha: float, prio: int) -> None:
if x < 0 or y < 0:
return
col, row = x // 2, y // 4
if col >= self.cols or row >= self.rows:
return
bit = _BRAILLE[y % 4][x % 2]
cell = self.cells.get((col, row))
if cell is None:
self.cells[(col, row)] = [bit, style, alpha, prio]
else:
cell[0] |= bit
if prio >= cell[3]:
cell[1], cell[2], cell[3] = style, alpha, prio
def line(self, x0: int, y0: int, x1: int, y1: int, style: str, alpha: float, prio: int) -> None:
dx, dy = abs(x1 - x0), -abs(y1 - y0)
sx = 1 if x0 < x1 else -1
sy = 1 if y0 < y1 else -1
err = dx + dy
while True:
self.plot(x0, y0, style, alpha, prio)
if x0 == x1 and y0 == y1:
break
e2 = 2 * err
if e2 >= dy:
err += dy
x0 += sx
if e2 <= dx:
err += dx
y0 += sy
def to_grid(self) -> Grid:
grid: Grid = []
for row in range(self.rows):
runs: Row = []
last_col = -1
for col in range(self.cols):
if (col, row) in self.cells:
last_col = col
for col in range(last_col + 1):
cell = self.cells.get((col, row))
if cell:
ch, style, alpha = chr(0x2800 | cell[0]), cell[1], cell[2]
else:
ch, style, alpha = " ", STYLE_BG, 1.0
if runs and runs[-1][1] == style and abs(runs[-1][2] - alpha) < 1e-6:
runs[-1][0] += ch
else:
runs.append([ch, style, alpha])
grid.append(runs)
return grid
def _blit_braille(field: _CharField, sky: _Braille, ox: int = 0, oy: int = 0) -> None:
"""Copy a braille canvas into the character field."""
for (col, row), (mask, style, alpha, _prio) in sky.cells.items():
field.put(ox + col, oy + row, chr(0x2800 | mask), style, alpha, 2)
class _CharField:
"""Priority-buffered character scene for the terminal star chart."""
def __init__(self, cols: int, rows: int):
self.cols = max(20, cols)
self.rows = max(8, rows)
self.ch = [[" "] * self.cols for _ in range(self.rows)]
self.style = [[STYLE_BG] * self.cols for _ in range(self.rows)]
self.alpha = [[1.0] * self.cols for _ in range(self.rows)]
self.prio = [[0] * self.cols for _ in range(self.rows)]
def put(self, x: int, y: int, ch: str, style: str, alpha: float, prio: int) -> None:
if 0 <= x < self.cols and 0 <= y < self.rows and prio >= self.prio[y][x]:
self.ch[y][x] = ch
self.style[y][x] = style
self.alpha[y][x] = alpha
self.prio[y][x] = prio
def text(self, x: int, y: int, text: str, style: str, alpha: float, prio: int) -> None:
for i, ch in enumerate(text):
self.put(x + i, y, ch, style, alpha, prio)
def line(self, x0: int, y0: int, x1: int, y1: int, style: str, alpha: float, prio: int) -> None:
dx = abs(x1 - x0)
dy = -abs(y1 - y0)
sx = 1 if x0 < x1 else -1
sy = 1 if y0 < y1 else -1
err = dx + dy
while True:
self.put(x0, y0, "·", style, alpha, prio)
if x0 == x1 and y0 == y1:
break
e2 = 2 * err
if e2 >= dy:
err += dy
x0 += sx
if e2 <= dx:
err += dx
y0 += sy
def to_grid(self) -> Grid:
out: Grid = []
for y in range(self.rows):
row: Row = []
last = self.cols - 1
while last >= 0 and self.ch[y][last] == " ":
last -= 1
if last < 0:
out.append([])
continue
for x in range(last + 1):
run = [self.ch[y][x], self.style[y][x], self.alpha[y][x]]
if row and row[-1][1] == run[1] and abs(row[-1][2] - run[2]) < 1e-6:
row[-1][0] += run[0]
else:
row.append(run)
out.append(row)
return out
def _star_glyph(node: dict[str, Any], rec: float, ignited: bool) -> str:
if not ignited:
return "·" if node.get("kind") != "memory" else ""
if node.get("kind") == "memory":
return "" if rec > 0.55 else ""
use = int(node.get("useCount", 0) or 0)
if node.get("pinned") or use >= 8 or rec > 0.86:
return ""
if use >= 3:
return ""
return "·"
def _node_score(node: dict[str, Any], rec: float) -> float:
"""Pick which visible objects deserve map markers + label rows."""
if node.get("kind") == "memory":
return 3.5 + rec
use = float(node.get("useCount", 0) or 0)
return rec * 2 + math.sqrt(max(0.0, use)) + (2.0 if node.get("pinned") else 0.0)
def _node_label(node: dict[str, Any]) -> str:
text = str(node.get("label") or node.get("id") or "unknown").strip()
return text if len(text) <= 26 else text[:23].rstrip() + ""
def _node_meta(node: dict[str, Any]) -> str:
if node.get("kind") == "memory":
source = "profile memory" if node.get("memorySource") == "profile" else "memory"
return f"{source} · {format_date(_to_ts(node.get('timestamp')))}"
bits = [str(node.get("category") or "skill"), format_date(_to_ts(node.get("timestamp")))]
count = int(node.get("useCount", 0) or 0)
if count:
bits.append(f"x{count}")
if node.get("pinned"):
bits.append("pinned")
return " · ".join(bits)
def _ring_recencies(recencies: list[float]) -> list[float]:
if not recencies:
return []
# A few time shells, not one noisy ring per date. The outer rim is always now.
count = min(5, max(3, len({round(r, 1) for r in recencies})))
return [LEAD_IN + (1 - LEAD_IN) * (i / (count - 1)) for i in range(count)]
def _angle_for(node: dict[str, Any], categories: dict[str, int]) -> float:
cat = str(node.get("category") or ("memory" if node.get("kind") == "memory" else "skill"))
n_cats = max(1, len(categories))
rec_hint = _to_ts(node.get("timestamp")) or 0
base = (categories.get(cat, 0) / n_cats) * math.tau - math.pi / 2
# Stable local fan so a category becomes a sector/cloud, not a vertical hash.
jitter = (((_hash(str(node.get("id", ""))) % 1000) / 1000.0) - 0.5) * (math.tau / max(4, n_cats)) * 0.9
# Slight deterministic phase shift stops same-category rows from looking
# mechanically radial before the real recency spiral is applied.
phase = ((_hash(cat + str(int(rec_hint))) % 1000) / 1000.0 - 0.5) * 0.18
return base + jitter + phase
# ── Timeline chart frame ─────────────────────────────────────────────────────
class _ChartBucket:
__slots__ = ("label", "ts", "skills", "memories", "nodes", "rec")
def __init__(self, label: str, ts: float):
self.label = label
self.ts = ts
self.skills = 0
self.memories = 0
self.nodes: list[dict[str, Any]] = []
self.rec = 1.0
@property
def total(self) -> int:
return self.skills + self.memories
def _period_key(ts: float, granularity: str) -> tuple[int, ...]:
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
if granularity == "day":
return (dt.year, dt.month, dt.day)
if granularity == "month":
return (dt.year, dt.month)
return (dt.year,)
def _period_label(ts: float, granularity: str) -> str:
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
if granularity == "day":
return dt.strftime("%-d %b")
if granularity == "month":
return dt.strftime("%b %Y")
return dt.strftime("%Y")
def _build_chart_buckets(nodes: list[dict[str, Any]], rec: dict[str, Any], max_rows: int) -> list[_ChartBucket]:
"""Timeline rows: finest date granularity that fits, oldest → newest."""
if not nodes:
return []
if not rec["timed"]:
ordered = sorted(nodes, key=lambda n: rec["rec"].get(str(n.get("id", "")), 0.0))
n_bins = min(max_rows, max(1, len(ordered)))
buckets = [_ChartBucket(f"#{i + 1}", float(i)) for i in range(n_bins)]
for node in ordered:
idx = int(_clamp(math.floor(rec["rec"].get(str(node.get("id", "")), 0.0) * n_bins), 0, n_bins - 1))
b = buckets[idx]
b.nodes.append(node)
if node.get("kind") == "memory":
b.memories += 1
else:
b.skills += 1
return buckets
chosen: Optional[list[_ChartBucket]] = None
for granularity in ("day", "month", "year"):
groups: dict[tuple[int, ...], _ChartBucket] = {}
for node in nodes:
ts = _to_ts(node.get("timestamp"))
if ts is None:
continue
key = _period_key(ts, granularity)
bucket = groups.get(key)
if bucket is None:
bucket = _ChartBucket(_period_label(ts, granularity), ts)
groups[key] = bucket
bucket.nodes.append(node)
if node.get("kind") == "memory":
bucket.memories += 1
else:
bucket.skills += 1
# For short spans, keep the useful day-by-day graph even when the caller
# asked for fewer rows; terminal scrollback is better than collapsing a
# month of activity into one unreadable bar.
if len(groups) <= max_rows or (granularity == "day" and len(groups) <= 32):
chosen = [groups[key] for key in sorted(groups)]
break
if chosen is None:
# If even yearly buckets overflow, fall back to even time bins.
min_ts, max_ts = rec.get("minTs"), rec.get("maxTs")
n_bins = max(1, max_rows)
chosen = []
for i in range(n_bins):
ts = min_ts + (i / max(1, n_bins - 1)) * (max_ts - min_ts) if min_ts and max_ts else float(i)
chosen.append(_ChartBucket(format_date(ts), ts))
for node in nodes:
r = rec["rec"].get(str(node.get("id", "")), 0.0)
idx = int(_clamp(math.floor(r * n_bins), 0, n_bins - 1))
b = chosen[idx]
b.nodes.append(node)
if node.get("kind") == "memory":
b.memories += 1
else:
b.skills += 1
min_ts, max_ts = rec.get("minTs"), rec.get("maxTs")
span = (max_ts - min_ts) if min_ts is not None and max_ts is not None and max_ts > min_ts else 0
for bucket in chosen:
bucket.rec = LEAD_IN + (1 - LEAD_IN) * ((bucket.ts - min_ts) / span) if span else 1.0
return chosen
def _bucket_label_node(bucket: _ChartBucket) -> Optional[dict[str, Any]]:
if not bucket.nodes:
return None
return max(bucket.nodes, key=lambda node: _node_score(node, _to_ts(node.get("timestamp")) or bucket.ts))
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)
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()
memory = (memory_lookup or {}).get(str(node.get("id", "")))
out.append(
{
"id": str(node.get("id", "")),
"glyph": MEMORY_GLYPH if node.get("kind") == "memory" else SKILL_GLYPH,
"label": _node_label(node),
"fullLabel": raw_label,
"meta": _node_meta(node),
"body": str(memory.get("body", "")) if memory else "",
"style": style,
}
)
return out
def _bucket_rows(buckets: list[_ChartBucket], payload: dict[str, Any]) -> list[dict[str, Any]]:
cmap = category_color_map(payload)
memory_lookup = {
f"memory:{card.get('source')}:{idx}": card
for idx, card in enumerate(payload.get("memory", []) or [])
if isinstance(card, dict)
}
rows: list[dict[str, Any]] = []
for idx, bucket in enumerate(buckets):
cat = _bucket_category(bucket)
rows.append(
{
"index": idx,
"label": bucket.label,
"date": format_date(bucket.ts),
"skills": bucket.skills,
"memories": bucket.memories,
"total": bucket.total,
"category": cat,
"color": cmap.get(cat) if cat else None,
"nodes": _bucket_nodes(bucket, memory_lookup),
}
)
return rows
def _category_counts(payload: dict[str, Any]) -> list[tuple[str, int]]:
clusters = [
(str(c.get("category")), int(c.get("count", 0)))
for c in payload.get("clusters", []) or []
if c.get("category") and c.get("category") != "memory"
]
if clusters:
return clusters
counts: dict[str, int] = {}
for node in payload.get("nodes", []):
if node.get("kind") == "memory":
continue
cat = str(node.get("category") or "skill")
counts[cat] = counts.get(cat, 0) + 1
return sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
def category_color_map(payload: dict[str, Any]) -> dict[str, str]:
"""Deterministic, evenly-spread hue per skill category (theme-independent)."""
clusters = _category_counts(payload)
n = max(1, len(clusters))
# Golden-angle hue spacing so adjacent categories never collide in color.
return {cat: rgb_to_hex(_hsl_to_rgb((i * 137.508) % 360, 0.55, 0.62)) for i, (cat, _c) in enumerate(clusters)}
def category_legend(payload: dict[str, Any], limit: int = 4) -> list[dict[str, Any]]:
cmap = category_color_map(payload)
cats = _category_counts(payload)
shown = cats[:limit]
hidden = max(0, len(cats) - len(shown))
return [
{"glyph": "", "color": cmap.get(cat, ""), "label": f"{cat} ({count})"}
for cat, count in shown
] + ([{"glyph": "·", "color": "", "label": f"+{hidden}"}] if hidden else [])
def _bucket_category(bucket: _ChartBucket) -> Optional[str]:
counts: dict[str, int] = {}
for node in bucket.nodes:
if node.get("kind") == "memory":
continue
cat = str(node.get("category") or "skill")
counts[cat] = counts.get(cat, 0) + 1
return max(counts, key=lambda k: counts[k]) if counts else None
def _dust(label: str, col: int, density: int = 9) -> bool:
return (_hash(f"{label}:{col}") % density) == 0
def _trajectory_row(buckets: list[_ChartBucket], width: int, reveal: float) -> Row:
"""Cumulative learning curve as a compact star-path sparkline."""
if not buckets:
return []
total = sum(b.total for b in buckets) or 1
visible = int(_clamp(math.ceil(reveal * len(buckets)), 0, len(buckets)))
acc = 0
points: list[int] = []
for b in buckets[:visible]:
acc += b.total
points.append(round((acc / total) * (width - 1)))
cells = [" "] * width
last = 0
for p in points:
for x in range(min(last, p), max(last, p) + 1):
if 0 <= x < width and cells[x] == " ":
cells[x] = "·"
if 0 <= p < width:
cells[p] = ""
last = p
return [["trajectory ", STYLE_LABEL, 0.55], ["".join(cells), STYLE_SKILL, 0.48]]
def render_graph(
payload: dict[str, Any],
*,
cols: int = 80,
rows: int = 16,
reveal: float = 1.0,
seed: int = 0,
links: bool = True,
) -> dict[str, Any]:
"""Render one starmap-flavored timeline frame at ``reveal`` (0→1).
The useful part is the first boring graph: date rows, proportional bars, and
counts. The starmap layer is restrained: star/diamond glyphs inside the bars,
a sparse constellation strip, and numbered row markers tied to label rows.
"""
del seed
reveal = _clamp(reveal, 0.0, 1.0)
cols = max(44, cols)
rows = max(14, rows)
nodes = list(payload.get("nodes", []))
if not nodes:
field = _CharField(cols, rows)
field.text(2, rows // 2, "no learning yet — keep using Hermes and it maps out here", STYLE_DIM, 0.7, 2)
return {"grid": field.to_grid(), "date": "", "reveal": reveal, "visible": 0}
rec = compute_recency(nodes)
cmap = category_color_map(payload)
buckets = _build_chart_buckets(nodes, rec, max_rows=max(4, rows - 3))
n_buckets = len(buckets)
visible_bucket_count = int(_clamp(math.ceil(reveal * n_buckets), 0, n_buckets))
max_total = max((b.total for b in buckets), default=1) or 1
label_w = min(9, max(len(b.label) for b in buckets))
bar_w = max(14, cols - label_w - 16)
grid: Grid = []
labels: list[dict[str, Any]] = []
visible = 0
for i, bucket in enumerate(buckets):
if i >= visible_bucket_count:
grid.append([])
continue
visible += bucket.total
ink = recency_ink(bucket.rec)
bar_len = max(1, round((bucket.total / max_total) * bar_w)) if bucket.total else 0
skill_len = round((bucket.skills / bucket.total) * bar_len) if bucket.total else 0
if bucket.skills and skill_len == 0:
skill_len = 1
memory_len = bar_len - skill_len
if bucket.memories and memory_len == 0 and bar_len > 1:
memory_len = 1
skill_len = bar_len - 1
node = _bucket_label_node(bucket)
marker = ""
if node and len(labels) < 6:
marker = _LABEL_KEYS[len(labels)]
style = STYLE_MEMORY if node.get("kind") == "memory" else STYLE_SKILL
labels.append(
{
"key": marker,
"glyph": MEMORY_GLYPH if node.get("kind") == "memory" else SKILL_GLYPH,
"label": _node_label(node),
"meta": _node_meta(node),
"style": style,
"alpha": round(ink, 3),
}
)
cat = _bucket_category(bucket)
cat_hex = cmap.get(cat) if cat else None
row: Row = [[f"{bucket.label:>{label_w}} ", STYLE_LABEL, ink], ["", STYLE_DIM, 0.55]]
if marker:
row.append([marker, STYLE_LABEL, 0.95])
elif bucket.total:
head_hex = cat_hex if bucket.skills else None
row.append(["" if bucket.skills else "", STYLE_SKILL if bucket.skills else STYLE_MEMORY, ink, head_hex])
if skill_len:
# Bar colored by the day's dominant category — a learning heatmap.
row.append(["" * skill_len, STYLE_SKILL, ink, cat_hex])
if memory_len:
if memory_len == 1:
mem_trail = ""
else:
mem_trail = "" + ("" * (memory_len - 2)) + ""
row.append([mem_trail, STYLE_MEMORY, max(0.65, ink)])
if bar_len < bar_w:
# Empty space keeps counts aligned; starmap texture lives in the
# trajectory row below, where it reads as signal rather than noise.
row.append([" " * (bar_w - bar_len), STYLE_BG, 1.0])
row.append([" ", STYLE_BG, 1.0])
row.append([str(bucket.skills), STYLE_SKILL, max(0.72, ink)])
if bucket.memories:
row.append(["+", STYLE_DIM, 0.6])
row.append([str(bucket.memories), STYLE_MEMORY, max(0.72, ink)])
if i == visible_bucket_count - 1:
row.append([" ◀ now", STYLE_LABEL, 0.9])
elif bucket.total == max_total and max_total > 1:
row.append([" ☄ peak", STYLE_LABEL, 0.75])
grid.append(row)
# Cumulative learning trajectory underneath the rows.
grid.append([[(" " * (label_w + 2)), STYLE_BG, 1.0], *_trajectory_row(buckets, max(12, cols - label_w - 13), reveal)])
return {
"grid": grid,
"date": format_date(_date_at(rec, reveal)),
"reveal": reveal,
"visible": visible,
"labels": labels,
}
# ── Trimmings ──────────────────────────────────────────────────────────────
def build_legend(payload: dict[str, Any]) -> list[dict[str, Any]]:
nodes = payload.get("nodes", [])
skills = sum(1 for n in nodes if n.get("kind") != "memory")
memories = sum(1 for n in nodes if n.get("kind") == "memory")
return [
{"glyph": SKILL_GLYPH, "style": STYLE_SKILL, "label": f"skills ({skills})"},
{"glyph": MEMORY_GLYPH, "style": STYLE_MEMORY, "label": f"memories ({memories})"},
]
def axis_labels(payload: dict[str, Any]) -> dict[str, str]:
rec = compute_recency(list(payload.get("nodes", [])))
if not rec["timed"]:
return {"start": "oldest", "end": "now"}
return {"start": format_date(rec.get("minTs")), "end": format_date(rec.get("maxTs"))}
def _peak_day(payload: dict[str, Any]) -> Optional[str]:
counts: dict[tuple[int, ...], int] = {}
reps: dict[tuple[int, ...], float] = {}
for node in payload.get("nodes", []):
ts = _to_ts(node.get("timestamp"))
if ts is None:
continue
key = _period_key(ts, "day")
counts[key] = counts.get(key, 0) + 1
reps[key] = ts
if not counts:
return None
best = max(counts, key=lambda k: counts[k])
return f"busiest day {_period_label(reps[best], 'day')} · {counts[best]} learned"
def build_summary(payload: dict[str, Any]) -> list[str]:
stats = payload.get("stats", {}) or {}
lines: list[str] = []
learned = stats.get("learned_skills", stats.get("nodes", 0))
mem = stats.get("memory_nodes", 0)
edges = stats.get("related_edges", 0)
lines.append(f"{learned} learned skills · {mem} memories · {edges} skill links")
extra = []
if stats.get("memory_skill_edges"):
extra.append(f"{stats['memory_skill_edges']} memory↔skill links")
peak = _peak_day(payload)
if peak:
extra.append(peak)
if extra:
lines.append(" · ".join(extra))
return lines
def _merge_runs(cells: Iterable[Run]) -> Row:
out: Row = []
for run in cells:
text, style, alpha = run[0], run[1], (run[2] if len(run) > 2 else 1.0)
hex_override = run[3] if len(run) > 3 else None
prev_hex = out[-1][3] if out and len(out[-1]) > 3 else None
if out and out[-1][1] == style and abs(out[-1][2] - alpha) < 1e-6 and prev_hex == hex_override:
out[-1][0] += text
else:
merged: Run = [text, style, alpha]
if hex_override:
merged.append(hex_override)
out.append(merged)
return out
def render_frames(
payload: dict[str, Any],
*,
cols: int = 80,
rows: int = 16,
frames: int = 48,
links: bool = True,
) -> dict[str, Any]:
"""Pre-render a full play-through (reveal 0→1) plus static legend/summary."""
frames = max(2, min(frames, 240))
nodes = list(payload.get("nodes", []))
rec = compute_recency(nodes)
# Mirror render_graph's bucketing so the interactive row list lines up with
# what the user sees.
buckets = _build_chart_buckets(nodes, rec, max_rows=max(4, rows - 3)) if nodes else []
out_frames = []
for i in range(frames):
reveal = i / (frames - 1)
frame = render_graph(payload, cols=cols, rows=rows, reveal=reveal, links=links)
out_frames.append(
{
"reveal": frame["reveal"],
"date": frame["date"],
"visible": frame["visible"],
"grid": frame["grid"],
"labels": frame.get("labels", []),
}
)
return {
"frames": out_frames,
"legend": build_legend(payload),
"categories": category_legend(payload),
"buckets": _bucket_rows(buckets, payload),
"summary": build_summary(payload),
"axis": axis_labels(payload),
"count": len(payload.get("nodes", [])),
"cols": cols,
"rows": rows,
}

16
cli.py
View file

@ -8485,6 +8485,22 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._handle_stop_command()
elif canonical == "agents":
self._handle_agents_command()
elif canonical == "journey":
try:
import argparse
import shlex
from hermes_cli.journey import register_cli as _register_journey_cli
parser = argparse.ArgumentParser(prog="/journey", add_help=False)
_register_journey_cli(parser)
argv = shlex.split(cmd_original.split(None, 1)[1]) if len(cmd_original.split(None, 1)) > 1 else []
args = parser.parse_args(argv)
args.func(args)
except SystemExit:
pass
except Exception as exc:
_cprint(f" /journey failed: {exc}")
elif canonical == "background":
self._handle_background_command(cmd_original)
elif canonical == "queue":

View file

@ -103,6 +103,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
aliases=("bg", "btw"), args_hint="<prompt>"),
CommandDef("agents", "Show active agents and running tasks", "Session",
aliases=("tasks",)),
CommandDef("journey", "Open the learning journey timeline",
"Session", aliases=("learning", "memory-graph"), cli_only=True),
CommandDef("queue", "Queue a prompt for the next turn (doesn't interrupt)", "Session",
aliases=("q",), args_hint="<prompt>"),
CommandDef("steer", "Inject a message after the next tool call without interrupting", "Session",

250
hermes_cli/journey.py Normal file
View file

@ -0,0 +1,250 @@
"""``hermes journey`` — what Hermes has learned, on a timeline.
A terminal-native rendition of the desktop Star Map / Memory Graph: a horizontal
timeline bar chart of learned skills and memories over time (oldest at top,
newest at bottom) plus the playable constellation scrubber. Graph assembly,
layout, and the (ported-from-desktop) palette all live in
``agent.learning_graph`` / ``agent.learning_graph_render`` so the CLI, the TUI
``/journey`` overlay, and the desktop panel draw the same data.
"""
from __future__ import annotations
import argparse
import shutil
import sys
import time
from functools import lru_cache
from typing import Any, Optional
_TITLE_COLOR = "#E8C463"
def _build_payload() -> dict[str, Any]:
from agent.learning_graph import build_learning_graph
return build_learning_graph()
@lru_cache(maxsize=1)
def _primary_hex() -> str:
"""The active skin's primary color (mirrors the TUI theme primary)."""
try:
from hermes_cli.skin_engine import get_active_skin
skin = get_active_skin()
return skin.get_color("ui_primary", "") or skin.get_color("banner_title", "#FFD700")
except Exception:
return "#FFD700"
@lru_cache(maxsize=1)
def _palette() -> dict[str, str]:
from agent.learning_graph_render import derive_palette
return derive_palette(_primary_hex(), dark=True)
def _fade(base: Optional[str], alpha: float) -> Optional[str]:
from agent.learning_graph_render import hex_to_rgb, mix_rgb, rgb_to_hex
if not base:
return None
if alpha >= 0.999:
return base
return rgb_to_hex(mix_rgb(hex_to_rgb(_palette()["bg"]), hex_to_rgb(base), alpha))
def _resolve(style: str, alpha: float) -> Optional[str]:
"""Fade the style's base ink toward the background by ``alpha`` (rgba-over-bg)."""
return _fade(_palette().get(style), alpha)
def _row_to_text(row: list, color: bool):
from rich.text import Text
text = Text()
for run in row:
chunk = run[0]
style = run[1]
alpha = run[2] if len(run) > 2 else 1.0
override = run[3] if len(run) > 3 else None
if not color:
text.append(chunk)
elif override:
text.append(chunk, style=_fade(override, alpha))
else:
text.append(chunk, style=_resolve(style, alpha))
return text
def _term_size(width: Optional[int], height: Optional[int]) -> tuple[int, int]:
size = shutil.get_terminal_size((90, 30))
return max(40, width or size.columns), max(10, height or size.lines)
def _frame_renderable(payload, *, cols, rows, reveal, color):
from rich.console import Group
from rich.text import Text
from agent import learning_graph_render as render
legend = render.build_legend(payload)
categories = render.category_legend(payload)
summary = render.build_summary(payload)
axis = render.axis_labels(payload)
# Lines are pad_left(2), so content must fit in cols-2.
inner = max(24, cols - 2)
# Reserve rows for title/legend/blank/axis/footer/labels + summary; field gets rest.
field_rows = max(6, rows - 10 - len(summary))
frame = render.render_graph(payload, cols=inner, rows=field_rows, reveal=reveal)
count = len(payload.get("nodes", []))
parts: list[Any] = []
title = Text()
title.append("✦ Journey ", style=f"bold {_TITLE_COLOR}" if color else None)
title.append("· learned skills & memories over time", style="grey62" if color else None)
parts.append(title)
legend_line = Text(" ")
for i, item in enumerate(legend):
if i:
legend_line.append(" ")
legend_line.append(item["glyph"] + " ", style=_resolve(item["style"], 1.0) if color else None)
legend_line.append(item["label"], style="grey62" if color else None)
parts.append(legend_line)
if categories:
cat_line = Text(" ")
for i, item in enumerate(categories):
if i:
cat_line.append(" ")
cat_line.append(item["glyph"] + " ", style=_fade(item.get("color"), 1.0) if color else None)
cat_line.append(item["label"], style="grey54" if color else None)
parts.append(cat_line)
parts.append(Text(""))
for grow in frame["grid"]:
line = _row_to_text(grow, color)
line.pad_left(2)
parts.append(line)
# Date axis under the field (oldest → now), with the playhead date centered.
axis_line = Text(" ")
axis_line.append(axis["start"], style="grey54" if color else None)
gap = max(1, inner - len(axis["start"]) - len(axis["end"]))
axis_line.append(" " * gap)
axis_line.append(axis["end"], style="grey54" if color else None)
parts.append(axis_line)
pct = int(round(reveal * 100))
foot = Text(" ")
foot.append("", style="grey54" if color else None)
foot.append(frame["date"] or "", style=_TITLE_COLOR if color else None)
foot.append(f" {frame['visible']}/{count} revealed · {pct}%", style="grey54" if color else None)
parts.append(foot)
labels = frame.get("labels", [])
if labels:
parts.append(Text(""))
heading = Text(" charted signals", style="grey62" if color else None)
parts.append(heading)
def label_row(item) -> Text:
row = Text(" ")
row.append(f"{item['key']} ", style="grey70" if color else None)
row.append(f"{item['glyph']} ", style=_resolve(item["style"], float(item.get("alpha", 1.0))) if color else None)
row.append(str(item["label"]), style=_resolve(item["style"], float(item.get("alpha", 1.0))) if color else None)
meta = str(item["meta"])
row.append(f" {meta if len(meta) <= 32 else meta[:29] + ''}", style="grey54" if color else None)
return row
for item in labels[:6]:
row = label_row(item)
parts.append(row)
for line_text in summary:
parts.append(Text(" " + line_text, style="grey62" if color else None))
return Group(*parts)
def _cmd_show(args: argparse.Namespace) -> int:
from rich.console import Console
if getattr(args, "json", False):
import json
Console(no_color=bool(getattr(args, "no_color", False))).print_json(json.dumps(_build_payload()))
return 0
payload = _build_payload()
color = not bool(getattr(args, "no_color", False))
cols, rows = _term_size(getattr(args, "width", None), getattr(args, "height", None))
console = Console(no_color=not color, width=cols)
if not payload.get("nodes"):
console.print(
"[grey62]No learning yet — use Hermes a while and your learned skills and "
"memories will start mapping out here.[/grey62]"
)
return 0
if getattr(args, "play", False):
return _play(console, payload, cols=cols, rows=rows, color=color, fps=getattr(args, "fps", 12))
reveal = _clamp(float(getattr(args, "reveal", 1.0) or 1.0), 0.0, 1.0)
console.print(_frame_renderable(payload, cols=cols, rows=rows, reveal=reveal, color=color))
return 0
def _play(console, payload, *, cols, rows, color, fps: int) -> int:
from rich.live import Live
frames = 42
delay = 1.0 / max(1, min(60, fps))
try:
with Live(console=console, refresh_per_second=max(1, fps), screen=False) as live:
for i in range(frames):
reveal = i / (frames - 1)
live.update(_frame_renderable(payload, cols=cols, rows=rows, reveal=reveal, color=color))
time.sleep(delay)
live.update(_frame_renderable(payload, cols=cols, rows=rows, reveal=1.0, color=color))
except KeyboardInterrupt:
console.print("[grey54]interrupted[/grey54]")
return 130
return 0
def _clamp(v: float, lo: float, hi: float) -> float:
return lo if v < lo else hi if v > hi else v
def register_cli(parent: argparse.ArgumentParser) -> None:
parent.add_argument(
"--reveal",
type=float,
default=1.0,
metavar="0..1",
help="Render the timeline built up to this point (0=oldest, 1=now).",
)
parent.add_argument("--play", action="store_true", help="Animate the build-up over time (Ctrl-C to stop).")
parent.add_argument("--fps", type=int, default=12, help="Animation frames per second for --play (default 12).")
parent.add_argument("--width", type=int, default=None, help="Override render width in columns.")
parent.add_argument("--height", type=int, default=None, help="Override render height in rows.")
parent.add_argument("--no-color", action="store_true", help="Disable color output.")
parent.add_argument("--json", action="store_true", help="Print the raw graph payload as JSON and exit.")
parent.set_defaults(func=_cmd_show)
def cmd_journey(args: argparse.Namespace) -> int:
return _cmd_show(args)
if __name__ == "__main__":
_p = argparse.ArgumentParser(prog="hermes journey")
register_cli(_p)
_a = _p.parse_args()
sys.exit(_a.func(_a))

View file

@ -11925,6 +11925,7 @@ _BUILTIN_SUBCOMMANDS = frozenset(
"config", "cron", "curator", "dashboard", "serve", "debug", "doctor",
"dump", "fallback", "gateway", "hooks", "import", "insights",
"gui", "desktop", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate", "moa",
"journey", "memory-graph", "learning",
"model", "pairing", "pets", "plugins", "portal", "postinstall", "profile",
"project", "proxy",
"prompt-size",
@ -12861,6 +12862,27 @@ def main():
except Exception as _exc:
logging.getLogger(__name__).debug("pets CLI wiring failed: %s", _exc)
# =========================================================================
# journey command — learned skills + memories over time, in the terminal
# =========================================================================
journey_parser = subparsers.add_parser(
"journey",
aliases=["learning", "memory-graph"],
help="Timeline of learned skills + memories over time",
description=(
"A terminal rendition of the desktop Star Map / Memory Graph: a "
"timeline bar chart of learned skills and memories over time "
"(oldest at top, newest at bottom) plus a playable constellation "
"scrubber. Mirrors the TUI `/journey` overlay and the desktop panel."
),
)
try:
from hermes_cli.journey import register_cli as _register_journey_cli
_register_journey_cli(journey_parser)
except Exception as _exc:
logging.getLogger(__name__).debug("journey CLI wiring failed: %s", _exc)
# =========================================================================
# memory command (parser built in hermes_cli/subcommands/memory.py)
# =========================================================================

View file

@ -0,0 +1,187 @@
"""Behavior contracts for the terminal Star Map renderer.
Asserts invariants of the timeline layout, the ported age gradient + palette, and
the constellation scrubber never a cell snapshot, which would be a
change-detector against layout tuning.
"""
from __future__ import annotations
from agent import learning_graph_render as render
LEAD_IN = render.LEAD_IN
def _payload(skills: int = 8, memories: int = 3, *, base_ts: int = 1_700_000_000):
nodes = []
for i in range(skills):
nodes.append(
{
"id": f"skill{i}",
"label": f"skill{i}",
"kind": "skill",
"timestamp": base_ts + i * 86400 * 20,
"category": "devops" if i % 2 else "research",
"useCount": i,
}
)
for j in range(memories):
nodes.append(
{
"id": f"memory:memory:{j}",
"label": f"mem {j}",
"kind": "memory",
"timestamp": base_ts + (skills + j) * 86400 * 20,
"category": "memory",
}
)
edges = [{"source": "skill0", "target": "skill1"}] if skills > 1 else []
return {
"nodes": nodes,
"edges": edges,
"clusters": [{"category": "devops", "count": skills}, {"category": "memory", "count": memories}],
"stats": {
"learned_skills": skills,
"memory_nodes": memories,
"related_edges": len(edges),
"memory_skill_edges": 0,
},
}
def _flatten(grid):
return "".join(run[0] for row in grid for run in row)
def _styles(grid):
return {run[1] for row in grid for run in row}
def test_recency_is_timed_and_bounded():
rec = render.compute_recency(_payload()["nodes"])
assert rec["timed"] is True
for ratio in rec["rec"].values():
assert LEAD_IN - 1e-9 <= ratio <= 1 + 1e-9
assert abs(min(rec["rec"].values()) - LEAD_IN) < 1e-9
assert abs(max(rec["rec"].values()) - 1.0) < 1e-9
def test_recency_ink_follows_age_gradient():
# Old quiet → recent bright (constants.ts AGE_GRADIENT), monotonic in between.
assert abs(render.recency_ink(0.0) - render.AGE_OLD_INK) < 1e-6
assert abs(render.recency_ink(1.0) - render.AGE_NEW_INK) < 1e-6
samples = [render.recency_ink(x / 10) for x in range(11)]
assert samples == sorted(samples)
def test_undated_graph_falls_back_to_ordinal():
nodes = [{"id": f"n{i}", "kind": "skill"} for i in range(5)]
rec = render.compute_recency(nodes)
assert rec["timed"] is False
assert len(set(rec["rec"].values())) == len(nodes)
def test_grid_runs_are_text_style_alpha():
# Runs are [text, style, alpha] with an optional 4th hex override for
# category-colored bars.
frame = render.render_graph(_payload(), cols=60, rows=20)
for row in frame["grid"]:
for run in row:
assert 3 <= len(run) <= 4
assert isinstance(run[0], str) and isinstance(run[1], str)
assert isinstance(run[2], (int, float)) and 0.0 <= run[2] <= 1.0
assert run[0] != ""
if len(run) == 4:
assert run[3] is None or isinstance(run[3], str)
def test_bars_render_skills_and_memories():
frame = render.render_graph(_payload(skills=10, memories=4), cols=72, rows=18, reveal=1.0)
flat = _flatten(frame["grid"])
# Skills draw as comet trails (━), memories anchor on diamonds (◆).
assert "" in flat
assert render.MEMORY_GLYPH in flat
styles = _styles(frame["grid"])
assert render.STYLE_SKILL in styles
assert render.STYLE_MEMORY in styles
def test_run_alpha_follows_age_for_lit_stars():
# An all-skill, dated graph at full reveal: the newest star is brighter ink
# than the oldest (age gradient carried in the run alpha).
payload = _payload(skills=12, memories=0)
frame = render.render_graph(payload, cols=80, rows=20, reveal=1.0)
alphas = [run[2] for row in frame["grid"] for run in row if run[1] == render.STYLE_SKILL]
assert max(alphas) > min(alphas)
def test_reveal_monotonically_builds_up():
payload = _payload(skills=12, memories=5)
counts = [render.render_graph(payload, cols=60, rows=20, reveal=r)["visible"] for r in (0.0, 0.25, 0.5, 0.75, 1.0)]
assert counts == sorted(counts)
assert counts[-1] == len(payload["nodes"])
def test_empty_payload_renders_placeholder():
frame = render.render_graph({"nodes": []}, cols=40, rows=12)
assert frame["visible"] == 0
assert "no learning yet" in _flatten(frame["grid"])
def test_grid_fits_within_row_budget():
# The chart is a timeline of dated buckets + a trajectory row; it fills up to
# the row budget but never overflows it.
frame = render.render_graph(_payload(), cols=60, rows=14, reveal=1.0)
assert 0 < len(frame["grid"]) <= 14
def test_legend_counts_and_glyphs():
payload = _payload(skills=9, memories=4)
legend = render.build_legend(payload)
labels = {item["label"] for item in legend}
assert "skills (9)" in labels
assert "memories (4)" in labels
glyphs = {item["glyph"] for item in legend}
assert render.SKILL_GLYPH in glyphs and render.MEMORY_GLYPH in glyphs
def test_axis_labels_present_when_dated():
axis = render.axis_labels(_payload())
assert axis["start"] != "oldest" # dated → real dates
assert axis["end"] != "now"
def test_frames_play_through_grows_visibility():
payload = _payload(skills=10, memories=4)
out = render.render_frames(payload, cols=50, rows=16, frames=12)
assert out["count"] == len(payload["nodes"])
assert len(out["frames"]) == 12
assert out["frames"][0]["visible"] <= out["frames"][-1]["visible"]
assert out["frames"][-1]["visible"] == len(payload["nodes"])
assert "axis" in out
for fr in out["frames"]:
assert fr["grid"]
def test_frames_count_is_clamped():
payload = _payload(skills=3, memories=1)
assert len(render.render_frames(payload, cols=40, rows=12, frames=1)["frames"]) == 2
assert len(render.render_frames(payload, cols=40, rows=12, frames=9999)["frames"]) == 240
def test_format_date_handles_missing():
assert render.format_date(None) == "unknown"
assert render.format_date(0) == "unknown"
assert render.format_date(1_700_000_000) != "unknown"
def test_derive_palette_distinct_memory_hue():
pal = render.derive_palette("#FFD700", dark=True)
assert pal["skill"].startswith("#") and pal["memory"].startswith("#")
# Memory is a complement of the gold primary → clearly different ink.
assert pal["memory"].lower() != pal["skill"].lower()
def test_summary_reports_learning_totals():
lines = render.build_summary(_payload(skills=7, memories=2))
assert any("7 learned skills" in line and "2 memories" in line for line in lines)

View file

@ -203,6 +203,7 @@ _LONG_HANDLERS = frozenset(
"pet.hatch",
"pet.select",
"pet.thumb",
"learning.frames",
"plugins.manage",
"projects.discover_repos",
"projects.record_repos",
@ -13454,6 +13455,40 @@ def _(rid, params: dict) -> dict:
return _err(rid, 5023, str(e))
@method("learning.frames")
def _(rid, params: dict) -> dict:
"""Pre-render the Memory Graph play-through for the TUI overlay.
Returns ``frames`` (reveal 01) plus static legend/summary, so Ink can
play/scrub locally without round-tripping the gateway per frame. The radial
layout is shared with the desktop panel and the ``hermes memory-graph`` CLI.
"""
try:
cols = int(params.get("cols", 80) or 80)
rows = int(params.get("rows", 24) or 24)
frames = int(params.get("frames", 48) or 48)
except (TypeError, ValueError):
cols, rows, frames = 80, 24, 48
links = params.get("links", True)
try:
from agent.learning_graph import build_learning_graph
from agent.learning_graph_render import render_frames
payload = build_learning_graph()
return _ok(
rid,
render_frames(
payload,
cols=max(20, cols),
rows=max(10, rows),
frames=frames,
links=bool(links),
),
)
except Exception as exc: # noqa: BLE001
return _err(rid, 5000, f"learning.frames failed: {exc}")
@method("skills.manage")
def _(rid, params: dict) -> dict:
action, query = params.get("action", "list"), params.get("query", "")

View file

@ -0,0 +1,32 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
import { findSlashCommand } from '../app/slash/registry.js'
describe('/journey slash command', () => {
beforeEach(() => {
resetOverlayState()
})
it('resolves by name and aliases', () => {
expect(findSlashCommand('journey')?.name).toBe('journey')
for (const alias of ['learning', 'memory-graph']) {
expect(findSlashCommand(alias)?.name).toBe('journey')
}
})
it('opens the journey overlay when run', () => {
expect(getOverlayState().journey).toBe(false)
findSlashCommand('journey')!.run('', {} as never, 'journey')
expect(getOverlayState().journey).toBe(true)
})
it('is preserved by the flow-overlay soft reset (deliberate, user-opened)', async () => {
findSlashCommand('journey')!.run('', {} as never, 'journey')
// Mirror turnController.idle(): flow overlays clear, user-opened panels stay.
const { resetFlowOverlays } = await import('../app/overlayStore.js')
resetFlowOverlays()
expect(getOverlayState().journey).toBe(true)
})
})

View file

@ -134,6 +134,7 @@ export interface OverlayState {
billing: BillingOverlayState | null
clarify: ClarifyReq | null
confirm: ConfirmReq | null
journey: boolean
modelPicker: boolean
pager: null | PagerState
petPicker: boolean

View file

@ -9,6 +9,7 @@ const buildOverlayState = (): OverlayState => ({
billing: null,
clarify: null,
confirm: null,
journey: false,
modelPicker: false,
pager: null,
petPicker: false,
@ -29,6 +30,7 @@ export const $isBlocked = computed(
billing,
clarify,
confirm,
journey,
modelPicker,
pager,
petPicker,
@ -44,6 +46,7 @@ export const $isBlocked = computed(
billing ||
clarify ||
confirm ||
journey ||
modelPicker ||
pager ||
petPicker ||
@ -76,6 +79,7 @@ export const resetFlowOverlays = () =>
...buildOverlayState(),
agents: $overlayState.get().agents,
agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex,
journey: $overlayState.get().journey,
modelPicker: $overlayState.get().modelPicker,
petPicker: $overlayState.get().petPicker,
pluginsHub: $overlayState.get().pluginsHub,

View file

@ -325,6 +325,16 @@ export const opsCommands: SlashCommand[] = [
}
},
{
aliases: ['learning', 'memory-graph'],
help: 'open your learning journey — skills + memories on a timeline',
name: 'journey',
run: (_arg, ctx) => {
void ctx
patchOverlayState({ journey: true })
}
},
{
help: 'replay a completed spawn tree · `/replay [N|last|list|load <path>]`',
name: 'replay',

View file

@ -188,6 +188,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
if (overlay.agents) {
return patchOverlayState({ agents: false })
}
if (overlay.journey) {
return patchOverlayState({ journey: false })
}
}
const cycleQueue = (dir: 1 | -1) => {

View file

@ -1,6 +1,6 @@
import { Box, NoSelect, ScrollBox, type ScrollBoxHandle, Text, useInput, useStdout } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from 'react'
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
import {
$delegationState,
@ -32,6 +32,8 @@ import { compactPreview } from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { SubagentNode, SubagentProgress } from '../types.js'
import { OverlayScrollbar } from './overlayScrollbar.js'
// ── Types + lookup tables ────────────────────────────────────────────
type SortMode = 'depth-first' | 'duration-desc' | 'status' | 'tools-desc'
@ -138,91 +140,6 @@ const diffMetricLine = (name: string, a: number, b: number, fmt: (n: number) =>
// ── Sub-components ───────────────────────────────────────────────────
/** Polled on parent `tick` so accordions can resize the thumb without a scroll event. */
function OverlayScrollbar({
scrollRef,
t,
tick
}: {
scrollRef: RefObject<null | ScrollBoxHandle>
t: Theme
tick: number
}) {
void tick // ensures re-render when the parent clock advances
const [hover, setHover] = useState(false)
const [grab, setGrab] = useState<null | number>(null)
const s = scrollRef.current
const vp = Math.max(0, s?.getViewportHeight() ?? 0)
if (!vp) {
return <Box width={1} />
}
const total = Math.max(vp, s?.getScrollHeight() ?? vp)
const scrollable = total > vp
const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp
const travel = Math.max(1, vp - thumb)
const pos = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0))
const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0
const below = Math.max(0, vp - thumbTop - thumb)
const vBar = (n: number) => (n > 0 ? `${'│\n'.repeat(n - 1)}` : '')
const thumbBody = `${'┃\n'.repeat(Math.max(0, thumb - 1))}`
const thumbColor = grab !== null ? t.color.primary : t.color.accent
const trackColor = hover ? t.color.border : t.color.muted
const jump = (row: number, offset: number) => {
if (!s || !scrollable) {
return
}
s.scrollTo(Math.round((Math.max(0, Math.min(travel, row - offset)) / travel) * Math.max(0, total - vp)))
}
return (
<Box
flexDirection="column"
onMouseDown={(e: { localRow?: number }) => {
const row = Math.max(0, Math.min(vp - 1, e.localRow ?? 0))
const off = row >= thumbTop && row < thumbTop + thumb ? row - thumbTop : Math.floor(thumb / 2)
setGrab(off)
jump(row, off)
}}
onMouseDrag={(e: { localRow?: number }) =>
jump(Math.max(0, Math.min(vp - 1, e.localRow ?? 0)), grab ?? Math.floor(thumb / 2))
}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
onMouseUp={() => setGrab(null)}
width={1}
>
{!scrollable ? (
<Text color={trackColor} dim>
{vBar(vp)}
</Text>
) : (
<>
{thumbTop > 0 ? (
<Text color={trackColor} dim={!hover}>
{vBar(thumbTop)}
</Text>
) : null}
<Text color={thumbColor}>{thumbBody}</Text>
{below > 0 ? (
<Text color={trackColor} dim={!hover}>
{vBar(below)}
</Text>
) : null}
</>
)}
</Box>
)
}
function GanttStrip({
cols,
cursor,

View file

@ -25,6 +25,7 @@ import { FloatingOverlays, PromptZone } from './appOverlays.js'
import { Banner, Panel, SessionPanel } from './branding.js'
import { FpsOverlay } from './fpsOverlay.js'
import { HelpHint } from './helpHint.js'
import { Journey } from './journey.js'
import { MessageLine } from './messageLine.js'
import { PetKitty, PetSprite } from './petSprite.js'
import { QueuedMessages } from './queuedMessages.js'
@ -382,6 +383,13 @@ const AgentsOverlayPane = memo(function AgentsOverlayPane() {
)
})
const JourneyPane = memo(function JourneyPane() {
const { gw } = useGateway()
const ui = useStore($uiState)
return <Journey gw={gw} onClose={() => patchOverlayState({ journey: false })} t={ui.theme} />
})
const StatusRulePane = memo(function StatusRulePane({
at,
composer,
@ -445,6 +453,10 @@ export const AppLayout = memo(function AppLayout({
<PerfPane id="agents">
<AgentsOverlayPane />
</PerfPane>
) : overlay.journey ? (
<PerfPane id="journey">
<JourneyPane />
</PerfPane>
) : (
<PerfPane id="transcript">
<TranscriptPane actions={actions} composer={composer} progress={progress} transcript={transcript} />
@ -452,7 +464,7 @@ export const AppLayout = memo(function AppLayout({
)}
</Box>
{!overlay.agents && (
{!overlay.agents && !overlay.journey && (
<>
<PetPane />

View file

@ -0,0 +1,494 @@
import { Box, NoSelect, ScrollBox, type ScrollBoxHandle, Text, useInput, useStdout } from '@hermes/ink'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { GatewayClient } from '../gatewayClient.js'
import { rpcErrorMessage } from '../lib/rpc.js'
import { deriveStarmapPalette, fadeHex, fadeInk, type StarmapPalette } from '../lib/starmapPalette.js'
import type { Theme } from '../theme.js'
import { OverlayScrollbar } from './overlayScrollbar.js'
// A run is [text, styleKey, alpha?, hexOverride?] from learning_graph_render.py.
type Run = [string, string, number?, (string | null)?]
interface LegendItem {
color?: string
glyph: string
label: string
style?: string
}
interface BucketNode {
body?: string
fullLabel?: string
glyph: string
label: string
meta: string
style: string
}
interface BucketRow {
category?: string | null
color?: string | null
date: string
index: number
label: string
memories: number
nodes: BucketNode[]
skills: number
}
interface FramesResponse {
axis: { end: string; start: string }
buckets?: BucketRow[]
categories?: LegendItem[]
count: number
frames: { grid: Run[][] }[]
legend: LegendItem[]
summary: string[]
}
interface JourneyProps {
gw: GatewayClient
onClose: () => void
t: Theme
}
type Mode = 'detail' | 'item' | 'timeline'
type Cell = { color?: string; text: string }
const MAX_CHART_ROWS = 8
const rowText = (row: Run[]) => row.map(run => run[0]).join('')
// 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)))
function ChartRow({ palette, row }: { palette: StarmapPalette; row: Run[] }) {
if (!row.length) {
return <Text> </Text>
}
return (
<Text>
{row.map((run, i) => (
<Text color={run[3] ? fadeHex(palette, run[3], run[2] ?? 1) : fadeInk(palette, run[1], run[2] ?? 1)} key={i}>
{run[0]}
</Text>
))}
</Text>
)
}
// Full-width selectable row, matching the /agents list treatment: the active
// row inverts and collapses every segment onto the accent foreground.
function ListRow({ active, cells, t }: { active: boolean; cells: Cell[]; t: Theme }) {
const fg = active ? t.color.accent : t.color.text
return (
<Text bold={active} color={fg} inverse={active} wrap="truncate-end">
{cells.map((c, i) => (
<Text color={active ? fg : (c.color ?? t.color.text)} key={i}>
{c.text}
</Text>
))}
</Text>
)
}
export function Journey({ gw, onClose, t }: JourneyProps) {
const { stdout } = useStdout()
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 palette = useMemo(() => deriveStarmapPalette(t.color.primary, t.color.text), [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 [tick, setTick] = useState(0)
const itemScroll = useRef<null | ScrollBoxHandle>(null)
// The renderer is size-aware, so refetch when the terminal resizes.
useEffect(() => {
let alive = true
setData(null)
setErr('')
gw.request<FramesResponse>('learning.frames', { cols, frames: 2, rows: chartRows })
.then(r => {
if (!alive) {
return
}
setData(r)
setSelectedRow(Math.max(0, (r?.buckets?.length ?? 1) - 1))
setSelectedNode(0)
setMode('timeline')
})
.catch((e: unknown) => alive && setErr(rpcErrorMessage(e)))
return () => {
alive = false
}
}, [gw, cols, chartRows])
useEffect(() => setSelectedNode(0), [selectedRow])
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)
const scrollItem = (dy: number) => {
itemScroll.current?.scrollBy(dy)
setTick(x => x + 1)
}
useInput((ch, key) => {
const back = key.escape || key.leftArrow || ch === 'h'
if (ch === 'q') {
return onClose()
}
if (mode === 'item') {
if (back) {
return setMode('detail')
}
if (key.upArrow || ch === 'k') {
return scrollItem(-2)
}
if (key.downArrow || ch === 'j') {
return scrollItem(2)
}
if (key.pageUp || (key.ctrl && ch === 'u')) {
return scrollItem(-page)
}
if (key.pageDown || (key.ctrl && ch === 'd') || ch === ' ') {
return scrollItem(page)
}
if (ch === 'g') {
itemScroll.current?.scrollTo(0)
return setTick(x => x + 1)
}
if (ch === 'G') {
itemScroll.current?.scrollToBottom()
return setTick(x => x + 1)
}
return
}
if (mode === 'detail') {
if (back) {
return setMode('timeline')
}
if ((key.return || key.rightArrow || ch === 'l') && nodes.length) {
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')
}
if (key.upArrow || ch === 'k') {
return setSelectedRow(v => Math.max(0, v - 1))
}
if (key.downArrow || ch === 'j') {
return setSelectedRow(v => Math.min(buckets.length - 1, v + 1))
}
if (ch === 'g') {
return setSelectedRow(0)
}
if (ch === 'G') {
return setSelectedRow(Math.max(0, buckets.length - 1))
}
})
if (err) {
return (
<Shell t={t}>
<Text color={t.color.error}>error: {err}</Text>
</Shell>
)
}
if (!data) {
return (
<Shell t={t}>
<Text color={t.color.muted}>assembling your learning map</Text>
</Shell>
)
}
if (!data.count) {
return (
<Shell t={t}>
<Text color={t.color.muted}>
No learning yet your learned skills and memories will start mapping out here as you use Hermes.
</Text>
</Shell>
)
}
// ── Item: a single skill/memory, body scrolled via the shared ScrollBox ──
if (mode === 'item' && selected && activeNode) {
const body = activeNode.body ? activeNode.body.split(/\r?\n/) : ['No additional detail recorded yet.']
return (
<Box alignItems="stretch" flexDirection="column" flexGrow={1} paddingX={1} paddingY={1}>
<Box flexDirection="column" marginBottom={1}>
<Text wrap="truncate-end">
<Text bold color={fadeInk(palette, activeNode.style, 1)}>
{activeNode.glyph} {activeNode.fullLabel || activeNode.label}
</Text>
</Text>
<Text color={t.color.muted}>
{selected.label} · {activeNode.meta} · item {selectedNode + 1}/{nodes.length}
</Text>
</Box>
<Box flexDirection="row" flexGrow={1} flexShrink={1} minHeight={0}>
<ScrollBox flexDirection="column" flexGrow={1} flexShrink={1} ref={itemScroll}>
<Box flexDirection="column" paddingBottom={2} paddingRight={1}>
{body.map((line, i) => (
<Text color={t.color.text} key={i} wrap="wrap">
{line || ' '}
</Text>
))}
</Box>
</ScrollBox>
<NoSelect flexShrink={0} marginLeft={1}>
<OverlayScrollbar scrollRef={itemScroll} t={t} tick={tick} />
</NoSelect>
</Box>
<Footer>
<Hint t={t}>/jk scroll · PgUp/PgDn page · g/G top/bottom · Esc/ back · q close</Hint>
</Footer>
</Box>
)
}
// ── 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}` }
]}
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 · Enter/ open · g/G top/bottom · Esc/ back ·
q close
</Hint>
</Footer>
</Box>
)
}
// ── Timeline: static chart overview + selectable slice list ──
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)
return (
<Box alignItems="stretch" flexDirection="column" flexGrow={1} paddingX={1} paddingY={1}>
<Box flexDirection="column" marginBottom={1}>
<Text wrap="truncate-end">
<Text bold color={t.color.primary}>
Journey
</Text>
<Text color={t.color.muted}> learned skills &amp; memories over time</Text>
</Text>
<Text wrap="wrap">
{data.legend.map((item, i) => (
<Text key={item.label}>
{i ? ' ' : ''}
<Text color={fadeInk(palette, item.style ?? 'dim', 1)}>{item.glyph} </Text>
<Text color={t.color.muted}>{item.label}</Text>
</Text>
))}
</Text>
{data.categories?.length ? (
<Text wrap="wrap">
{data.categories.map((item, i) => (
<Text key={item.label}>
{i ? ' ' : ''}
<Text color={item.color ? fadeHex(palette, item.color, 1) : t.color.muted}>{item.glyph} </Text>
<Text color={t.color.muted}>{item.label}</Text>
</Text>
))}
</Text>
) : null}
</Box>
<Box flexDirection="column" marginBottom={1}>
{chartGrid.map((row, i) => (
<ChartRow key={i} palette={palette} row={row} />
))}
<Text color={t.color.muted}>
{data.axis.start}
{' '.repeat(axisGap)}
{data.axis.end}
</Text>
</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}
/>
)
})}
</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>
</Footer>
</Box>
)
}
function Shell({ children, t }: { children: React.ReactNode; t: Theme }) {
return (
<Box flexDirection="column" paddingX={1} paddingY={1}>
<Text bold color={t.color.primary}>
Journey
</Text>
{children}
<Text color={t.color.muted}>Esc/q close</Text>
</Box>
)
}
function Footer({ children }: { children: React.ReactNode }) {
return (
<Box flexDirection="column" marginTop={1}>
{children}
</Box>
)
}
function Hint({ children, t }: { children: React.ReactNode; t: Theme }) {
return (
<Text color={t.color.muted} wrap="truncate-end">
{children}
</Text>
)
}

View file

@ -0,0 +1,93 @@
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
import { type RefObject, useState } from 'react'
import type { Theme } from '../theme.js'
/**
* Mouse-draggable scrollbar bound to a `ScrollBox` ref. Re-renders off the
* parent `tick` so accordions / async content can resize the thumb without a
* scroll event. Shared by every full-screen overlay that scrolls a pane.
*/
export function OverlayScrollbar({
scrollRef,
t,
tick
}: {
scrollRef: RefObject<null | ScrollBoxHandle>
t: Theme
tick: number
}) {
void tick
const [hover, setHover] = useState(false)
const [grab, setGrab] = useState<null | number>(null)
const s = scrollRef.current
const vp = Math.max(0, s?.getViewportHeight() ?? 0)
if (!vp) {
return <Box width={1} />
}
const total = Math.max(vp, s?.getScrollHeight() ?? vp)
const scrollable = total > vp
const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp
const travel = Math.max(1, vp - thumb)
const pos = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0))
const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0
const below = Math.max(0, vp - thumbTop - thumb)
const vBar = (n: number) => (n > 0 ? `${'│\n'.repeat(n - 1)}` : '')
const thumbBody = `${'┃\n'.repeat(Math.max(0, thumb - 1))}`
const thumbColor = grab !== null ? t.color.primary : t.color.accent
const trackColor = hover ? t.color.border : t.color.muted
const jump = (row: number, offset: number) => {
if (!s || !scrollable) {
return
}
s.scrollTo(Math.round((Math.max(0, Math.min(travel, row - offset)) / travel) * Math.max(0, total - vp)))
}
return (
<Box
flexDirection="column"
onMouseDown={(e: { localRow?: number }) => {
const row = Math.max(0, Math.min(vp - 1, e.localRow ?? 0))
const off = row >= thumbTop && row < thumbTop + thumb ? row - thumbTop : Math.floor(thumb / 2)
setGrab(off)
jump(row, off)
}}
onMouseDrag={(e: { localRow?: number }) =>
jump(Math.max(0, Math.min(vp - 1, e.localRow ?? 0)), grab ?? Math.floor(thumb / 2))
}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
onMouseUp={() => setGrab(null)}
width={1}
>
{!scrollable ? (
<Text color={trackColor} dim>
{vBar(vp)}
</Text>
) : (
<>
{thumbTop > 0 ? (
<Text color={trackColor} dim={!hover}>
{vBar(thumbTop)}
</Text>
) : null}
<Text color={thumbColor}>{thumbBody}</Text>
{below > 0 ? (
<Text color={trackColor} dim={!hover}>
{vBar(below)}
</Text>
) : null}
</>
)}
</Box>
)
}

View file

@ -0,0 +1,145 @@
// Star Map palette — ported from apps/desktop/src/app/starmap/color.ts so the
// TUI overlay derives the same memory ink (complement of the theme primary) and
// the same age fade (rgba(ink, alpha) over the background) as the desktop panel.
interface Rgb {
b: number
g: number
r: number
}
function hexToRgb(hex: string): Rgb {
let s = hex.trim().replace(/^#/, '')
if (s.length === 3) {
s = s
.split('')
.map(c => c + c)
.join('')
}
const n = parseInt(s, 16)
if (Number.isNaN(n) || s.length < 6) {
return { b: 0, g: 215, r: 255 }
}
return { b: n & 255, g: (n >> 8) & 255, r: (n >> 16) & 255 }
}
function rgbToHex(c: Rgb): string {
const h = (v: number) =>
Math.max(0, Math.min(255, Math.round(v)))
.toString(16)
.padStart(2, '0')
return `#${h(c.r)}${h(c.g)}${h(c.b)}`
}
function mix(a: Rgb, b: Rgb, t: number): Rgb {
const p = Math.max(0, Math.min(1, t))
return { b: a.b + (b.b - a.b) * p, g: a.g + (b.g - a.g) * p, r: a.r + (b.r - a.r) * p }
}
function luminance(c: Rgb): number {
return (0.2126 * c.r + 0.7152 * c.g + 0.114 * c.b) / 255
}
function rgbToHsl(c: Rgb): [number, number, number] {
const r = c.r / 255
const g = c.g / 255
const b = c.b / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const l = (max + min) / 2
const d = max - min
if (!d) {
return [0, 0, l]
}
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
const h = (max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4) * 60
return [h, s, l]
}
function hslToRgb(h: number, s: number, l: number): Rgb {
const hue = ((h % 360) + 360) % 360
const c = (1 - Math.abs(2 * l - 1)) * s
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1))
const m = l - c / 2
const [r, g, b] =
hue < 60
? [c, x, 0]
: hue < 120
? [x, c, 0]
: hue < 180
? [0, c, x]
: hue < 240
? [0, x, c]
: hue < 300
? [x, 0, c]
: [c, 0, x]
return { b: (b + m) * 255, g: (g + m) * 255, r: (r + m) * 255 }
}
function complementaryInk(c: Rgb): Rgb {
const [h, s, l] = rgbToHsl(c)
return hslToRgb(h + 165, Math.max(s, 0.5), Math.max(0.5, Math.min(0.7, l)))
}
export interface StarmapPalette {
bg: Rgb
dim: Rgb
label: Rgb
memory: Rgb
skill: Rgb
}
/** Derive the Star Map inks from the theme primary + foreground color. */
export function deriveStarmapPalette(primaryHex: string, fgHex: string): StarmapPalette {
const primary = hexToRgb(primaryHex)
const dark = luminance(hexToRgb(fgHex)) > 0.55
const base: Rgb = dark ? { b: 255, g: 255, r: 255 } : { b: 0, g: 0, r: 0 }
const bg: Rgb = dark ? { b: 12, g: 8, r: 8 } : { b: 250, g: 250, r: 250 }
return {
bg,
dim: mix(base, bg, 0.7),
label: mix(base, bg, 0.35),
memory: mix(complementaryInk(primary), bg, 0.45),
skill: mix(primary, base, dark ? 0.12 : 0.18)
}
}
/** Fade an explicit hex ink toward the background by alpha (for category bars). */
export function fadeHex(palette: StarmapPalette, hex: string, alpha: number): string {
const base = hexToRgb(hex)
return rgbToHex(alpha >= 0.999 ? base : mix(palette.bg, base, alpha))
}
/** Fade a base ink toward the background by alpha (rgba-over-bg), as a hex. */
export function fadeInk(palette: StarmapPalette, style: string, alpha: number): string | undefined {
const base =
style === 'skill'
? palette.skill
: style === 'memory'
? palette.memory
: style === 'label'
? palette.label
: style === 'dim'
? palette.dim
: null
if (!base) {
return undefined
}
return rgbToHex(alpha >= 0.999 ? base : mix(palette.bg, base, alpha))
}