From fd7461ddb9cd439fe175e1aae596ad0ff1fb610d Mon Sep 17 00:00:00 2001 From: Erosika Date: Wed, 15 Apr 2026 17:53:32 -0400 Subject: [PATCH] fix: card list crash, peer_id propagation, _render name collision, animations, gray theme --- honcho-cli/pyproject.toml | 2 +- honcho-cli/src/honcho_tui/__init__.py | 2 +- honcho-cli/src/honcho_tui/app.py | 4 + honcho-cli/src/honcho_tui/theme.py | 143 +++++++------ .../src/honcho_tui/widgets/animations.py | 195 ++++++++++++++++++ .../src/honcho_tui/widgets/peer_panel.py | 84 ++++---- .../src/honcho_tui/widgets/status_bar.py | 101 ++++----- .../src/honcho_tui/widgets/transcript.py | 64 ++---- 8 files changed, 401 insertions(+), 194 deletions(-) create mode 100644 honcho-cli/src/honcho_tui/widgets/animations.py diff --git a/honcho-cli/pyproject.toml b/honcho-cli/pyproject.toml index 8cbfbff9..c8f07375 100644 --- a/honcho-cli/pyproject.toml +++ b/honcho-cli/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "honcho-ai-cli" -version = "0.1.1" +version = "0.1.2" description = "A terminal for Honcho — memory that reasons." readme = "README.md" requires-python = ">=3.11" diff --git a/honcho-cli/src/honcho_tui/__init__.py b/honcho-cli/src/honcho_tui/__init__.py index 1e38e8ab..a60f8703 100644 --- a/honcho-cli/src/honcho_tui/__init__.py +++ b/honcho-cli/src/honcho_tui/__init__.py @@ -1,3 +1,3 @@ """Honcho TUI — a Textual interface for Honcho.""" -__version__ = "0.1.1" +__version__ = "0.1.2" diff --git a/honcho-cli/src/honcho_tui/app.py b/honcho-cli/src/honcho_tui/app.py index a578058c..02a5e6dd 100644 --- a/honcho-cli/src/honcho_tui/app.py +++ b/honcho-cli/src/honcho_tui/app.py @@ -201,6 +201,10 @@ class HonchoTUI(App[None]): ) -> None: self.active_session = session_id + # Propagate discovered peer back to app state so queries can use it + if peer_id and not self.peer_id: + self.peer_id = peer_id + # Update status bar bar = self.query_one("#status-bar", StatusBar) bar.session_id = session_id diff --git a/honcho-cli/src/honcho_tui/theme.py b/honcho-cli/src/honcho_tui/theme.py index 5852f7dc..67c00bec 100644 --- a/honcho-cli/src/honcho_tui/theme.py +++ b/honcho-cli/src/honcho_tui/theme.py @@ -2,24 +2,27 @@ from __future__ import annotations -# ── Color tokens — cyan/navy to match the honcho CLI aesthetic ──────────────── -BG = "#0d1117" # near-black with blue cast -BG_ELEVATED = "#161b22" # panel header / status bar -BG_PANEL = "#0d1117" # side panels -BG_INPUT = "#0a0e14" # query input background -FG = "#B6DAFD" # periwinkle — matches BRAND in branding.py -FG_DIM = "#7aadce" # mid-brightness cyan-blue -FG_MUTED = "#3d6480" # dim inactive text -BORDER = "#2a4a5a" # panel borders -ACCENT = "#56d4dd" # bright cyan — primary accent -ACCENT_DIM = "#1e4a55" # dim cyan for inactive states -BLUE = "#4169e1" # royal blue +# ── Color tokens — kitty gray (warm charcoal, no navy) ──────────────────────── +BG = "#1a1a1a" # warm dark charcoal +BG_ELEVATED = "#242424" # slightly raised surface +BG_PANEL = "#1e1e1e" # side panels +BG_INPUT = "#141414" # query input +FG = "#e2e2e2" # near-white foreground +FG_DIM = "#9a9a9a" # mid-gray +FG_MUTED = "#555555" # inactive / decorative +BORDER = "#333333" # subtle border +ACCENT = "#56d4dd" # bright cyan — matches CLI branding +ACCENT_DIM = "#1e6e78" # dim cyan +BLUE = "#8EA8FF" # indigo accent (workstation shell) CYAN = "#56d4dd" +SAGE = "#63D0A6" # green / good +RUST = "#F7A072" # warn / orange +CLAY = "#D4A373" # secondary warm accent PURPLE = "#bc8cff" -GREEN = "#7ee6a8" # good / success -WARN = "#e6c855" # warn +GREEN = "#63D0A6" # good / success +WARN = "#F7A072" # warn CRITICAL = "#FF6B6B" # critical -ORANGE = "#e6a855" +ORANGE = "#D4A373" # ── Spinner frame sets ───────────────────────────────────────────────────────── FRAMES_HELIX: list[str] = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"] @@ -35,14 +38,12 @@ CURSOR = "▍" TREE_LAST = "└ " TREE_MID = "├ " SEP = "─" -CHEVRON_OPEN = "▾" -CHEVRON_CLOSED = "▸" # ── TCSS ────────────────────────────────────────────────────────────────────── TCSS = """ Screen { - background: #0d1117; - color: #B6DAFD; + background: #1a1a1a; + color: #e2e2e2; layers: base overlay; } @@ -54,15 +55,15 @@ Screen { /* ── LEFT: SESSIONS ── */ #sessions-panel { - width: 28; - background: #0d1117; - border-right: solid #2a4a5a; + width: 26; + background: #1e1e1e; + border-right: solid #333333; layout: vertical; } .panel-title { height: 1; - background: #161b22; + background: #242424; color: #56d4dd; text-style: bold; content-align: left middle; @@ -70,8 +71,9 @@ Screen { } ListView { - background: #0d1117; + background: #1e1e1e; height: 1fr; + padding: 0; } ListView:focus { @@ -79,46 +81,59 @@ ListView:focus { } ListItem { - background: #0d1117; - height: 2; + background: #1e1e1e; + height: 1; padding: 0 1; - color: #3d6480; + color: #555555; } ListItem:hover { - background: #161b22; - color: #B6DAFD; + background: #242424; + color: #e2e2e2; } ListItem.--highlight { - background: #0f1e2e; - color: #B6DAFD; + background: #2a2a2a; + color: #56d4dd; } /* ── CENTER ── */ #center { width: 1fr; layout: vertical; + background: #1a1a1a; } +/* ── WAVEFORM ── */ +WaveformWidget { + height: 1; + background: #1a1a1a; +} + +SineWaveWidget { + height: 1; + background: #1a1a1a; +} + +/* ── TRANSCRIPT LOG ── */ RichLog { height: 1fr; - background: #0d1117; - padding: 0 1; - scrollbar-color: #2a4a5a; - scrollbar-background: #0d1117; - scrollbar-corner-color: #0d1117; + background: #1a1a1a; + padding: 0 2; + scrollbar-color: #333333; + scrollbar-background: #1a1a1a; + scrollbar-corner-color: #1a1a1a; border: none; } /* ── QUERY BAR ── */ #query-bar { height: 3; - background: #0a0e14; - border-top: solid #2a4a5a; + background: #141414; + border-top: solid #333333; layout: horizontal; align: left middle; - padding: 0 1; + padding: 0 2; } #query-prefix { @@ -130,8 +145,8 @@ RichLog { #query-input { width: 1fr; - background: #0a0e14; - color: #B6DAFD; + background: #141414; + color: #e2e2e2; border: none; padding: 0 0; } @@ -142,18 +157,25 @@ RichLog { /* ── RIGHT: PEER PANEL ── */ #peer-panel { - width: 34; - background: #0d1117; - border-left: solid #2a4a5a; + width: 32; + background: #1e1e1e; + border-left: solid #333333; layout: vertical; } +FreqBarsWidget { + height: 4; + background: #1e1e1e; + padding: 0 1; + border-bottom: solid #333333; +} + #peer-scroll { height: 1fr; - background: #0d1117; + background: #1e1e1e; padding: 0 1; - scrollbar-color: #2a4a5a; - scrollbar-background: #0d1117; + scrollbar-color: #333333; + scrollbar-background: #1e1e1e; } .section-label { @@ -164,11 +186,11 @@ RichLog { } .card-body { - color: #7aadce; + color: #9a9a9a; } .conclusion-row { - color: #7aadce; + color: #9a9a9a; height: auto; } @@ -180,34 +202,27 @@ RichLog { StatusBar { dock: bottom; height: 1; - background: #161b22; - color: #3d6480; - layout: horizontal; - padding: 0 1; -} - -/* ── SPINNER ── */ -.spinner { - color: #56d4dd; - width: 1; + background: #242424; + color: #555555; + padding: 0 2; } /* ── COLLAPSIBLE ── */ Collapsible { - background: #0d1117; + background: #1e1e1e; border: none; padding: 0; margin: 0; } CollapsibleTitle { - color: #3d6480; - background: #0d1117; + color: #555555; + background: #1e1e1e; padding: 0; } CollapsibleTitle:hover { - color: #B6DAFD; - background: #161b22; + color: #e2e2e2; + background: #242424; } """ diff --git a/honcho-cli/src/honcho_tui/widgets/animations.py b/honcho-cli/src/honcho_tui/widgets/animations.py new file mode 100644 index 00000000..ce2233bb --- /dev/null +++ b/honcho-cli/src/honcho_tui/widgets/animations.py @@ -0,0 +1,195 @@ +"""Animated display widgets: waveform, frequency bars, matrix cascade. + +IMPORTANT: All Static subclasses here must: + 1. Call super().__init__("", ...) to set initial content before first render + 2. Never define a method named _render() — that's a Textual internal +""" + +from __future__ import annotations + +import math +import random + +from rich.text import Text +from textual.widgets import Static + +from honcho_tui.theme import ACCENT, CYAN, FG_DIM, FG_MUTED + +BARS = " ▁▂▃▄▅▆▇█" +BLOCK = "█" +HALF_BOT = "▄" +_MC = "アイウエオカキクケコサシスセソタチツテト0123456789ABCDEF◆◇○●" + + +class WaveformWidget(Static): + """Scrolling dual-sine waveform using Unicode block characters.""" + + DEFAULT_CSS = """ + WaveformWidget { + height: 1; + background: #1a1a1a; + } + """ + + _phase: float = 0.0 + _noise: list[float] = [] + + def __init__(self, **kwargs) -> None: + super().__init__("", **kwargs) + + def on_mount(self) -> None: + self._noise = [random.random() for _ in range(256)] + self.set_interval(0.07, self._advance) + + def _advance(self) -> None: + self._phase += 0.20 + width = max(self.size.width, 40) if self.size.width > 0 else 60 + t = Text() + for i in range(width): + s1 = math.sin(self._phase + i * 0.28) + s2 = math.sin(self._phase * 0.6 + i * 0.45 + 1.7) + n = self._noise[(i + int(self._phase * 5)) % len(self._noise)] + val = (s1 * 0.5 + s2 * 0.3 + n * 0.2 + 1.0) / 2.0 + idx = int(val * (len(BARS) - 1)) + bar = BARS[max(0, min(idx, len(BARS) - 1))] + if val > 0.85: + style = ACCENT + elif val > 0.65: + style = CYAN + elif val > 0.35: + style = FG_DIM + else: + style = FG_MUTED + t.append(bar, style=style) + self.update(t) + + +class FreqBarsWidget(Static): + """Animated vertical frequency bars — sci-fi instrument readout.""" + + DEFAULT_CSS = """ + FreqBarsWidget { + height: 4; + background: #1e1e1e; + padding: 0 1; + border-bottom: solid #333333; + } + """ + + NUM_BARS = 14 + MAX_H = 4.0 + + _heights: list[float] = [] + _targets: list[float] = [] + _phase: float = 0.0 + + def __init__(self, **kwargs) -> None: + super().__init__("", **kwargs) + + def on_mount(self) -> None: + self._heights = [random.random() * self.MAX_H for _ in range(self.NUM_BARS)] + self._targets = list(self._heights) + self.set_interval(0.09, self._advance) + + def _advance(self) -> None: + self._phase += 0.10 + for i in range(self.NUM_BARS): + self._heights[i] += (self._targets[i] - self._heights[i]) * 0.30 + if random.random() < 0.13: + sine = (math.sin(self._phase + i * 0.55) + 1) / 2 + self._targets[i] = sine * self.MAX_H * 0.65 + random.random() * self.MAX_H * 0.35 + self._draw() + + def _draw(self) -> None: + rows: list[Text] = [] + max_r = int(self.MAX_H) + for row in range(max_r, 0, -1): + t = Text() + for h in self._heights: + frac = h - (row - 1) + if frac >= 1.0: + style = ACCENT if row == max_r else (CYAN if row >= max_r - 1 else FG_DIM) + t.append(BLOCK, style=style) + elif frac >= 0.5: + t.append(HALF_BOT, style=FG_DIM) + else: + t.append(" ") + t.append(" ") + rows.append(t) + + combined = Text() + for line in rows: + combined.append_text(line) + combined.append("\n") + self.update(combined) + + +class MatrixCascadeWidget(Static): + """Falling character cascade for loading states.""" + + DEFAULT_CSS = """ + MatrixCascadeWidget { + height: 5; + background: #1a1a1a; + padding: 0 1; + } + """ + + COLS = 10 + ROWS = 5 + _grid: list[list[str]] = [] + + def __init__(self, **kwargs) -> None: + super().__init__("", **kwargs) + + def on_mount(self) -> None: + self._grid = [ + [random.choice(_MC) for _ in range(self.COLS)] + for _ in range(self.ROWS) + ] + self.set_interval(0.13, self._advance) + + def _advance(self) -> None: + self._grid.pop() + self._grid.insert(0, [random.choice(_MC) for _ in range(self.COLS)]) + t = Text() + for r_idx, row in enumerate(self._grid): + fade = r_idx / max(1, self.ROWS - 1) + style = ACCENT if fade < 0.2 else (CYAN if fade < 0.45 else (FG_DIM if fade < 0.75 else FG_MUTED)) + for ch in row: + t.append(ch, style=style) + t.append(" ") + t.append("\n") + self.update(t) + + +class SineWaveWidget(Static): + """Horizontal sine wave using dot characters.""" + + DEFAULT_CSS = """ + SineWaveWidget { + height: 1; + background: #1a1a1a; + } + """ + + _CHARS = ["·", "∘", "○", "⊙", "●", "⊙", "○", "∘"] + _phase: float = 0.0 + + def __init__(self, **kwargs) -> None: + super().__init__("", **kwargs) + + def on_mount(self) -> None: + self.set_interval(0.09, self._advance) + + def _advance(self) -> None: + self._phase += 0.22 + width = max(self.size.width, 40) if self.size.width > 0 else 60 + t = Text() + for i in range(width): + val = (math.sin(self._phase + i * 0.35) + 1) / 2 + idx = int(val * (len(self._CHARS) - 1)) + ch = self._CHARS[idx] + style = ACCENT if val > 0.8 else (CYAN if val > 0.55 else FG_MUTED) + t.append(ch, style=style) + self.update(t) diff --git a/honcho-cli/src/honcho_tui/widgets/peer_panel.py b/honcho-cli/src/honcho_tui/widgets/peer_panel.py index ddddf968..17ac05a9 100644 --- a/honcho-cli/src/honcho_tui/widgets/peer_panel.py +++ b/honcho-cli/src/honcho_tui/widgets/peer_panel.py @@ -11,7 +11,6 @@ from textual.containers import VerticalScroll from honcho_tui.theme import ( ACCENT, ACCENT_DIM, - CRITICAL, DOT, DOT_EMPTY, FG, @@ -23,10 +22,22 @@ from honcho_tui.theme import ( TREE_MID, WARN, ) +from honcho_tui.widgets.animations import FreqBarsWidget + + +def _card_to_str(card) -> str: + """Normalize whatever get_card() returns to a plain string.""" + if card is None: + return "" + if isinstance(card, str): + return card + if isinstance(card, list): + return "\n".join(str(item) for item in card) + return str(card) class PeerPanel(Widget): - """Right-side panel: peer card + conclusions + queue status.""" + """Right-side panel: freq bars + peer card + conclusions + queue.""" DEFAULT_CSS = """ PeerPanel { @@ -40,7 +51,8 @@ class PeerPanel(Widget): _loading: bool = False def compose(self) -> ComposeResult: - yield Static(f" [{ACCENT}]PEER[/{ACCENT}]", classes="panel-title") + yield Static(f" [{ACCENT}]PEER[/{ACCENT}]", classes="panel-title", markup=True) + yield FreqBarsWidget(id="freq-bars") yield VerticalScroll(id="peer-scroll") def on_mount(self) -> None: @@ -63,20 +75,19 @@ class PeerPanel(Widget): def _show_empty(self) -> None: scroll = self.query_one("#peer-scroll", VerticalScroll) scroll.remove_children() - scroll.mount(Static(f"\n [{FG_DIM}]select a session[/{FG_DIM}]")) + scroll.mount(Static(f"\n [{FG_DIM}]select a session[/{FG_DIM}]", markup=True)) def load_peer( self, peer_id: str, - card: str | None, + card, conclusions: list[dict], queue: dict | None, ) -> None: """Populate panel with peer data (called from main thread).""" self.set_loading(False) - # Update header with peer ID - short_pid = peer_id[:18] + "…" if len(peer_id) > 18 else peer_id + short_pid = peer_id[:16] + "…" if len(peer_id) > 16 else peer_id header = self.query_one(".panel-title", Static) header.update(f" [{ACCENT}]PEER[/{ACCENT}] [{FG_DIM}]{short_pid}[/{FG_DIM}]") @@ -84,64 +95,63 @@ class PeerPanel(Widget): scroll.remove_children() # ── Card ───────────────────────────────────────────────────────────── - scroll.mount(Static(f"[{ACCENT}]CARD[/{ACCENT}]", classes="section-label")) + scroll.mount(Static(f"[{ACCENT}]CARD[/{ACCENT}]", classes="section-label", markup=True)) - if card and card.strip(): - raw = card.strip() - preview = markup_escape(raw[:400]) - if len(raw) > 400: - preview += f"\n[{FG_MUTED}]… {len(raw) - 400} more chars[/{FG_MUTED}]" + card_str = _card_to_str(card).strip() + if card_str: + preview = markup_escape(card_str[:380]) + if len(card_str) > 380: + preview += f"\n[{FG_MUTED}]… {len(card_str) - 380} more[/{FG_MUTED}]" scroll.mount(Static(preview, classes="card-body", markup=True)) else: - scroll.mount(Static(f"[{FG_DIM}]no card yet[/{FG_DIM}]", classes="card-body")) + scroll.mount(Static(f"[{FG_DIM}]no card yet[/{FG_DIM}]", classes="card-body", markup=True)) # ── Conclusions ─────────────────────────────────────────────────────── count = len(conclusions) - scroll.mount(Static(f"\n[{ACCENT}]CONCLUSIONS[/{ACCENT}] [{FG_MUTED}]({count})[/{FG_MUTED}]", classes="section-label")) + scroll.mount(Static( + f"\n[{ACCENT}]CONCLUSIONS[/{ACCENT}] [{FG_MUTED}]({count})[/{FG_MUTED}]", + classes="section-label", markup=True, + )) if not conclusions: - scroll.mount(Static(f"[{FG_DIM}]no conclusions yet[/{FG_DIM}]", classes="card-body")) + scroll.mount(Static(f"[{FG_DIM}]no conclusions yet[/{FG_DIM}]", classes="card-body", markup=True)) else: for i, c in enumerate(conclusions[:12]): content = c.get("content", "") - safe = markup_escape(content[:55] + "…" if len(content) > 55 else content) + safe = markup_escape(content[:54] + "…" if len(content) > 54 else content) prefix = TREE_LAST if i == min(len(conclusions), 12) - 1 else TREE_MID - scroll.mount( - Static( - f"[{FG_DIM}]{prefix}[/{FG_DIM}][{FG}]{safe}[/{FG}]", - classes="conclusion-row", - markup=True, - ) - ) + scroll.mount(Static( + f"[{FG_DIM}]{prefix}[/{FG_DIM}][{FG}]{safe}[/{FG}]", + classes="conclusion-row", markup=True, + )) if count > 12: - scroll.mount(Static(f"[{FG_MUTED}] … {count - 12} more[/{FG_MUTED}]")) + scroll.mount(Static(f"[{FG_MUTED}] … {count - 12} more[/{FG_MUTED}]", markup=True)) # ── Queue ───────────────────────────────────────────────────────────── - scroll.mount(Static(f"\n[{ACCENT}]QUEUE[/{ACCENT}]", classes="section-label")) + scroll.mount(Static(f"\n[{ACCENT}]QUEUE[/{ACCENT}]", classes="section-label", markup=True)) if queue is None: - scroll.mount(Static(f"[{FG_DIM}]unavailable[/{FG_DIM}]")) + scroll.mount(Static(f"[{FG_DIM}]unavailable[/{FG_DIM}]", markup=True)) else: - pending = queue.get("pending", 0) - running = queue.get("running", 0) - completed = queue.get("completed", 0) + pending = queue.get("pending", 0) or 0 + running = queue.get("running", 0) or 0 + completed = queue.get("completed", 0) or 0 p_dot = f"[{WARN}]{DOT}[/{WARN}]" if pending > 0 else f"[{FG_MUTED}]{DOT_EMPTY}[/{FG_MUTED}]" r_dot = f"[{GREEN}]{DOT}[/{GREEN}]" if running > 0 else f"[{FG_MUTED}]{DOT_EMPTY}[/{FG_MUTED}]" - d_dot = f"[{FG_DIM}]{DOT}[/{FG_DIM}]" scroll.mount(Static( - f"{p_dot} [{FG_DIM}]{pending} pending[/{FG_DIM}] " - f"{r_dot} [{FG_DIM}]{running} running[/{FG_DIM}]", - classes="queue-row", + f"{p_dot} [{FG_DIM}]{pending} pending[/{FG_DIM}] {r_dot} [{FG_DIM}]{running} running[/{FG_DIM}]", + classes="queue-row", markup=True, )) scroll.mount(Static( - f"{d_dot} [{FG_MUTED}]{completed} done[/{FG_MUTED}]", - classes="queue-row", + f"[{FG_MUTED}]{DOT} {completed} done[/{FG_MUTED}]", + classes="queue-row", markup=True, )) def show_error(self, msg: str) -> None: self.set_loading(False) scroll = self.query_one("#peer-scroll", VerticalScroll) scroll.remove_children() - scroll.mount(Static(f"\n[red]{msg}[/red]")) + safe = markup_escape(msg[:80]) + scroll.mount(Static(f"\n[red]{safe}[/red]", markup=True)) diff --git a/honcho-cli/src/honcho_tui/widgets/status_bar.py b/honcho-cli/src/honcho_tui/widgets/status_bar.py index cf3050c3..806fce8b 100644 --- a/honcho-cli/src/honcho_tui/widgets/status_bar.py +++ b/honcho-cli/src/honcho_tui/widgets/status_bar.py @@ -1,26 +1,24 @@ -"""Bottom status bar: workspace, peer, queue summary, uptime.""" +"""Bottom status bar: workspace, peer, queue summary, uptime, pulsing dot.""" from __future__ import annotations -from textual.widget import Widget -from textual.app import ComposeResult -from textual.widgets import Static +from rich.text import Text from textual.reactive import reactive +from textual.widgets import Static -from honcho_tui.theme import ACCENT, FG_DIM, FG_MUTED, GREEN, WARN, DOT +from honcho_tui.theme import ACCENT, DOT, DOT_EMPTY, FG_DIM, FG_MUTED, GREEN, WARN -class StatusBar(Widget): - """Fixed bottom bar showing workspace, peer, queue, and uptime.""" +class StatusBar(Static): + """Fixed bottom bar — rendered as a single Rich Text line.""" DEFAULT_CSS = """ StatusBar { dock: bottom; height: 1; - layout: horizontal; - background: #141820; - color: #3d6480; - padding: 0 1; + background: #242424; + color: #555555; + padding: 0 2; } """ @@ -31,64 +29,69 @@ class StatusBar(Widget): running: reactive[int] = reactive(0) uptime: reactive[int] = reactive(0) - def compose(self) -> ComposeResult: - yield Static("", id="status-content") + _pulse_on: bool = True def on_mount(self) -> None: - self._render_status() + self.set_interval(0.7, self._pulse) + self._update_content() - def watch_workspace_id(self, value: str) -> None: - self._render_status() + def _pulse(self) -> None: + self._pulse_on = not self._pulse_on + self._update_content() - def watch_peer_id(self, value: str) -> None: - self._render_status() + def watch_workspace_id(self, _: str) -> None: + self._update_content() - def watch_session_id(self, value: str) -> None: - self._render_status() + def watch_peer_id(self, _: str) -> None: + self._update_content() - def watch_pending(self, value: int) -> None: - self._render_status() + def watch_session_id(self, _: str) -> None: + self._update_content() - def watch_running(self, value: int) -> None: - self._render_status() + def watch_pending(self, _: int) -> None: + self._update_content() - def watch_uptime(self, value: int) -> None: - self._render_status() + def watch_running(self, _: int) -> None: + self._update_content() - def _render_status(self) -> None: - try: - content = self.query_one("#status-content", Static) - except Exception: - return + def watch_uptime(self, _: int) -> None: + self._update_content() - parts: list[str] = [] + def _update_content(self) -> None: + t = Text(no_wrap=True, overflow="ellipsis") + + # Pulsing connection dot + conn_style = ACCENT if self._pulse_on else FG_MUTED + t.append(f"{DOT} ", style=conn_style) if self.workspace_id: - ws = self.workspace_id[:16] + "…" if len(self.workspace_id) > 16 else self.workspace_id - parts.append(f"[{ACCENT}]ws[/{ACCENT}] {ws}") + ws = self.workspace_id[:14] + "…" if len(self.workspace_id) > 14 else self.workspace_id + t.append("ws ", style=ACCENT) + t.append(f"{ws} ", style=FG_DIM) else: - parts.append(f"[{FG_MUTED}]no workspace[/{FG_MUTED}]") + t.append("no workspace ", style=FG_MUTED) if self.peer_id: - p = self.peer_id[:14] + "…" if len(self.peer_id) > 14 else self.peer_id - parts.append(f"[{FG_DIM}]peer[/{FG_DIM}] {p}") + p = self.peer_id[:12] + "…" if len(self.peer_id) > 12 else self.peer_id + t.append("peer ", style=FG_DIM) + t.append(f"{p} ", style=FG_DIM) if self.session_id: s = self.session_id[:12] + "…" if len(self.session_id) > 12 else self.session_id - parts.append(f"[{FG_DIM}]sess[/{FG_DIM}] {s}") + t.append("sess ", style=FG_DIM) + t.append(f"{s} ", style=FG_DIM) - # Queue if self.running > 0: - parts.append(f"[{GREEN}]{DOT}[/{GREEN}] [{FG_DIM}]{self.running} running[/{FG_DIM}]") + t.append(f"{DOT} ", style=GREEN) + t.append(f"{self.running} running ", style=FG_DIM) if self.pending > 0: - parts.append(f"[{WARN}]{DOT}[/{WARN}] [{FG_DIM}]{self.pending} pending[/{FG_DIM}]") + t.append(f"{DOT} ", style=WARN) + t.append(f"{self.pending} pending ", style=FG_DIM) - # Uptime (right-aligned via spacer) - h = self.uptime // 3600 - m = (self.uptime % 3600) // 60 - s = self.uptime % 60 - uptime_str = f"{h:02d}:{m:02d}:{s:02d}" + total = int(self.uptime) + h = total // 3600 + m = (total % 3600) // 60 + s_val = total % 60 + t.append(f"{h:02d}:{m:02d}:{s_val:02d}", style=FG_MUTED) - sep = f" [{FG_MUTED}]·[/{FG_MUTED}] " - left = sep.join(parts) - content.update(f"{left} [{FG_MUTED}]{uptime_str}[/{FG_MUTED}]") + self.update(t) diff --git a/honcho-cli/src/honcho_tui/widgets/transcript.py b/honcho-cli/src/honcho_tui/widgets/transcript.py index 37f7d9fa..1671fcb5 100644 --- a/honcho-cli/src/honcho_tui/widgets/transcript.py +++ b/honcho-cli/src/honcho_tui/widgets/transcript.py @@ -4,7 +4,6 @@ from __future__ import annotations import datetime -from rich.markup import escape as markup_escape from rich.text import Text from textual.app import ComposeResult from textual.message import Message @@ -13,16 +12,15 @@ from textual.widgets import Input, RichLog, Static from honcho_tui.theme import ( ACCENT, - ACCENT_DIM, BLUE, - CURSOR, FG, FG_DIM, FG_MUTED, FRAMES_HELIX, GREEN, - ORANGE, + RUST, ) +from honcho_tui.widgets.animations import WaveformWidget class QuerySubmitted(Message): @@ -34,7 +32,7 @@ class QuerySubmitted(Message): class TranscriptPanel(Widget): - """Center pane: message log above, query input below.""" + """Center pane: waveform header + message log + query input.""" DEFAULT_CSS = """ TranscriptPanel { @@ -46,31 +44,22 @@ class TranscriptPanel(Widget): _frame_idx: int = 0 _streaming: bool = False - _cursor_on: bool = True def compose(self) -> ComposeResult: - yield RichLog(highlight=False, markup=True, wrap=True, id="log") + yield WaveformWidget(id="transcript-wave") + yield RichLog(highlight=False, markup=False, wrap=True, id="log") with Widget(id="query-bar"): - yield Static(f"[{ACCENT}]>[/{ACCENT}]", id="query-prefix") - yield Input( - placeholder="query dialectic…", - id="query-input", - ) + yield Static(f"[{ACCENT}]>[/{ACCENT}]", id="query-prefix", markup=True) + yield Input(placeholder="query dialectic…", id="query-input") def on_mount(self) -> None: self._spinner_timer = self.set_interval(0.08, self._tick) - self._cursor_timer = self.set_interval(0.42, self._blink_cursor) self.show_welcome() - # ── Timers ──────────────────────────────────────────────────────────────── - def _tick(self) -> None: if self._streaming: self._frame_idx = (self._frame_idx + 1) % len(FRAMES_HELIX) - def _blink_cursor(self) -> None: - self._cursor_on = not self._cursor_on - # ── Public API ──────────────────────────────────────────────────────────── def show_welcome(self) -> None: @@ -82,13 +71,12 @@ class TranscriptPanel(Widget): t.append(" select a session from the left panel\n", style=FG_DIM) t.append(" or query the dialectic below\n\n", style=FG_DIM) t.append(" bindings\n", style=f"bold {FG_DIM}") - binds = [ + for key, desc in [ ("ctrl+d", "focus dialectic input"), ("tab", "focus session list"), ("r", "refresh"), ("q", "quit"), - ] - for key, desc in binds: + ]: t.append(f" {key}", style=f"bold {ACCENT}") t.append(f" {desc}\n", style=FG_MUTED) log.write(t) @@ -97,7 +85,7 @@ class TranscriptPanel(Widget): log = self.query_one("#log", RichLog) log.clear() t = Text() - t.append("\n no workspace configured\n\n", style=f"bold {ORANGE}") + t.append("\n no workspace configured\n\n", style=f"bold {RUST}") t.append(" set HONCHO_WORKSPACE_ID or pass --workspace\n", style=FG_DIM) t.append(" run 'honcho init' to configure\n", style=FG_DIM) log.write(t) @@ -121,16 +109,14 @@ class TranscriptPanel(Widget): log = self.query_one("#log", RichLog) log.clear() - # Header - short_sid = session_id[:28] + "…" if len(session_id) > 28 else session_id + short_sid = session_id[:32] + "…" if len(session_id) > 32 else session_id header = Text() - header.append(f" session ", style=FG_MUTED) - header.append(short_sid, style=f"bold {FG}") - header.append(f"\n {'─' * 50}\n", style=FG_MUTED) + header.append(f"\n {short_sid}\n", style=f"bold {FG}") + header.append(f" {'─' * 40}\n\n", style=FG_MUTED) log.write(header) if not messages: - log.write(Text(f" [{FG_DIM}]no messages[/{FG_DIM}]\n")) + log.write(Text(" no messages\n", style=FG_DIM)) return for msg in messages: @@ -141,7 +127,6 @@ class TranscriptPanel(Widget): content = msg.get("content", "") created_at = msg.get("created_at") - # Timestamp ts = "" if created_at: try: @@ -150,14 +135,12 @@ class TranscriptPanel(Widget): except Exception: ts = str(created_at)[:5] - # Header line header = Text() - header.append(f" {ts} ", style=FG_MUTED) + header.append(f" {ts} ", style=FG_MUTED) header.append(peer_id, style=f"bold {BLUE}") header.append("\n") log.write(header) - # Content — escape raw text so Rich markup chars don't break rendering body = Text() for line in content.splitlines(): body.append(" ", style=FG) @@ -169,20 +152,19 @@ class TranscriptPanel(Widget): log.write(Text("\n")) def append_response(self, label: str, content: str, is_query: bool = False) -> None: - """Append a query + response pair to the log.""" log = self.query_one("#log", RichLog) self._streaming = False if is_query: - q_text = Text() - q_text.append(f" > ", style=f"bold {ACCENT}") - q_text.append(f"{label}\n\n", style=f"bold {FG}") - log.write(q_text) + q = Text() + q.append(f" > ", style=f"bold {ACCENT}") + q.append(f"{label}\n\n", style=f"bold {FG}") + log.write(q) resp = Text() resp.append(" dialectic\n", style=f"bold {GREEN}") for line in content.splitlines(): - resp.append(" ", style=FG) + resp.append(" ") resp.append(line, style=FG) resp.append("\n") resp.append("\n") @@ -192,15 +174,13 @@ class TranscriptPanel(Widget): self._streaming = False log = self.query_one("#log", RichLog) t = Text() - t.append(f"\n error ", style="bold red") - t.append(f"{msg}\n", style=FG_DIM) + t.append(f"\n error\n", style="bold red") + t.append(f" {msg}\n", style=FG_DIM) log.write(t) def focus_input(self) -> None: self.query_one("#query-input", Input).focus() - # ── Events ──────────────────────────────────────────────────────────────── - def on_input_submitted(self, event: Input.Submitted) -> None: query = event.value.strip() if not query: