From e971dc1e9d2afb307d3cea3e576b8de9614baaf7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 04:43:47 -0500 Subject: [PATCH] feat(journey): CLI + TUI learning timeline (/journey) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`). --- agent/learning_graph_render.py | 881 ++++++++++++++++++++ cli.py | 16 + hermes_cli/commands.py | 2 + hermes_cli/journey.py | 250 ++++++ hermes_cli/main.py | 22 + tests/agent/test_learning_graph_render.py | 187 +++++ tui_gateway/server.py | 35 + ui-tui/src/__tests__/journeyCommand.test.ts | 32 + ui-tui/src/app/interfaces.ts | 1 + ui-tui/src/app/overlayStore.ts | 4 + ui-tui/src/app/slash/commands/ops.ts | 10 + ui-tui/src/app/useInputHandlers.ts | 4 + ui-tui/src/components/agentsOverlay.tsx | 89 +- ui-tui/src/components/appLayout.tsx | 14 +- ui-tui/src/components/journey.tsx | 494 +++++++++++ ui-tui/src/components/overlayScrollbar.tsx | 93 +++ ui-tui/src/lib/starmapPalette.ts | 145 ++++ 17 files changed, 2192 insertions(+), 87 deletions(-) create mode 100644 agent/learning_graph_render.py create mode 100644 hermes_cli/journey.py create mode 100644 tests/agent/test_learning_graph_render.py create mode 100644 ui-tui/src/__tests__/journeyCommand.test.ts create mode 100644 ui-tui/src/components/journey.tsx create mode 100644 ui-tui/src/components/overlayScrollbar.tsx create mode 100644 ui-tui/src/lib/starmapPalette.ts diff --git a/agent/learning_graph_render.py b/agent/learning_graph_render.py new file mode 100644 index 000000000..0028312e6 --- /dev/null +++ b/agent/learning_graph_render.py @@ -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 older→newer shells (core→rim), 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, + } diff --git a/cli.py b/cli.py index fc7dd5941..ef91e39cc 100644 --- a/cli.py +++ b/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": diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index bdba0af1c..3cdbc6faa 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -103,6 +103,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ aliases=("bg", "btw"), args_hint=""), 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=""), CommandDef("steer", "Inject a message after the next tool call without interrupting", "Session", diff --git a/hermes_cli/journey.py b/hermes_cli/journey.py new file mode 100644 index 000000000..c576e4526 --- /dev/null +++ b/hermes_cli/journey.py @@ -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)) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 5f76c1fc8..b71c59f38 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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) # ========================================================================= diff --git a/tests/agent/test_learning_graph_render.py b/tests/agent/test_learning_graph_render.py new file mode 100644 index 000000000..9400779c8 --- /dev/null +++ b/tests/agent/test_learning_graph_render.py @@ -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) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 8a5e778b7..ccc7048c2 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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 0→1) 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", "") diff --git a/ui-tui/src/__tests__/journeyCommand.test.ts b/ui-tui/src/__tests__/journeyCommand.test.ts new file mode 100644 index 000000000..0634b2e65 --- /dev/null +++ b/ui-tui/src/__tests__/journeyCommand.test.ts @@ -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) + }) +}) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index cd8789d44..65285467d 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -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 diff --git a/ui-tui/src/app/overlayStore.ts b/ui-tui/src/app/overlayStore.ts index 58f29e4bd..b09ed08a4 100644 --- a/ui-tui/src/app/overlayStore.ts +++ b/ui-tui/src/app/overlayStore.ts @@ -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, diff --git a/ui-tui/src/app/slash/commands/ops.ts b/ui-tui/src/app/slash/commands/ops.ts index 969ef4444..f6f1d5ed5 100644 --- a/ui-tui/src/app/slash/commands/ops.ts +++ b/ui-tui/src/app/slash/commands/ops.ts @@ -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 ]`', name: 'replay', diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 948c4fc92..2f95c565e 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -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) => { diff --git a/ui-tui/src/components/agentsOverlay.tsx b/ui-tui/src/components/agentsOverlay.tsx index b04c20551..ba9d4b74b 100644 --- a/ui-tui/src/components/agentsOverlay.tsx +++ b/ui-tui/src/components/agentsOverlay.tsx @@ -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 - t: Theme - tick: number -}) { - void tick // ensures re-render when the parent clock advances - - const [hover, setHover] = useState(false) - const [grab, setGrab] = useState(null) - - const s = scrollRef.current - const vp = Math.max(0, s?.getViewportHeight() ?? 0) - - if (!vp) { - return - } - - 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 ( - { - 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 ? ( - - {vBar(vp)} - - ) : ( - <> - {thumbTop > 0 ? ( - - {vBar(thumbTop)} - - ) : null} - - {thumbBody} - - {below > 0 ? ( - - {vBar(below)} - - ) : null} - - )} - - ) -} - function GanttStrip({ cols, cursor, diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 66961f70e..4e215628e 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -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 patchOverlayState({ journey: false })} t={ui.theme} /> +}) + const StatusRulePane = memo(function StatusRulePane({ at, composer, @@ -445,6 +453,10 @@ export const AppLayout = memo(function AppLayout({ + ) : overlay.journey ? ( + + + ) : ( @@ -452,7 +464,7 @@ export const AppLayout = memo(function AppLayout({ )} - {!overlay.agents && ( + {!overlay.agents && !overlay.journey && ( <> diff --git a/ui-tui/src/components/journey.tsx b/ui-tui/src/components/journey.tsx new file mode 100644 index 000000000..fd4b83682 --- /dev/null +++ b/ui-tui/src/components/journey.tsx @@ -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 + } + + return ( + + {row.map((run, i) => ( + + {run[0]} + + ))} + + ) +} + +// 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 ( + + {cells.map((c, i) => ( + + {c.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(null) + const [err, setErr] = useState('') + const [selectedRow, setSelectedRow] = useState(0) + const [selectedNode, setSelectedNode] = useState(0) + const [mode, setMode] = useState('timeline') + const [tick, setTick] = useState(0) + const itemScroll = useRef(null) + + // The renderer is size-aware, so refetch when the terminal resizes. + useEffect(() => { + let alive = true + setData(null) + setErr('') + + gw.request('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 ( + + error: {err} + + ) + } + + if (!data) { + return ( + + assembling your learning map… + + ) + } + + if (!data.count) { + return ( + + + No learning yet — your learned skills and memories will start mapping out here as you use Hermes. + + + ) + } + + // ── 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 ( + + + + + {activeNode.glyph} {activeNode.fullLabel || activeNode.label} + + + + {selected.label} · {activeNode.meta} · item {selectedNode + 1}/{nodes.length} + + + + + + + {body.map((line, i) => ( + + {line || ' '} + + ))} + + + + + + + +
+ ↑↓/jk scroll · PgUp/PgDn page · g/G top/bottom · Esc/← back · q close +
+
+ ) + } + + // ── Detail: the slice's skills + memories as a selectable list ── + if (mode === 'detail' && selected) { + const h = Math.max(4, rows - 6) + const start = windowStart(selectedNode, nodes.length, h) + + return ( + + + + + {selected.label} + + + {' '} + {selected.skills} skills · {selected.memories} memories + {selected.category ? ` · ${selected.category}` : ''} · slice {selected.index + 1}/{buckets.length} + + + + + + {nodes.length ? ( + nodes.slice(start, start + h).map((node, i) => { + const idx = start + i + + return ( + + ) + }) + ) : ( + No objects in this slice. + )} + + +
+ + {nodes.length ? `${selectedNode + 1}/${nodes.length} · ` : ''}↑↓/jk move · Enter/→ open · g/G top/bottom · Esc/← back · + q close + +
+
+ ) + } + + // ── 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 ( + + + + + ✦ Journey + + learned skills & memories over time + + + {data.legend.map((item, i) => ( + + {i ? ' ' : ''} + {item.glyph} + {item.label} + + ))} + + {data.categories?.length ? ( + + {data.categories.map((item, i) => ( + + {i ? ' ' : ''} + {item.glyph} + {item.label} + + ))} + + ) : null} + + + + {chartGrid.map((row, i) => ( + + ))} + + {data.axis.start} + {' '.repeat(axisGap)} + {data.axis.end} + + + + + Timeline slices + {buckets.slice(start, start + listH).map((bucket, i) => { + const idx = start + i + const top = bucket.nodes[0] + + return ( + + ) + })} + + +
+ {data.summary.length ? {data.summary.join(' · ')} : null} + ↑↓/jk move · Enter/→ open · g/G top/bottom · q close +
+
+ ) +} + +function Shell({ children, t }: { children: React.ReactNode; t: Theme }) { + return ( + + + ✦ Journey + + {children} + Esc/q close + + ) +} + +function Footer({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +function Hint({ children, t }: { children: React.ReactNode; t: Theme }) { + return ( + + {children} + + ) +} diff --git a/ui-tui/src/components/overlayScrollbar.tsx b/ui-tui/src/components/overlayScrollbar.tsx new file mode 100644 index 000000000..9b5ba54ff --- /dev/null +++ b/ui-tui/src/components/overlayScrollbar.tsx @@ -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 + t: Theme + tick: number +}) { + void tick + + const [hover, setHover] = useState(false) + const [grab, setGrab] = useState(null) + + const s = scrollRef.current + const vp = Math.max(0, s?.getViewportHeight() ?? 0) + + if (!vp) { + return + } + + 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 ( + { + 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 ? ( + + {vBar(vp)} + + ) : ( + <> + {thumbTop > 0 ? ( + + {vBar(thumbTop)} + + ) : null} + + {thumbBody} + + {below > 0 ? ( + + {vBar(below)} + + ) : null} + + )} + + ) +} diff --git a/ui-tui/src/lib/starmapPalette.ts b/ui-tui/src/lib/starmapPalette.ts new file mode 100644 index 000000000..749d5ce64 --- /dev/null +++ b/ui-tui/src/lib/starmapPalette.ts @@ -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)) +}