mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
Merge pull request #55555 from NousResearch/bb/memory-graph-cli-tui
feat(journey): CLI + TUI learning timeline (/journey)
This commit is contained in:
commit
9f8de4dfbe
17 changed files with 1937 additions and 87 deletions
658
agent/learning_graph_render.py
Normal file
658
agent/learning_graph_render.py
Normal file
|
|
@ -0,0 +1,658 @@
|
|||
"""Terminal renderer for the learning timeline (learned skills + memories).
|
||||
|
||||
The desktop app (``apps/desktop/src/app/starmap``) paints a GPU radial
|
||||
constellation; a terminal can't, so this is a *rendition* of the same data as a
|
||||
timeline bar chart — date rows, proportional skill/memory bars colored by the
|
||||
day's dominant category, and a cumulative trajectory sparkline — plus per-slice
|
||||
bucket metadata the TUI walks as a tree. The age gradient and complementary
|
||||
memory ink are ported from the desktop source, not guessed.
|
||||
|
||||
Grids are emitted as style runs — ``[text, style, alpha, hex?]`` — so each
|
||||
consumer maps the semantic style + brightness onto its own palette; the
|
||||
optional 4th element overrides the base color (category heatmap). 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")
|
||||
|
||||
Run = list # [text, style, alpha, hex?]
|
||||
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 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,
|
||||
# Memories are drillable → primary "clickable" ink; skills are dead-ends
|
||||
# → muted complement.
|
||||
"memory": rgb_to_hex(mix_rgb(primary, base, 0.12 if dark else 0.18)),
|
||||
"skill": 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),
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# ── 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]] = []
|
||||
# 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()
|
||||
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 _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) -> dict[str, Any]:
|
||||
"""Render one timeline frame at ``reveal`` (0→1).
|
||||
|
||||
Date rows with proportional skill/memory bars colored by the day's dominant
|
||||
category, numbered markers tied to label rows, and a cumulative trajectory
|
||||
sparkline underneath.
|
||||
"""
|
||||
reveal = _clamp(reveal, 0.0, 1.0)
|
||||
cols = max(44, cols)
|
||||
rows = max(14, rows)
|
||||
nodes = list(payload.get("nodes", []))
|
||||
if not nodes:
|
||||
placeholder = [["no learning yet — keep using Hermes and it maps out here", STYLE_DIM, 0.7]]
|
||||
return {"grid": [placeholder], "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) -> 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)
|
||||
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
16
cli.py
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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
250
hermes_cli/journey.py
Normal 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))
|
||||
|
|
@ -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)
|
||||
# =========================================================================
|
||||
|
|
|
|||
187
tests/agent/test_learning_graph_render.py
Normal file
187
tests/agent/test_learning_graph_render.py
Normal 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("#")
|
||||
# Skills wear the muted complement, memories the primary ink → distinct.
|
||||
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)
|
||||
|
|
@ -204,6 +204,7 @@ _LONG_HANDLERS = frozenset(
|
|||
"pet.info",
|
||||
"pet.select",
|
||||
"pet.thumb",
|
||||
"learning.frames",
|
||||
"plugins.manage",
|
||||
"process.list",
|
||||
"projects.discover_repos",
|
||||
|
|
@ -13470,6 +13471,30 @@ def _(rid, params: dict) -> dict:
|
|||
return _err(rid, 5023, str(e))
|
||||
|
||||
|
||||
@method("learning.frames")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Pre-render the learning timeline for the TUI ``/journey`` overlay.
|
||||
|
||||
Returns ``frames`` (reveal 0→1) plus static legend/summary/bucket metadata,
|
||||
so Ink can render and walk the tree locally without round-tripping the
|
||||
gateway. Shares its renderer with the ``hermes journey`` 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
|
||||
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))
|
||||
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", "")
|
||||
|
|
|
|||
32
ui-tui/src/__tests__/journeyCommand.test.ts
Normal file
32
ui-tui/src/__tests__/journeyCommand.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
||||
|
|
|
|||
470
ui-tui/src/components/journey.tsx
Normal file
470
ui-tui/src/components/journey.tsx
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
import { Box, NoSelect, ScrollBox, type ScrollBoxHandle, Text, useInput, useStdout } from '@hermes/ink'
|
||||
import { useEffect, 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
|
||||
}
|
||||
|
||||
// Flattened timeline tree: each slice header is preceded by a blank gap row
|
||||
// (except the first) and followed by its chronological items.
|
||||
type TreeRow =
|
||||
| { bucket: BucketRow; kind: 'node'; last: boolean; node: BucketNode }
|
||||
| { bucket: BucketRow; kind: 'slice' }
|
||||
| { kind: 'gap' }
|
||||
|
||||
type Cell = { color?: string; text: string }
|
||||
|
||||
const MAX_CHART_ROWS = 8
|
||||
|
||||
const rowText = (row: Run[]) => row.map(run => run[0]).join('')
|
||||
|
||||
const buildTree = (buckets: BucketRow[]): TreeRow[] => {
|
||||
const out: TreeRow[] = []
|
||||
|
||||
buckets.forEach((bucket, b) => {
|
||||
if (b > 0) {
|
||||
out.push({ kind: 'gap' }) // breathing room between groups
|
||||
}
|
||||
|
||||
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)))
|
||||
|
||||
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 page = Math.max(4, rows - 6)
|
||||
|
||||
const palette = deriveStarmapPalette(t.color.primary, t.color.text)
|
||||
|
||||
const [data, setData] = useState<FramesResponse | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [cursor, setCursor] = useState(0)
|
||||
const [mode, setMode] = useState<'item' | 'timeline'>('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)
|
||||
setCursor(Math.max(0, buildTree(r?.buckets ?? []).length - 1)) // open on the newest entry
|
||||
setMode('timeline')
|
||||
})
|
||||
.catch((e: unknown) => alive && setErr(rpcErrorMessage(e)))
|
||||
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [gw, cols, chartRows])
|
||||
|
||||
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 && activeRow.kind !== 'gap' ? activeRow.bucket : undefined
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'item') {
|
||||
itemScroll.current?.scrollTo(0)
|
||||
setTick(x => x + 1)
|
||||
}
|
||||
}, [mode, cursor])
|
||||
|
||||
const scrollItem = (dy: number) => {
|
||||
itemScroll.current?.scrollBy(dy)
|
||||
setTick(x => x + 1)
|
||||
}
|
||||
|
||||
// Cursor only ever rests on real rows — gaps are visual padding.
|
||||
const stepRow = (from: number, dir: -1 | 1) => {
|
||||
let i = from + dir
|
||||
|
||||
while (tree[i]?.kind === 'gap') {
|
||||
i += dir
|
||||
}
|
||||
|
||||
return i >= 0 && i < tree.length ? i : from
|
||||
}
|
||||
|
||||
const snapRow = (i: number) => {
|
||||
const c = Math.max(0, Math.min(tree.length - 1, i))
|
||||
|
||||
if (tree[c]?.kind !== 'gap') {
|
||||
return c
|
||||
}
|
||||
|
||||
return stepRow(c, 1) === c ? stepRow(c, -1) : stepRow(c, 1)
|
||||
}
|
||||
|
||||
useInput((ch, key) => {
|
||||
const back = key.escape || key.leftArrow || ch === 'h'
|
||||
|
||||
if (ch === 'q') {
|
||||
return onClose()
|
||||
}
|
||||
|
||||
if (mode === 'item') {
|
||||
if (back) {
|
||||
return setMode('timeline')
|
||||
}
|
||||
|
||||
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 (back) {
|
||||
return onClose()
|
||||
}
|
||||
|
||||
// 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 setCursor(v => stepRow(v, -1))
|
||||
}
|
||||
|
||||
if (key.downArrow || ch === 'j') {
|
||||
return setCursor(v => stepRow(v, 1))
|
||||
}
|
||||
|
||||
if (key.pageUp || (key.ctrl && ch === 'u')) {
|
||||
return setCursor(v => snapRow(v - page))
|
||||
}
|
||||
|
||||
if (key.pageDown || (key.ctrl && ch === 'd')) {
|
||||
return setCursor(v => snapRow(v + page))
|
||||
}
|
||||
|
||||
if (ch === 'g') {
|
||||
return setCursor(0)
|
||||
}
|
||||
|
||||
if (ch === 'G') {
|
||||
return setCursor(Math.max(0, tree.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 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 (
|
||||
<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}>
|
||||
{activeBucket.label} · {activeNode.meta}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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(cursor, tree.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 & 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">
|
||||
{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{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 === 'gap') {
|
||||
return <Text> </Text>
|
||||
}
|
||||
|
||||
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}>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
93
ui-tui/src/components/overlayScrollbar.tsx
Normal file
93
ui-tui/src/components/overlayScrollbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
147
ui-tui/src/lib/starmapPalette.ts
Normal file
147
ui-tui/src/lib/starmapPalette.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
// 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),
|
||||
// Memories are drillable, so they wear the primary "clickable" ink; skills
|
||||
// are dead-ends and get the muted complement.
|
||||
memory: mix(primary, base, dark ? 0.12 : 0.18),
|
||||
skill: mix(complementaryInk(primary), bg, 0.45)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue